@commonlyai/cli 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/README.md +27 -0
- package/package.json +55 -0
- package/src/commands/agent.js +1264 -0
- package/src/commands/dev.js +653 -0
- package/src/commands/login.js +105 -0
- package/src/commands/pod.js +123 -0
- package/src/index.js +73 -0
- package/src/lib/adapters/claude.js +296 -0
- package/src/lib/adapters/codex.js +251 -0
- package/src/lib/adapters/index.js +22 -0
- package/src/lib/adapters/stub.js +24 -0
- package/src/lib/api.js +62 -0
- package/src/lib/config.js +138 -0
- package/src/lib/environment.js +326 -0
- package/src/lib/memory-bridge.js +54 -0
- package/src/lib/memory-import.js +156 -0
- package/src/lib/poller.js +89 -0
- package/src/lib/sandbox/bwrap.js +127 -0
- package/src/lib/session-store.js +56 -0
- package/src/lib/skills-import.js +112 -0
- package/src/lib/webhook-server.js +88 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commonly login [--instance <url>] [--key <name>]
|
|
3
|
+
*
|
|
4
|
+
* Authenticates and stores the token in ~/.commonly/config.json.
|
|
5
|
+
* Supports multiple named instances.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createInterface } from 'readline';
|
|
9
|
+
import { login as apiLogin } from '../lib/api.js';
|
|
10
|
+
import { saveInstance, LOCAL_URL } from '../lib/config.js';
|
|
11
|
+
|
|
12
|
+
const prompt = (rl, question) => new Promise((resolve) => rl.question(question, resolve));
|
|
13
|
+
|
|
14
|
+
const promptSecret = (question) => new Promise((resolve) => {
|
|
15
|
+
process.stdout.write(question);
|
|
16
|
+
const { stdin } = process;
|
|
17
|
+
stdin.setRawMode?.(true);
|
|
18
|
+
stdin.resume();
|
|
19
|
+
stdin.setEncoding('utf8');
|
|
20
|
+
|
|
21
|
+
let password = '';
|
|
22
|
+
const onData = (ch) => {
|
|
23
|
+
if (ch === '\n' || ch === '\r' || ch === '\u0003') {
|
|
24
|
+
stdin.setRawMode?.(false);
|
|
25
|
+
stdin.pause();
|
|
26
|
+
stdin.removeListener('data', onData);
|
|
27
|
+
process.stdout.write('\n');
|
|
28
|
+
resolve(password);
|
|
29
|
+
} else if (ch === '\u007f') {
|
|
30
|
+
password = password.slice(0, -1);
|
|
31
|
+
} else {
|
|
32
|
+
password += ch;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
stdin.on('data', onData);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const registerLogin = (program) => {
|
|
39
|
+
program
|
|
40
|
+
.command('login')
|
|
41
|
+
.description('Authenticate to a Commonly instance')
|
|
42
|
+
.option('--instance <url>', 'Instance URL (default: https://api.commonly.me)')
|
|
43
|
+
.option('--key <name>', 'Config key to save as (default: "default" or "local")')
|
|
44
|
+
.addHelpText('after', `
|
|
45
|
+
Examples:
|
|
46
|
+
$ commonly login # production (default key)
|
|
47
|
+
$ commonly login --instance https://api.commonly.me --key dev # named profile
|
|
48
|
+
$ commonly login --instance http://localhost:5000 # saved as "local"
|
|
49
|
+
|
|
50
|
+
Tokens are stored in ~/.commonly/config.json. Other commands take
|
|
51
|
+
--instance <url-or-key> to target the right profile.
|
|
52
|
+
`)
|
|
53
|
+
.action(async (opts) => {
|
|
54
|
+
const instanceUrl = opts.instance
|
|
55
|
+
? opts.instance.replace(/\/$/, '')
|
|
56
|
+
: 'https://api.commonly.me';
|
|
57
|
+
|
|
58
|
+
const isLocal = instanceUrl.includes('localhost') || instanceUrl.includes('127.0.0.1');
|
|
59
|
+
const configKey = opts.key || (isLocal ? 'local' : 'default');
|
|
60
|
+
|
|
61
|
+
console.log(`Logging in to ${instanceUrl}`);
|
|
62
|
+
|
|
63
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
64
|
+
const email = await prompt(rl, 'Email: ');
|
|
65
|
+
rl.close();
|
|
66
|
+
|
|
67
|
+
const password = await promptSecret('Password: ');
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const data = await apiLogin(instanceUrl, email.trim(), password);
|
|
71
|
+
const token = data.token;
|
|
72
|
+
const userId = data.user?._id || data.user?.id;
|
|
73
|
+
const username = data.user?.username;
|
|
74
|
+
|
|
75
|
+
saveInstance({ key: configKey, url: instanceUrl, token, userId, username });
|
|
76
|
+
|
|
77
|
+
console.log(`\nLogged in as ${username} (${configKey})`);
|
|
78
|
+
console.log(`Token saved to ~/.commonly/config.json`);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.error(`Login failed: ${err.message}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const registerWhoami = (program) => {
|
|
87
|
+
program
|
|
88
|
+
.command('whoami')
|
|
89
|
+
.description('Show current auth state')
|
|
90
|
+
.option('--instance <url>', 'Target instance')
|
|
91
|
+
.action(async (opts) => {
|
|
92
|
+
const { getActiveInstance, listInstances } = await import('../lib/config.js');
|
|
93
|
+
const instances = listInstances();
|
|
94
|
+
|
|
95
|
+
if (instances.length === 0) {
|
|
96
|
+
console.log('Not logged in. Run: commonly login');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
instances.forEach(({ key, url, username, active, savedAt }) => {
|
|
101
|
+
const marker = active ? '→' : ' ';
|
|
102
|
+
console.log(`${marker} ${key} ${username || '?'}@${url} (saved ${new Date(savedAt).toLocaleDateString()})`);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commonly pod <subcommand>
|
|
3
|
+
*
|
|
4
|
+
* list — list pods you're a member of
|
|
5
|
+
* send — post a message to a pod
|
|
6
|
+
* tail — watch a pod's messages live
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createClient } from '../lib/api.js';
|
|
10
|
+
import { getToken, resolveInstanceUrl } from '../lib/config.js';
|
|
11
|
+
|
|
12
|
+
export const registerPod = (program) => {
|
|
13
|
+
const pod = program.command('pod').description('Manage pods');
|
|
14
|
+
|
|
15
|
+
pod.addHelpText('after', `
|
|
16
|
+
Examples:
|
|
17
|
+
$ commonly pod list
|
|
18
|
+
$ commonly pod send <podId> "hello from the CLI"
|
|
19
|
+
$ commonly pod tail <podId>
|
|
20
|
+
|
|
21
|
+
Each command accepts --instance <url-or-key> to target a non-default
|
|
22
|
+
Commonly instance (see: commonly login --help).
|
|
23
|
+
`);
|
|
24
|
+
|
|
25
|
+
// ── list ──────────────────────────────────────────────────────────────────
|
|
26
|
+
pod
|
|
27
|
+
.command('list')
|
|
28
|
+
.description('List pods you are a member of')
|
|
29
|
+
.option('--instance <url>', 'Target Commonly instance')
|
|
30
|
+
.action(async (opts) => {
|
|
31
|
+
const token = getToken(opts.instance);
|
|
32
|
+
if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
|
|
33
|
+
|
|
34
|
+
const client = createClient({ instance: resolveInstanceUrl(opts.instance), token });
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const data = await client.get('/api/pods');
|
|
38
|
+
const pods = Array.isArray(data) ? data : data.pods || [];
|
|
39
|
+
|
|
40
|
+
if (pods.length === 0) { console.log('No pods found.'); return; }
|
|
41
|
+
|
|
42
|
+
const col = (s, w) => String(s ?? '').padEnd(w).slice(0, w);
|
|
43
|
+
console.log(`${col('NAME', 24)} ${col('TYPE', 12)} ${col('MEMBERS', 8)} ID`);
|
|
44
|
+
console.log('─'.repeat(70));
|
|
45
|
+
pods.forEach((p) => {
|
|
46
|
+
console.log(`${col(p.name, 24)} ${col(p.type || 'chat', 12)} ${col(p.memberCount ?? p.members?.length ?? '?', 8)} ${p._id}`);
|
|
47
|
+
});
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(`Failed: ${err.message}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── send ──────────────────────────────────────────────────────────────────
|
|
55
|
+
pod
|
|
56
|
+
.command('send <podId> <message>')
|
|
57
|
+
.description('Post a message to a pod')
|
|
58
|
+
.option('--instance <url>', 'Target Commonly instance')
|
|
59
|
+
.action(async (podId, message, opts) => {
|
|
60
|
+
const token = getToken(opts.instance);
|
|
61
|
+
if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
|
|
62
|
+
|
|
63
|
+
const client = createClient({ instance: resolveInstanceUrl(opts.instance), token });
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
// Try agent runtime endpoint first (agent token), fall back to user endpoint
|
|
67
|
+
const result = await client.post(`/api/agents/runtime/pods/${podId}/messages`, {
|
|
68
|
+
content: message,
|
|
69
|
+
}).catch(() => client.post(`/api/messages/${podId}`, {
|
|
70
|
+
content: message,
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
console.log(`Sent (${result._id || result.id || 'ok'})`);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(`Failed: ${err.message}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── tail ──────────────────────────────────────────────────────────────────
|
|
81
|
+
pod
|
|
82
|
+
.command('tail <podId>')
|
|
83
|
+
.description('Watch pod messages live')
|
|
84
|
+
.option('--filter <type>', 'Filter by sender type: agents, humans, all', 'all')
|
|
85
|
+
.option('--instance <url>', 'Target Commonly instance')
|
|
86
|
+
.action(async (podId, opts) => {
|
|
87
|
+
const token = getToken(opts.instance);
|
|
88
|
+
if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
|
|
89
|
+
|
|
90
|
+
const client = createClient({ instance: resolveInstanceUrl(opts.instance), token });
|
|
91
|
+
|
|
92
|
+
let lastId = null;
|
|
93
|
+
console.log(`Watching pod ${podId}... (Ctrl+C to stop)\n`);
|
|
94
|
+
|
|
95
|
+
const poll = async () => {
|
|
96
|
+
try {
|
|
97
|
+
const data = await client.get(`/api/messages/${podId}`, {
|
|
98
|
+
limit: 20,
|
|
99
|
+
after: lastId,
|
|
100
|
+
});
|
|
101
|
+
const messages = Array.isArray(data) ? data : data.messages || [];
|
|
102
|
+
|
|
103
|
+
messages.forEach((m) => {
|
|
104
|
+
const isBot = m.isBot || m.sender?.isBot;
|
|
105
|
+
if (opts.filter === 'agents' && !isBot) return;
|
|
106
|
+
if (opts.filter === 'humans' && isBot) return;
|
|
107
|
+
|
|
108
|
+
const ts = new Date(m.createdAt || m.timestamp).toTimeString().slice(0, 8);
|
|
109
|
+
const who = m.username || m.sender?.username || '?';
|
|
110
|
+
const marker = isBot ? '🤖' : '👤';
|
|
111
|
+
console.log(`[${ts}] ${marker} ${who}: ${m.content}`);
|
|
112
|
+
lastId = m._id || m.id;
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(`[poll error] ${err.message}`);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
await poll();
|
|
120
|
+
const interval = setInterval(poll, 3000);
|
|
121
|
+
process.on('SIGINT', () => { clearInterval(interval); process.exit(0); });
|
|
122
|
+
});
|
|
123
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @commonlyai/cli — the developer interface to CAP.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* commonly login
|
|
7
|
+
* commonly agent connect --name my-agent --port 3001
|
|
8
|
+
* commonly pod send <podId> "hello"
|
|
9
|
+
* commonly dev up
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Command } from 'commander';
|
|
13
|
+
import { readFileSync } from 'fs';
|
|
14
|
+
import { join, dirname } from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
|
|
17
|
+
import { registerLogin, registerWhoami } from './commands/login.js';
|
|
18
|
+
import { registerAgent } from './commands/agent.js';
|
|
19
|
+
import { registerPod } from './commands/pod.js';
|
|
20
|
+
import { registerDev } from './commands/dev.js';
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
|
|
24
|
+
|
|
25
|
+
const program = new Command();
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.name('commonly')
|
|
29
|
+
.description('The Commonly CLI — connect agents, manage pods, iterate fast')
|
|
30
|
+
.version(pkg.version)
|
|
31
|
+
.showHelpAfterError('(run `commonly --help` for usage)');
|
|
32
|
+
|
|
33
|
+
// Auth
|
|
34
|
+
registerLogin(program);
|
|
35
|
+
registerWhoami(program);
|
|
36
|
+
|
|
37
|
+
// Agent management
|
|
38
|
+
registerAgent(program);
|
|
39
|
+
|
|
40
|
+
// Pod management
|
|
41
|
+
registerPod(program);
|
|
42
|
+
|
|
43
|
+
// Local dev environment
|
|
44
|
+
registerDev(program);
|
|
45
|
+
|
|
46
|
+
// Each subcommand owns its own `--instance <url>` flag; a program-level
|
|
47
|
+
// duplicate shadowed the subcommand value on commander v12, causing
|
|
48
|
+
// `commonly login --instance …` to silently fall back to the default URL.
|
|
49
|
+
|
|
50
|
+
program.addHelpText('after', `
|
|
51
|
+
Quick start:
|
|
52
|
+
$ commonly login --instance https://api.commonly.me --key dev
|
|
53
|
+
$ commonly agent attach claude --pod <podId> --name my-claude
|
|
54
|
+
$ commonly agent run my-claude # Ctrl+C to stop
|
|
55
|
+
$ commonly agent detach my-claude # clean uninstall
|
|
56
|
+
|
|
57
|
+
Custom Python agent:
|
|
58
|
+
$ commonly agent init --language python --name research-bot --pod <podId>
|
|
59
|
+
$ python3 research-bot.py
|
|
60
|
+
|
|
61
|
+
Subcommand help:
|
|
62
|
+
$ commonly login --help
|
|
63
|
+
$ commonly agent attach --help
|
|
64
|
+
$ commonly agent run --help
|
|
65
|
+
$ commonly pod tail --help
|
|
66
|
+
|
|
67
|
+
Note: --instance is a subcommand option, not a program option. It accepts
|
|
68
|
+
either a saved key name ("dev", "local", "default") or a full URL.
|
|
69
|
+
|
|
70
|
+
Docs: https://github.com/Team-Commonly/commonly/blob/main/docs/cli/README.md
|
|
71
|
+
`);
|
|
72
|
+
|
|
73
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude adapter — wraps the local `claude` CLI as a Commonly agent.
|
|
3
|
+
*
|
|
4
|
+
* Contract: ADR-005 §Adapter pattern.
|
|
5
|
+
*
|
|
6
|
+
* Memory preamble: if ctx.memoryLongTerm is non-empty, the adapter prepends
|
|
7
|
+
* it to the prompt as a system-context preamble (§Memory bridge).
|
|
8
|
+
*
|
|
9
|
+
* Environment (ADR-008 Phase 1): if ctx.environment is present, the adapter
|
|
10
|
+
* symlinks declared Claude skills into `<cwd>/.claude/skills/`, writes an MCP
|
|
11
|
+
* config file at `<cwd>/.commonly/mcp-config.json` when `mcp` is declared,
|
|
12
|
+
* and wraps the argv with bwrap when `sandbox.mode === 'bwrap'`. The spawn
|
|
13
|
+
* binary becomes `bwrap` in that case — claude moves to the inner argv.
|
|
14
|
+
* When ctx.environment is absent, behaviour is identical to pre-ADR-008.
|
|
15
|
+
*
|
|
16
|
+
* Session continuity (IMPORTANT — the two claude flags are not interchangeable):
|
|
17
|
+
* - First turn (no persisted id): mint a UUID and pass `--session-id <uuid>`.
|
|
18
|
+
* claude treats `--session-id` as "CREATE a session with this exact UUID"
|
|
19
|
+
* and rejects with "Session ID ... is already in use" if the UUID was
|
|
20
|
+
* already used.
|
|
21
|
+
* - Subsequent turns (persisted id present): pass `--resume <uuid>` instead.
|
|
22
|
+
* `--resume` means "continue this existing session."
|
|
23
|
+
* The wrapper persists the UUID so the SAME id is used across turns — this
|
|
24
|
+
* adapter just picks the right flag for it.
|
|
25
|
+
*
|
|
26
|
+
* Purity (§Load-bearing invariants #1): input = argv + env + prompt;
|
|
27
|
+
* output = text + session id. No direct network, no direct CAP calls.
|
|
28
|
+
*
|
|
29
|
+
* Test seam: `ctx._spawnImpl` is the sanctioned way for any adapter in this
|
|
30
|
+
* codebase to swap `child_process.spawn` out for a mock. Unit tests pass a
|
|
31
|
+
* fake that returns an EventEmitter; production never sets `_spawnImpl` so
|
|
32
|
+
* the default `childSpawn` is used. Future adapters should follow the same
|
|
33
|
+
* convention rather than inventing their own seam.
|
|
34
|
+
*
|
|
35
|
+
* Timeout caveat: we SIGTERM at `timeoutMs` and wait for `close`. If claude
|
|
36
|
+
* ignores SIGTERM the promise never resolves — the run loop stalls silently.
|
|
37
|
+
* SIGKILL escalation is a post-v1 concern (ADR-005 §Spawning semantics).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
41
|
+
import { randomUUID } from 'crypto';
|
|
42
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
43
|
+
import { join } from 'path';
|
|
44
|
+
|
|
45
|
+
import { linkSkills } from '../environment.js';
|
|
46
|
+
import { wrapArgvWithBwrap } from '../sandbox/bwrap.js';
|
|
47
|
+
|
|
48
|
+
// See codex.js for the rationale on bumping the default + env override.
|
|
49
|
+
// Keeping both adapters in lockstep so any wrapper agent runtime has the
|
|
50
|
+
// same timeout posture regardless of which CLI is underneath.
|
|
51
|
+
const DEFAULT_TIMEOUT_MS = (() => {
|
|
52
|
+
const fallback = 15 * 60 * 1000;
|
|
53
|
+
const raw = process.env.COMMONLY_AGENT_RUN_TIMEOUT_MS;
|
|
54
|
+
if (!raw) return fallback;
|
|
55
|
+
const parsed = Number(raw);
|
|
56
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
const buildPrompt = (prompt, memoryLongTerm) => {
|
|
60
|
+
if (!memoryLongTerm) return prompt;
|
|
61
|
+
return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
65
|
+
const proc = spawnImpl(cmd, args, { cwd, env });
|
|
66
|
+
let stdout = '';
|
|
67
|
+
let stderr = '';
|
|
68
|
+
let timedOut = false;
|
|
69
|
+
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
timedOut = true;
|
|
72
|
+
proc.kill('SIGTERM');
|
|
73
|
+
}, timeoutMs);
|
|
74
|
+
|
|
75
|
+
proc.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
76
|
+
proc.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
77
|
+
proc.on('error', (err) => {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
reject(err);
|
|
80
|
+
});
|
|
81
|
+
proc.on('close', (code) => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
if (timedOut) return reject(new Error(`claude timed out after ${timeoutMs}ms`));
|
|
84
|
+
if (code !== 0) return reject(new Error(`claude exited with code ${code}: ${stderr.trim()}`));
|
|
85
|
+
resolve(stdout);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── MCP config write — claude consumes this via --mcp-config <path> ─────────
|
|
90
|
+
|
|
91
|
+
// Substitute Commonly-supplied placeholders in MCP env values, command args,
|
|
92
|
+
// and URLs so users don't have to hand-paste secrets into their env file
|
|
93
|
+
// every time they re-attach. Surfaced during the 2026-04-17 cross-agent demo:
|
|
94
|
+
// every spec referencing commonly-mcp had to be rewritten with the agent's
|
|
95
|
+
// runtime token after attach, because the token is minted at attach time and
|
|
96
|
+
// only known to the wrapper.
|
|
97
|
+
//
|
|
98
|
+
// Recognised placeholders (substituted everywhere a string appears in the
|
|
99
|
+
// MCP config):
|
|
100
|
+
// ${COMMONLY_AGENT_TOKEN} — the per-(agent, pod) cm_agent_* runtime token
|
|
101
|
+
// ${COMMONLY_API_URL} — the instance URL the agent is attached to
|
|
102
|
+
// ${COMMONLY_INSTANCE_URL} — alias for COMMONLY_API_URL (clearer in context)
|
|
103
|
+
//
|
|
104
|
+
// Substitution is one-pass + literal — no nested expansion, no shell quoting.
|
|
105
|
+
// Unknown placeholders are left intact so the user sees a clear runtime error
|
|
106
|
+
// from the MCP server rather than a silent empty string.
|
|
107
|
+
const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
|
|
108
|
+
const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
|
|
109
|
+
|
|
110
|
+
const substitutePlaceholders = (value, ctx) => {
|
|
111
|
+
if (typeof value !== 'string') return value;
|
|
112
|
+
if (!value.includes('${COMMONLY_')) return value;
|
|
113
|
+
const subs = {
|
|
114
|
+
COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
|
|
115
|
+
COMMONLY_API_URL: ctx.instanceUrl || '',
|
|
116
|
+
COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
|
|
117
|
+
};
|
|
118
|
+
return value.replace(PLACEHOLDER_RE, (whole, key) => (
|
|
119
|
+
SUBSTITUTION_KEYS.includes(key) && subs[key] ? subs[key] : whole
|
|
120
|
+
));
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const buildMcpConfig = (mcpServers, ctx = {}) => {
|
|
124
|
+
// Shape: `{ mcpServers: { <name>: { ... } } }` — the standard MCP client
|
|
125
|
+
// config, which claude's `--mcp-config` reads directly.
|
|
126
|
+
const mcpServersMap = {};
|
|
127
|
+
for (const server of mcpServers) {
|
|
128
|
+
const entry = { type: server.transport || 'stdio' };
|
|
129
|
+
if (server.url) entry.url = substitutePlaceholders(server.url, ctx);
|
|
130
|
+
if (server.command) {
|
|
131
|
+
const [command, ...args] = server.command;
|
|
132
|
+
entry.command = command;
|
|
133
|
+
if (args.length) entry.args = args.map((a) => substitutePlaceholders(a, ctx));
|
|
134
|
+
}
|
|
135
|
+
if (server.env) {
|
|
136
|
+
entry.env = {};
|
|
137
|
+
for (const [k, v] of Object.entries(server.env)) {
|
|
138
|
+
entry.env[k] = substitutePlaceholders(v, ctx);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
mcpServersMap[server.name] = entry;
|
|
142
|
+
}
|
|
143
|
+
return { mcpServers: mcpServersMap };
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Regenerated on every spawn from the env spec; do not hand-edit — the file
|
|
147
|
+
// is overwritten before each `claude` invocation, so any local changes are
|
|
148
|
+
// silently clobbered. ADR-008 §invariant #5 (edits propagate on next spawn).
|
|
149
|
+
const writeMcpConfig = async (cwd, mcpServers, ctx = {}) => {
|
|
150
|
+
const dir = join(cwd, '.commonly');
|
|
151
|
+
await mkdir(dir, { recursive: true });
|
|
152
|
+
const file = join(dir, 'mcp-config.json');
|
|
153
|
+
await writeFile(file, JSON.stringify(buildMcpConfig(mcpServers, ctx), null, 2), 'utf8');
|
|
154
|
+
return file;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ── argv preparation — environment-aware ────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
// Resolve the absolute path of `claude` so bwrap's execvp doesn't depend on
|
|
160
|
+
// PATH being correctly populated inside the sandbox namespace. Surfaced live
|
|
161
|
+
// during the 2026-04-17 demo validation: bwrap silently inherits parent
|
|
162
|
+
// PATH but cannot reach the user's `~/.local/bin` without an absolute path
|
|
163
|
+
// argv[0], even when that directory is bound read-only into the sandbox.
|
|
164
|
+
const resolveClaudePath = () => {
|
|
165
|
+
// Defensive: spawnSync can return undefined under aggressive mocks (the
|
|
166
|
+
// adapters.claude.environment.test.mjs suite stubs child_process so no real
|
|
167
|
+
// process runs). Treat any failure mode as "use the bare command name."
|
|
168
|
+
let which;
|
|
169
|
+
try { which = spawnSync('which', ['claude'], { encoding: 'utf8' }); } catch { /* ignore */ }
|
|
170
|
+
if (which && which.status === 0) {
|
|
171
|
+
const p = (which.stdout || '').trim();
|
|
172
|
+
if (p) return p;
|
|
173
|
+
}
|
|
174
|
+
return 'claude';
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const prepareArgv = async (innerArgv, ctx) => {
|
|
178
|
+
const env = ctx.environment;
|
|
179
|
+
if (!env) return { cmd: 'claude', args: innerArgv };
|
|
180
|
+
|
|
181
|
+
if (Array.isArray(env.mcp) && env.mcp.length > 0 && ctx.cwd) {
|
|
182
|
+
const configPath = await writeMcpConfig(ctx.cwd, env.mcp, {
|
|
183
|
+
runtimeToken: ctx.runtimeToken,
|
|
184
|
+
instanceUrl: ctx.instanceUrl,
|
|
185
|
+
});
|
|
186
|
+
// Insert --mcp-config immediately after the subcommand-style `-p` block
|
|
187
|
+
// so claude parses it before prompt collection begins.
|
|
188
|
+
innerArgv = [...innerArgv, '--mcp-config', configPath];
|
|
189
|
+
// Pre-approve every MCP tool from the declared servers (`mcp__<name>__*`).
|
|
190
|
+
// Without this claude runs in non-interactive `-p` mode and asks for
|
|
191
|
+
// permission before invoking any MCP tool — the wrapper has no way to
|
|
192
|
+
// approve, so the model just narrates the request and bails. The user
|
|
193
|
+
// already opted into these servers by declaring them in the env spec, so
|
|
194
|
+
// auto-allowing the corresponding tool prefix is the honest default.
|
|
195
|
+
// Surfaced live during the 2026-04-17 cross-agent demo validation.
|
|
196
|
+
const allowedPatterns = env.mcp
|
|
197
|
+
.map((server) => server && server.name)
|
|
198
|
+
.filter(Boolean)
|
|
199
|
+
.map((name) => `mcp__${name}__*`);
|
|
200
|
+
if (allowedPatterns.length > 0) {
|
|
201
|
+
innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const sandboxMode = env.sandbox?.mode;
|
|
206
|
+
if (sandboxMode === 'bwrap') {
|
|
207
|
+
const claudeBin = resolveClaudePath();
|
|
208
|
+
const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
|
|
209
|
+
workspacePath: ctx.cwd,
|
|
210
|
+
});
|
|
211
|
+
return { cmd: wrapped[0], args: wrapped.slice(1) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { cmd: 'claude', args: innerArgv };
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export default {
|
|
218
|
+
name: 'claude',
|
|
219
|
+
// Identity-bearing runtime tag written into AgentInstallation.config.runtime
|
|
220
|
+
// by `commonly agent attach`. The CLI command stays `commonly agent attach
|
|
221
|
+
// claude` (ergonomic) but the persisted runtimeType matches AGENT_TYPES so
|
|
222
|
+
// the inspector + API can classify it as the same thing as cloud Claude
|
|
223
|
+
// Code. Pair with `host: 'byo'` (set in agent.js) to disambiguate from the
|
|
224
|
+
// hosted variant.
|
|
225
|
+
runtimeType: 'claude-code',
|
|
226
|
+
|
|
227
|
+
async detect() {
|
|
228
|
+
try {
|
|
229
|
+
const res = spawnSync('claude', ['--version'], { encoding: 'utf8' });
|
|
230
|
+
if (res.error || res.status !== 0) return null;
|
|
231
|
+
// `claude --version` prints e.g. "2.5.1 (Claude Code)" — first token is enough
|
|
232
|
+
const version = (res.stdout || '').trim().split(/\s+/)[0] || 'unknown';
|
|
233
|
+
// Best-effort resolve of the binary path for clearer UX ("claude detected
|
|
234
|
+
// at /usr/local/bin/claude"). Falls back to the bare command name on
|
|
235
|
+
// platforms without `which` (e.g. Windows).
|
|
236
|
+
const where = spawnSync('which', ['claude'], { encoding: 'utf8' });
|
|
237
|
+
const path = where.status === 0 ? (where.stdout || '').trim() || 'claude' : 'claude';
|
|
238
|
+
return { path, version };
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
async spawn(prompt, ctx = {}) {
|
|
245
|
+
const isResume = !!ctx.sessionId;
|
|
246
|
+
const sessionId = ctx.sessionId || randomUUID();
|
|
247
|
+
const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm || '');
|
|
248
|
+
const sessionFlag = isResume ? '--resume' : '--session-id';
|
|
249
|
+
const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId];
|
|
250
|
+
|
|
251
|
+
if (ctx.environment && ctx.cwd) {
|
|
252
|
+
const skills = await linkSkills(ctx.environment, ctx.cwd);
|
|
253
|
+
if (skills.conflicted.length > 0) {
|
|
254
|
+
for (const c of skills.conflicted) {
|
|
255
|
+
// eslint-disable-next-line no-console
|
|
256
|
+
console.warn(`[claude] skill not linked (${c.reason}): ${c.path}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (ctx.onSkillsLinked) ctx.onSkillsLinked(skills);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const { cmd, args } = await prepareArgv(baseArgs, ctx);
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const stdout = await runClaude({
|
|
266
|
+
cmd,
|
|
267
|
+
args,
|
|
268
|
+
cwd: ctx.cwd,
|
|
269
|
+
env: ctx.env,
|
|
270
|
+
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
271
|
+
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
272
|
+
});
|
|
273
|
+
return { text: stdout.trim(), newSessionId: sessionId };
|
|
274
|
+
} catch (err) {
|
|
275
|
+
// Self-heal against a corrupted session store. If the persisted id is
|
|
276
|
+
// already in use (or claude has forgotten about it), discard it and
|
|
277
|
+
// restart the turn with a fresh UUID. Without this, a single bad
|
|
278
|
+
// session id poisons every subsequent event re-delivery.
|
|
279
|
+
if (isResume && /already in use|no conversation|no session/i.test(String(err.message))) {
|
|
280
|
+
const freshId = randomUUID();
|
|
281
|
+
const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId];
|
|
282
|
+
const retry = await prepareArgv(retryBase, ctx);
|
|
283
|
+
const stdout = await runClaude({
|
|
284
|
+
cmd: retry.cmd,
|
|
285
|
+
args: retry.args,
|
|
286
|
+
cwd: ctx.cwd,
|
|
287
|
+
env: ctx.env,
|
|
288
|
+
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
289
|
+
spawnImpl: ctx._spawnImpl,
|
|
290
|
+
});
|
|
291
|
+
return { text: stdout.trim(), newSessionId: freshId };
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
};
|