diff --git a/.changeset/peek-cli-connect-add-local.md b/.changeset/peek-cli-connect-add-local.md new file mode 100644 index 0000000..24034f4 --- /dev/null +++ b/.changeset/peek-cli-connect-add-local.md @@ -0,0 +1,5 @@ +--- +'@peekdev/cli': minor +--- + +Add `peek connect add --local ` to register a locally-built connector (sugar for `--command node --args=`). diff --git a/packages/connector-slack/README.md b/packages/connector-slack/README.md index dfc98cd..4c66521 100644 --- a/packages/connector-slack/README.md +++ b/packages/connector-slack/README.md @@ -37,3 +37,38 @@ adapter.onMessage(async (msg) => { await adapter.start(); ``` + +## Run from a local build (no npm publish) + +`@peekdev/connector-slack` is not published to npm — it runs from a local +build, spawned by the `peek connect` daemon. + +1. Build the connector: + + ```sh + pnpm --filter @peekdev/connector-slack build + ``` + +2. Register it with peek using the `--local` shorthand (resolves the path to + an absolute path and sets `command=node`): + + ```sh + peek connect add slack --local /packages/connector-slack/dist/index.js + ``` + +3. Start the daemon: + + ```sh + peek connect start + ``` + +**Notes:** + +- Slack tokens (`SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`) are captured + interactively on first run and stored in the system keychain (SP6b-1); you + do not need to set them manually after the first interactive run. +- The daemon inherits `PEEK_LLM_*` and `PEEK_MCP_COMMAND` from the shell + that ran `peek connect start` — set those in your shell profile before + starting the daemon. +- Pairing with the browser extension happens via the extension side panel + after the daemon is running. diff --git a/packages/peek-cli/src/commands/connect.test.ts b/packages/peek-cli/src/commands/connect.test.ts index a796a51..2dcaae9 100644 --- a/packages/peek-cli/src/commands/connect.test.ts +++ b/packages/peek-cli/src/commands/connect.test.ts @@ -6,7 +6,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { mkdirSync as fsMkdirSync, writeFileSync as fsWriteFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { isAbsolute, join, resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { run } from '../index.js'; import { readConnectors } from '../lib/connect/registry.js'; @@ -102,6 +102,63 @@ describe('peek connect add', () => { const entry = Object.values(file.connectors)[0]; expect(entry?.args).toEqual(['--token', 'xoxb-test']); }); + + it('--local resolves to absolute path, sets command=node and args=[absPath]', async () => { + silenced(); + const relPath = 'packages/connector-slack/dist/index.js'; + const code = await runConnect(['add', 'slack', `--local=${relPath}`]); + expect(code).toBe(0); + + const file = readConnectors(); + const entry = Object.values(file.connectors)[0]; + expect(entry?.command).toBe('node'); + // args must contain exactly the absolute resolved path + expect(entry?.args).toHaveLength(1); + const absPath = entry?.args?.[0]; + expect(absPath).toBeDefined(); + // must be absolute + expect(isAbsolute(absPath ?? '')).toBe(true); + // must end with the relative tail (separator-agnostic) + expect(absPath).toBe(resolve(process.cwd(), relPath)); + }); + + it('--local + --command together returns 1 and prints conflict message', async () => { + const { err } = silenced(); + const code = await runConnect([ + 'add', + 'slack', + '--local=packages/connector-slack/dist/index.js', + '--command=my-bin', + ]); + expect(code).toBe(1); + expect(err.join('')).toMatch(/--local.*--command|--command.*--local|conflict/i); + }); + + it('--local + --args together returns 1 and prints conflict message', async () => { + const { err } = silenced(); + const code = await runConnect([ + 'add', + 'slack', + '--local=packages/connector-slack/dist/index.js', + '--args=--foo', + ]); + expect(code).toBe(1); + expect(err.join('')).toMatch(/--local.*--args|--args.*--local|conflict/i); + }); + + it('plain add slack (no --local) still works unchanged', async () => { + silenced(); + const code = await runConnect(['add', 'slack']); + expect(code).toBe(0); + + const file = readConnectors(); + const entry = Object.values(file.connectors)[0]; + // no command or args injected — descriptor default only + expect(entry?.surface).toBe('slack'); + expect(entry?.enabled).toBe(true); + expect(entry?.command).toBeUndefined(); + expect(entry?.args).toBeUndefined(); + }); }); // ── list ─────────────────────────────────────────────────────────────────── diff --git a/packages/peek-cli/src/commands/connect.ts b/packages/peek-cli/src/commands/connect.ts index 3ea4dc6..18bf343 100644 --- a/packages/peek-cli/src/commands/connect.ts +++ b/packages/peek-cli/src/commands/connect.ts @@ -6,7 +6,7 @@ import { spawn as _realSpawn } from 'node:child_process'; import { closeSync, mkdirSync, openSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { parseArgs } from 'node:util'; import { getDescriptor, resolveSpawn } from '../lib/connect/descriptors.js'; import { @@ -31,6 +31,8 @@ const USAGE = `Usage: peek connect [options] Subcommands: add [--name ] [--command ] [--args=] (repeatable) Register a connector for a surface + add --local Run a locally-built connector: sugar for + --command node --args= list List all configured connectors remove Remove a connector from the registry start Start the connector supervisor daemon @@ -568,6 +570,7 @@ const ADD_FLAGS = { name: { type: 'string' }, command: { type: 'string' }, args: { type: 'string', multiple: true }, + local: { type: 'string' }, help: { type: 'boolean' }, } as const; @@ -583,6 +586,7 @@ function runAdd(rest: string[]): number { name?: string; command?: string; args?: string[]; + local?: string; help?: boolean; }; try { @@ -596,22 +600,50 @@ function runAdd(rest: string[]): number { return 0; } + // Mutual exclusion: --local cannot be combined with --command or --args. + if (values.local !== undefined) { + if (values.command !== undefined) { + process.stderr.write( + 'peek connect add: --local and --command conflict — use one or the other\n', + ); + return 1; + } + if (values.args !== undefined && values.args.length > 0) { + process.stderr.write( + 'peek connect add: --local and --args conflict — use one or the other\n', + ); + return 1; + } + } + const descriptor = getDescriptor(surface); - if (descriptor === undefined && values.command === undefined) { + if (descriptor === undefined && values.command === undefined && values.local === undefined) { process.stderr.write( - `peek connect add: unknown surface '${surface}' — pass --command to use a custom connector binary\n`, + `peek connect add: unknown surface '${surface}' — pass --command or --local to use a custom connector binary\n`, ); return 1; } const name = values.name ?? surface; + // --local : desugar to command=node, args=[absPath]. + let effectiveCommand: string | undefined; + let effectiveArgs: string[] | undefined; + + if (values.local !== undefined) { + effectiveCommand = 'node'; + effectiveArgs = [resolve(process.cwd(), values.local)]; + } else { + effectiveCommand = values.command; + effectiveArgs = values.args !== undefined && values.args.length > 0 ? values.args : undefined; + } + // Build entry conditionally to satisfy exactOptionalPropertyTypes. const entry = { surface, enabled: true, - ...(values.command !== undefined ? { command: values.command } : {}), - ...(values.args !== undefined && values.args.length > 0 ? { args: values.args } : {}), + ...(effectiveCommand !== undefined ? { command: effectiveCommand } : {}), + ...(effectiveArgs !== undefined ? { args: effectiveArgs } : {}), }; addConnector(name, entry);