@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,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bubblewrap sandbox adapter — ADR-008 Phase 1.
|
|
3
|
+
*
|
|
4
|
+
* Wraps an inner adapter argv (e.g. claude -p ...) in a bwrap invocation that
|
|
5
|
+
* pins the spawn into a workspace, isolates the network namespace, and
|
|
6
|
+
* read-only-binds a small allowlist of host paths the adapter needs to start
|
|
7
|
+
* (TLS certs, libc, the binary itself).
|
|
8
|
+
*
|
|
9
|
+
* Linux-only by design: bwrap is a setuid Linux helper. macOS callers get a
|
|
10
|
+
* loud, specific error at attach time so the failure shape never reaches run.
|
|
11
|
+
*
|
|
12
|
+
* Detection idiom mirrors `claude.js#detect`: spawnSync with --version, treat
|
|
13
|
+
* non-zero or spawn error as "not available."
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'child_process';
|
|
17
|
+
import { isAbsolute, join } from 'path';
|
|
18
|
+
import { homedir } from 'os';
|
|
19
|
+
|
|
20
|
+
// Tilde-expansion lives here (and not in environment.js's expandHome) because
|
|
21
|
+
// bwrap reads OS-level paths verbatim — `~` is a shell construct, not a
|
|
22
|
+
// kernel one, so any `~/foo` entry in `read-outside` / `write-outside` would
|
|
23
|
+
// be passed literally to `--ro-bind-try` and silently skipped (the `-try`
|
|
24
|
+
// variant tolerates missing paths). Surfaced live during the 2026-04-17
|
|
25
|
+
// demo validation: `~/.local/bin` was declared but `claude` still wasn't
|
|
26
|
+
// reachable inside the sandbox.
|
|
27
|
+
const expandHome = (p) => {
|
|
28
|
+
if (!p || typeof p !== 'string') return p;
|
|
29
|
+
if (p === '~') return homedir();
|
|
30
|
+
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
|
|
31
|
+
return p;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const MACOS_MESSAGE = 'bwrap is Linux-only; use sandbox.mode: none in Phase 1 or wait for sandbox-exec adapter';
|
|
35
|
+
|
|
36
|
+
const DEFAULT_RO_BINDS = [
|
|
37
|
+
'/etc/ssl/certs',
|
|
38
|
+
'/etc/resolv.conf',
|
|
39
|
+
'/usr/lib',
|
|
40
|
+
'/usr/lib64',
|
|
41
|
+
'/usr/bin',
|
|
42
|
+
'/bin',
|
|
43
|
+
'/lib',
|
|
44
|
+
'/lib64',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export const detectBwrap = () => {
|
|
48
|
+
if (process.platform !== 'linux') {
|
|
49
|
+
return { available: false, error: MACOS_MESSAGE };
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const res = spawnSync('bwrap', ['--version'], { encoding: 'utf8' });
|
|
53
|
+
if (res.error || res.status !== 0) {
|
|
54
|
+
return {
|
|
55
|
+
available: false,
|
|
56
|
+
error: 'bwrap not found on PATH. Install bubblewrap: `apt install bubblewrap` (Debian/Ubuntu) or `dnf install bubblewrap` (Fedora).',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { available: true, path: 'bwrap' };
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return { available: false, error: `bwrap detection failed: ${err.message}` };
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the bwrap-wrapped argv. Caller spawns argv[0] with argv.slice(1).
|
|
67
|
+
*
|
|
68
|
+
* Network policy honesty (ADR-008 §invariant #4 informs this):
|
|
69
|
+
* - unrestricted → --share-net (host networking inside the namespace)
|
|
70
|
+
* - restricted → --unshare-net (NO network at all). Host-allowlist
|
|
71
|
+
* filtering needs an outbound proxy or per-host TLS interception, neither
|
|
72
|
+
* of which is in Phase 1 scope. Attach-time emits a loud warning so the
|
|
73
|
+
* user sees the gap before they think they're protected.
|
|
74
|
+
*
|
|
75
|
+
* The expansion order matches `bwrap`'s left-to-right argument application:
|
|
76
|
+
* isolation flags first, then binds (later binds win on collision), then
|
|
77
|
+
* --setenv, then `--` and the inner argv.
|
|
78
|
+
*/
|
|
79
|
+
export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => {
|
|
80
|
+
if (process.platform !== 'linux') {
|
|
81
|
+
throw new Error(MACOS_MESSAGE);
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(innerArgv) || innerArgv.length === 0) {
|
|
84
|
+
throw new Error('wrapArgvWithBwrap requires a non-empty innerArgv');
|
|
85
|
+
}
|
|
86
|
+
if (!opts.workspacePath || !isAbsolute(opts.workspacePath)) {
|
|
87
|
+
throw new Error('wrapArgvWithBwrap requires opts.workspacePath as an absolute path');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const policy = env?.sandbox?.network?.policy || 'unrestricted';
|
|
91
|
+
const networkFlags = policy === 'restricted'
|
|
92
|
+
? ['--unshare-net']
|
|
93
|
+
: ['--share-net'];
|
|
94
|
+
|
|
95
|
+
const flags = [
|
|
96
|
+
'--unshare-all',
|
|
97
|
+
...networkFlags,
|
|
98
|
+
'--die-with-parent',
|
|
99
|
+
'--new-session',
|
|
100
|
+
'--proc', '/proc',
|
|
101
|
+
'--dev', '/dev',
|
|
102
|
+
'--tmpfs', '/tmp',
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const userReadOutside = Array.isArray(env?.sandbox?.filesystem?.['read-outside'])
|
|
106
|
+
? env.sandbox.filesystem['read-outside'].map(expandHome)
|
|
107
|
+
: [];
|
|
108
|
+
const roBinds = [...new Set([...DEFAULT_RO_BINDS, ...userReadOutside])];
|
|
109
|
+
for (const p of roBinds) {
|
|
110
|
+
flags.push('--ro-bind-try', p, p);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const userWriteOutside = Array.isArray(env?.sandbox?.filesystem?.['write-outside'])
|
|
114
|
+
? env.sandbox.filesystem['write-outside'].map(expandHome)
|
|
115
|
+
: [];
|
|
116
|
+
for (const p of userWriteOutside) {
|
|
117
|
+
flags.push('--bind-try', p, p);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
flags.push('--bind', opts.workspacePath, opts.workspacePath);
|
|
121
|
+
flags.push('--chdir', opts.workspacePath);
|
|
122
|
+
flags.push('--setenv', 'HOME', opts.workspacePath);
|
|
123
|
+
|
|
124
|
+
return ['bwrap', ...flags, '--', ...innerArgv];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const BWRAP_MACOS_MESSAGE = MACOS_MESSAGE;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session store — per-(agent, pod) session IDs at ~/.commonly/sessions/<agent>.json.
|
|
3
|
+
*
|
|
4
|
+
* Used by the local-CLI wrapper driver (ADR-005). Wrapped CLIs that support
|
|
5
|
+
* conversation session IDs (claude --session-id, codex --session, …) call
|
|
6
|
+
* getSession / setSession around each spawn so the next turn continues where
|
|
7
|
+
* the previous one left off.
|
|
8
|
+
*
|
|
9
|
+
* One file per agent so two `commonly agent run` processes for different
|
|
10
|
+
* agents never race on the same file (ADR-005 §Spawning semantics permits
|
|
11
|
+
* parallel agents). Shape on disk:
|
|
12
|
+
* {
|
|
13
|
+
* "<podId>": { "sessionId": "abc123", "lastTurn": "2026-04-14T18:00:00Z" }
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* CLIs without sessions simply never call setSession — getSession returns null.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'fs';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
import { homedir } from 'os';
|
|
22
|
+
|
|
23
|
+
const sessionsDir = () => join(homedir(), '.commonly', 'sessions');
|
|
24
|
+
const sessionsFile = (agentName) => join(sessionsDir(), `${agentName}.json`);
|
|
25
|
+
|
|
26
|
+
const readAgent = (agentName) => {
|
|
27
|
+
const file = sessionsFile(agentName);
|
|
28
|
+
if (!existsSync(file)) return {};
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const writeAgent = (agentName, state) => {
|
|
37
|
+
if (!existsSync(sessionsDir())) mkdirSync(sessionsDir(), { recursive: true });
|
|
38
|
+
writeFileSync(sessionsFile(agentName), JSON.stringify(state, null, 2), 'utf8');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const getSession = (agentName, podId) => {
|
|
42
|
+
if (!agentName || !podId) return null;
|
|
43
|
+
return readAgent(agentName)[podId]?.sessionId || null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const setSession = (agentName, podId, sessionId) => {
|
|
47
|
+
if (!agentName || !podId) return;
|
|
48
|
+
const state = readAgent(agentName);
|
|
49
|
+
state[podId] = { sessionId, lastTurn: new Date().toISOString() };
|
|
50
|
+
writeAgent(agentName, state);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const clearSessions = (agentName) => {
|
|
54
|
+
const file = sessionsFile(agentName);
|
|
55
|
+
if (existsSync(file)) rmSync(file);
|
|
56
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local skills import — the metadata half of "your agent arrives whole"
|
|
3
|
+
* (retention plan Phase C, D3). Scans a local skills directory
|
|
4
|
+
* (<cwd>/.claude/skills or ~/.claude/skills) for SKILL.md files, parses
|
|
5
|
+
* NAME + DESCRIPTION out of the frontmatter, and registers agent-scoped
|
|
6
|
+
* skill entries so the agent's profile shows what it can do.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately metadata-only (plan D3): skill *content* stays on the
|
|
9
|
+
* user's machine — local agents execute their skills locally, Commonly
|
|
10
|
+
* doesn't need the body, and skill files can carry private material. The
|
|
11
|
+
* uploaded SKILL.md is a frontmatter-only stub that says exactly that.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
15
|
+
import { homedir } from 'os';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
|
|
18
|
+
export const MAX_SKILLS = 50;
|
|
19
|
+
|
|
20
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
21
|
+
|
|
22
|
+
/** Parse `name:` and `description:` out of SKILL.md frontmatter. */
|
|
23
|
+
export const parseSkillMeta = (markdown, fallbackName) => {
|
|
24
|
+
const fm = FRONTMATTER_RE.exec(markdown);
|
|
25
|
+
if (!fm) return { name: fallbackName, description: '' };
|
|
26
|
+
const fields = {};
|
|
27
|
+
for (const line of fm[1].split(/\r?\n/)) {
|
|
28
|
+
const m = /^(name|description):\s*(.+)$/.exec(line.trim());
|
|
29
|
+
if (m && !fields[m[1]]) fields[m[1]] = m[2].trim().replace(/^['"]|['"]$/g, '');
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
name: fields.name || fallbackName,
|
|
33
|
+
description: (fields.description || '').slice(0, 500),
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find skill directories. Explicit dir wins; otherwise check the two
|
|
39
|
+
* well-known locations. Each candidate is a directory whose subdirs
|
|
40
|
+
* contain a SKILL.md.
|
|
41
|
+
*/
|
|
42
|
+
export const detectSkills = ({ cwd = process.cwd(), home = homedir(), explicitDir = null } = {}) => {
|
|
43
|
+
const roots = [];
|
|
44
|
+
if (explicitDir) {
|
|
45
|
+
if (!existsSync(explicitDir) || !statSync(explicitDir).isDirectory()) {
|
|
46
|
+
throw new Error(`No such directory: ${explicitDir}`);
|
|
47
|
+
}
|
|
48
|
+
roots.push(explicitDir);
|
|
49
|
+
} else {
|
|
50
|
+
for (const candidate of [join(cwd, '.claude', 'skills'), join(home, '.claude', 'skills')]) {
|
|
51
|
+
try {
|
|
52
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory()) roots.push(candidate);
|
|
53
|
+
} catch {
|
|
54
|
+
// symlinked skill roots can dangle; skip quietly
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const skills = [];
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
for (const root of roots) {
|
|
62
|
+
for (const entry of readdirSync(root).sort()) {
|
|
63
|
+
const skillFile = join(root, entry, 'SKILL.md');
|
|
64
|
+
try {
|
|
65
|
+
if (!existsSync(skillFile) || !statSync(skillFile).isFile()) continue;
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const meta = parseSkillMeta(readFileSync(skillFile, 'utf8'), entry);
|
|
70
|
+
if (seen.has(meta.name)) continue;
|
|
71
|
+
seen.add(meta.name);
|
|
72
|
+
skills.push({ ...meta, path: skillFile });
|
|
73
|
+
if (skills.length >= MAX_SKILLS) return skills;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return skills;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The frontmatter-only stub that gets uploaded in place of skill content. */
|
|
80
|
+
export const skillStub = ({ name, description }) => [
|
|
81
|
+
'---',
|
|
82
|
+
`name: ${name}`,
|
|
83
|
+
`description: ${description || name}`,
|
|
84
|
+
'---',
|
|
85
|
+
'',
|
|
86
|
+
'Imported as metadata from a local agent — the skill itself runs on the',
|
|
87
|
+
"agent's own machine; its content was intentionally not uploaded.",
|
|
88
|
+
'',
|
|
89
|
+
].join('\n');
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Register the skills as agent-scoped entries via POST /api/skills/import.
|
|
93
|
+
* `client` must be authenticated as the USER (pod membership is checked),
|
|
94
|
+
* not the agent runtime token.
|
|
95
|
+
*/
|
|
96
|
+
export const importSkills = async (client, { skills, podId, agentName, instanceId = 'default' }) => {
|
|
97
|
+
const results = [];
|
|
98
|
+
for (const skill of skills) {
|
|
99
|
+
// eslint-disable-next-line no-await-in-loop
|
|
100
|
+
await client.post('/api/skills/import', {
|
|
101
|
+
podId,
|
|
102
|
+
name: skill.name,
|
|
103
|
+
content: skillStub(skill),
|
|
104
|
+
description: skill.description || undefined,
|
|
105
|
+
scope: 'agent',
|
|
106
|
+
agentName,
|
|
107
|
+
instanceId,
|
|
108
|
+
});
|
|
109
|
+
results.push(skill.name);
|
|
110
|
+
}
|
|
111
|
+
return { imported: results.length, names: results };
|
|
112
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local webhook server — receives forwarded events from the poller
|
|
3
|
+
* and delivers them to the developer's agent process.
|
|
4
|
+
*
|
|
5
|
+
* The developer's agent code handles POST /cap (or custom path)
|
|
6
|
+
* and returns { outcome, content? }.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createServer } from 'http';
|
|
10
|
+
import { createHmac } from 'crypto';
|
|
11
|
+
|
|
12
|
+
export const startWebhookServer = ({
|
|
13
|
+
port = 3001,
|
|
14
|
+
path = '/cap',
|
|
15
|
+
secret = null,
|
|
16
|
+
onEvent, // async (event) => { outcome, content? }
|
|
17
|
+
onReady, // (url) => void
|
|
18
|
+
}) => new Promise((resolve, reject) => {
|
|
19
|
+
const server = createServer(async (req, res) => {
|
|
20
|
+
if (req.method !== 'POST' || req.url !== path) {
|
|
21
|
+
res.writeHead(404);
|
|
22
|
+
res.end();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Read body
|
|
27
|
+
const chunks = [];
|
|
28
|
+
req.on('data', (c) => chunks.push(c));
|
|
29
|
+
req.on('end', async () => {
|
|
30
|
+
const rawBody = Buffer.concat(chunks).toString('utf8');
|
|
31
|
+
|
|
32
|
+
// Verify signature if secret configured
|
|
33
|
+
if (secret) {
|
|
34
|
+
const sig = req.headers['x-commonly-signature'];
|
|
35
|
+
const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;
|
|
36
|
+
if (sig !== expected) {
|
|
37
|
+
res.writeHead(401);
|
|
38
|
+
res.end(JSON.stringify({ error: 'Invalid signature' }));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let event;
|
|
44
|
+
try { event = JSON.parse(rawBody); } catch {
|
|
45
|
+
res.writeHead(400);
|
|
46
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let result = { outcome: 'acknowledged' };
|
|
51
|
+
try {
|
|
52
|
+
result = (await onEvent(event)) || { outcome: 'acknowledged' };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
result = { outcome: 'error', reason: err.message };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
58
|
+
res.end(JSON.stringify(result));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
server.on('error', reject);
|
|
63
|
+
server.listen(port, () => {
|
|
64
|
+
const url = `http://localhost:${port}${path}`;
|
|
65
|
+
onReady?.(url);
|
|
66
|
+
resolve({ server, url, close: () => server.close() });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Forward a polled event to a local webhook server.
|
|
72
|
+
* Used by `agent connect` to bridge polling → local HTTP.
|
|
73
|
+
*/
|
|
74
|
+
export const forwardToLocalWebhook = async (event, webhookUrl, secret = null) => {
|
|
75
|
+
const payload = JSON.stringify(event);
|
|
76
|
+
const headers = {
|
|
77
|
+
'Content-Type': 'application/json',
|
|
78
|
+
'X-Commonly-Event': event.type,
|
|
79
|
+
'X-Commonly-Delivery': String(event._id),
|
|
80
|
+
};
|
|
81
|
+
if (secret) {
|
|
82
|
+
headers['X-Commonly-Signature'] = `sha256=${createHmac('sha256', secret).update(payload).digest('hex')}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const res = await fetch(webhookUrl, { method: 'POST', headers, body: payload });
|
|
86
|
+
if (!res.ok) throw new Error(`Local webhook returned HTTP ${res.status}`);
|
|
87
|
+
try { return await res.json(); } catch { return { outcome: 'acknowledged' }; }
|
|
88
|
+
};
|