@commonlyai/cli 0.1.42 → 0.1.44
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/package.json +1 -1
- package/src/commands/agent.js +71 -0
- package/src/lib/adapters/claude.js +2 -1
- package/src/lib/hooks-config.js +198 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -40,6 +40,12 @@ import {
|
|
|
40
40
|
} from '../lib/pod-focus.js';
|
|
41
41
|
import { detectBwrap } from '../lib/sandbox/bwrap.js';
|
|
42
42
|
import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_HOOK_TIMEOUT_MS,
|
|
45
|
+
clampHookTimeoutMs,
|
|
46
|
+
forwardHookEvent,
|
|
47
|
+
writeHooksConfig,
|
|
48
|
+
} from '../lib/hooks-config.js';
|
|
43
49
|
import {
|
|
44
50
|
formatRetryDelay,
|
|
45
51
|
spawnRetryJitter,
|
|
@@ -2816,4 +2822,69 @@ Use --local to find the name you'd pass to 'agent run' or 'agent detach'.
|
|
|
2816
2822
|
process.exit(1);
|
|
2817
2823
|
}
|
|
2818
2824
|
});
|
|
2825
|
+
|
|
2826
|
+
// ── hooks-config (piece 7) ───────────────────────────────────────────────
|
|
2827
|
+
agent
|
|
2828
|
+
.command('hooks-config <name>')
|
|
2829
|
+
.description('Write Claude Code HTTP hook entries for an attached agent')
|
|
2830
|
+
.option('--scope <scope>', 'Write project or user settings', 'project')
|
|
2831
|
+
.option('--pod <podId>', 'Pod to receive hook events (defaults to the attached pod)')
|
|
2832
|
+
.option('--file <path>', 'Explicit Claude settings file')
|
|
2833
|
+
.option('--timeout <ms>', 'Hook request timeout in milliseconds', String(DEFAULT_HOOK_TIMEOUT_MS))
|
|
2834
|
+
.action((name, opts) => {
|
|
2835
|
+
const record = loadAgentToken(name);
|
|
2836
|
+
if (!record?.runtimeToken) {
|
|
2837
|
+
console.error(`No runtime token found for '${name}'. Run commonly agent attach first.`);
|
|
2838
|
+
process.exit(1);
|
|
2839
|
+
}
|
|
2840
|
+
const podId = opts.pod || record.podId;
|
|
2841
|
+
if (!podId) {
|
|
2842
|
+
console.error('A pod is required (pass --pod or attach the agent to a pod).');
|
|
2843
|
+
process.exit(1);
|
|
2844
|
+
}
|
|
2845
|
+
const timeoutMs = Number(opts.timeout);
|
|
2846
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 250) {
|
|
2847
|
+
console.error('--timeout must be at least 250 milliseconds.');
|
|
2848
|
+
process.exit(1);
|
|
2849
|
+
}
|
|
2850
|
+
const effectiveTimeoutMs = clampHookTimeoutMs(timeoutMs);
|
|
2851
|
+
const result = writeHooksConfig({
|
|
2852
|
+
filePath: opts.file ? pathResolve(opts.file) : null,
|
|
2853
|
+
scope: opts.scope,
|
|
2854
|
+
agentName: name,
|
|
2855
|
+
timeoutMs: effectiveTimeoutMs,
|
|
2856
|
+
});
|
|
2857
|
+
console.log(`✓ Claude hooks written to ${result.filePath}`);
|
|
2858
|
+
console.log(` Events: PreToolUse, PostToolUse, Stop, SubagentStop (timeout ${Math.ceil(effectiveTimeoutMs / 1000)}s)`);
|
|
2859
|
+
});
|
|
2860
|
+
|
|
2861
|
+
// ── hooks-forward (internal command emitted by hooks-config) ─────────────
|
|
2862
|
+
agent
|
|
2863
|
+
.command('hooks-forward <name>')
|
|
2864
|
+
.description('Forward one Claude Code hook event to Commonly')
|
|
2865
|
+
.option('--pod <podId>', 'Pod to receive hook events (defaults to the attached pod)')
|
|
2866
|
+
.option('--timeout <ms>', 'Hook request timeout in milliseconds', String(DEFAULT_HOOK_TIMEOUT_MS))
|
|
2867
|
+
.action(async (name, opts) => {
|
|
2868
|
+
const record = loadAgentToken(name);
|
|
2869
|
+
const podId = opts.pod || record?.podId;
|
|
2870
|
+
const instanceUrl = record?.instanceUrl || process.env.COMMONLY_API_URL || resolveInstanceUrl(undefined);
|
|
2871
|
+
const token = process.env.COMMONLY_AGENT_TOKEN;
|
|
2872
|
+
const chunks = [];
|
|
2873
|
+
if (!process.stdin.isTTY) {
|
|
2874
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
2875
|
+
}
|
|
2876
|
+
const input = chunks.length > 0 ? Buffer.concat(chunks).toString('utf8') : '{}';
|
|
2877
|
+
await forwardHookEvent({
|
|
2878
|
+
endpoint: podId
|
|
2879
|
+
? `${instanceUrl.replace(/\/$/, '')}/api/agents/runtime/pods/${encodeURIComponent(podId)}/hooks`
|
|
2880
|
+
: null,
|
|
2881
|
+
token,
|
|
2882
|
+
input,
|
|
2883
|
+
timeoutMs: Number(opts.timeout),
|
|
2884
|
+
});
|
|
2885
|
+
});
|
|
2819
2886
|
};
|
|
2887
|
+
|
|
2888
|
+
// Re-exported for consumers that build their own wrapper command rather than
|
|
2889
|
+
// invoking commander (and for unit tests of the config contract).
|
|
2890
|
+
export { forwardHookEvent, writeHooksConfig } from '../lib/hooks-config.js';
|
|
@@ -261,7 +261,7 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) =
|
|
|
261
261
|
|
|
262
262
|
// Keep Commonly placeholders in the MCP JSON and expose their values only in
|
|
263
263
|
// Claude's per-spawn environment. Claude Code natively expands ${VAR} in MCP
|
|
264
|
-
// command/args/env/url fields. Substituting here used to materialize the raw
|
|
264
|
+
// command/args/env/url/headers fields. Substituting here used to materialize the raw
|
|
265
265
|
// cm_agent_* bearer token in a transient JSON file, which made the token
|
|
266
266
|
// readable to any co-confined child allowed to read that config directory.
|
|
267
267
|
//
|
|
@@ -300,6 +300,7 @@ const buildMcpConfig = (mcpServers) => {
|
|
|
300
300
|
if (args.length) entry.args = args;
|
|
301
301
|
}
|
|
302
302
|
if (server.env) entry.env = { ...server.env };
|
|
303
|
+
if (server.headers) entry.headers = { ...server.headers };
|
|
303
304
|
mcpServersMap[server.name] = entry;
|
|
304
305
|
}
|
|
305
306
|
return { mcpServers: mcpServersMap };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs';
|
|
2
|
+
import { dirname, join, resolve as pathResolve, isAbsolute, relative } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { createHash } from 'crypto';
|
|
5
|
+
|
|
6
|
+
export const HOOK_EVENTS = ['PreToolUse', 'PostToolUse', 'Stop', 'SubagentStop'];
|
|
7
|
+
export const DEFAULT_HOOK_TIMEOUT_MS = 3000;
|
|
8
|
+
export const MAX_HOOK_TIMEOUT_MS = 5000;
|
|
9
|
+
|
|
10
|
+
export const clampHookTimeoutMs = (value) => Math.min(
|
|
11
|
+
MAX_HOOK_TIMEOUT_MS,
|
|
12
|
+
Math.max(250, Math.trunc(Number.isFinite(Number(value)) ? Number(value) : DEFAULT_HOOK_TIMEOUT_MS)),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Keep hook commands free of bearer material. The dispatcher reads
|
|
19
|
+
* COMMONLY_AGENT_TOKEN from the process environment at invocation time.
|
|
20
|
+
*/
|
|
21
|
+
export const buildHookCommand = ({ agentName, timeoutMs = DEFAULT_HOOK_TIMEOUT_MS }) => (
|
|
22
|
+
`commonly agent hooks-forward ${shellQuote(agentName)} --timeout ${clampHookTimeoutMs(timeoutMs)}`
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const hookEntry = ({ agentName, timeoutMs }) => ({
|
|
26
|
+
matcher: '',
|
|
27
|
+
hooks: [{
|
|
28
|
+
type: 'command',
|
|
29
|
+
command: buildHookCommand({ agentName, timeoutMs }),
|
|
30
|
+
// Claude interprets this as seconds. Keep it short so a dead backend
|
|
31
|
+
// cannot stall every tool call for the 600s default.
|
|
32
|
+
timeout: Math.ceil(clampHookTimeoutMs(timeoutMs) / 1000),
|
|
33
|
+
}],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const stableJson = (value) => {
|
|
37
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
38
|
+
if (value && typeof value === 'object') {
|
|
39
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
40
|
+
}
|
|
41
|
+
return JSON.stringify(value);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const digestArgs = (value) => createHash('sha256').update(stableJson(value ?? {})).digest('hex');
|
|
45
|
+
|
|
46
|
+
/** Resolve a caller-side path and emit the repo-relative POSIX wire form. */
|
|
47
|
+
const localPath = (value, root = process.cwd()) => {
|
|
48
|
+
if (typeof value !== 'string' || !value.trim() || value.includes('\0')) return null;
|
|
49
|
+
if (value.trim().split(/[\\/]/).includes('..')) return null;
|
|
50
|
+
const resolvedRoot = pathResolve(root);
|
|
51
|
+
const rootReal = (() => {
|
|
52
|
+
try { return realpathSync(resolvedRoot); } catch { return resolvedRoot; }
|
|
53
|
+
})();
|
|
54
|
+
const absolute = isAbsolute(value) ? pathResolve(value) : pathResolve(resolvedRoot, value);
|
|
55
|
+
if (absolute !== resolvedRoot && !absolute.startsWith(`${resolvedRoot}/`)) return null;
|
|
56
|
+
let current = absolute;
|
|
57
|
+
const suffix = [];
|
|
58
|
+
while (!existsSync(current)) {
|
|
59
|
+
const parent = dirname(current);
|
|
60
|
+
if (parent === current) return null;
|
|
61
|
+
suffix.unshift(current.slice(parent.length + 1));
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
let resolved;
|
|
65
|
+
try { resolved = realpathSync(current); } catch { resolved = current; }
|
|
66
|
+
const candidate = pathResolve(resolved, ...suffix);
|
|
67
|
+
if (candidate !== rootReal && !candidate.startsWith(`${rootReal}/`)) return null;
|
|
68
|
+
const repoPath = relative(rootReal, candidate).replaceAll('\\', '/');
|
|
69
|
+
return repoPath || '.';
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const inputPaths = (input = {}) => {
|
|
73
|
+
const values = [input.path, input.file_path, input.filePath, input.target_file, input.paths]
|
|
74
|
+
.flatMap((value) => Array.isArray(value) ? value : [value]);
|
|
75
|
+
return values.filter((value) => typeof value === 'string' && value.trim());
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Strip raw tool arguments before they leave the local harness. */
|
|
79
|
+
export const sanitizeHookPayload = (payload = {}, { cwd = process.cwd() } = {}) => {
|
|
80
|
+
const event = payload.event || payload.hook_event_name || payload.event_name || '';
|
|
81
|
+
const input = payload.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
|
|
82
|
+
const tool = payload.tool || payload.tool_name || payload.toolName;
|
|
83
|
+
const root = payload.cwd || cwd;
|
|
84
|
+
const declaredPaths = [payload.paths, payload.resolvedPaths]
|
|
85
|
+
.flatMap((value) => Array.isArray(value) ? value : [value])
|
|
86
|
+
.filter((value) => typeof value === 'string');
|
|
87
|
+
const paths = [...inputPaths(input), ...declaredPaths]
|
|
88
|
+
.map((value) => localPath(value, root)).filter(Boolean);
|
|
89
|
+
return {
|
|
90
|
+
...(event ? { event } : {}),
|
|
91
|
+
...(payload.eventId || payload.event_id ? { eventId: payload.eventId || payload.event_id } : {}),
|
|
92
|
+
...(typeof tool === 'string' && tool ? { tool } : {}),
|
|
93
|
+
argsDigest: payload.argsDigest || payload.args_digest || digestArgs(input),
|
|
94
|
+
...(paths.length > 0 ? { paths: Array.from(new Set(paths)) } : {}),
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Merge Commonly hooks into settings while preserving every user setting. */
|
|
99
|
+
export const mergeHooksConfig = (
|
|
100
|
+
existing = {},
|
|
101
|
+
{ agentName, events = HOOK_EVENTS, timeoutMs = DEFAULT_HOOK_TIMEOUT_MS } = {},
|
|
102
|
+
) => {
|
|
103
|
+
if (!agentName) throw new Error('agentName is required');
|
|
104
|
+
const source = existing && typeof existing === 'object' ? existing : {};
|
|
105
|
+
const hooks = source.hooks && typeof source.hooks === 'object' ? { ...source.hooks } : {};
|
|
106
|
+
for (const event of events) {
|
|
107
|
+
const prior = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
108
|
+
// Replace only the entry generated for this agent; unrelated matchers and
|
|
109
|
+
// command hooks remain byte-for-byte represented in the resulting JSON.
|
|
110
|
+
const retained = prior.filter((entry) => !entry?.hooks?.some((h) => (
|
|
111
|
+
h?.command?.includes(`commonly agent hooks-forward '${String(agentName).replaceAll("'", "'\\''")}'`)
|
|
112
|
+
)));
|
|
113
|
+
hooks[event] = [...retained, hookEntry({ agentName, timeoutMs })];
|
|
114
|
+
}
|
|
115
|
+
return { ...source, hooks };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const settingsPathForScope = ({ scope = 'project', cwd = process.cwd(), home = homedir() } = {}) => {
|
|
119
|
+
if (scope === 'user') return join(home, '.claude', 'settings.json');
|
|
120
|
+
if (scope !== 'project') throw new Error("scope must be 'project' or 'user'");
|
|
121
|
+
return join(cwd, '.claude', 'settings.local.json');
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const readJsonSettings = (filePath, fsApi = { readFileSync, existsSync }) => {
|
|
125
|
+
if (!fsApi.existsSync(filePath)) return {};
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(fsApi.readFileSync(filePath, 'utf8'));
|
|
128
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new Error(`Could not parse ${filePath}: ${error.message}`);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const writeHooksConfig = ({
|
|
135
|
+
filePath,
|
|
136
|
+
agentName,
|
|
137
|
+
scope = 'project',
|
|
138
|
+
cwd = process.cwd(),
|
|
139
|
+
home = homedir(),
|
|
140
|
+
timeoutMs = DEFAULT_HOOK_TIMEOUT_MS,
|
|
141
|
+
fsApi = { readFileSync, writeFileSync, mkdirSync, existsSync },
|
|
142
|
+
} = {}) => {
|
|
143
|
+
const target = filePath || settingsPathForScope({ scope, cwd, home });
|
|
144
|
+
const existing = readJsonSettings(target, fsApi);
|
|
145
|
+
const next = mergeHooksConfig(existing, { agentName, timeoutMs });
|
|
146
|
+
fsApi.mkdirSync(dirname(target), { recursive: true });
|
|
147
|
+
fsApi.writeFileSync(target, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
148
|
+
return { filePath: target, config: next };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Execute one hook event. It is intentionally dependency-light so the
|
|
153
|
+
* generated Claude command works on a fresh CLI install (Node 20's fetch).
|
|
154
|
+
* On any transport failure, fail open with no output. A deny is only the
|
|
155
|
+
* positive decision returned by the Commonly endpoint; D7 does not let a
|
|
156
|
+
* missing/slow ledger become an accidental write blocker.
|
|
157
|
+
*/
|
|
158
|
+
export const forwardHookEvent = async ({
|
|
159
|
+
endpoint,
|
|
160
|
+
token = process.env.COMMONLY_AGENT_TOKEN,
|
|
161
|
+
input = '',
|
|
162
|
+
timeoutMs = DEFAULT_HOOK_TIMEOUT_MS,
|
|
163
|
+
fetchImpl = globalThis.fetch,
|
|
164
|
+
stdout = (value) => process.stdout.write(value),
|
|
165
|
+
} = {}) => {
|
|
166
|
+
let payload;
|
|
167
|
+
try { payload = typeof input === 'string' ? JSON.parse(input || '{}') : input; } catch {
|
|
168
|
+
payload = {};
|
|
169
|
+
}
|
|
170
|
+
const event = payload?.event || payload?.hook_event_name || payload?.event_name || '';
|
|
171
|
+
const preTool = event === 'PreToolUse';
|
|
172
|
+
if (!endpoint || !token || !fetchImpl) {
|
|
173
|
+
return { acknowledged: false, reason: 'hook_unavailable' };
|
|
174
|
+
}
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
const timer = setTimeout(() => controller.abort(), clampHookTimeoutMs(timeoutMs));
|
|
177
|
+
try {
|
|
178
|
+
const response = await fetchImpl(endpoint, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
181
|
+
body: JSON.stringify(sanitizeHookPayload(payload)),
|
|
182
|
+
signal: controller.signal,
|
|
183
|
+
});
|
|
184
|
+
const body = await response.json().catch(() => ({}));
|
|
185
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
186
|
+
if (preTool && body.permissionDecision === 'deny') {
|
|
187
|
+
stdout(JSON.stringify({ permissionDecision: 'deny', ...(body.reason ? { reason: body.reason } : {}) }));
|
|
188
|
+
}
|
|
189
|
+
return body;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// Deliberately silent and exit-0 for every failure. Claude's default is
|
|
192
|
+
// fail-open, and an unavailable advisory hook must not block a tool.
|
|
193
|
+
void error;
|
|
194
|
+
return { acknowledged: false, reason: 'hook_unavailable' };
|
|
195
|
+
} finally {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
}
|
|
198
|
+
};
|