@tenphi/harnesspm 0.0.0-stage → 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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +87 -2
- package/dist/added.js +189 -0
- package/dist/check.js +160 -0
- package/dist/cli.js +139 -0
- package/dist/commands.js +414 -0
- package/dist/config.js +43 -0
- package/dist/exec-outputs.js +360 -0
- package/dist/exec-units.js +144 -0
- package/dist/fetch.js +93 -0
- package/dist/frontmatter.js +35 -0
- package/dist/homes.js +17 -0
- package/dist/lock.js +102 -0
- package/dist/manifest.js +154 -0
- package/dist/new.js +223 -0
- package/dist/outputs.js +453 -0
- package/dist/render.js +119 -0
- package/dist/resolve.js +396 -0
- package/dist/run.js +83 -0
- package/dist/sections.js +121 -0
- package/dist/user-outputs.js +195 -0
- package/dist/util.js +140 -0
- package/dist/workspaces.js +61 -0
- package/docs/spec/01-overview.md +130 -0
- package/docs/spec/02-package-format.md +238 -0
- package/docs/spec/03-resolution.md +104 -0
- package/docs/spec/04-outputs.md +109 -0
- package/docs/spec/05-executables.md +158 -0
- package/docs/spec/06-trust.md +72 -0
- package/docs/spec/07-added.md +104 -0
- package/docs/spec/08-improving.md +77 -0
- package/docs/spec/09-cloud.md +47 -0
- package/docs/spec/10-cli.md +60 -0
- package/docs/spec/11-standards.md +49 -0
- package/docs/spec/12-dogfooding.md +51 -0
- package/docs/spec/13-open-questions.md +85 -0
- package/docs/spec/README.md +33 -0
- package/harness/dev.harnesspm/rules/generated-files.md +4 -0
- package/harness/dev.harnesspm/rules/harnesspm-config.md +11 -0
- package/harness/plugin.json +13 -0
- package/harness/skills/harnesspm-export/SKILL.md +39 -0
- package/harness/skills/harnesspm-import/SKILL.md +35 -0
- package/harness/skills/harnesspm-improve/SKILL.md +38 -0
- package/package.json +53 -4
- package/schema/harnesspm.schema.json +31 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// Executable outputs (spec 4.3, 4.4, 5.2–5.4) with trust (spec 6.2) and ownership of shared files (spec 4.7).
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { stringify } from 'yaml';
|
|
5
|
+
import { AGENTS, WIDENING_AGENT_KEYS } from './exec-units.js';
|
|
6
|
+
import { tomlString } from './render.js';
|
|
7
|
+
import { ensureDir, isFile, isRecord, readJsonIfExists, readTextIfExists, writeIfChanged } from './util.js';
|
|
8
|
+
/** Trust of a package's executable units (spec 6.2). The project and added harnesses are trusted. */
|
|
9
|
+
export function trustOf(pkg, res, lock) {
|
|
10
|
+
if (pkg.layer !== 'dependencies')
|
|
11
|
+
return { trusted: true };
|
|
12
|
+
const value = res.config.trust[pkg.name];
|
|
13
|
+
if (value === true)
|
|
14
|
+
return { trusted: true };
|
|
15
|
+
if (value === 'pin') {
|
|
16
|
+
const approved = lock?.packages[pkg.name]?.approved;
|
|
17
|
+
if (approved === undefined || approved === pkg.version)
|
|
18
|
+
return { trusted: true };
|
|
19
|
+
return { trusted: false, reason: `version ${pkg.version} is not approved (approved: ${approved}); run \`npx harnesspm approve ${pkg.name}\`` };
|
|
20
|
+
}
|
|
21
|
+
return { trusted: false, reason: `not trusted; add it to "trust" in harnesspm.json` };
|
|
22
|
+
}
|
|
23
|
+
export function emptyEntries() {
|
|
24
|
+
return { claude: { allow: [], deny: [], ask: [], hooks: [], mcp: {} }, codex: { hooks: [], rules: [], mcp: {} }, agentFiles: new Map(), pending: [], warnings: [] };
|
|
25
|
+
}
|
|
26
|
+
function tomlValue(value) {
|
|
27
|
+
if (typeof value === 'string')
|
|
28
|
+
return JSON.stringify(value);
|
|
29
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
30
|
+
return String(value);
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
const items = value.map(tomlValue);
|
|
33
|
+
return items.every((i) => i !== undefined) ? `[${items.join(', ')}]` : undefined;
|
|
34
|
+
}
|
|
35
|
+
if (isRecord(value)) {
|
|
36
|
+
const items = Object.entries(value).map(([k, v]) => [k, tomlValue(v)]);
|
|
37
|
+
return items.every(([, v]) => v !== undefined) ? `{ ${items.map(([k, v]) => `${JSON.stringify(k)} = ${v}`).join(', ')} }` : undefined;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
export function tomlTables(prefix, tables) {
|
|
42
|
+
return Object.entries(tables)
|
|
43
|
+
.map(([name, table]) => {
|
|
44
|
+
const lines = Object.entries(table).flatMap(([k, v]) => {
|
|
45
|
+
const value = tomlValue(v);
|
|
46
|
+
return value === undefined ? [] : [`${k} = ${value}`];
|
|
47
|
+
});
|
|
48
|
+
return `[${prefix}.${JSON.stringify(name)}]\n${lines.join('\n')}`;
|
|
49
|
+
})
|
|
50
|
+
.join('\n\n');
|
|
51
|
+
}
|
|
52
|
+
/** Words of a command prefix, for Claude Code's Bash rules. */
|
|
53
|
+
const bashRule = (words) => `Bash(${words.join(' ')} *)`;
|
|
54
|
+
const codexRule = (words, decision) => `prefix_rule(pattern = ${JSON.stringify(words)}, decision = "${decision}")`;
|
|
55
|
+
/**
|
|
56
|
+
* The entries that `packages` ask for. `npx` is the command prefix: `npx harnesspm` where the project
|
|
57
|
+
* depends on harnesspm, `npx -y @tenphi/harnesspm` elsewhere. Allow rules use the same prefix as the commands.
|
|
58
|
+
*/
|
|
59
|
+
export function desiredEntries(res, packages, trust, where) {
|
|
60
|
+
const entries = emptyEntries();
|
|
61
|
+
const mine = (units) => units.filter((u) => packages.has(u.pkg));
|
|
62
|
+
const pendingCounts = new Map();
|
|
63
|
+
const pend = (pkg, what) => {
|
|
64
|
+
const counts = pendingCounts.get(pkg) ?? new Map();
|
|
65
|
+
counts.set(what, (counts.get(what) ?? 0) + 1);
|
|
66
|
+
pendingCounts.set(pkg, counts);
|
|
67
|
+
};
|
|
68
|
+
for (const skill of mine(res.skills))
|
|
69
|
+
if (trust(skill.pkg).trusted)
|
|
70
|
+
entries.claude.allow.push(`Skill(${skill.name})`);
|
|
71
|
+
for (const script of mine(res.scripts)) {
|
|
72
|
+
if (!trust(script.pkg).trusted) {
|
|
73
|
+
pend(script.pkg, 'script');
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!script.allow)
|
|
77
|
+
continue;
|
|
78
|
+
const words = [...where.npx(script.pkg).split(' '), 'run', `${script.pkg.plugin}:${script.name}`];
|
|
79
|
+
// `Bash(<prefix> *)` also matches the command without arguments (observed with Claude Code 2.1.322).
|
|
80
|
+
entries.claude.allow.push(bashRule(words));
|
|
81
|
+
entries.codex.rules.push(codexRule(words, 'allow'));
|
|
82
|
+
}
|
|
83
|
+
for (const hook of mine(res.hooks)) {
|
|
84
|
+
if (!trust(hook.pkg).trusted) {
|
|
85
|
+
pend(hook.pkg, 'hook');
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
for (const agent of AGENTS) {
|
|
89
|
+
if (!(agent in hook.matchers))
|
|
90
|
+
continue;
|
|
91
|
+
const matcher = hook.matchers[agent];
|
|
92
|
+
const entry = {
|
|
93
|
+
event: hook.event,
|
|
94
|
+
...(matcher !== undefined ? { matcher } : {}),
|
|
95
|
+
command: `${where.npx(hook.pkg)} run --agent ${agent} ${hook.pkg.plugin}:${hook.run}`,
|
|
96
|
+
...(hook.timeout !== undefined ? { timeout: hook.timeout } : {}),
|
|
97
|
+
};
|
|
98
|
+
entries[agent].hooks.push(entry);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const server of mine(res.mcp)) {
|
|
102
|
+
if (!trust(server.pkg).trusted) {
|
|
103
|
+
pend(server.pkg, 'MCP server');
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const name = `${server.pkg.plugin}-${server.name}`;
|
|
107
|
+
const type = server.config['type'];
|
|
108
|
+
for (const agent of AGENTS) {
|
|
109
|
+
let config;
|
|
110
|
+
if (type === 'stdio') {
|
|
111
|
+
const [command, ...prefix] = where.npx(server.pkg).split(' ');
|
|
112
|
+
config = { command, args: [...prefix, 'mcp', '--agent', agent, `${server.pkg.plugin}:${server.name}`] };
|
|
113
|
+
if (agent === 'claude')
|
|
114
|
+
config = { type: 'stdio', ...config };
|
|
115
|
+
}
|
|
116
|
+
else if (agent === 'claude') {
|
|
117
|
+
config = { type: type === 'sse' ? 'sse' : 'http', url: server.config['url'], ...(isRecord(server.config['headers']) ? { headers: server.config['headers'] } : {}) };
|
|
118
|
+
}
|
|
119
|
+
else if (type === 'streamable-http') {
|
|
120
|
+
config = { url: server.config['url'], ...(isRecord(server.config['headers']) ? { http_headers: server.config['headers'] } : {}) };
|
|
121
|
+
}
|
|
122
|
+
else
|
|
123
|
+
entries.warnings.push(`${server.id}: Codex has no SSE transport, so the server is written for Claude Code only`);
|
|
124
|
+
if (config)
|
|
125
|
+
entries[agent].mcp[name] = config;
|
|
126
|
+
}
|
|
127
|
+
entries.claude.allow.push(`mcp__${name}`);
|
|
128
|
+
}
|
|
129
|
+
for (const perms of mine(res.permissions)) {
|
|
130
|
+
for (const words of perms.deny) {
|
|
131
|
+
entries.claude.deny.push(bashRule(words));
|
|
132
|
+
entries.codex.rules.push(codexRule(words, 'forbidden'));
|
|
133
|
+
}
|
|
134
|
+
for (const words of perms.ask) {
|
|
135
|
+
entries.claude.ask.push(bashRule(words));
|
|
136
|
+
entries.codex.rules.push(codexRule(words, 'prompt'));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Sub-agents are text, but some agent-specific keys widen what the agent may do (spec 2.6).
|
|
140
|
+
for (const agentUnit of mine(res.agents)) {
|
|
141
|
+
const trusted = trust(agentUnit.pkg).trusted;
|
|
142
|
+
const keys = (agent) => {
|
|
143
|
+
const source = agent === 'claude' ? agentUnit.claude : agentUnit.codex;
|
|
144
|
+
const result = {};
|
|
145
|
+
for (const [key, value] of Object.entries(source)) {
|
|
146
|
+
if (!trusted && WIDENING_AGENT_KEYS[agent].includes(key))
|
|
147
|
+
entries.warnings.push(`${agentUnit.id}: "${key}" is left out, because ${agentUnit.pkg.name} is not trusted`);
|
|
148
|
+
else
|
|
149
|
+
result[key] = value;
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
};
|
|
153
|
+
const note = `Generated by harnesspm from ${agentUnit.id}. To change it, run \`npx harnesspm why ${agentUnit.id}\`.`;
|
|
154
|
+
const front = stringify({ name: agentUnit.name, description: agentUnit.description, ...keys('claude') }).trimEnd();
|
|
155
|
+
entries.agentFiles.set(path.join(where.claudeAgents, `${agentUnit.name}.md`), `---\n${front}\n---\n\n<!-- ${note} -->\n\n${agentUnit.body}\n`);
|
|
156
|
+
const codexKeys = Object.entries(keys('codex')).flatMap(([k, v]) => {
|
|
157
|
+
const value = tomlValue(v);
|
|
158
|
+
return value === undefined ? [] : [`${k} = ${value}`];
|
|
159
|
+
});
|
|
160
|
+
entries.agentFiles.set(path.join(where.codexAgents, `${agentUnit.name}.toml`), `# ${note}\nname = ${JSON.stringify(agentUnit.name)}\ndescription = ${JSON.stringify(agentUnit.description)}\n${codexKeys.map((l) => `${l}\n`).join('')}developer_instructions = ${tomlString(agentUnit.body)}\n`);
|
|
161
|
+
}
|
|
162
|
+
for (const [pkg, counts] of pendingCounts) {
|
|
163
|
+
const what = [...counts].map(([kind, n]) => `${n} ${kind}${n === 1 ? '' : 's'}`).join(', ');
|
|
164
|
+
entries.pending.push(`${pkg.name}: ${what} (${trust(pkg).reason})`);
|
|
165
|
+
}
|
|
166
|
+
return entries;
|
|
167
|
+
}
|
|
168
|
+
const emptyOwned = () => ({ claude: { allow: [], deny: [], ask: [], hooks: [], mcp: [] }, codex: { hooks: [], mcp: {}, rules: [] }, agentFiles: [] });
|
|
169
|
+
function readOwned(stateFile) {
|
|
170
|
+
const raw = readJsonIfExists(stateFile);
|
|
171
|
+
return isRecord(raw) && isRecord(raw['exec']) ? raw['exec'] : {};
|
|
172
|
+
}
|
|
173
|
+
function writeOwned(stateFile, state) {
|
|
174
|
+
const raw = readJsonIfExists(stateFile);
|
|
175
|
+
const data = isRecord(raw) ? raw : {};
|
|
176
|
+
data['exec'] = state;
|
|
177
|
+
ensureDir(path.dirname(stateFile));
|
|
178
|
+
writeIfChanged(stateFile, `${JSON.stringify(data, null, 2)}\n`);
|
|
179
|
+
}
|
|
180
|
+
const hookKey = (h) => `${h.event}\u0000${h.matcher ?? ''}\u0000${h.command}`;
|
|
181
|
+
/** Keeps what a model run may keep: entries it already had. Returns the entries and what is held back. */
|
|
182
|
+
function limit(wanted, had, key, widen) {
|
|
183
|
+
if (widen)
|
|
184
|
+
return { kept: wanted, held: [] };
|
|
185
|
+
const before = new Set(had.map(key));
|
|
186
|
+
return { kept: wanted.filter((w) => before.has(key(w))), held: wanted.filter((w) => !before.has(key(w))) };
|
|
187
|
+
}
|
|
188
|
+
/** Replaces the owned hook entries in an agent's hooks object. */
|
|
189
|
+
function mergeHooks(hooks, previous, next) {
|
|
190
|
+
const drop = new Set(previous.map(hookKey));
|
|
191
|
+
const result = {};
|
|
192
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
193
|
+
const kept = groups
|
|
194
|
+
.map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !drop.has(hookKey({ event, ...(g.matcher !== undefined ? { matcher: g.matcher } : {}), command: h.command ?? '' }))) }))
|
|
195
|
+
.filter((g) => g.hooks.length > 0);
|
|
196
|
+
if (kept.length > 0)
|
|
197
|
+
result[event] = kept;
|
|
198
|
+
}
|
|
199
|
+
for (const entry of next) {
|
|
200
|
+
const list = (result[entry.event] ??= []);
|
|
201
|
+
list.push({ ...(entry.matcher !== undefined ? { matcher: entry.matcher } : {}), hooks: [{ type: 'command', command: entry.command, ...(entry.timeout !== undefined ? { timeout: entry.timeout } : {}) }] });
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
function editJson(file, edit) {
|
|
206
|
+
const raw = readJsonIfExists(file);
|
|
207
|
+
const data = isRecord(raw) ? raw : {};
|
|
208
|
+
const before = JSON.stringify(data);
|
|
209
|
+
edit(data);
|
|
210
|
+
if (JSON.stringify(data) === before)
|
|
211
|
+
return false;
|
|
212
|
+
if (Object.keys(data).length === 0) {
|
|
213
|
+
fs.rmSync(file, { force: true });
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
ensureDir(path.dirname(file));
|
|
217
|
+
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
function setList(holder, key, previous, next) {
|
|
221
|
+
const current = Array.isArray(holder[key]) ? holder[key].filter((v) => typeof v === 'string') : [];
|
|
222
|
+
const kept = current.filter((v) => !previous.includes(v));
|
|
223
|
+
const result = [...kept, ...next.filter((v) => !kept.includes(v))];
|
|
224
|
+
if (result.length > 0)
|
|
225
|
+
holder[key] = result;
|
|
226
|
+
else
|
|
227
|
+
delete holder[key];
|
|
228
|
+
}
|
|
229
|
+
/** Widening entries that `entries` asks for but harnesspm does not own yet, without writing anything. */
|
|
230
|
+
export function heldEntries(group, entries, stateFile) {
|
|
231
|
+
const prev = readOwned(stateFile)[group] ?? emptyOwned();
|
|
232
|
+
const missing = (wanted, had, key) => limit(wanted, had, key, false).held;
|
|
233
|
+
return [
|
|
234
|
+
...missing(entries.claude.allow, prev.claude.allow, (s) => s).map((a) => `allow ${a}`),
|
|
235
|
+
...missing(entries.claude.hooks, prev.claude.hooks, hookKey).map((h) => `Claude Code hook: ${h.command}`),
|
|
236
|
+
...missing(Object.keys(entries.claude.mcp), prev.claude.mcp, (s) => s).map((n) => `Claude Code MCP server ${n}`),
|
|
237
|
+
...missing(entries.codex.hooks, prev.codex.hooks, hookKey).map((h) => `Codex hook: ${h.command}`),
|
|
238
|
+
...missing(Object.keys(entries.codex.mcp), Object.keys(prev.codex.mcp), (s) => s).map((n) => `Codex MCP server ${n}`),
|
|
239
|
+
];
|
|
240
|
+
}
|
|
241
|
+
export function applyEntries(group, entries, targets, widen) {
|
|
242
|
+
const state = readOwned(targets.stateFile);
|
|
243
|
+
const prev = state[group] ?? emptyOwned();
|
|
244
|
+
const writeClaude = targets.writeClaude !== false;
|
|
245
|
+
const writeCodex = targets.writeCodex !== false;
|
|
246
|
+
const changed = [];
|
|
247
|
+
const held = [];
|
|
248
|
+
const files = [];
|
|
249
|
+
const allow = limit(entries.claude.allow, prev.claude.allow, (s) => s, widen);
|
|
250
|
+
const claudeHooks = limit(entries.claude.hooks, prev.claude.hooks, hookKey, widen);
|
|
251
|
+
const claudeMcpNames = limit(Object.keys(entries.claude.mcp), prev.claude.mcp, (s) => s, widen);
|
|
252
|
+
const codexHooks = limit(entries.codex.hooks, prev.codex.hooks, hookKey, widen);
|
|
253
|
+
const codexMcpNames = limit(Object.keys(entries.codex.mcp), Object.keys(prev.codex.mcp), (s) => s, widen);
|
|
254
|
+
const allowRules = entries.codex.rules.filter((r) => r.includes('decision = "allow"'));
|
|
255
|
+
const codexAllow = limit(allowRules, prev.codex.rules.filter((r) => r.includes('decision = "allow"')), (s) => s, widen);
|
|
256
|
+
// A model run never removes a restriction either.
|
|
257
|
+
const keepRestrictions = (next, before) => (widen ? next : [...new Set([...next, ...before])]);
|
|
258
|
+
const deny = keepRestrictions(entries.claude.deny, prev.claude.deny);
|
|
259
|
+
const ask = keepRestrictions(entries.claude.ask, prev.claude.ask);
|
|
260
|
+
const codexRestrict = keepRestrictions(entries.codex.rules.filter((r) => !r.includes('decision = "allow"')), prev.codex.rules.filter((r) => !r.includes('decision = "allow"')));
|
|
261
|
+
held.push(...allow.held.map((a) => `allow ${a}`), ...claudeHooks.held.map((h) => `Claude Code hook: ${h.command}`), ...claudeMcpNames.held.map((n) => `Claude Code MCP server ${n}`));
|
|
262
|
+
held.push(...codexHooks.held.map((h) => `Codex hook: ${h.command}`), ...codexMcpNames.held.map((n) => `Codex MCP server ${n}`), ...codexAllow.held.map((r) => `Codex rule ${r}`));
|
|
263
|
+
// Claude Code settings: permissions and hooks.
|
|
264
|
+
if (writeClaude &&
|
|
265
|
+
editJson(targets.claudeSettings, (data) => {
|
|
266
|
+
const permissions = isRecord(data['permissions']) ? data['permissions'] : {};
|
|
267
|
+
setList(permissions, 'allow', prev.claude.allow, allow.kept);
|
|
268
|
+
setList(permissions, 'deny', prev.claude.deny, deny);
|
|
269
|
+
setList(permissions, 'ask', prev.claude.ask, ask);
|
|
270
|
+
if (Object.keys(permissions).length > 0)
|
|
271
|
+
data['permissions'] = permissions;
|
|
272
|
+
else
|
|
273
|
+
delete data['permissions'];
|
|
274
|
+
const hooks = mergeHooks(isRecord(data['hooks']) ? data['hooks'] : {}, prev.claude.hooks, claudeHooks.kept);
|
|
275
|
+
if (Object.keys(hooks).length > 0)
|
|
276
|
+
data['hooks'] = hooks;
|
|
277
|
+
else
|
|
278
|
+
delete data['hooks'];
|
|
279
|
+
}))
|
|
280
|
+
changed.push(targets.claudeSettings);
|
|
281
|
+
if (isFile(targets.claudeSettings))
|
|
282
|
+
files.push(targets.claudeSettings);
|
|
283
|
+
// Claude Code MCP servers: ~/.claude.json, at user scope or in the project's local scope.
|
|
284
|
+
const claudeMcp = Object.fromEntries(claudeMcpNames.kept.map((n) => [n, entries.claude.mcp[n]]));
|
|
285
|
+
if (writeClaude && (Object.keys(claudeMcp).length > 0 || prev.claude.mcp.length > 0)) {
|
|
286
|
+
if (editJson(targets.claudeJson, (data) => {
|
|
287
|
+
let holder = data;
|
|
288
|
+
if (targets.claudeProject !== undefined) {
|
|
289
|
+
const projects = isRecord(data['projects']) ? data['projects'] : {};
|
|
290
|
+
holder = isRecord(projects[targets.claudeProject]) ? projects[targets.claudeProject] : {};
|
|
291
|
+
projects[targets.claudeProject] = holder;
|
|
292
|
+
data['projects'] = projects;
|
|
293
|
+
}
|
|
294
|
+
const servers = isRecord(holder['mcpServers']) ? holder['mcpServers'] : {};
|
|
295
|
+
for (const name of prev.claude.mcp)
|
|
296
|
+
delete servers[name];
|
|
297
|
+
Object.assign(servers, claudeMcp);
|
|
298
|
+
if (Object.keys(servers).length > 0)
|
|
299
|
+
holder['mcpServers'] = servers;
|
|
300
|
+
else
|
|
301
|
+
delete holder['mcpServers'];
|
|
302
|
+
if (targets.claudeProject !== undefined && Object.keys(holder).length === 0)
|
|
303
|
+
delete data['projects'][targets.claudeProject];
|
|
304
|
+
if (isRecord(data['projects']) && Object.keys(data['projects']).length === 0)
|
|
305
|
+
delete data['projects'];
|
|
306
|
+
}))
|
|
307
|
+
changed.push(targets.claudeJson);
|
|
308
|
+
}
|
|
309
|
+
// Codex hooks.json, owned entries only.
|
|
310
|
+
if (writeCodex && (codexHooks.kept.length > 0 || prev.codex.hooks.length > 0)) {
|
|
311
|
+
if (editJson(targets.codexHooks, (data) => {
|
|
312
|
+
const hooks = mergeHooks(isRecord(data['hooks']) ? data['hooks'] : {}, prev.codex.hooks, codexHooks.kept);
|
|
313
|
+
if (Object.keys(hooks).length > 0)
|
|
314
|
+
data['hooks'] = hooks;
|
|
315
|
+
else
|
|
316
|
+
delete data['hooks'];
|
|
317
|
+
}))
|
|
318
|
+
changed.push(targets.codexHooks);
|
|
319
|
+
if (isFile(targets.codexHooks))
|
|
320
|
+
files.push(targets.codexHooks);
|
|
321
|
+
}
|
|
322
|
+
const codexMcp = Object.fromEntries(codexMcpNames.kept.map((n) => [n, entries.codex.mcp[n]]));
|
|
323
|
+
const nextOwned = {
|
|
324
|
+
claude: writeClaude ? { allow: allow.kept, deny, ask, hooks: claudeHooks.kept, mcp: claudeMcpNames.kept } : prev.claude,
|
|
325
|
+
codex: writeCodex ? { hooks: codexHooks.kept, mcp: codexMcp, rules: [...codexAllow.kept, ...codexRestrict] } : prev.codex,
|
|
326
|
+
agentFiles: [...entries.agentFiles.keys()].sort(),
|
|
327
|
+
};
|
|
328
|
+
// Codex rules: one file that all groups share.
|
|
329
|
+
const nextState = { ...state, [group]: nextOwned };
|
|
330
|
+
const allRules = [...new Set(Object.values(nextState).flatMap((o) => o?.codex.rules ?? []))];
|
|
331
|
+
if (!writeCodex) {
|
|
332
|
+
// The repository owns its Codex files.
|
|
333
|
+
}
|
|
334
|
+
else if (allRules.length > 0) {
|
|
335
|
+
if (writeIfChanged(targets.codexRules, `# Generated by \`harnesspm sync\`. Do not edit.\n${allRules.join('\n')}\n`))
|
|
336
|
+
changed.push(targets.codexRules);
|
|
337
|
+
files.push(targets.codexRules);
|
|
338
|
+
}
|
|
339
|
+
else if (isFile(targets.codexRules)) {
|
|
340
|
+
fs.rmSync(targets.codexRules);
|
|
341
|
+
changed.push(targets.codexRules);
|
|
342
|
+
}
|
|
343
|
+
// Sub-agent files.
|
|
344
|
+
for (const [file, content] of entries.agentFiles) {
|
|
345
|
+
if (!writeCodex && file.endsWith('.toml'))
|
|
346
|
+
continue;
|
|
347
|
+
if (writeIfChanged(file, content))
|
|
348
|
+
changed.push(file);
|
|
349
|
+
files.push(file);
|
|
350
|
+
}
|
|
351
|
+
for (const old of prev.agentFiles) {
|
|
352
|
+
if (!entries.agentFiles.has(old) && isFile(old) && (readTextIfExists(old) ?? '').includes('Generated by harnesspm from')) {
|
|
353
|
+
fs.rmSync(old);
|
|
354
|
+
changed.push(old);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
writeOwned(targets.stateFile, nextState);
|
|
358
|
+
const allCodexMcp = Object.assign({}, ...Object.values(nextState).map((o) => o?.codex.mcp ?? {}));
|
|
359
|
+
return { changed, held, codexMcp: allCodexMcp, files };
|
|
360
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Scripts, hooks, MCP servers, permissions (spec chapter 5) and sub-agents (spec 2.6).
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
4
|
+
import { NS } from './manifest.js';
|
|
5
|
+
import { isFile, isRecord, listDir, readJson, readText } from './util.js';
|
|
6
|
+
export const AGENTS = ['claude', 'codex'];
|
|
7
|
+
/** Events both agents support (spec 5.2). */
|
|
8
|
+
export const PORTABLE_EVENTS = ['SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', 'SubagentStart', 'SubagentStop', 'Stop'];
|
|
9
|
+
/** Events only one agent supports. Others are dropped with a warning. */
|
|
10
|
+
const AGENT_EVENTS = {
|
|
11
|
+
claude: [...PORTABLE_EVENTS, 'Notification'],
|
|
12
|
+
codex: PORTABLE_EVENTS,
|
|
13
|
+
};
|
|
14
|
+
function commandLists(value, field, problems) {
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return [];
|
|
17
|
+
if (Array.isArray(value) && value.every((v) => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')))
|
|
18
|
+
return value;
|
|
19
|
+
problems.push(`permissions.json: "${field}" must be a list of command prefixes, such as [["git", "push", "--force"]]`);
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
/** Finds the executable units and sub-agents of a package. Problems are what `harnesspm check` reports. */
|
|
23
|
+
export function discoverExecUnits(pkg, problems) {
|
|
24
|
+
const scripts = Object.entries(pkg.ext.scripts).map(([name, def]) => ({
|
|
25
|
+
kind: 'scripts',
|
|
26
|
+
id: `${pkg.plugin}:scripts/${name}`,
|
|
27
|
+
name,
|
|
28
|
+
file: path.resolve(pkg.harnessDir, def.file),
|
|
29
|
+
...(def.description !== undefined ? { description: def.description } : {}),
|
|
30
|
+
allow: def.allow === true,
|
|
31
|
+
pkg,
|
|
32
|
+
}));
|
|
33
|
+
const hooks = [];
|
|
34
|
+
const hooksFile = path.join(pkg.harnessDir, NS, 'hooks.json');
|
|
35
|
+
if (isFile(hooksFile)) {
|
|
36
|
+
const raw = readJson(hooksFile);
|
|
37
|
+
const byEvent = isRecord(raw) && isRecord(raw['hooks']) ? raw['hooks'] : {};
|
|
38
|
+
for (const [event, list] of Object.entries(byEvent)) {
|
|
39
|
+
if (!Array.isArray(list)) {
|
|
40
|
+
problems.push(`hooks.json: "${event}" must be a list`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const def of list) {
|
|
44
|
+
if (!isRecord(def) || typeof def['id'] !== 'string' || typeof def['run'] !== 'string') {
|
|
45
|
+
problems.push(`hooks.json: every ${event} hook needs "id" and "run"`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (pkg.ext.scripts[def['run']] === undefined)
|
|
49
|
+
problems.push(`hooks.json: hook "${def['id']}" runs "${def['run']}", which is not in "scripts"`);
|
|
50
|
+
const matcher = def['matcher'];
|
|
51
|
+
let matchers;
|
|
52
|
+
if (matcher === undefined || typeof matcher === 'string')
|
|
53
|
+
matchers = { claude: matcher, codex: matcher };
|
|
54
|
+
else if (isRecord(matcher)) {
|
|
55
|
+
matchers = {};
|
|
56
|
+
for (const agent of AGENTS)
|
|
57
|
+
if (typeof matcher[agent] === 'string')
|
|
58
|
+
matchers[agent] = matcher[agent];
|
|
59
|
+
for (const key of Object.keys(matcher))
|
|
60
|
+
if (!AGENTS.includes(key))
|
|
61
|
+
problems.push(`hooks.json: hook "${def['id']}" has an unknown agent "${key}" in "matcher"`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
problems.push(`hooks.json: hook "${def['id']}": "matcher" must be a string or an object`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const agent of AGENTS)
|
|
68
|
+
if (agent in matchers && !AGENT_EVENTS[agent].includes(event))
|
|
69
|
+
delete matchers[agent];
|
|
70
|
+
if (Object.keys(matchers).length === 0)
|
|
71
|
+
problems.push(`hooks.json: hook "${def['id']}" uses ${event}, which no agent it names supports`);
|
|
72
|
+
hooks.push({
|
|
73
|
+
kind: 'hooks',
|
|
74
|
+
id: `${pkg.plugin}:hooks/${def['id']}`,
|
|
75
|
+
name: def['id'],
|
|
76
|
+
event,
|
|
77
|
+
matchers,
|
|
78
|
+
run: def['run'],
|
|
79
|
+
...(typeof def['timeout'] === 'number' ? { timeout: def['timeout'] } : {}),
|
|
80
|
+
pkg,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const mcp = [];
|
|
86
|
+
const mcpFile = path.join(pkg.harnessDir, 'mcp.json');
|
|
87
|
+
if (isFile(mcpFile)) {
|
|
88
|
+
const raw = readJson(mcpFile);
|
|
89
|
+
const servers = isRecord(raw) && isRecord(raw['mcpServers']) ? raw['mcpServers'] : {};
|
|
90
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
91
|
+
if (!isRecord(config)) {
|
|
92
|
+
problems.push(`mcp.json: server "${name}" must be an object`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const type = config['type'] ?? 'stdio';
|
|
96
|
+
if (type === 'stdio' && typeof config['command'] !== 'string')
|
|
97
|
+
problems.push(`mcp.json: stdio server "${name}" needs a "command"`);
|
|
98
|
+
if ((type === 'streamable-http' || type === 'sse') && typeof config['url'] !== 'string')
|
|
99
|
+
problems.push(`mcp.json: server "${name}" needs a "url"`);
|
|
100
|
+
if (!['stdio', 'streamable-http', 'sse'].includes(String(type)))
|
|
101
|
+
problems.push(`mcp.json: server "${name}" has an unknown type "${String(type)}"`);
|
|
102
|
+
mcp.push({ kind: 'mcp', id: `${pkg.plugin}:mcp/${name}`, name, config: { ...config, type }, pkg });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
let permissions;
|
|
106
|
+
const permFile = path.join(pkg.harnessDir, NS, 'permissions.json');
|
|
107
|
+
if (isFile(permFile)) {
|
|
108
|
+
const raw = readJson(permFile);
|
|
109
|
+
const data = isRecord(raw) ? raw : {};
|
|
110
|
+
for (const key of Object.keys(data)) {
|
|
111
|
+
if (key === 'allow')
|
|
112
|
+
problems.push('permissions.json: "allow" is not supported. Set "allow": true on a script instead (spec 5.4).');
|
|
113
|
+
else if (key !== 'deny' && key !== 'ask')
|
|
114
|
+
problems.push(`permissions.json: unknown field "${key}"`);
|
|
115
|
+
}
|
|
116
|
+
permissions = { kind: 'permissions', id: `${pkg.plugin}:permissions`, deny: commandLists(data['deny'], 'deny', problems), ask: commandLists(data['ask'], 'ask', problems), pkg };
|
|
117
|
+
}
|
|
118
|
+
const agentsDir = path.join(pkg.harnessDir, NS, 'agents');
|
|
119
|
+
const agents = [];
|
|
120
|
+
for (const file of listDir(agentsDir).filter((f) => f.endsWith('.md'))) {
|
|
121
|
+
const full = path.join(agentsDir, file);
|
|
122
|
+
const { data, body } = parseFrontmatter(readText(full), full);
|
|
123
|
+
const name = typeof data['name'] === 'string' ? data['name'] : file.slice(0, -3);
|
|
124
|
+
if (typeof data['description'] !== 'string')
|
|
125
|
+
problems.push(`agents/${file}: needs a "description"`);
|
|
126
|
+
agents.push({
|
|
127
|
+
kind: 'agents',
|
|
128
|
+
id: `${pkg.plugin}:agents/${name}`,
|
|
129
|
+
name,
|
|
130
|
+
description: typeof data['description'] === 'string' ? data['description'] : '',
|
|
131
|
+
body: body.trim(),
|
|
132
|
+
claude: isRecord(data['claude']) ? data['claude'] : {},
|
|
133
|
+
codex: isRecord(data['codex']) ? data['codex'] : {},
|
|
134
|
+
file: full,
|
|
135
|
+
pkg,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return { scripts, hooks, mcp, ...(permissions ? { permissions } : {}), agents };
|
|
139
|
+
}
|
|
140
|
+
/** Sub-agent keys that widen what the agent may do. Written only for trusted owners. */
|
|
141
|
+
export const WIDENING_AGENT_KEYS = {
|
|
142
|
+
claude: ['permissionMode'],
|
|
143
|
+
codex: ['sandbox_mode', 'approval_policy'],
|
|
144
|
+
};
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// harnesspm fetch: the harness packages in the lock, without an install (spec 4.2, 13.6).
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as os from 'node:os';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { readLock } from './lock.js';
|
|
8
|
+
import { computePlan, STATE_DIR } from './outputs.js';
|
|
9
|
+
import { extractBlock } from './render.js';
|
|
10
|
+
import { resolve, resolvePackageDir } from './resolve.js';
|
|
11
|
+
import { HarnessError, ensureDir, isRecord, linkIfChanged, readJsonIfExists, readTextIfExists, writeIfChanged } from './util.js';
|
|
12
|
+
function sri(file, algorithm) {
|
|
13
|
+
return `${algorithm}-${createHash(algorithm).update(fs.readFileSync(file)).digest('base64')}`;
|
|
14
|
+
}
|
|
15
|
+
function installedVersion(dir) {
|
|
16
|
+
const pkg = readJsonIfExists(path.join(dir, 'package.json'));
|
|
17
|
+
return isRecord(pkg) && typeof pkg['version'] === 'string' ? pkg['version'] : undefined;
|
|
18
|
+
}
|
|
19
|
+
/** Downloads one package with npm, checks its integrity and unpacks it. Never runs its code. */
|
|
20
|
+
function download(projectDir, name, spec, integrity, target) {
|
|
21
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'harnesspm-fetch-'));
|
|
22
|
+
try {
|
|
23
|
+
const packSpec = spec.startsWith('file:') ? path.resolve(projectDir, spec.slice('file:'.length)) : spec;
|
|
24
|
+
const packed = spawnSync('npm', ['pack', packSpec, '--silent', '--ignore-scripts', '--pack-destination', tmp], { cwd: projectDir, encoding: 'utf8' });
|
|
25
|
+
if (packed.status !== 0)
|
|
26
|
+
throw new HarnessError(`Cannot download ${name} (${spec}): ${packed.stderr.trim()}`);
|
|
27
|
+
const tarball = path.join(tmp, packed.stdout.trim().split('\n').pop().trim());
|
|
28
|
+
if (integrity !== undefined) {
|
|
29
|
+
const algorithm = integrity.split('-')[0];
|
|
30
|
+
const actual = sri(tarball, algorithm);
|
|
31
|
+
if (actual !== integrity)
|
|
32
|
+
throw new HarnessError(`${name}: the downloaded tarball does not match the integrity in the lock`);
|
|
33
|
+
}
|
|
34
|
+
const unpacked = path.join(tmp, 'unpacked');
|
|
35
|
+
ensureDir(unpacked);
|
|
36
|
+
const tar = spawnSync('tar', ['-xzf', tarball, '-C', unpacked], { encoding: 'utf8' });
|
|
37
|
+
if (tar.status !== 0)
|
|
38
|
+
throw new HarnessError(`Cannot unpack ${name}: ${tar.stderr.trim()}`);
|
|
39
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
40
|
+
ensureDir(path.dirname(target));
|
|
41
|
+
fs.renameSync(path.join(unpacked, 'package'), target);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function fetchLocked(projectDir) {
|
|
48
|
+
const lock = readLock(projectDir);
|
|
49
|
+
if (!lock)
|
|
50
|
+
throw new HarnessError('No harnesspm.lock.json here. Run `npx harnesspm sync` where the dependencies are installed, and commit the lock.');
|
|
51
|
+
const report = { fetched: [], reused: [], warnings: [] };
|
|
52
|
+
const located = new Map();
|
|
53
|
+
for (const [name, entry] of Object.entries(lock.packages)) {
|
|
54
|
+
if (entry.project)
|
|
55
|
+
continue;
|
|
56
|
+
const installed = resolvePackageDir(name, projectDir);
|
|
57
|
+
if (installed && installedVersion(installed) === entry.version) {
|
|
58
|
+
located.set(name, installed);
|
|
59
|
+
report.reused.push(name);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const target = path.join(projectDir, STATE_DIR, 'fetched', name);
|
|
63
|
+
if (installedVersion(target) !== entry.version) {
|
|
64
|
+
const spec = entry.resolved ?? `${name}@${entry.version}`;
|
|
65
|
+
if (entry.integrity === undefined)
|
|
66
|
+
report.warnings.push(`${name}: the lock has no integrity, so the download is not checked`);
|
|
67
|
+
download(projectDir, name, spec, entry.integrity, target);
|
|
68
|
+
report.fetched.push(`${name} ${entry.version}`);
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
report.reused.push(name);
|
|
72
|
+
located.set(name, target);
|
|
73
|
+
}
|
|
74
|
+
// Stable paths and generated files (docs indexes, patched rules), so the committed pointers work.
|
|
75
|
+
const team = resolve(projectDir, { locate: (name) => located.get(name) });
|
|
76
|
+
report.warnings.push(...team.warnings);
|
|
77
|
+
const plan = computePlan(team, team);
|
|
78
|
+
const agents = readTextIfExists(path.join(projectDir, 'AGENTS.md'));
|
|
79
|
+
if (extractBlock(agents) !== extractBlock(plan.committed.agentsMd))
|
|
80
|
+
report.warnings.push('AGENTS.md does not match the packages in the lock. Run `npx harnesspm sync` where the dependencies are installed.');
|
|
81
|
+
// Only files under node_modules/.harnesspm: a job without an install needs no agent settings.
|
|
82
|
+
const state = path.join(projectDir, STATE_DIR) + path.sep;
|
|
83
|
+
for (const root of plan.generatedRoots)
|
|
84
|
+
if (root.startsWith(state))
|
|
85
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
86
|
+
for (const [file, content] of plan.generated)
|
|
87
|
+
if (file.startsWith(state))
|
|
88
|
+
writeIfChanged(file, content);
|
|
89
|
+
for (const [link, target] of plan.links)
|
|
90
|
+
if (link.startsWith(path.join(state, 'pkgs') + path.sep))
|
|
91
|
+
linkIfChanged(link, target);
|
|
92
|
+
return report;
|
|
93
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { parse, stringify } from 'yaml';
|
|
2
|
+
import { HarnessError, isRecord } from './util.js';
|
|
3
|
+
const FRONTMATTER = /^---\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/;
|
|
4
|
+
/** Splits a Markdown file into its YAML frontmatter and its body. */
|
|
5
|
+
export function parseFrontmatter(text, file = 'file') {
|
|
6
|
+
const match = FRONTMATTER.exec(text);
|
|
7
|
+
if (!match)
|
|
8
|
+
return { data: {}, body: text, hasFrontmatter: false };
|
|
9
|
+
let data;
|
|
10
|
+
try {
|
|
11
|
+
data = match[1] ? parse(match[1]) : {};
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
throw new HarnessError(`Invalid frontmatter in ${file}: ${error.message}`);
|
|
15
|
+
}
|
|
16
|
+
if (data === null || data === undefined)
|
|
17
|
+
data = {};
|
|
18
|
+
if (!isRecord(data))
|
|
19
|
+
throw new HarnessError(`Frontmatter in ${file} must be a mapping`);
|
|
20
|
+
return { data, body: text.slice(match[0].length), hasFrontmatter: true };
|
|
21
|
+
}
|
|
22
|
+
export function withFrontmatter(data, body) {
|
|
23
|
+
if (Object.keys(data).length === 0)
|
|
24
|
+
return body;
|
|
25
|
+
return `---\n${stringify(data).trimEnd()}\n---\n\n${body.replace(/^\n+/, '')}`;
|
|
26
|
+
}
|
|
27
|
+
export function stringList(value) {
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (typeof value === 'string')
|
|
31
|
+
return [value];
|
|
32
|
+
if (Array.isArray(value) && value.every((v) => typeof v === 'string'))
|
|
33
|
+
return value;
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
package/dist/homes.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Where each agent keeps its user-level files. Tests and people can move them with environment variables.
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
export function claudeHome() {
|
|
5
|
+
return process.env['CLAUDE_CONFIG_DIR'] ?? path.join(os.homedir(), '.claude');
|
|
6
|
+
}
|
|
7
|
+
/** Claude Code keeps MCP servers of user and local scope here. */
|
|
8
|
+
export function claudeJsonFile() {
|
|
9
|
+
const dir = process.env['CLAUDE_CONFIG_DIR'];
|
|
10
|
+
return dir !== undefined ? path.join(dir, '.claude.json') : path.join(os.homedir(), '.claude.json');
|
|
11
|
+
}
|
|
12
|
+
export function codexHome() {
|
|
13
|
+
return process.env['CODEX_HOME'] ?? path.join(os.homedir(), '.codex');
|
|
14
|
+
}
|
|
15
|
+
export function agentsHome() {
|
|
16
|
+
return path.join(os.homedir(), '.agents');
|
|
17
|
+
}
|