plumb-bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bunfig.toml ADDED
@@ -0,0 +1,2 @@
1
+ [test]
2
+ root = "."
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "plumb-bridge",
3
+ "version": "0.1.0",
4
+ "description": "A2A bridge for CLI coding agents. Bun runtime. Example: plumb wrap cat --port 3001. Use plumb wrap opencode (not opencode run — the adapter adds run --format json).",
5
+ "type": "module",
6
+ "module": "src/main.ts",
7
+ "bin": {
8
+ "plumb": "src/main.ts"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "test",
13
+ "tsconfig.json",
14
+ "bunfig.toml"
15
+ ],
16
+ "engines": {
17
+ "bun": ">=1.1.0"
18
+ },
19
+ "scripts": {
20
+ "start": "bun run src/main.ts",
21
+ "wrap": "bun run src/main.ts wrap",
22
+ "typecheck": "tsc --noEmit",
23
+ "test": "bun test test/conformance.test.ts",
24
+ "prepack": "npm run typecheck",
25
+ "prepublishOnly": "npm run test && npm run typecheck"
26
+ },
27
+ "dependencies": {
28
+ "@a2a-js/sdk": "^0.3.13",
29
+ "commander": "^14.0.3",
30
+ "express": "^5.2.1"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "latest",
34
+ "@types/express": "^5.0.6",
35
+ "typescript": "^5"
36
+ },
37
+ "peerDependencies": {
38
+ "typescript": "^5"
39
+ }
40
+ }
@@ -0,0 +1,109 @@
1
+ // PLUMB — Claude Adapter
2
+ // Wraps `claude --print --output-format stream-json --verbose`. Streaming JSONL.
3
+ // Filters system/rate-limit events. Captures assistant messages and results.
4
+
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import type { AgentAdapter, AgentTask, AdapterEvent, DetectionResult, PlumbConfig } from '../types.ts';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ interface ClaudeStreamEvent {
12
+ type: string;
13
+ subtype?: string;
14
+ message?: {
15
+ content?: Array<{ type: string; text?: string }>;
16
+ };
17
+ result?: string;
18
+ is_error?: boolean;
19
+ error?: string;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ export class ClaudeAdapter implements AgentAdapter {
24
+ readonly id = 'claude';
25
+ readonly binary = 'claude';
26
+ readonly tier = 1 as const;
27
+ readonly displayName = 'Claude';
28
+ readonly mode = 'oneshot' as const;
29
+
30
+ skills = [
31
+ { id: 'code', name: 'Code generation and editing', tags: ['code', 'edit', 'write'] },
32
+ { id: 'bash', name: 'Execute shell commands', tags: ['bash', 'shell', 'terminal'] },
33
+ { id: 'read', name: 'Read files', tags: ['read', 'file'] },
34
+ { id: 'web', name: 'Web search and fetch', tags: ['web', 'search', 'fetch'] },
35
+ ];
36
+
37
+ buildArgs(_task: AgentTask, _config: PlumbConfig): string[] {
38
+ return ['--print', '--output-format', 'stream-json', '--verbose'];
39
+ }
40
+
41
+ formatInput(task: AgentTask): string {
42
+ return task.message + '\n';
43
+ }
44
+
45
+ parseLine(line: string): AdapterEvent[] {
46
+ if (!line.trim()) return [];
47
+
48
+ let event: ClaudeStreamEvent;
49
+ try {
50
+ event = JSON.parse(line);
51
+ } catch {
52
+ // Non-JSON line — treat as raw text
53
+ return [{ type: 'text-delta', text: line + '\n' }];
54
+ }
55
+
56
+ // Filter non-content events
57
+ if (event.type === 'rate_limit_event') return [];
58
+ if (event.type === 'system') return [];
59
+
60
+ // Assistant message — extract text content
61
+ if (event.type === 'assistant' && event.message?.content) {
62
+ const texts = event.message.content
63
+ .filter(c => c.type === 'text' && c.text)
64
+ .map(c => c.text!);
65
+ if (texts.length > 0) {
66
+ return [{ type: 'text-delta', text: texts.join('\n') }];
67
+ }
68
+ return [];
69
+ }
70
+
71
+ // Result event — final output
72
+ if (event.type === 'result') {
73
+ if (event.is_error || event.error) {
74
+ return [{ type: 'error', message: event.error ?? 'Unknown error' }];
75
+ }
76
+ // Result text is already captured from assistant message, signal completion
77
+ return [{ type: 'status', state: 'completed' }];
78
+ }
79
+
80
+ // Error event
81
+ if (event.type === 'error') {
82
+ return [{ type: 'error', message: String(event.error ?? event.message ?? 'Unknown error') }];
83
+ }
84
+
85
+ return [];
86
+ }
87
+
88
+ async detect(): Promise<DetectionResult | null> {
89
+ try {
90
+ const { stdout } = await execFileAsync('which', ['claude'], { timeout: 5000 });
91
+ let version = 'unknown';
92
+ try {
93
+ const { stdout: vOut } = await execFileAsync('claude', ['--version'], { timeout: 5000 });
94
+ version = vOut.trim().split('\n')[0] ?? 'unknown';
95
+ } catch {
96
+ // Version check failed
97
+ }
98
+ return {
99
+ binary: 'claude',
100
+ version,
101
+ path: stdout.trim(),
102
+ tier: 1,
103
+ protocol: 'stream-json',
104
+ };
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,50 @@
1
+ // PLUMB — Echo Adapter
2
+ // Wraps `cat`. Proves the bridge works. Emits a real A2A task lifecycle.
3
+ // Every line cat echoes becomes a progress event. Exit 0 → completed.
4
+ // Stolen from fangai's Echo First pattern, implemented as a real CLI wrapper.
5
+
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import type { AgentAdapter, AgentTask, AdapterEvent, DetectionResult, PlumbConfig } from '../types.ts';
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ export class EchoAdapter implements AgentAdapter {
13
+ readonly id = 'echo';
14
+ readonly binary = 'cat';
15
+ readonly tier = 1 as const;
16
+ readonly displayName = 'Echo';
17
+ readonly mode = 'oneshot' as const;
18
+
19
+ skills = [
20
+ { id: 'echo', name: 'Echo task input', tags: ['echo', 'test', 'conformance'] },
21
+ ];
22
+
23
+ buildArgs(_task: AgentTask, _config: PlumbConfig): string[] {
24
+ return [];
25
+ }
26
+
27
+ formatInput(task: AgentTask): string {
28
+ return task.message + '\n';
29
+ }
30
+
31
+ parseLine(line: string): AdapterEvent[] {
32
+ if (!line.trim()) return [];
33
+ return [{ type: 'text-delta', text: line + '\n' }];
34
+ }
35
+
36
+ async detect(): Promise<DetectionResult | null> {
37
+ try {
38
+ const { stdout } = await execFileAsync('which', ['cat'], { timeout: 5000 });
39
+ return {
40
+ binary: 'cat',
41
+ version: '1.0.0',
42
+ path: stdout.trim(),
43
+ tier: 1,
44
+ protocol: 'text',
45
+ };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,45 @@
1
+ // PLUMB — Generic Adapter
2
+ // Text passthrough. Wraps any CLI that reads stdin and writes to stdout.
3
+ // Every non-empty line becomes a progress event. Exit 0 = complete.
4
+
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import type { AgentAdapter, AgentTask, AdapterEvent, DetectionResult, PlumbConfig } from '../types.ts';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ export class GenericAdapter implements AgentAdapter {
12
+ readonly id = 'generic';
13
+ readonly binary = '';
14
+ readonly tier = 3 as const;
15
+ readonly displayName = 'Generic CLI';
16
+ readonly mode = 'oneshot' as const;
17
+ private cliCommand: string;
18
+
19
+ skills = [{ id: 'generic', name: 'CLI task', tags: ['code'] }];
20
+
21
+ constructor(cliCommand = '') {
22
+ this.cliCommand = cliCommand;
23
+ }
24
+
25
+ buildArgs(_task: AgentTask, _config: PlumbConfig): string[] { return []; }
26
+
27
+ formatInput(task: AgentTask): string { return task.message + '\n'; }
28
+
29
+ parseLine(line: string): AdapterEvent[] {
30
+ if (!line.trim()) return [];
31
+ return [{ type: 'text-delta', text: line.trim() + '\n' }];
32
+ }
33
+
34
+ async detect(): Promise<DetectionResult | null> {
35
+ if (!this.cliCommand) return null;
36
+ try {
37
+ const [cmd] = this.cliCommand.trim().split(/\s+/);
38
+ if (!cmd) return null;
39
+ const { stdout } = await execFileAsync('which', [cmd], { timeout: 5000 });
40
+ return { binary: cmd, version: 'unknown', path: stdout.trim(), tier: 3, protocol: 'text' };
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,117 @@
1
+ // PLUMB — OpenCode Adapter
2
+ // Wraps `opencode run --format json`. JSON event stream.
3
+ // Filters log/status events. Captures text content and results.
4
+
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import type { AgentAdapter, AgentTask, AdapterEvent, DetectionResult, PlumbConfig } from '../types.ts';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ interface OpenCodePart {
12
+ type?: string;
13
+ text?: string;
14
+ reason?: string;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ interface OpenCodeEvent {
19
+ type: string;
20
+ content?: string;
21
+ text?: string;
22
+ part?: OpenCodePart;
23
+ delta?: string;
24
+ error?: string;
25
+ message?: string;
26
+ result?: string;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ export class OpenCodeAdapter implements AgentAdapter {
31
+ readonly id = 'opencode';
32
+ readonly binary = 'opencode';
33
+ readonly tier = 2 as const;
34
+ readonly displayName = 'OpenCode';
35
+ readonly mode = 'oneshot' as const;
36
+
37
+ skills = [
38
+ { id: 'code', name: 'Code generation and editing', tags: ['code', 'edit', 'write'] },
39
+ { id: 'bash', name: 'Execute shell commands', tags: ['bash', 'shell', 'terminal'] },
40
+ { id: 'read', name: 'Read files', tags: ['read', 'file'] },
41
+ ];
42
+
43
+ buildArgs(_task: AgentTask, _config: PlumbConfig): string[] {
44
+ return ['run', '--format', 'json'];
45
+ }
46
+
47
+ formatInput(task: AgentTask): string {
48
+ // OpenCode reads one JSON line per stdin message with `prompt` (see fangai OpenCodeAdapter).
49
+ return JSON.stringify({ prompt: task.message }) + '\n';
50
+ }
51
+
52
+ parseLine(line: string): AdapterEvent[] {
53
+ if (!line.trim()) return [];
54
+
55
+ let event: OpenCodeEvent;
56
+ try {
57
+ event = JSON.parse(line);
58
+ } catch {
59
+ // Non-JSON line — treat as raw text
60
+ return [{ type: 'text-delta', text: line + '\n' }];
61
+ }
62
+
63
+ // Text content — OpenCode JSON uses part.text for assistant output
64
+ if (event.type === 'text' || event.type === 'content' || event.type === 'text-delta') {
65
+ const fromPart = typeof event.part?.text === 'string' ? event.part.text : '';
66
+ const text = fromPart || (event.text ?? event.content ?? event.delta ?? '');
67
+ if (text) return [{ type: 'text-delta', text }];
68
+ return [];
69
+ }
70
+
71
+ // Message part updated — streaming content
72
+ if (event.type === 'message.part.updated') {
73
+ const text = event.content ?? event.text ?? '';
74
+ if (text) return [{ type: 'text-delta', text }];
75
+ return [];
76
+ }
77
+
78
+ // End of agent step — primary completion signal for `opencode run --format json`
79
+ if (event.type === 'step_finish' && event.part?.reason === 'stop') {
80
+ return [{ type: 'status', state: 'completed' }];
81
+ }
82
+
83
+ // Session / run completed (alternate event names)
84
+ if (event.type === 'session.completed' || event.type === 'done' || event.type === 'complete') {
85
+ return [{ type: 'status', state: 'completed' }];
86
+ }
87
+
88
+ // Error events
89
+ if (event.type === 'error') {
90
+ return [{ type: 'error', message: event.error ?? event.message ?? 'Unknown error' }];
91
+ }
92
+
93
+ return [];
94
+ }
95
+
96
+ async detect(): Promise<DetectionResult | null> {
97
+ try {
98
+ const { stdout } = await execFileAsync('which', ['opencode'], { timeout: 5000 });
99
+ let version = 'unknown';
100
+ try {
101
+ const { stdout: vOut } = await execFileAsync('opencode', ['--version'], { timeout: 5000 });
102
+ version = vOut.trim().split('\n')[0] ?? 'unknown';
103
+ } catch {
104
+ // Version check failed
105
+ }
106
+ return {
107
+ binary: 'opencode',
108
+ version,
109
+ path: stdout.trim(),
110
+ tier: 2,
111
+ protocol: 'json-stream',
112
+ };
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,138 @@
1
+ // PLUMB — Pi Adapter
2
+ // Wraps `pi --mode rpc --print`. JSONL protocol. Highest value adapter.
3
+ // Extension UI requests are filtered. Text deltas and responses are captured.
4
+
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import type { AgentAdapter, AgentTask, AdapterEvent, DetectionResult, PlumbConfig } from '../types.ts';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ interface PiRpcEvent {
12
+ type: string;
13
+ text?: string;
14
+ content?: string;
15
+ success?: boolean;
16
+ error?: string;
17
+ delta?: string;
18
+ assistantMessageEvent?: { type?: string; delta?: string };
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ export class PiAdapter implements AgentAdapter {
23
+ readonly id = 'pi';
24
+ readonly binary = 'pi';
25
+ readonly tier = 1 as const;
26
+ readonly displayName = 'Pi';
27
+ readonly mode = 'persistent' as const;
28
+
29
+ skills = [
30
+ { id: 'code', name: 'Code generation and editing', tags: ['code', 'edit', 'write'] },
31
+ { id: 'bash', name: 'Execute shell commands', tags: ['bash', 'shell', 'terminal'] },
32
+ { id: 'read', name: 'Read files', tags: ['read', 'file'] },
33
+ ];
34
+
35
+ buildArgs(_task: AgentTask, _config: PlumbConfig): string[] {
36
+ return ['--mode', 'rpc', '--print', '--no-session'];
37
+ }
38
+
39
+ formatInput(task: AgentTask): string {
40
+ // Pi RPC mode expects JSONL input. Send prompt command.
41
+ const cmd = {
42
+ type: 'prompt',
43
+ message: task.message,
44
+ };
45
+ return JSON.stringify(cmd) + '\n';
46
+ }
47
+
48
+ parseLine(line: string): AdapterEvent[] {
49
+ if (!line.trim()) return [];
50
+
51
+ let event: PiRpcEvent;
52
+ try {
53
+ event = JSON.parse(line);
54
+ } catch {
55
+ // Non-JSON line — treat as raw text
56
+ return [{ type: 'text-delta', text: line + '\n' }];
57
+ }
58
+
59
+ // Filter extension UI requests — these are status updates, not task output
60
+ if (event.type === 'extension_ui_request') {
61
+ return [];
62
+ }
63
+
64
+ // Text delta events — streaming output
65
+ if (event.type === 'text-delta' || event.type === 'delta') {
66
+ const text = event.text ?? event.delta ?? event.content ?? '';
67
+ if (text) return [{ type: 'text-delta', text }];
68
+ return [];
69
+ }
70
+
71
+ // Pi message_update — streaming text from assistant
72
+ // Format: { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'text' } }
73
+ if (event.type === 'message_update') {
74
+ const ame = event.assistantMessageEvent as { type?: string; delta?: string } | undefined;
75
+ if (ame?.type === 'text_delta' && typeof ame.delta === 'string') {
76
+ return [{ type: 'text-delta', text: ame.delta }];
77
+ }
78
+ const text = event.text ?? event.delta ?? event.content ?? '';
79
+ if (text) return [{ type: 'text-delta', text }];
80
+ return [];
81
+ }
82
+
83
+ // Content block — full text
84
+ if (event.type === 'content' || event.type === 'text') {
85
+ const text = event.text ?? event.content ?? '';
86
+ if (text) return [{ type: 'text-delta', text }];
87
+ return [];
88
+ }
89
+
90
+ // Response event — task result
91
+ if (event.type === 'response') {
92
+ if (event.success === false && event.error) {
93
+ return [{ type: 'error', message: event.error }];
94
+ }
95
+ // Successful response with content
96
+ const text = event.text ?? event.content ?? '';
97
+ if (text) return [{ type: 'text-delta', text }];
98
+ return [];
99
+ }
100
+
101
+ // Error event
102
+ if (event.type === 'error') {
103
+ return [{ type: 'error', message: event.error ?? 'Unknown error' }];
104
+ }
105
+
106
+ // Done/complete signals (Pi uses agent_end and turn_end)
107
+ if (event.type === 'done' || event.type === 'complete' || event.type === 'finished' ||
108
+ event.type === 'agent_end' || event.type === 'turn_end') {
109
+ return [{ type: 'status', state: 'completed' }];
110
+ }
111
+
112
+ // Unknown event type — log but don't emit
113
+ return [];
114
+ }
115
+
116
+ async detect(): Promise<DetectionResult | null> {
117
+ try {
118
+ const { stdout } = await execFileAsync('which', ['pi'], { timeout: 5000 });
119
+ // Get version
120
+ let version = 'unknown';
121
+ try {
122
+ const { stdout: vOut } = await execFileAsync('pi', ['--version'], { timeout: 5000 });
123
+ version = vOut.trim().split('\n')[0] ?? 'unknown';
124
+ } catch {
125
+ // Version check failed, continue with unknown
126
+ }
127
+ return {
128
+ binary: 'pi',
129
+ version,
130
+ path: stdout.trim(),
131
+ tier: 1,
132
+ protocol: 'jsonl-rpc',
133
+ };
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,35 @@
1
+ // PLUMB — Adapter Registry
2
+ // detectAdapter: returns the first adapter whose binary matches the CLI command.
3
+ // EchoAdapter for 'cat'. GenericAdapter for everything else.
4
+ // Add real adapters here as they are built (Pi, Claude, Cursor...).
5
+
6
+ import { EchoAdapter } from './echo.ts';
7
+ import { PiAdapter } from './pi.ts';
8
+ import { ClaudeAdapter } from './claude.ts';
9
+ import { OpenCodeAdapter } from './opencode.ts';
10
+ import { GenericAdapter } from './generic.ts';
11
+ import type { AgentAdapter } from '../types.ts';
12
+
13
+ // Priority order: first match wins. GenericAdapter always last.
14
+ const KNOWN_ADAPTERS: AgentAdapter[] = [
15
+ new EchoAdapter(),
16
+ new PiAdapter(),
17
+ new ClaudeAdapter(),
18
+ new OpenCodeAdapter(),
19
+ ];
20
+
21
+ function escapeRegex(s: string): string {
22
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
23
+ }
24
+
25
+ export function detectAdapter(cli: string): AgentAdapter {
26
+ for (const adapter of KNOWN_ADAPTERS) {
27
+ if (!adapter.binary) continue;
28
+ // Match binary name at word boundaries — /usr/bin/cat matches 'cat', some-cat-wrapper does not.
29
+ const re = new RegExp('(?:^|[/\\s])' + escapeRegex(adapter.binary) + '(?:$|[/\\s])');
30
+ if (re.test(cli)) return adapter;
31
+ // Also match exact
32
+ if (cli.trim() === adapter.binary) return adapter;
33
+ }
34
+ return new GenericAdapter(cli);
35
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,85 @@
1
+ // PLUMB — CLI
2
+ // plumb wrap <cli> --port <n>
3
+ // That's the interface. Nothing else.
4
+
5
+ import { Command } from 'commander';
6
+ import express from 'express';
7
+ import { createPlumbServer } from './core/server.ts';
8
+ import { detectAdapter } from './adapters/registry.ts';
9
+
10
+ function log(level: string, msg: string, data?: Record<string, unknown>): void {
11
+ process.stderr.write(JSON.stringify({
12
+ ts: new Date().toISOString(),
13
+ l: level,
14
+ m: msg,
15
+ ...(data ?? {}),
16
+ }) + '\n');
17
+ }
18
+
19
+ const program = new Command()
20
+ .name('plumb')
21
+ .description('Quiet pipes for noisy agents. A2A bridge for any CLI coding agent.')
22
+ .version('0.1.0');
23
+
24
+ program
25
+ .command('wrap <cli>')
26
+ .description('Wrap a CLI agent as an A2A server')
27
+ .option('-p, --port <number>', 'Port to listen on', '3001')
28
+ .option('--name <name>', 'Agent name override')
29
+ .option('--workdir <dir>', 'Working directory for the CLI agent')
30
+ .option('--timeout <seconds>', 'Task timeout in seconds', '300')
31
+ .option('--key <apiKey>', 'Bearer token for /a2a endpoints')
32
+ .action((cli: string, opts: {
33
+ port: string;
34
+ name?: string;
35
+ workdir?: string;
36
+ timeout: string;
37
+ key?: string;
38
+ }) => {
39
+ const port = parseInt(opts.port, 10);
40
+ if (isNaN(port) || port < 1 || port > 65535) {
41
+ log('error', 'invalid_port', { port: opts.port });
42
+ process.exit(1);
43
+ }
44
+
45
+ const adapter = detectAdapter(cli);
46
+ log('info', 'adapter_detected', { cli, adapter: adapter.id, mode: adapter.mode, tier: adapter.tier });
47
+
48
+ const { executor, setupApp } = createPlumbServer({
49
+ adapter,
50
+ cli,
51
+ port,
52
+ name: opts.name,
53
+ workdir: opts.workdir,
54
+ taskTimeout: parseInt(opts.timeout, 10),
55
+ apiKey: opts.key,
56
+ });
57
+
58
+ const app = express();
59
+ setupApp(app);
60
+
61
+ const server = app.listen(port, () => {
62
+ log('info', 'plumb_listening', {
63
+ port,
64
+ adapter: adapter.id,
65
+ mode: adapter.mode,
66
+ endpoints: {
67
+ agentCard: `http://localhost:${port}/.well-known/agent-card.json`,
68
+ jsonrpc: `http://localhost:${port}/a2a/jsonrpc`,
69
+ rest: `http://localhost:${port}/a2a/rest`,
70
+ health: `http://localhost:${port}/health`,
71
+ },
72
+ });
73
+ });
74
+
75
+ const shutdown = async () => {
76
+ log('info', 'plumb_shutdown', {});
77
+ await executor.shutdown();
78
+ server.close(() => process.exit(0));
79
+ };
80
+
81
+ process.on('SIGINT', shutdown);
82
+ process.on('SIGTERM', shutdown);
83
+ });
84
+
85
+ export { program };