Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/peek-cli-connect-add-local.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@peekdev/cli': minor
---

Add `peek connect add --local <path>` to register a locally-built connector (sugar for `--command node --args=<abs path>`).
35 changes: 35 additions & 0 deletions packages/connector-slack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo>/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.
59 changes: 58 additions & 1 deletion packages/peek-cli/src/commands/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────────────
Expand Down
42 changes: 37 additions & 5 deletions packages/peek-cli/src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,6 +31,8 @@ const USAGE = `Usage: peek connect <subcommand> [options]
Subcommands:
add <surface> [--name <n>] [--command <c>] [--args=<arg>] (repeatable)
Register a connector for a surface
add <surface> --local <path> Run a locally-built connector: sugar for
--command node --args=<abs path>
list List all configured connectors
remove <name> Remove a connector from the registry
start Start the connector supervisor daemon
Expand Down Expand Up @@ -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;

Expand All @@ -583,6 +586,7 @@ function runAdd(rest: string[]): number {
name?: string;
command?: string;
args?: string[];
local?: string;
help?: boolean;
};
try {
Expand All @@ -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 <path>: 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);
Expand Down
Loading