@vaubaan/harness 1.3.2 → 1.4.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 +2 -2
- package/dist/cli/index.js +1275 -1152
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +763 -659
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +29 -6
- package/dist/init.js +1113 -471
- package/dist/init.js.map +1 -1
- package/dist/render.d.ts +1 -1
- package/package.json +1 -1
- package/dist/{render-B9_kNtQc.d.ts → render-Cu2XrkkY.d.ts} +3 -3
package/dist/index.js
CHANGED
|
@@ -1,186 +1,217 @@
|
|
|
1
1
|
// src/version.ts
|
|
2
|
-
var VERSION = "1.
|
|
2
|
+
var VERSION = "1.4.0";
|
|
3
3
|
|
|
4
4
|
// src/cli/init.ts
|
|
5
|
-
import { existsSync as
|
|
6
|
-
import { dirname as
|
|
7
|
-
import { fileURLToPath } from "url";
|
|
8
|
-
|
|
9
|
-
// src/loader.ts
|
|
10
|
-
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
11
|
-
import { join } from "path";
|
|
12
|
-
import fg from "fast-glob";
|
|
13
|
-
import { z as z2 } from "zod";
|
|
14
|
-
|
|
15
|
-
// src/parser/common.ts
|
|
16
|
-
import { readFileSync } from "fs";
|
|
17
|
-
import matter from "gray-matter";
|
|
18
|
-
|
|
19
|
-
// src/parser/schemas.ts
|
|
20
|
-
import { z } from "zod";
|
|
21
|
-
|
|
22
|
-
// src/types.ts
|
|
23
|
-
var AGENT_IDS = ["coding", "review", "merge", "planning"];
|
|
24
|
-
var ENGINES = ["claude-code", "codex-cli", "opencode"];
|
|
25
|
-
var CAPABILITIES = ["multi-step", "slash-command", "inline-exec"];
|
|
26
|
-
var CAPABILITIES_MATRIX = {
|
|
27
|
-
"claude-code": ["multi-step", "slash-command", "inline-exec"],
|
|
28
|
-
"codex-cli": ["multi-step", "slash-command", "inline-exec"],
|
|
29
|
-
opencode: ["multi-step", "slash-command", "inline-exec"]
|
|
30
|
-
};
|
|
31
|
-
var DEGRADED_CAPABILITIES = {
|
|
32
|
-
"codex-cli": ["multi-step"]
|
|
33
|
-
};
|
|
5
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync2, rmSync, writeFileSync as writeFileSync4 } from "fs";
|
|
6
|
+
import { dirname as dirname5, join as join11, resolve as resolve6 } from "path";
|
|
34
7
|
|
|
35
|
-
// src/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
Skill: SkillFrontmatterSchema,
|
|
69
|
-
Cmd: CmdFrontmatterSchema,
|
|
70
|
-
Agent: AgentFrontmatterSchema
|
|
71
|
-
};
|
|
72
|
-
var ParseError = class extends Error {
|
|
73
|
-
constructor(file, issues) {
|
|
74
|
-
const messages = issues.map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
|
|
75
|
-
super(`Frontmatter validation failed for ${file}:
|
|
76
|
-
${messages}`);
|
|
77
|
-
this.file = file;
|
|
78
|
-
this.issues = issues;
|
|
79
|
-
this.name = "ParseError";
|
|
8
|
+
// src/cli/managed-paths.ts
|
|
9
|
+
import { lstatSync, realpathSync, statSync } from "fs";
|
|
10
|
+
import { basename, dirname, isAbsolute, join, resolve, win32 } from "path";
|
|
11
|
+
var MANAGED_FILES = /* @__PURE__ */ new Set([
|
|
12
|
+
"AGENTS.md",
|
|
13
|
+
"CLAUDE.md",
|
|
14
|
+
".mcp.json",
|
|
15
|
+
".harness-manifest.json",
|
|
16
|
+
".claude/settings.json",
|
|
17
|
+
".codex/config.toml",
|
|
18
|
+
".codex/hooks.json",
|
|
19
|
+
".opencode/opencode.json",
|
|
20
|
+
".opencode/mcp.json"
|
|
21
|
+
]);
|
|
22
|
+
var MANAGED_DIRECTORIES = [
|
|
23
|
+
".claude/agents/",
|
|
24
|
+
".claude/commands/",
|
|
25
|
+
".claude/skills/",
|
|
26
|
+
".codex/agents/",
|
|
27
|
+
".codex/commands/",
|
|
28
|
+
".codex/skills/",
|
|
29
|
+
".agents/skills/",
|
|
30
|
+
".opencode/agents/",
|
|
31
|
+
".opencode/commands/",
|
|
32
|
+
".opencode/skills/",
|
|
33
|
+
".opencode/plugins/"
|
|
34
|
+
];
|
|
35
|
+
function canonicalTargetRoot(root) {
|
|
36
|
+
let ancestor = resolve(root);
|
|
37
|
+
const missing = [];
|
|
38
|
+
while (!lstatSync(ancestor, { throwIfNoEntry: false })) {
|
|
39
|
+
missing.unshift(basename(ancestor));
|
|
40
|
+
ancestor = dirname(ancestor);
|
|
80
41
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
// src/parser/common.ts
|
|
86
|
-
function parseWithSchema(filePath, source, schema) {
|
|
87
|
-
const parsed = matter(source);
|
|
88
|
-
const result = schema.safeParse(parsed.data);
|
|
89
|
-
if (!result.success) {
|
|
90
|
-
throw new ParseError(filePath, result.error.issues);
|
|
42
|
+
const canonical = realpathSync(ancestor);
|
|
43
|
+
if (!statSync(canonical).isDirectory()) {
|
|
44
|
+
throw new Error(`harness target is not a directory: ${root}`);
|
|
91
45
|
}
|
|
92
|
-
return
|
|
93
|
-
meta: result.data,
|
|
94
|
-
body: parsed.content.trim(),
|
|
95
|
-
sourcePath: filePath
|
|
96
|
-
};
|
|
46
|
+
return join(canonical, ...missing);
|
|
97
47
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
48
|
+
function assertManagedLocation(path) {
|
|
49
|
+
const invalidSegment = path.split("/").some((part) => part === "" || part === "." || part === "..");
|
|
50
|
+
if (isAbsolute(path) || win32.isAbsolute(path) || path.includes("\\") || path.includes("\0") || invalidSegment) {
|
|
51
|
+
throw new Error(`unsafe harness path: ${path}`);
|
|
52
|
+
}
|
|
53
|
+
if (!MANAGED_FILES.has(path) && !MANAGED_DIRECTORIES.some((prefix) => path.startsWith(prefix))) {
|
|
54
|
+
throw new Error(`path is outside harness-managed locations: ${path}`);
|
|
55
|
+
}
|
|
102
56
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
57
|
+
function assertNoSymbolicComponents(root, path) {
|
|
58
|
+
const parts = path.split("/");
|
|
59
|
+
let current = root;
|
|
60
|
+
for (const [index, part] of parts.entries()) {
|
|
61
|
+
current = join(current, part);
|
|
62
|
+
const info = lstatSync(current, { throwIfNoEntry: false });
|
|
63
|
+
if (!info) return;
|
|
64
|
+
if (info.isSymbolicLink()) {
|
|
65
|
+
throw new Error(`symbolic link in harness path: ${path}`);
|
|
66
|
+
}
|
|
67
|
+
const isLeaf = index === parts.length - 1;
|
|
68
|
+
if (isLeaf ? !info.isFile() : !info.isDirectory()) {
|
|
69
|
+
throw new Error(`unexpected file type in harness path: ${path}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
107
72
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
73
|
+
function assertManagedPaths(root, files) {
|
|
74
|
+
for (const path of files) assertManagedLocation(path);
|
|
75
|
+
const canonicalRoot2 = canonicalTargetRoot(root);
|
|
76
|
+
for (const path of files) assertNoSymbolicComponents(canonicalRoot2, path);
|
|
112
77
|
}
|
|
113
78
|
|
|
114
|
-
// src/
|
|
115
|
-
import {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
79
|
+
// src/cli/manifest.ts
|
|
80
|
+
import { createHash } from "crypto";
|
|
81
|
+
import { chmodSync, existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync } from "fs";
|
|
82
|
+
import { join as join4 } from "path";
|
|
83
|
+
import { z as z3 } from "zod";
|
|
119
84
|
|
|
120
|
-
// src/
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
85
|
+
// src/cli/opencode-config.ts
|
|
86
|
+
import { existsSync, readFileSync } from "fs";
|
|
87
|
+
import { join as join2 } from "path";
|
|
88
|
+
import { isDeepStrictEqual } from "util";
|
|
89
|
+
import { z } from "zod";
|
|
90
|
+
var OPENCODE_CONFIG_PATH = ".opencode/opencode.json";
|
|
91
|
+
var CLAUDE_MCP_PATH = ".mcp.json";
|
|
92
|
+
var OPENCODE_SCHEMA = "https://opencode.ai/config.json";
|
|
93
|
+
var ObjectSchema = z.record(z.unknown());
|
|
94
|
+
var OPENCODE_TARGET = {
|
|
95
|
+
path: OPENCODE_CONFIG_PATH,
|
|
96
|
+
key: "mcp",
|
|
97
|
+
label: "OpenCode",
|
|
98
|
+
schema: OPENCODE_SCHEMA
|
|
99
|
+
};
|
|
100
|
+
var CLAUDE_TARGET = {
|
|
101
|
+
path: CLAUDE_MCP_PATH,
|
|
102
|
+
key: "mcpServers",
|
|
103
|
+
label: "Claude Code"
|
|
104
|
+
};
|
|
105
|
+
var OpenCodeConfigOwnershipSchema = z.object({
|
|
106
|
+
createdFile: z.boolean(),
|
|
107
|
+
addedMcp: z.boolean(),
|
|
108
|
+
addedSchema: z.boolean(),
|
|
109
|
+
entries: z.record(ObjectSchema)
|
|
110
|
+
});
|
|
111
|
+
function parseConfig(target, content) {
|
|
112
|
+
try {
|
|
113
|
+
const config = ObjectSchema.parse(JSON.parse(content));
|
|
114
|
+
if (Object.hasOwn(config, target.key)) ObjectSchema.parse(config[target.key]);
|
|
115
|
+
return config;
|
|
116
|
+
} catch {
|
|
117
|
+
throw new Error(`invalid ${target.path}: expected a JSON object with an MCP object`);
|
|
118
|
+
}
|
|
134
119
|
}
|
|
135
|
-
function
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
120
|
+
function removeOwnedEntries(target, config, ownership, warnings) {
|
|
121
|
+
if (Object.hasOwn(config, target.key)) {
|
|
122
|
+
const mcp = ObjectSchema.parse(config[target.key]);
|
|
123
|
+
for (const [id, installed] of Object.entries(ownership.entries)) {
|
|
124
|
+
if (!Object.hasOwn(mcp, id)) continue;
|
|
125
|
+
if (isDeepStrictEqual(mcp[id], installed)) delete mcp[id];
|
|
126
|
+
else warnings.push(`preserved modified ${target.label} MCP server: ${id}`);
|
|
127
|
+
}
|
|
128
|
+
config[target.key] = mcp;
|
|
129
|
+
if (ownership.addedMcp && Object.keys(mcp).length === 0)
|
|
130
|
+
Reflect.deleteProperty(config, target.key);
|
|
131
|
+
}
|
|
132
|
+
if (target.schema && ownership.addedSchema && config.$schema === target.schema)
|
|
133
|
+
Reflect.deleteProperty(config, "$schema");
|
|
140
134
|
}
|
|
141
|
-
function
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (existsSync(disablePath)) {
|
|
152
|
-
try {
|
|
153
|
-
const parsed = DisableYamlSchema.safeParse(parseYAML(readFileSync2(disablePath, "utf8")));
|
|
154
|
-
if (parsed.success && parsed.data.disable) {
|
|
155
|
-
disable = parsed.data.disable;
|
|
135
|
+
function addMissingEntries(target, config, desired, createdFile, warnings) {
|
|
136
|
+
const requested = ObjectSchema.parse(parseConfig(target, desired.content)[target.key]);
|
|
137
|
+
const mcp = ObjectSchema.parse(config[target.key] ?? {});
|
|
138
|
+
const entries = {};
|
|
139
|
+
for (const [id, entry] of Object.entries(requested)) {
|
|
140
|
+
if (Object.hasOwn(mcp, id)) {
|
|
141
|
+
if (!isDeepStrictEqual(mcp[id], entry)) {
|
|
142
|
+
warnings.push(
|
|
143
|
+
`preserved existing ${target.label} MCP server instead of replacing it: ${id}`
|
|
144
|
+
);
|
|
156
145
|
}
|
|
157
|
-
|
|
146
|
+
continue;
|
|
158
147
|
}
|
|
148
|
+
entries[id] = ObjectSchema.parse(entry);
|
|
149
|
+
mcp[id] = entry;
|
|
159
150
|
}
|
|
160
|
-
|
|
151
|
+
if (Object.keys(entries).length === 0) return void 0;
|
|
152
|
+
const ownership = {
|
|
153
|
+
createdFile,
|
|
154
|
+
addedMcp: !Object.hasOwn(config, target.key),
|
|
155
|
+
addedSchema: target.schema !== void 0 && !Object.hasOwn(config, "$schema"),
|
|
156
|
+
entries
|
|
157
|
+
};
|
|
158
|
+
config[target.key] = mcp;
|
|
159
|
+
if (target.schema && ownership.addedSchema) config.$schema = target.schema;
|
|
160
|
+
return ownership;
|
|
161
161
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
162
|
+
function planMcpJson(target, root, desired, previous) {
|
|
163
|
+
const warnings = [];
|
|
164
|
+
if (!desired && !previous) return { remove: false, warnings };
|
|
165
|
+
const path = join2(root, target.path);
|
|
166
|
+
const existed = existsSync(path);
|
|
167
|
+
if (!existed && !desired) return { remove: false, warnings };
|
|
168
|
+
const original = existed ? readFileSync(path, "utf8") : void 0;
|
|
169
|
+
const config = original === void 0 ? {} : parseConfig(target, original);
|
|
170
|
+
const before = structuredClone(config);
|
|
171
|
+
if (previous) removeOwnedEntries(target, config, previous, warnings);
|
|
172
|
+
const createdFile = !existed || previous?.createdFile === true;
|
|
173
|
+
const ownership = desired ? addMissingEntries(target, config, desired, createdFile, warnings) : void 0;
|
|
174
|
+
if (!ownership && createdFile && Object.keys(config).length === 0) {
|
|
175
|
+
return { remove: existed, warnings };
|
|
171
176
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
return {
|
|
178
|
+
remove: false,
|
|
179
|
+
ownership,
|
|
180
|
+
warnings,
|
|
181
|
+
...!existed || !isDeepStrictEqual(before, config) ? { write: { path: target.path, content: `${JSON.stringify(config, null, 2)}
|
|
182
|
+
` } } : {}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function planOpenCodeConfig(root, desired, previous) {
|
|
186
|
+
return planMcpJson(OPENCODE_TARGET, root, desired, previous);
|
|
187
|
+
}
|
|
188
|
+
function planClaudeMcpConfig(root, desired, previous) {
|
|
189
|
+
return planMcpJson(CLAUDE_TARGET, root, desired, previous);
|
|
180
190
|
}
|
|
181
191
|
|
|
192
|
+
// src/cli/update-config.ts
|
|
193
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
194
|
+
import { join as join3 } from "path";
|
|
195
|
+
import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
|
|
196
|
+
import { z as z2 } from "zod";
|
|
197
|
+
|
|
182
198
|
// src/renderers/common.ts
|
|
183
199
|
import { stringify as yamlStringify } from "yaml";
|
|
200
|
+
|
|
201
|
+
// src/types.ts
|
|
202
|
+
var AGENT_IDS = ["coding", "review", "merge", "planning"];
|
|
203
|
+
var ENGINES = ["claude-code", "codex-cli", "opencode"];
|
|
204
|
+
var CAPABILITIES = ["multi-step", "slash-command", "inline-exec"];
|
|
205
|
+
var CAPABILITIES_MATRIX = {
|
|
206
|
+
"claude-code": ["multi-step", "slash-command", "inline-exec"],
|
|
207
|
+
"codex-cli": ["multi-step", "slash-command", "inline-exec"],
|
|
208
|
+
opencode: ["multi-step", "slash-command", "inline-exec"]
|
|
209
|
+
};
|
|
210
|
+
var DEGRADED_CAPABILITIES = {
|
|
211
|
+
"codex-cli": ["multi-step"]
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// src/renderers/common.ts
|
|
184
215
|
var STOP_HOOK_PACKAGE = "@vaubaan/harness";
|
|
185
216
|
function checkSkillCapabilities(skill, engine) {
|
|
186
217
|
const blockingWarnings = [];
|
|
@@ -624,274 +655,7 @@ function getRenderer(engine) {
|
|
|
624
655
|
return RENDERERS[engine];
|
|
625
656
|
}
|
|
626
657
|
|
|
627
|
-
// src/render.ts
|
|
628
|
-
var RenderError = class extends Error {
|
|
629
|
-
constructor(message) {
|
|
630
|
-
super(message);
|
|
631
|
-
this.name = "RenderError";
|
|
632
|
-
}
|
|
633
|
-
};
|
|
634
|
-
function mergeById(stock, added) {
|
|
635
|
-
const result = /* @__PURE__ */ new Map();
|
|
636
|
-
for (const item of stock) result.set(item.meta.id, item);
|
|
637
|
-
if (added) {
|
|
638
|
-
for (const item of added) result.set(item.meta.id, item);
|
|
639
|
-
}
|
|
640
|
-
return Array.from(result.values());
|
|
641
|
-
}
|
|
642
|
-
function removeByIds(items, disabled) {
|
|
643
|
-
if (disabled.length === 0) return [...items];
|
|
644
|
-
const disabledSet = new Set(disabled);
|
|
645
|
-
return items.filter((item) => !disabledSet.has(item.meta.id));
|
|
646
|
-
}
|
|
647
|
-
function applyOverrides(harness, overrides) {
|
|
648
|
-
const disabled = overrides?.disable ?? [];
|
|
649
|
-
return {
|
|
650
|
-
agents: removeByIds(mergeById(harness.agents, overrides?.add?.agents), disabled),
|
|
651
|
-
skills: removeByIds(mergeById(harness.skills, overrides?.add?.skills), disabled),
|
|
652
|
-
cmds: removeByIds(mergeById(harness.cmds, overrides?.add?.cmds), disabled)
|
|
653
|
-
};
|
|
654
|
-
}
|
|
655
|
-
var CLAUDE_MD_STUB = "@AGENTS.md\n";
|
|
656
|
-
function opencodeLast(engines) {
|
|
657
|
-
return [...engines].sort((a, b) => Number(a === "opencode") - Number(b === "opencode"));
|
|
658
|
-
}
|
|
659
|
-
function render(input) {
|
|
660
|
-
const { engines } = input;
|
|
661
|
-
if (engines.length === 0) {
|
|
662
|
-
throw new RenderError("at least one engine must be selected");
|
|
663
|
-
}
|
|
664
|
-
const base = input.projectSkills?.length ? { ...input.harness, skills: mergeById(input.harness.skills, input.projectSkills) } : input.harness;
|
|
665
|
-
const harness = applyOverrides(base, input.overrides);
|
|
666
|
-
if (harness.agents.length === 0) {
|
|
667
|
-
throw new RenderError("No agents found in harness");
|
|
668
|
-
}
|
|
669
|
-
const files = [];
|
|
670
|
-
const warnings = [];
|
|
671
|
-
const exposedSkills = /* @__PURE__ */ new Map();
|
|
672
|
-
const exposedCmds = /* @__PURE__ */ new Map();
|
|
673
|
-
for (const engine of opencodeLast(engines)) {
|
|
674
|
-
const renderer = getRenderer(engine);
|
|
675
|
-
const renderedSkills = [];
|
|
676
|
-
for (const skill of harness.skills) {
|
|
677
|
-
const result = renderer.renderSkill(skill);
|
|
678
|
-
warnings.push(...result.warnings);
|
|
679
|
-
if (result.files.length > 0) {
|
|
680
|
-
const alreadyVisible = engine === "opencode" && exposedSkills.has(skill.meta.id);
|
|
681
|
-
if (!alreadyVisible) files.push(...result.files);
|
|
682
|
-
renderedSkills.push(skill);
|
|
683
|
-
exposedSkills.set(skill.meta.id, skill);
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
const renderedSkillIds = new Set(renderedSkills.map((s) => s.meta.id));
|
|
687
|
-
const renderedCmds = [];
|
|
688
|
-
for (const cmd of harness.cmds) {
|
|
689
|
-
if (!renderedSkillIds.has(cmd.meta.invokes_skill)) continue;
|
|
690
|
-
const result = renderer.renderCmd(cmd);
|
|
691
|
-
warnings.push(...result.warnings);
|
|
692
|
-
if (result.files.length > 0) {
|
|
693
|
-
files.push(...result.files);
|
|
694
|
-
renderedCmds.push(cmd);
|
|
695
|
-
exposedCmds.set(cmd.meta.id, cmd);
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
const agentsResult = renderer.renderAgents(harness.agents);
|
|
699
|
-
warnings.push(...agentsResult.warnings);
|
|
700
|
-
files.push(...agentsResult.files);
|
|
701
|
-
if (input.mcpServers && input.mcpServers.length > 0) {
|
|
702
|
-
const specs = input.mcpServers.map((id) => MCP_SERVERS[id]);
|
|
703
|
-
const mcpResult = renderer.renderMcp(specs);
|
|
704
|
-
warnings.push(...mcpResult.warnings);
|
|
705
|
-
files.push(...mcpResult.files);
|
|
706
|
-
}
|
|
707
|
-
if (input.withStopHook !== false) {
|
|
708
|
-
const hookResult = renderer.renderStopHook();
|
|
709
|
-
warnings.push(...hookResult.warnings);
|
|
710
|
-
files.push(...hookResult.files);
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
const hasProjectSkills = (input.projectSkills?.length ?? 0) > 0;
|
|
714
|
-
const umbrellaContent = buildUmbrellaBody(harness.agents, {
|
|
715
|
-
availableSkills: Array.from(exposedSkills.values()),
|
|
716
|
-
availableCommands: Array.from(exposedCmds.values()),
|
|
717
|
-
hasProjectSkills,
|
|
718
|
-
hasQualityDoc: input.hasQualityDoc === true
|
|
719
|
-
});
|
|
720
|
-
files.push({ path: "AGENTS.md", content: umbrellaContent });
|
|
721
|
-
if (engines.includes("claude-code")) {
|
|
722
|
-
files.push({ path: "CLAUDE.md", content: CLAUDE_MD_STUB });
|
|
723
|
-
}
|
|
724
|
-
return { files, warnings };
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
// src/cli/managed-paths.ts
|
|
728
|
-
import { lstatSync, realpathSync, statSync } from "fs";
|
|
729
|
-
import { basename, dirname, isAbsolute, join as join2, resolve, win32 } from "path";
|
|
730
|
-
var MANAGED_FILES = /* @__PURE__ */ new Set([
|
|
731
|
-
"AGENTS.md",
|
|
732
|
-
"CLAUDE.md",
|
|
733
|
-
".mcp.json",
|
|
734
|
-
".harness-manifest.json",
|
|
735
|
-
".claude/settings.json",
|
|
736
|
-
".codex/config.toml",
|
|
737
|
-
".codex/hooks.json",
|
|
738
|
-
".opencode/opencode.json",
|
|
739
|
-
".opencode/mcp.json"
|
|
740
|
-
]);
|
|
741
|
-
var MANAGED_DIRECTORIES = [
|
|
742
|
-
".claude/agents/",
|
|
743
|
-
".claude/commands/",
|
|
744
|
-
".claude/skills/",
|
|
745
|
-
".codex/agents/",
|
|
746
|
-
".codex/commands/",
|
|
747
|
-
".codex/skills/",
|
|
748
|
-
".agents/skills/",
|
|
749
|
-
".opencode/agents/",
|
|
750
|
-
".opencode/commands/",
|
|
751
|
-
".opencode/skills/",
|
|
752
|
-
".opencode/plugins/"
|
|
753
|
-
];
|
|
754
|
-
function canonicalTargetRoot(root) {
|
|
755
|
-
let ancestor = resolve(root);
|
|
756
|
-
const missing = [];
|
|
757
|
-
while (!lstatSync(ancestor, { throwIfNoEntry: false })) {
|
|
758
|
-
missing.unshift(basename(ancestor));
|
|
759
|
-
ancestor = dirname(ancestor);
|
|
760
|
-
}
|
|
761
|
-
const canonical = realpathSync(ancestor);
|
|
762
|
-
if (!statSync(canonical).isDirectory()) {
|
|
763
|
-
throw new Error(`harness target is not a directory: ${root}`);
|
|
764
|
-
}
|
|
765
|
-
return join2(canonical, ...missing);
|
|
766
|
-
}
|
|
767
|
-
function assertManagedLocation(path) {
|
|
768
|
-
const invalidSegment = path.split("/").some((part) => part === "" || part === "." || part === "..");
|
|
769
|
-
if (isAbsolute(path) || win32.isAbsolute(path) || path.includes("\\") || path.includes("\0") || invalidSegment) {
|
|
770
|
-
throw new Error(`unsafe harness path: ${path}`);
|
|
771
|
-
}
|
|
772
|
-
if (!MANAGED_FILES.has(path) && !MANAGED_DIRECTORIES.some((prefix) => path.startsWith(prefix))) {
|
|
773
|
-
throw new Error(`path is outside harness-managed locations: ${path}`);
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
function assertNoSymbolicComponents(root, path) {
|
|
777
|
-
const parts = path.split("/");
|
|
778
|
-
let current = root;
|
|
779
|
-
for (const [index, part] of parts.entries()) {
|
|
780
|
-
current = join2(current, part);
|
|
781
|
-
const info = lstatSync(current, { throwIfNoEntry: false });
|
|
782
|
-
if (!info) return;
|
|
783
|
-
if (info.isSymbolicLink()) {
|
|
784
|
-
throw new Error(`symbolic link in harness path: ${path}`);
|
|
785
|
-
}
|
|
786
|
-
const isLeaf = index === parts.length - 1;
|
|
787
|
-
if (isLeaf ? !info.isFile() : !info.isDirectory()) {
|
|
788
|
-
throw new Error(`unexpected file type in harness path: ${path}`);
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
function assertManagedPaths(root, files) {
|
|
793
|
-
for (const path of files) assertManagedLocation(path);
|
|
794
|
-
const canonicalRoot2 = canonicalTargetRoot(root);
|
|
795
|
-
for (const path of files) assertNoSymbolicComponents(canonicalRoot2, path);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// src/cli/manifest.ts
|
|
799
|
-
import { createHash } from "crypto";
|
|
800
|
-
import { chmodSync, existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync } from "fs";
|
|
801
|
-
import { join as join5 } from "path";
|
|
802
|
-
import { z as z5 } from "zod";
|
|
803
|
-
|
|
804
|
-
// src/cli/opencode-config.ts
|
|
805
|
-
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
806
|
-
import { join as join3 } from "path";
|
|
807
|
-
import { isDeepStrictEqual } from "util";
|
|
808
|
-
import { z as z3 } from "zod";
|
|
809
|
-
var OPENCODE_CONFIG_PATH = ".opencode/opencode.json";
|
|
810
|
-
var OPENCODE_SCHEMA = "https://opencode.ai/config.json";
|
|
811
|
-
var ObjectSchema = z3.record(z3.unknown());
|
|
812
|
-
var OpenCodeConfigOwnershipSchema = z3.object({
|
|
813
|
-
createdFile: z3.boolean(),
|
|
814
|
-
addedMcp: z3.boolean(),
|
|
815
|
-
addedSchema: z3.boolean(),
|
|
816
|
-
entries: z3.record(ObjectSchema)
|
|
817
|
-
});
|
|
818
|
-
function parseConfig(content) {
|
|
819
|
-
try {
|
|
820
|
-
const config = ObjectSchema.parse(JSON.parse(content));
|
|
821
|
-
if (Object.hasOwn(config, "mcp")) ObjectSchema.parse(config.mcp);
|
|
822
|
-
return config;
|
|
823
|
-
} catch {
|
|
824
|
-
throw new Error(`invalid ${OPENCODE_CONFIG_PATH}: expected a JSON object with an MCP object`);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
function removeOwnedEntries(config, ownership, warnings) {
|
|
828
|
-
if (Object.hasOwn(config, "mcp")) {
|
|
829
|
-
const mcp = ObjectSchema.parse(config.mcp);
|
|
830
|
-
for (const [id, installed] of Object.entries(ownership.entries)) {
|
|
831
|
-
if (!Object.hasOwn(mcp, id)) continue;
|
|
832
|
-
if (isDeepStrictEqual(mcp[id], installed)) delete mcp[id];
|
|
833
|
-
else warnings.push(`preserved modified OpenCode MCP server: ${id}`);
|
|
834
|
-
}
|
|
835
|
-
config.mcp = mcp;
|
|
836
|
-
if (ownership.addedMcp && Object.keys(mcp).length === 0) Reflect.deleteProperty(config, "mcp");
|
|
837
|
-
}
|
|
838
|
-
if (ownership.addedSchema && config.$schema === OPENCODE_SCHEMA)
|
|
839
|
-
Reflect.deleteProperty(config, "$schema");
|
|
840
|
-
}
|
|
841
|
-
function addMissingEntries(config, desired, createdFile, warnings) {
|
|
842
|
-
const requested = ObjectSchema.parse(parseConfig(desired.content).mcp);
|
|
843
|
-
const mcp = ObjectSchema.parse(config.mcp ?? {});
|
|
844
|
-
const entries = {};
|
|
845
|
-
for (const [id, entry] of Object.entries(requested)) {
|
|
846
|
-
if (Object.hasOwn(mcp, id)) {
|
|
847
|
-
if (!isDeepStrictEqual(mcp[id], entry)) {
|
|
848
|
-
warnings.push(`preserved existing OpenCode MCP server instead of replacing it: ${id}`);
|
|
849
|
-
}
|
|
850
|
-
continue;
|
|
851
|
-
}
|
|
852
|
-
entries[id] = ObjectSchema.parse(entry);
|
|
853
|
-
mcp[id] = entry;
|
|
854
|
-
}
|
|
855
|
-
if (Object.keys(entries).length === 0) return void 0;
|
|
856
|
-
const ownership = {
|
|
857
|
-
createdFile,
|
|
858
|
-
addedMcp: !Object.hasOwn(config, "mcp"),
|
|
859
|
-
addedSchema: !Object.hasOwn(config, "$schema"),
|
|
860
|
-
entries
|
|
861
|
-
};
|
|
862
|
-
config.mcp = mcp;
|
|
863
|
-
if (ownership.addedSchema) config.$schema = OPENCODE_SCHEMA;
|
|
864
|
-
return ownership;
|
|
865
|
-
}
|
|
866
|
-
function planOpenCodeConfig(root, desired, previous) {
|
|
867
|
-
const warnings = [];
|
|
868
|
-
if (!desired && !previous) return { remove: false, warnings };
|
|
869
|
-
const path = join3(root, OPENCODE_CONFIG_PATH);
|
|
870
|
-
const existed = existsSync2(path);
|
|
871
|
-
if (!existed && !desired) return { remove: false, warnings };
|
|
872
|
-
const original = existed ? readFileSync3(path, "utf8") : void 0;
|
|
873
|
-
const config = original === void 0 ? {} : parseConfig(original);
|
|
874
|
-
const before = structuredClone(config);
|
|
875
|
-
if (previous) removeOwnedEntries(config, previous, warnings);
|
|
876
|
-
const createdFile = !existed || previous?.createdFile === true;
|
|
877
|
-
const ownership = desired ? addMissingEntries(config, desired, createdFile, warnings) : void 0;
|
|
878
|
-
if (!ownership && createdFile && Object.keys(config).length === 0) {
|
|
879
|
-
return { remove: existed, warnings };
|
|
880
|
-
}
|
|
881
|
-
return {
|
|
882
|
-
remove: false,
|
|
883
|
-
ownership,
|
|
884
|
-
warnings,
|
|
885
|
-
...!existed || !isDeepStrictEqual(before, config) ? { write: { path: OPENCODE_CONFIG_PATH, content: `${JSON.stringify(config, null, 2)}
|
|
886
|
-
` } } : {}
|
|
887
|
-
};
|
|
888
|
-
}
|
|
889
|
-
|
|
890
658
|
// src/cli/update-config.ts
|
|
891
|
-
import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
|
|
892
|
-
import { join as join4 } from "path";
|
|
893
|
-
import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
|
|
894
|
-
import { z as z4 } from "zod";
|
|
895
659
|
var LEGACY_OPENCODE_CONFIG = ".opencode/mcp.json";
|
|
896
660
|
var SHARED_CONFIG_PATHS = [
|
|
897
661
|
".mcp.json",
|
|
@@ -901,15 +665,15 @@ var SHARED_CONFIG_PATHS = [
|
|
|
901
665
|
".claude/settings.json",
|
|
902
666
|
".codex/hooks.json"
|
|
903
667
|
];
|
|
904
|
-
var ObjectSchema2 =
|
|
905
|
-
var HookEntrySchema =
|
|
906
|
-
var HookConfigOwnershipSchema =
|
|
907
|
-
createdFile:
|
|
908
|
-
addedHooks:
|
|
909
|
-
addedStop:
|
|
910
|
-
entries:
|
|
668
|
+
var ObjectSchema2 = z2.record(z2.unknown());
|
|
669
|
+
var HookEntrySchema = z2.object({ group: ObjectSchema2, hook: ObjectSchema2 });
|
|
670
|
+
var HookConfigOwnershipSchema = z2.object({
|
|
671
|
+
createdFile: z2.boolean(),
|
|
672
|
+
addedHooks: z2.boolean(),
|
|
673
|
+
addedStop: z2.boolean(),
|
|
674
|
+
entries: z2.array(HookEntrySchema)
|
|
911
675
|
});
|
|
912
|
-
var HookConfigsOwnershipSchema =
|
|
676
|
+
var HookConfigsOwnershipSchema = z2.object({
|
|
913
677
|
".claude/settings.json": HookConfigOwnershipSchema.optional(),
|
|
914
678
|
".codex/hooks.json": HookConfigOwnershipSchema.optional()
|
|
915
679
|
}).strict();
|
|
@@ -1033,8 +797,8 @@ function planHookConfigCleanup(root, ownership) {
|
|
|
1033
797
|
assertManagedPaths(root, Object.keys(validated));
|
|
1034
798
|
for (const path of Object.keys(HOOK_ENGINES)) {
|
|
1035
799
|
const owned = validated[path];
|
|
1036
|
-
if (!owned || !
|
|
1037
|
-
const before = parseObject(
|
|
800
|
+
if (!owned || !existsSync2(join3(root, path))) continue;
|
|
801
|
+
const before = parseObject(readFileSync2(join3(root, path), "utf8"), path);
|
|
1038
802
|
const config = structuredClone(before);
|
|
1039
803
|
removeOwnedHooks(config, owned, path, plan.warnings);
|
|
1040
804
|
recordMutation(plan, path, before, config, owned.createdFile);
|
|
@@ -1043,12 +807,12 @@ function planHookConfigCleanup(root, ownership) {
|
|
|
1043
807
|
}
|
|
1044
808
|
function planActiveHook(root, path, previous, claimExisting, plan) {
|
|
1045
809
|
assertManagedPaths(root, [path]);
|
|
1046
|
-
const existed =
|
|
810
|
+
const existed = existsSync2(join3(root, path));
|
|
1047
811
|
if (!existed && previous?.entries.length) {
|
|
1048
812
|
plan.warnings.push(`preserved removed harness Stop hook configuration: ${path}`);
|
|
1049
813
|
return previous;
|
|
1050
814
|
}
|
|
1051
|
-
const before = existed ? parseObject(
|
|
815
|
+
const before = existed ? parseObject(readFileSync2(join3(root, path), "utf8"), path) : void 0;
|
|
1052
816
|
const config = structuredClone(before ?? {});
|
|
1053
817
|
const groups = stopHooks(config, path);
|
|
1054
818
|
const hooks = asObject(config.hooks) ?? {};
|
|
@@ -1060,10 +824,11 @@ function planActiveHook(root, path, previous, claimExisting, plan) {
|
|
|
1060
824
|
plan.warnings,
|
|
1061
825
|
path
|
|
1062
826
|
);
|
|
827
|
+
const legacyWholeFile = !previous && claimExisting && existed;
|
|
1063
828
|
const ownership = {
|
|
1064
|
-
createdFile: !existed || previous?.createdFile === true,
|
|
1065
|
-
addedHooks: previous?.addedHooks ?? !Object.hasOwn(config, "hooks"),
|
|
1066
|
-
addedStop: previous?.addedStop ?? !Object.hasOwn(hooks, "Stop"),
|
|
829
|
+
createdFile: !existed || previous?.createdFile === true || legacyWholeFile,
|
|
830
|
+
addedHooks: previous?.addedHooks ?? (legacyWholeFile || !Object.hasOwn(config, "hooks")),
|
|
831
|
+
addedStop: previous?.addedStop ?? (legacyWholeFile || !Object.hasOwn(hooks, "Stop")),
|
|
1067
832
|
entries: normalized.entries
|
|
1068
833
|
};
|
|
1069
834
|
if (!isDeepStrictEqual2(groups, normalized.groups)) {
|
|
@@ -1074,10 +839,10 @@ function planActiveHook(root, path, previous, claimExisting, plan) {
|
|
|
1074
839
|
}
|
|
1075
840
|
function planLegacyOpenCode(root, previous, plan) {
|
|
1076
841
|
if (!previous.files.includes(LEGACY_OPENCODE_CONFIG)) return;
|
|
1077
|
-
const legacyPath =
|
|
1078
|
-
if (!
|
|
842
|
+
const legacyPath = join3(root, LEGACY_OPENCODE_CONFIG);
|
|
843
|
+
if (!existsSync2(legacyPath)) return;
|
|
1079
844
|
assertManagedPaths(root, [LEGACY_OPENCODE_CONFIG, OPENCODE_CONFIG_PATH]);
|
|
1080
|
-
const legacy = parseObject(
|
|
845
|
+
const legacy = parseObject(readFileSync2(legacyPath, "utf8"), LEGACY_OPENCODE_CONFIG);
|
|
1081
846
|
if (!asObject(legacy.mcp)) {
|
|
1082
847
|
throw new Error(
|
|
1083
848
|
`invalid shared configuration ${LEGACY_OPENCODE_CONFIG}: expected an MCP object`
|
|
@@ -1141,45 +906,24 @@ function planUpdateConfigs(root, previous, hookEngines) {
|
|
|
1141
906
|
// src/cli/manifest.ts
|
|
1142
907
|
var MANIFEST_FILENAME = ".harness-manifest.json";
|
|
1143
908
|
var MANIFEST_VERSION = "6";
|
|
1144
|
-
var ManifestSchema =
|
|
1145
|
-
version:
|
|
1146
|
-
engines:
|
|
1147
|
-
renderedAt:
|
|
1148
|
-
files:
|
|
1149
|
-
stopHookInstalled:
|
|
1150
|
-
stopHookEngines:
|
|
909
|
+
var ManifestSchema = z3.object({
|
|
910
|
+
version: z3.enum(["4", "5", MANIFEST_VERSION]),
|
|
911
|
+
engines: z3.array(z3.string()),
|
|
912
|
+
renderedAt: z3.string(),
|
|
913
|
+
files: z3.array(z3.string()),
|
|
914
|
+
stopHookInstalled: z3.boolean().optional(),
|
|
915
|
+
stopHookEngines: z3.array(z3.string()).optional(),
|
|
1151
916
|
opencodeConfig: OpenCodeConfigOwnershipSchema.optional(),
|
|
917
|
+
/** Entries the harness added to `.mcp.json` (Claude Code). */
|
|
918
|
+
mcpConfig: OpenCodeConfigOwnershipSchema.optional(),
|
|
1152
919
|
hookConfigs: HookConfigsOwnershipSchema.optional(),
|
|
1153
|
-
harnessVersion:
|
|
1154
|
-
overridesDir:
|
|
1155
|
-
fileHashes:
|
|
1156
|
-
preservedFiles:
|
|
920
|
+
harnessVersion: z3.string().optional(),
|
|
921
|
+
overridesDir: z3.string().optional(),
|
|
922
|
+
fileHashes: z3.record(z3.string().regex(/^[a-f0-9]{64}$/)).optional(),
|
|
923
|
+
preservedFiles: z3.array(z3.string()).optional()
|
|
1157
924
|
});
|
|
1158
925
|
function manifestPath(targetDir) {
|
|
1159
|
-
return
|
|
1160
|
-
}
|
|
1161
|
-
function writeManifest(targetDir, engines, files, extras = {}) {
|
|
1162
|
-
assertManagedPaths(targetDir, [MANIFEST_FILENAME]);
|
|
1163
|
-
const manifest = {
|
|
1164
|
-
version: MANIFEST_VERSION,
|
|
1165
|
-
harnessVersion: VERSION,
|
|
1166
|
-
engines: [...engines].sort(),
|
|
1167
|
-
renderedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1168
|
-
files: [...files].sort(),
|
|
1169
|
-
fileHashes: Object.fromEntries(
|
|
1170
|
-
files.filter((file) => existsSync4(join5(targetDir, file))).sort().map((file) => [file, hashContent(readFileSync5(join5(targetDir, file)))])
|
|
1171
|
-
),
|
|
1172
|
-
...extras.overridesDir ? { overridesDir: extras.overridesDir } : {},
|
|
1173
|
-
...extras.preservedFiles ? { preservedFiles: extras.preservedFiles } : {},
|
|
1174
|
-
...extras.hookConfigs ? { hookConfigs: extras.hookConfigs } : {},
|
|
1175
|
-
...extras.opencodeConfig ? { opencodeConfig: extras.opencodeConfig } : {},
|
|
1176
|
-
...extras.stopHookInstalled !== void 0 ? { stopHookInstalled: extras.stopHookInstalled } : {},
|
|
1177
|
-
...extras.stopHookEngines !== void 0 ? { stopHookEngines: [...extras.stopHookEngines].sort() } : {}
|
|
1178
|
-
};
|
|
1179
|
-
const path = manifestPath(targetDir);
|
|
1180
|
-
if (existsSync4(path)) chmodSync(path, 384);
|
|
1181
|
-
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}
|
|
1182
|
-
`, { mode: 384 });
|
|
926
|
+
return join4(targetDir, MANIFEST_FILENAME);
|
|
1183
927
|
}
|
|
1184
928
|
function hashContent(content) {
|
|
1185
929
|
return createHash("sha256").update(content).digest("hex");
|
|
@@ -1187,66 +931,291 @@ function hashContent(content) {
|
|
|
1187
931
|
function readManifest(targetDir) {
|
|
1188
932
|
assertManagedPaths(targetDir, [MANIFEST_FILENAME]);
|
|
1189
933
|
const path = manifestPath(targetDir);
|
|
1190
|
-
if (!
|
|
1191
|
-
const raw = JSON.parse(
|
|
934
|
+
if (!existsSync3(path)) return null;
|
|
935
|
+
const raw = JSON.parse(readFileSync3(path, "utf8"));
|
|
1192
936
|
return ManifestSchema.parse(raw);
|
|
1193
937
|
}
|
|
1194
938
|
|
|
1195
|
-
// src/cli/
|
|
1196
|
-
import { existsSync as existsSync5
|
|
1197
|
-
import { dirname as dirname2, join as join6,
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
939
|
+
// src/cli/plan-render.ts
|
|
940
|
+
import { existsSync as existsSync5 } from "fs";
|
|
941
|
+
import { dirname as dirname2, join as join6, resolve as resolve2 } from "path";
|
|
942
|
+
import { fileURLToPath } from "url";
|
|
943
|
+
|
|
944
|
+
// src/loader.ts
|
|
945
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
|
|
946
|
+
import { join as join5 } from "path";
|
|
947
|
+
import fg from "fast-glob";
|
|
948
|
+
import { z as z5 } from "zod";
|
|
949
|
+
|
|
950
|
+
// src/parser/common.ts
|
|
951
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
952
|
+
import matter from "gray-matter";
|
|
953
|
+
|
|
954
|
+
// src/parser/schemas.ts
|
|
955
|
+
import { z as z4 } from "zod";
|
|
956
|
+
var IdSchema = z4.string().min(1).regex(/^[a-z][a-z0-9-]*$/, "id must be kebab-case (lowercase + digits + hyphens)");
|
|
957
|
+
var SkillFrontmatterSchema = z4.object({
|
|
958
|
+
id: IdSchema,
|
|
959
|
+
description: z4.string().min(1),
|
|
960
|
+
engines: z4.array(z4.enum(ENGINES)).min(1).optional(),
|
|
961
|
+
requires: z4.array(z4.enum(CAPABILITIES)).optional(),
|
|
962
|
+
tags: z4.array(z4.string()).optional()
|
|
963
|
+
}).strict();
|
|
964
|
+
var CmdArgEnumSchema = z4.object({
|
|
965
|
+
name: z4.string().min(1),
|
|
966
|
+
type: z4.literal("enum"),
|
|
967
|
+
values: z4.array(z4.string()).min(1),
|
|
968
|
+
default: z4.string().optional()
|
|
969
|
+
}).strict();
|
|
970
|
+
var CmdArgStringSchema = z4.object({
|
|
971
|
+
name: z4.string().min(1),
|
|
972
|
+
type: z4.literal("string"),
|
|
973
|
+
default: z4.string().optional()
|
|
974
|
+
}).strict();
|
|
975
|
+
var CmdArgSchema = z4.discriminatedUnion("type", [CmdArgEnumSchema, CmdArgStringSchema]);
|
|
976
|
+
var CmdFrontmatterSchema = z4.object({
|
|
977
|
+
id: IdSchema,
|
|
978
|
+
trigger: z4.string().regex(/^\/[a-z][a-z0-9-]*$/, "trigger must be /kebab-case"),
|
|
979
|
+
invokes_skill: IdSchema,
|
|
980
|
+
args: z4.array(CmdArgSchema).optional(),
|
|
981
|
+
description: z4.string().min(1)
|
|
982
|
+
}).strict();
|
|
983
|
+
var AgentFrontmatterSchema = z4.object({
|
|
984
|
+
id: z4.enum(AGENT_IDS),
|
|
985
|
+
description: z4.string().min(1)
|
|
986
|
+
}).strict();
|
|
987
|
+
var Schemas = {
|
|
988
|
+
Skill: SkillFrontmatterSchema,
|
|
989
|
+
Cmd: CmdFrontmatterSchema,
|
|
990
|
+
Agent: AgentFrontmatterSchema
|
|
991
|
+
};
|
|
992
|
+
var ParseError = class extends Error {
|
|
993
|
+
constructor(file, issues) {
|
|
994
|
+
const messages = issues.map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
|
|
995
|
+
super(`Frontmatter validation failed for ${file}:
|
|
996
|
+
${messages}`);
|
|
997
|
+
this.file = file;
|
|
998
|
+
this.issues = issues;
|
|
999
|
+
this.name = "ParseError";
|
|
1000
|
+
}
|
|
1001
|
+
file;
|
|
1002
|
+
issues;
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
// src/parser/common.ts
|
|
1006
|
+
function parseWithSchema(filePath, source, schema) {
|
|
1007
|
+
const parsed = matter(source);
|
|
1008
|
+
const result = schema.safeParse(parsed.data);
|
|
1009
|
+
if (!result.success) {
|
|
1010
|
+
throw new ParseError(filePath, result.error.issues);
|
|
1011
|
+
}
|
|
1012
|
+
return {
|
|
1013
|
+
meta: result.data,
|
|
1014
|
+
body: parsed.content.trim(),
|
|
1015
|
+
sourcePath: filePath
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// src/parser/agent.ts
|
|
1020
|
+
function parseAgent(filePath, source) {
|
|
1021
|
+
return parseWithSchema(filePath, source, Schemas.Agent);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/parser/cmd.ts
|
|
1025
|
+
function parseCmd(filePath, source) {
|
|
1026
|
+
return parseWithSchema(filePath, source, Schemas.Cmd);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// src/parser/skill.ts
|
|
1030
|
+
function parseSkill(filePath, source) {
|
|
1031
|
+
return parseWithSchema(filePath, source, Schemas.Skill);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// src/util/yaml.ts
|
|
1035
|
+
import { parse as yamlParse } from "yaml";
|
|
1036
|
+
function parseYAML(source) {
|
|
1037
|
+
return yamlParse(source);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/loader.ts
|
|
1041
|
+
var DisableYamlSchema = z5.object({ disable: z5.array(z5.string()).optional() }).passthrough();
|
|
1042
|
+
function loadHarness(packageDir, opts = {}) {
|
|
1043
|
+
const agentsDir = opts.agentsDir ?? join5(packageDir, "agents");
|
|
1044
|
+
const skillsDir = opts.skillsDir ?? join5(packageDir, "skills");
|
|
1045
|
+
const commandsDir = opts.commandsDir ?? join5(packageDir, "commands");
|
|
1046
|
+
const agentFiles = fg.sync("*.agent.md", { cwd: agentsDir, absolute: true });
|
|
1047
|
+
const skillFiles = fg.sync("*.skill.md", { cwd: skillsDir, absolute: true });
|
|
1048
|
+
const cmdFiles = fg.sync("*.cmd.md", { cwd: commandsDir, absolute: true });
|
|
1049
|
+
return {
|
|
1050
|
+
agents: agentFiles.map((f) => parseAgent(f, readFileSync5(f, "utf8"))),
|
|
1051
|
+
skills: skillFiles.map((f) => parseSkill(f, readFileSync5(f, "utf8"))),
|
|
1052
|
+
cmds: cmdFiles.map((f) => parseCmd(f, readFileSync5(f, "utf8")))
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
function loadProjectSkills(targetDir) {
|
|
1056
|
+
const skillsDir = join5(targetDir, "docs", "skills");
|
|
1057
|
+
if (!existsSync4(skillsDir)) return [];
|
|
1058
|
+
const skillFiles = fg.sync("*.skill.md", { cwd: skillsDir, absolute: true });
|
|
1059
|
+
return skillFiles.map((f) => parseSkill(f, readFileSync5(f, "utf8")));
|
|
1060
|
+
}
|
|
1061
|
+
function loadOverrides(worktreeDir) {
|
|
1062
|
+
const overridesDir = join5(worktreeDir, ".harness-overrides");
|
|
1063
|
+
if (!existsSync4(overridesDir)) return void 0;
|
|
1064
|
+
const added = loadHarness(overridesDir, {
|
|
1065
|
+
agentsDir: join5(overridesDir, "agents"),
|
|
1066
|
+
skillsDir: join5(overridesDir, "skills"),
|
|
1067
|
+
commandsDir: join5(overridesDir, "commands")
|
|
1068
|
+
});
|
|
1069
|
+
const disablePath = join5(overridesDir, "disable.yaml");
|
|
1070
|
+
let disable = [];
|
|
1071
|
+
if (existsSync4(disablePath)) {
|
|
1072
|
+
try {
|
|
1073
|
+
const parsed = DisableYamlSchema.safeParse(parseYAML(readFileSync5(disablePath, "utf8")));
|
|
1074
|
+
if (parsed.success && parsed.data.disable) {
|
|
1075
|
+
disable = parsed.data.disable;
|
|
1076
|
+
}
|
|
1077
|
+
} catch {
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return { add: added, disable };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// src/mcp.ts
|
|
1084
|
+
var MCP_SERVER_IDS = ["context7"];
|
|
1085
|
+
var MCP_SERVERS = {
|
|
1086
|
+
context7: {
|
|
1087
|
+
id: "context7",
|
|
1088
|
+
description: "Upstash Context7 \u2014 fetches up-to-date library docs via resolve-library-id + get-library-docs.",
|
|
1089
|
+
command: "npx",
|
|
1090
|
+
args: ["-y", "@upstash/context7-mcp"]
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
function resolveMcpIds(requested) {
|
|
1094
|
+
return requested.map((id) => {
|
|
1095
|
+
if (!MCP_SERVER_IDS.includes(id)) {
|
|
1096
|
+
throw new Error(`unknown MCP server: ${id} (expected one of ${MCP_SERVER_IDS.join(", ")})`);
|
|
1211
1097
|
}
|
|
1098
|
+
return id;
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// src/render.ts
|
|
1103
|
+
var RenderError = class extends Error {
|
|
1104
|
+
constructor(message) {
|
|
1105
|
+
super(message);
|
|
1106
|
+
this.name = "RenderError";
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
function mergeById(stock, added) {
|
|
1110
|
+
const result = /* @__PURE__ */ new Map();
|
|
1111
|
+
for (const item of stock) result.set(item.meta.id, item);
|
|
1112
|
+
if (added) {
|
|
1113
|
+
for (const item of added) result.set(item.meta.id, item);
|
|
1114
|
+
}
|
|
1115
|
+
return Array.from(result.values());
|
|
1116
|
+
}
|
|
1117
|
+
function removeByIds(items, disabled) {
|
|
1118
|
+
if (disabled.length === 0) return [...items];
|
|
1119
|
+
const disabledSet = new Set(disabled);
|
|
1120
|
+
return items.filter((item) => !disabledSet.has(item.meta.id));
|
|
1121
|
+
}
|
|
1122
|
+
function applyOverrides(harness, overrides) {
|
|
1123
|
+
const disabled = overrides?.disable ?? [];
|
|
1124
|
+
return {
|
|
1125
|
+
agents: removeByIds(mergeById(harness.agents, overrides?.add?.agents), disabled),
|
|
1126
|
+
skills: removeByIds(mergeById(harness.skills, overrides?.add?.skills), disabled),
|
|
1127
|
+
cmds: removeByIds(mergeById(harness.cmds, overrides?.add?.cmds), disabled)
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
var CLAUDE_MD_STUB = "@AGENTS.md\n";
|
|
1131
|
+
function opencodeLast(engines) {
|
|
1132
|
+
return [...engines].sort((a, b) => Number(a === "opencode") - Number(b === "opencode"));
|
|
1133
|
+
}
|
|
1134
|
+
function render(input) {
|
|
1135
|
+
const { engines } = input;
|
|
1136
|
+
if (engines.length === 0) {
|
|
1137
|
+
throw new RenderError("at least one engine must be selected");
|
|
1138
|
+
}
|
|
1139
|
+
const base = input.projectSkills?.length ? { ...input.harness, skills: mergeById(input.harness.skills, input.projectSkills) } : input.harness;
|
|
1140
|
+
const harness = applyOverrides(base, input.overrides);
|
|
1141
|
+
if (harness.agents.length === 0) {
|
|
1142
|
+
throw new RenderError("No agents found in harness");
|
|
1212
1143
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
const
|
|
1217
|
-
for (const
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1144
|
+
const files = [];
|
|
1145
|
+
const warnings = [];
|
|
1146
|
+
const exposedSkills = /* @__PURE__ */ new Map();
|
|
1147
|
+
const exposedCmds = /* @__PURE__ */ new Map();
|
|
1148
|
+
for (const engine of opencodeLast(engines)) {
|
|
1149
|
+
const renderer = getRenderer(engine);
|
|
1150
|
+
const renderedSkills = [];
|
|
1151
|
+
for (const skill of harness.skills) {
|
|
1152
|
+
const result = renderer.renderSkill(skill);
|
|
1153
|
+
warnings.push(...result.warnings);
|
|
1154
|
+
if (result.files.length > 0) {
|
|
1155
|
+
const alreadyVisible = engine === "opencode" && exposedSkills.has(skill.meta.id);
|
|
1156
|
+
if (!alreadyVisible) files.push(...result.files);
|
|
1157
|
+
renderedSkills.push(skill);
|
|
1158
|
+
exposedSkills.set(skill.meta.id, skill);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
const renderedSkillIds = new Set(renderedSkills.map((s) => s.meta.id));
|
|
1162
|
+
const renderedCmds = [];
|
|
1163
|
+
for (const cmd of harness.cmds) {
|
|
1164
|
+
if (!renderedSkillIds.has(cmd.meta.invokes_skill)) continue;
|
|
1165
|
+
const result = renderer.renderCmd(cmd);
|
|
1166
|
+
warnings.push(...result.warnings);
|
|
1167
|
+
if (result.files.length > 0) {
|
|
1168
|
+
files.push(...result.files);
|
|
1169
|
+
renderedCmds.push(cmd);
|
|
1170
|
+
exposedCmds.set(cmd.meta.id, cmd);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
const agentsResult = renderer.renderAgents(harness.agents);
|
|
1174
|
+
warnings.push(...agentsResult.warnings);
|
|
1175
|
+
files.push(...agentsResult.files);
|
|
1176
|
+
if (input.mcpServers && input.mcpServers.length > 0) {
|
|
1177
|
+
const specs = input.mcpServers.map((id) => MCP_SERVERS[id]);
|
|
1178
|
+
const mcpResult = renderer.renderMcp(specs);
|
|
1179
|
+
warnings.push(...mcpResult.warnings);
|
|
1180
|
+
files.push(...mcpResult.files);
|
|
1181
|
+
}
|
|
1182
|
+
if (input.withStopHook !== false) {
|
|
1183
|
+
const hookResult = renderer.renderStopHook();
|
|
1184
|
+
warnings.push(...hookResult.warnings);
|
|
1185
|
+
files.push(...hookResult.files);
|
|
1222
1186
|
}
|
|
1223
1187
|
}
|
|
1224
|
-
const
|
|
1225
|
-
const
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1188
|
+
const hasProjectSkills = (input.projectSkills?.length ?? 0) > 0;
|
|
1189
|
+
const umbrellaContent = buildUmbrellaBody(harness.agents, {
|
|
1190
|
+
availableSkills: Array.from(exposedSkills.values()),
|
|
1191
|
+
availableCommands: Array.from(exposedCmds.values()),
|
|
1192
|
+
hasProjectSkills,
|
|
1193
|
+
hasQualityDoc: input.hasQualityDoc === true
|
|
1194
|
+
});
|
|
1195
|
+
files.push({ path: "AGENTS.md", content: umbrellaContent });
|
|
1196
|
+
if (engines.includes("claude-code")) {
|
|
1197
|
+
files.push({ path: "CLAUDE.md", content: CLAUDE_MD_STUB });
|
|
1231
1198
|
}
|
|
1232
|
-
return
|
|
1199
|
+
return { files, warnings };
|
|
1233
1200
|
}
|
|
1234
1201
|
|
|
1235
|
-
// src/cli/
|
|
1202
|
+
// src/cli/plan-render.ts
|
|
1236
1203
|
var QUALITY_DOC_PATH = "docs/quality/code-quality.md";
|
|
1237
1204
|
function resolveEngineList(requested) {
|
|
1238
|
-
if (!requested
|
|
1239
|
-
|
|
1205
|
+
if (!requested) return [...ENGINES];
|
|
1206
|
+
if (requested.length === 0) throw new Error("no engine selected");
|
|
1207
|
+
const engines = requested.map((e) => {
|
|
1240
1208
|
if (!ENGINES.includes(e)) {
|
|
1241
1209
|
throw new Error(`unknown engine: ${e} (expected one of ${ENGINES.join(", ")})`);
|
|
1242
1210
|
}
|
|
1243
1211
|
return e;
|
|
1244
1212
|
});
|
|
1213
|
+
return [...new Set(engines)];
|
|
1245
1214
|
}
|
|
1246
1215
|
function defaultPackageDir() {
|
|
1247
|
-
const here =
|
|
1248
|
-
if (
|
|
1249
|
-
return
|
|
1216
|
+
const here = dirname2(fileURLToPath(import.meta.url));
|
|
1217
|
+
if (existsSync5(join6(here, "agents"))) return here;
|
|
1218
|
+
return resolve2(here, "..");
|
|
1250
1219
|
}
|
|
1251
1220
|
function planInit(args) {
|
|
1252
1221
|
const engines = resolveEngineList(args.engines);
|
|
@@ -1254,7 +1223,7 @@ function planInit(args) {
|
|
|
1254
1223
|
const harness = loadHarness(packageDir);
|
|
1255
1224
|
const overrides = args.overridesDir ? loadOverrides(args.overridesDir) : void 0;
|
|
1256
1225
|
const projectSkills = loadProjectSkills(args.targetDir);
|
|
1257
|
-
const hasQualityDoc =
|
|
1226
|
+
const hasQualityDoc = existsSync5(join6(args.targetDir, QUALITY_DOC_PATH));
|
|
1258
1227
|
const mcpServers = args.mcpServers ? resolveMcpIds(args.mcpServers) : void 0;
|
|
1259
1228
|
const stopHookInstalled = args.withStopHook !== false;
|
|
1260
1229
|
const output = render({
|
|
@@ -1268,107 +1237,35 @@ function planInit(args) {
|
|
|
1268
1237
|
});
|
|
1269
1238
|
return { engines, output, stopHookInstalled };
|
|
1270
1239
|
}
|
|
1271
|
-
function writeFilesToTarget(files, targetDir) {
|
|
1272
|
-
assertManagedPaths(
|
|
1273
|
-
targetDir,
|
|
1274
|
-
files.map((file) => file.path)
|
|
1275
|
-
);
|
|
1276
|
-
const written = [];
|
|
1277
|
-
for (const f of files) {
|
|
1278
|
-
const full = join7(targetDir, f.path);
|
|
1279
|
-
mkdirSync(dirname3(full), { recursive: true });
|
|
1280
|
-
writeFileSync3(full, f.content);
|
|
1281
|
-
written.push(f.path);
|
|
1282
|
-
}
|
|
1283
|
-
return written;
|
|
1284
|
-
}
|
|
1285
|
-
function initCommand(args) {
|
|
1286
|
-
const { engines, output, stopHookInstalled } = planInit(args);
|
|
1287
|
-
const previous = readManifest(args.targetDir);
|
|
1288
|
-
assertManagedPaths(args.targetDir, [
|
|
1289
|
-
MANIFEST_FILENAME,
|
|
1290
|
-
...previous?.files ?? [],
|
|
1291
|
-
...output.files.map((file) => file.path),
|
|
1292
|
-
...previous?.opencodeConfig ? [OPENCODE_CONFIG_PATH] : []
|
|
1293
|
-
]);
|
|
1294
|
-
const desiredConfig = output.files.find((file) => file.path === OPENCODE_CONFIG_PATH);
|
|
1295
|
-
const config = planOpenCodeConfig(args.targetDir, desiredConfig, previous?.opencodeConfig);
|
|
1296
|
-
const next = new Set(output.files.map((f) => f.path));
|
|
1297
|
-
const stale = (previous?.files ?? []).filter((f) => !next.has(f) && f !== OPENCODE_CONFIG_PATH);
|
|
1298
|
-
if (config.remove) stale.push(OPENCODE_CONFIG_PATH);
|
|
1299
|
-
const staleFilesRemoved = removeRenderedFiles(resolve3(args.targetDir), stale).filesRemoved;
|
|
1300
|
-
const ownedFiles = output.files.filter((file) => file.path !== OPENCODE_CONFIG_PATH);
|
|
1301
|
-
const filesWritten = writeFilesToTarget(ownedFiles, args.targetDir);
|
|
1302
|
-
if (config.write) writeFilesToTarget([config.write], args.targetDir);
|
|
1303
|
-
writeManifest(args.targetDir, engines, filesWritten, {
|
|
1304
|
-
stopHookInstalled,
|
|
1305
|
-
stopHookEngines: stopHookInstalled ? engines : void 0,
|
|
1306
|
-
opencodeConfig: config.ownership,
|
|
1307
|
-
overridesDir: args.overridesDir ? resolve3(args.overridesDir) === resolve3(args.targetDir) ? "." : resolve3(args.overridesDir) : void 0
|
|
1308
|
-
});
|
|
1309
|
-
if (config.write) filesWritten.push(OPENCODE_CONFIG_PATH);
|
|
1310
|
-
const warnings = [...output.warnings, ...config.warnings];
|
|
1311
|
-
if (previous?.files.includes(OPENCODE_CONFIG_PATH) && !previous.opencodeConfig) {
|
|
1312
|
-
warnings.push(`preserved legacy shared configuration: ${OPENCODE_CONFIG_PATH}`);
|
|
1313
|
-
}
|
|
1314
|
-
return { filesWritten, staleFilesRemoved, warnings, stopHookInstalled };
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
// src/cli/update.ts
|
|
1318
|
-
import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
|
|
1319
|
-
import { join as join10, resolve as resolve5 } from "path";
|
|
1320
|
-
import { isDeepStrictEqual as isDeepStrictEqual3 } from "util";
|
|
1321
1240
|
|
|
1322
|
-
// src/cli/
|
|
1323
|
-
import {
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
]
|
|
1341
|
-
};
|
|
1342
|
-
function readUpdateInstallation(root) {
|
|
1343
|
-
assertManagedPaths(root, [MANIFEST_FILENAME]);
|
|
1344
|
-
if (!existsSync7(join8(root, MANIFEST_FILENAME))) {
|
|
1345
|
-
throw new Error("no harness manifest found; use init for a new installation");
|
|
1346
|
-
}
|
|
1347
|
-
const raw = JSON.parse(readFileSync6(join8(root, MANIFEST_FILENAME), "utf8"));
|
|
1348
|
-
const sourceVersion = String(raw?.version);
|
|
1349
|
-
let manifest;
|
|
1350
|
-
if (["1", "2", "3"].includes(sourceVersion)) {
|
|
1351
|
-
const legacy = LegacySchema.parse(raw);
|
|
1352
|
-
const engines2 = legacy.version === "1" ? [EngineSchema.parse(legacy.engine)] : legacy.engines;
|
|
1353
|
-
manifest = {
|
|
1354
|
-
version: "4",
|
|
1355
|
-
engines: z6.array(EngineSchema).nonempty().parse(engines2),
|
|
1356
|
-
files: legacy.files,
|
|
1357
|
-
renderedAt: legacy.renderedAt
|
|
1358
|
-
};
|
|
1359
|
-
} else {
|
|
1360
|
-
const current = readManifest(root);
|
|
1361
|
-
if (!current) throw new Error("harness manifest disappeared during planning");
|
|
1362
|
-
manifest = current;
|
|
1241
|
+
// src/cli/uninstall.ts
|
|
1242
|
+
import {
|
|
1243
|
+
existsSync as existsSync6,
|
|
1244
|
+
readFileSync as readFileSync6,
|
|
1245
|
+
readdirSync,
|
|
1246
|
+
rmdirSync,
|
|
1247
|
+
unlinkSync,
|
|
1248
|
+
writeFileSync as writeFileSync2
|
|
1249
|
+
} from "fs";
|
|
1250
|
+
import { dirname as dirname3, join as join7, relative, resolve as resolve3 } from "path";
|
|
1251
|
+
function pruneEmptyDirs(root, leaves) {
|
|
1252
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1253
|
+
for (const leaf of leaves) {
|
|
1254
|
+
let cur = leaf;
|
|
1255
|
+
while (cur !== root && cur.startsWith(`${root}/`)) {
|
|
1256
|
+
candidates.add(cur);
|
|
1257
|
+
cur = dirname3(cur);
|
|
1258
|
+
}
|
|
1363
1259
|
}
|
|
1364
|
-
const
|
|
1365
|
-
const
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1260
|
+
const pruned = [];
|
|
1261
|
+
const sorted = [...candidates].sort((a, b) => b.length - a.length);
|
|
1262
|
+
for (const dir of sorted) {
|
|
1263
|
+
if (!existsSync6(dir)) continue;
|
|
1264
|
+
if (readdirSync(dir).length > 0) continue;
|
|
1265
|
+
rmdirSync(dir);
|
|
1266
|
+
pruned.push(relative(root, dir));
|
|
1370
1267
|
}
|
|
1371
|
-
return
|
|
1268
|
+
return pruned;
|
|
1372
1269
|
}
|
|
1373
1270
|
|
|
1374
1271
|
// src/cli/update-transaction.ts
|
|
@@ -1378,17 +1275,17 @@ import {
|
|
|
1378
1275
|
closeSync,
|
|
1379
1276
|
fsyncSync,
|
|
1380
1277
|
lstatSync as lstatSync2,
|
|
1381
|
-
mkdirSync
|
|
1278
|
+
mkdirSync,
|
|
1382
1279
|
openSync,
|
|
1383
1280
|
readFileSync as readFileSync7,
|
|
1384
1281
|
readdirSync as readdirSync2,
|
|
1385
1282
|
realpathSync as realpathSync2,
|
|
1386
1283
|
rmdirSync as rmdirSync2,
|
|
1387
1284
|
unlinkSync as unlinkSync2,
|
|
1388
|
-
writeFileSync as
|
|
1285
|
+
writeFileSync as writeFileSync3
|
|
1389
1286
|
} from "fs";
|
|
1390
1287
|
import { homedir } from "os";
|
|
1391
|
-
import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as
|
|
1288
|
+
import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as join8, relative as relative2, resolve as resolve4, sep } from "path";
|
|
1392
1289
|
var MANIFEST_PATH = ".harness-manifest.json";
|
|
1393
1290
|
var PRIVATE_FILES = /* @__PURE__ */ new Set([
|
|
1394
1291
|
MANIFEST_PATH,
|
|
@@ -1406,7 +1303,7 @@ function canonicalRoot(root) {
|
|
|
1406
1303
|
missing.unshift(basename2(ancestor));
|
|
1407
1304
|
ancestor = dirname4(ancestor);
|
|
1408
1305
|
}
|
|
1409
|
-
return
|
|
1306
|
+
return join8(realpathSync2(ancestor), ...missing);
|
|
1410
1307
|
}
|
|
1411
1308
|
function contains(parent, child) {
|
|
1412
1309
|
const path = relative2(parent, child);
|
|
@@ -1438,7 +1335,7 @@ function createDirectories(path, created, mode = 511) {
|
|
|
1438
1335
|
missing.unshift(current);
|
|
1439
1336
|
}
|
|
1440
1337
|
for (const directory of missing) {
|
|
1441
|
-
|
|
1338
|
+
mkdirSync(directory, { mode });
|
|
1442
1339
|
created.push(directory);
|
|
1443
1340
|
}
|
|
1444
1341
|
}
|
|
@@ -1446,7 +1343,7 @@ function writeBackupFile(path, content) {
|
|
|
1446
1343
|
createDirectories(dirname4(path), [], 448);
|
|
1447
1344
|
const fd = openSync(path, "wx", 384);
|
|
1448
1345
|
try {
|
|
1449
|
-
|
|
1346
|
+
writeFileSync3(fd, content);
|
|
1450
1347
|
fsyncSync(fd);
|
|
1451
1348
|
} finally {
|
|
1452
1349
|
closeSync(fd);
|
|
@@ -1455,16 +1352,16 @@ function writeBackupFile(path, content) {
|
|
|
1455
1352
|
function saveSnapshot(root, paths, backup) {
|
|
1456
1353
|
const entries = [];
|
|
1457
1354
|
createDirectories(dirname4(backup), [], 448);
|
|
1458
|
-
|
|
1355
|
+
mkdirSync(backup, { mode: 448 });
|
|
1459
1356
|
for (const path of paths) {
|
|
1460
|
-
const full =
|
|
1357
|
+
const full = join8(root, path);
|
|
1461
1358
|
const info = lstatSync2(full, { throwIfNoEntry: false });
|
|
1462
1359
|
if (!info) {
|
|
1463
1360
|
entries.push({ path, exists: false });
|
|
1464
1361
|
continue;
|
|
1465
1362
|
}
|
|
1466
1363
|
const content = readFileSync7(full);
|
|
1467
|
-
writeBackupFile(
|
|
1364
|
+
writeBackupFile(join8(backup, "files", path), content);
|
|
1468
1365
|
entries.push({
|
|
1469
1366
|
path,
|
|
1470
1367
|
exists: true,
|
|
@@ -1473,7 +1370,7 @@ function saveSnapshot(root, paths, backup) {
|
|
|
1473
1370
|
});
|
|
1474
1371
|
}
|
|
1475
1372
|
writeBackupFile(
|
|
1476
|
-
|
|
1373
|
+
join8(backup, "snapshot.json"),
|
|
1477
1374
|
`${JSON.stringify({ version: 1, root, createdAt: (/* @__PURE__ */ new Date()).toISOString(), entries }, null, 2)}
|
|
1478
1375
|
`
|
|
1479
1376
|
);
|
|
@@ -1488,16 +1385,16 @@ function rollback(root, backup, entries, touched, createdDirectories) {
|
|
|
1488
1385
|
try {
|
|
1489
1386
|
assertManagedPaths(root, [path]);
|
|
1490
1387
|
const entry = entries.get(path);
|
|
1491
|
-
const full =
|
|
1388
|
+
const full = join8(root, path);
|
|
1492
1389
|
const current = lstatSync2(full, { throwIfNoEntry: false });
|
|
1493
1390
|
if (!entry?.exists) {
|
|
1494
1391
|
if (current) unlinkSync2(full);
|
|
1495
1392
|
continue;
|
|
1496
1393
|
}
|
|
1497
|
-
const original = readFileSync7(
|
|
1394
|
+
const original = readFileSync7(join8(backup, "files", path));
|
|
1498
1395
|
if (!current || !readFileSync7(full).equals(original)) {
|
|
1499
1396
|
if (current) unlinkSync2(full);
|
|
1500
|
-
|
|
1397
|
+
writeFileSync3(full, original, { mode: 384 });
|
|
1501
1398
|
}
|
|
1502
1399
|
chmodSync2(full, entry.mode);
|
|
1503
1400
|
} catch (error) {
|
|
@@ -1518,7 +1415,7 @@ function applyUpdateTransaction(root, writes, removals, backupDir) {
|
|
|
1518
1415
|
assertManagedPaths(root, paths);
|
|
1519
1416
|
const target = canonicalRoot(root);
|
|
1520
1417
|
const backup = resolve4(
|
|
1521
|
-
backupDir ??
|
|
1418
|
+
backupDir ?? join8(homedir(), ".local", "state", "vaubaan-harness", "backups", randomUUID())
|
|
1522
1419
|
);
|
|
1523
1420
|
assertBackupLocation(target, backup);
|
|
1524
1421
|
let snapshot;
|
|
@@ -1536,18 +1433,18 @@ function applyUpdateTransaction(root, writes, removals, backupDir) {
|
|
|
1536
1433
|
try {
|
|
1537
1434
|
assertManagedPaths(target, paths);
|
|
1538
1435
|
for (const path of removals) {
|
|
1539
|
-
if (!lstatSync2(
|
|
1436
|
+
if (!lstatSync2(join8(target, path), { throwIfNoEntry: false })) continue;
|
|
1540
1437
|
touched.add(path);
|
|
1541
|
-
unlinkSync2(
|
|
1438
|
+
unlinkSync2(join8(target, path));
|
|
1542
1439
|
}
|
|
1543
1440
|
for (const file of writes) {
|
|
1544
|
-
const full =
|
|
1441
|
+
const full = join8(target, file.path);
|
|
1545
1442
|
createDirectories(dirname4(full), createdDirectories);
|
|
1546
1443
|
touched.add(file.path);
|
|
1547
1444
|
const entry = entries.get(file.path);
|
|
1548
1445
|
const mode = file.path === MANIFEST_PATH ? 384 : entry?.exists ? entry.mode : PRIVATE_FILES.has(file.path) ? 384 : 420;
|
|
1549
1446
|
if (entry?.exists && file.path === MANIFEST_PATH) chmodSync2(full, mode);
|
|
1550
|
-
|
|
1447
|
+
writeFileSync3(full, file.content, { mode });
|
|
1551
1448
|
if (entry?.exists) chmodSync2(full, mode);
|
|
1552
1449
|
}
|
|
1553
1450
|
} catch (error) {
|
|
@@ -1561,29 +1458,159 @@ function applyUpdateTransaction(root, writes, removals, backupDir) {
|
|
|
1561
1458
|
}
|
|
1562
1459
|
|
|
1563
1460
|
// src/cli/update.ts
|
|
1461
|
+
import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
|
|
1462
|
+
import { join as join10, resolve as resolve5 } from "path";
|
|
1463
|
+
import { isDeepStrictEqual as isDeepStrictEqual3 } from "util";
|
|
1464
|
+
|
|
1465
|
+
// src/cli/update-manifest.ts
|
|
1466
|
+
import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
|
|
1467
|
+
import { join as join9 } from "path";
|
|
1468
|
+
import { z as z6 } from "zod";
|
|
1469
|
+
var EngineSchema = z6.enum(ENGINES);
|
|
1470
|
+
var LegacySchema = z6.object({
|
|
1471
|
+
version: z6.enum(["1", "2", "3"]),
|
|
1472
|
+
engine: EngineSchema.optional(),
|
|
1473
|
+
engines: z6.array(EngineSchema).nonempty().optional(),
|
|
1474
|
+
renderedAt: z6.string(),
|
|
1475
|
+
files: z6.array(z6.string())
|
|
1476
|
+
});
|
|
1477
|
+
var HOOK_PATHS = {
|
|
1478
|
+
"claude-code": [".claude/settings.json"],
|
|
1479
|
+
"codex-cli": [".codex/hooks.json"],
|
|
1480
|
+
opencode: [
|
|
1481
|
+
".opencode/plugins/harness-stop-hook/index.ts",
|
|
1482
|
+
".opencode/plugins/harness-stop-hook.ts"
|
|
1483
|
+
]
|
|
1484
|
+
};
|
|
1485
|
+
function readUpdateInstallation(root) {
|
|
1486
|
+
assertManagedPaths(root, [MANIFEST_FILENAME]);
|
|
1487
|
+
if (!existsSync7(join9(root, MANIFEST_FILENAME))) {
|
|
1488
|
+
throw new Error("no harness manifest found; use init for a new installation");
|
|
1489
|
+
}
|
|
1490
|
+
const raw = JSON.parse(readFileSync8(join9(root, MANIFEST_FILENAME), "utf8"));
|
|
1491
|
+
const sourceVersion = String(raw?.version);
|
|
1492
|
+
let manifest;
|
|
1493
|
+
if (["1", "2", "3"].includes(sourceVersion)) {
|
|
1494
|
+
const legacy = LegacySchema.parse(raw);
|
|
1495
|
+
const engines2 = legacy.version === "1" ? [EngineSchema.parse(legacy.engine)] : legacy.engines;
|
|
1496
|
+
manifest = {
|
|
1497
|
+
version: "4",
|
|
1498
|
+
engines: z6.array(EngineSchema).nonempty().parse(engines2),
|
|
1499
|
+
files: legacy.files,
|
|
1500
|
+
renderedAt: legacy.renderedAt
|
|
1501
|
+
};
|
|
1502
|
+
} else {
|
|
1503
|
+
const current = readManifest(root);
|
|
1504
|
+
if (!current) throw new Error("harness manifest disappeared during planning");
|
|
1505
|
+
manifest = current;
|
|
1506
|
+
}
|
|
1507
|
+
const engines = [...new Set(z6.array(EngineSchema).nonempty().parse(manifest.engines))];
|
|
1508
|
+
const hookEngines = manifest.stopHookInstalled === false ? [] : manifest.stopHookEngines !== void 0 ? z6.array(EngineSchema).parse(manifest.stopHookEngines) : manifest.stopHookInstalled === true ? engines : engines.filter(
|
|
1509
|
+
(engine) => HOOK_PATHS[engine].some((path) => manifest.files.includes(path))
|
|
1510
|
+
);
|
|
1511
|
+
if (hookEngines.some((engine) => !engines.includes(engine))) {
|
|
1512
|
+
throw new Error("manifest stopHookEngines must be a subset of installed engines");
|
|
1513
|
+
}
|
|
1514
|
+
return { manifest, sourceVersion, engines, hookEngines: [...new Set(hookEngines)] };
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// src/cli/update.ts
|
|
1518
|
+
var CODEX_CONFIG_PATH = ".codex/config.toml";
|
|
1564
1519
|
function currentContent(root, path) {
|
|
1565
|
-
return existsSync8(join10(root, path)) ?
|
|
1520
|
+
return existsSync8(join10(root, path)) ? readFileSync9(join10(root, path), "utf8") : void 0;
|
|
1521
|
+
}
|
|
1522
|
+
function ownedUnchanged(root, previous, path) {
|
|
1523
|
+
const content = currentContent(root, path);
|
|
1524
|
+
if (content === void 0 || !previous.files.includes(path)) return false;
|
|
1525
|
+
const hash = previous.fileHashes?.[path];
|
|
1526
|
+
return hash === void 0 || hash === hashContent(content);
|
|
1566
1527
|
}
|
|
1567
1528
|
function checkArtifactConflicts(root, previous, writes, removals) {
|
|
1568
1529
|
const desired = new Map(writes.map((file) => [file.path, file.content]));
|
|
1530
|
+
const conflicts = [];
|
|
1569
1531
|
for (const path of [...desired.keys(), ...removals]) {
|
|
1570
1532
|
if (SHARED_CONFIG_PATHS.includes(path)) continue;
|
|
1571
1533
|
const content = currentContent(root, path);
|
|
1572
|
-
if (content === void 0) continue;
|
|
1534
|
+
if (content === void 0 || content === desired.get(path)) continue;
|
|
1573
1535
|
if (!previous.files.includes(path)) {
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
);
|
|
1536
|
+
conflicts.push(`existing file outside harness manifest: ${path}`);
|
|
1537
|
+
continue;
|
|
1577
1538
|
}
|
|
1578
1539
|
const hash = previous.fileHashes?.[path];
|
|
1579
|
-
if (hash && hash !== hashContent(content)
|
|
1580
|
-
|
|
1540
|
+
if (hash && hash !== hashContent(content)) conflicts.push(`modified harness file: ${path}`);
|
|
1541
|
+
}
|
|
1542
|
+
if (conflicts.length) {
|
|
1543
|
+
throw new Error(
|
|
1544
|
+
`${conflicts.join("\n")}
|
|
1545
|
+
move their content to docs/ or .harness-overrides/, or keep them as yours with --preserve <path>`
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
function legacyMcpOwnership(root, previous) {
|
|
1550
|
+
if (!ownedUnchanged(root, previous, CLAUDE_MCP_PATH)) return void 0;
|
|
1551
|
+
try {
|
|
1552
|
+
const servers = JSON.parse(currentContent(root, CLAUDE_MCP_PATH) ?? "").mcpServers;
|
|
1553
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) return void 0;
|
|
1554
|
+
return { createdFile: true, addedMcp: true, addedSchema: false, entries: servers };
|
|
1555
|
+
} catch {
|
|
1556
|
+
return void 0;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
function planMcp(root, settings, rendered, pendingWrites) {
|
|
1560
|
+
const { previous } = settings;
|
|
1561
|
+
const plan = {
|
|
1562
|
+
writes: [],
|
|
1563
|
+
removals: [],
|
|
1564
|
+
owned: [],
|
|
1565
|
+
plannedOpenCode: false,
|
|
1566
|
+
warnings: []
|
|
1567
|
+
};
|
|
1568
|
+
const claudeOwnership = previous.mcpConfig ?? legacyMcpOwnership(root, previous);
|
|
1569
|
+
const codexOwned = ownedUnchanged(root, previous, CODEX_CONFIG_PATH);
|
|
1570
|
+
if (settings.mcpServers === void 0) {
|
|
1571
|
+
if (codexOwned) plan.owned.push(CODEX_CONFIG_PATH);
|
|
1572
|
+
plan.mcpConfig = claudeOwnership;
|
|
1573
|
+
return plan;
|
|
1574
|
+
}
|
|
1575
|
+
const desired = (path) => rendered.find((file) => file.path === path);
|
|
1576
|
+
if (!pendingWrites.some((file) => file.path === OPENCODE_CONFIG_PATH)) {
|
|
1577
|
+
plan.plannedOpenCode = true;
|
|
1578
|
+
if (previous.files.includes(OPENCODE_CONFIG_PATH) && !previous.opencodeConfig) {
|
|
1579
|
+
plan.warnings.push(`preserved legacy shared configuration: ${OPENCODE_CONFIG_PATH}`);
|
|
1581
1580
|
}
|
|
1581
|
+
const opencode = planOpenCodeConfig(
|
|
1582
|
+
root,
|
|
1583
|
+
desired(OPENCODE_CONFIG_PATH),
|
|
1584
|
+
previous.opencodeConfig
|
|
1585
|
+
);
|
|
1586
|
+
if (opencode.write) plan.writes.push(opencode.write);
|
|
1587
|
+
if (opencode.remove) plan.removals.push(OPENCODE_CONFIG_PATH);
|
|
1588
|
+
plan.opencodeConfig = opencode.ownership;
|
|
1589
|
+
plan.warnings.push(...opencode.warnings);
|
|
1590
|
+
}
|
|
1591
|
+
const claude = planClaudeMcpConfig(root, desired(CLAUDE_MCP_PATH), claudeOwnership);
|
|
1592
|
+
if (claude.write) plan.writes.push(claude.write);
|
|
1593
|
+
if (claude.remove) plan.removals.push(CLAUDE_MCP_PATH);
|
|
1594
|
+
plan.mcpConfig = claude.ownership;
|
|
1595
|
+
plan.warnings.push(...claude.warnings);
|
|
1596
|
+
const codex = desired(CODEX_CONFIG_PATH);
|
|
1597
|
+
const codexExists = existsSync8(join10(root, CODEX_CONFIG_PATH));
|
|
1598
|
+
if (codex && (!codexExists || codexOwned)) {
|
|
1599
|
+
plan.writes.push(codex);
|
|
1600
|
+
plan.owned.push(CODEX_CONFIG_PATH);
|
|
1601
|
+
} else if (codex) {
|
|
1602
|
+
plan.warnings.push(
|
|
1603
|
+
`left ${CODEX_CONFIG_PATH} unchanged; add this block to it to enable MCP in Codex:
|
|
1604
|
+
${codex.content.trimEnd()}`
|
|
1605
|
+
);
|
|
1606
|
+
} else if (codexOwned) {
|
|
1607
|
+
plan.removals.push(CODEX_CONFIG_PATH);
|
|
1582
1608
|
}
|
|
1609
|
+
return plan;
|
|
1583
1610
|
}
|
|
1584
|
-
function
|
|
1611
|
+
function planInstallation(args, settings) {
|
|
1585
1612
|
const root = resolve5(args.targetDir);
|
|
1586
|
-
const {
|
|
1613
|
+
const { previous, engines, hookEngines } = settings;
|
|
1587
1614
|
const preserved = [
|
|
1588
1615
|
.../* @__PURE__ */ new Set([...previous.preservedFiles ?? [], ...args.preserveFiles ?? []])
|
|
1589
1616
|
].sort();
|
|
@@ -1604,7 +1631,7 @@ function planUpdate(args) {
|
|
|
1604
1631
|
const disablePath = join10(overridesDir, ".harness-overrides", "disable.yaml");
|
|
1605
1632
|
if (existsSync8(disablePath)) {
|
|
1606
1633
|
try {
|
|
1607
|
-
DisableYamlSchema.parse(parseYAML(
|
|
1634
|
+
DisableYamlSchema.parse(parseYAML(readFileSync9(disablePath, "utf8")));
|
|
1608
1635
|
} catch {
|
|
1609
1636
|
throw new Error(`invalid override selection: ${disablePath}`);
|
|
1610
1637
|
}
|
|
@@ -1614,21 +1641,27 @@ function planUpdate(args) {
|
|
|
1614
1641
|
packageDir: args.packageDir,
|
|
1615
1642
|
engines,
|
|
1616
1643
|
overridesDir: hasOverrides ? overridesDir : void 0,
|
|
1644
|
+
mcpServers: settings.mcpServers,
|
|
1617
1645
|
withStopHook: false
|
|
1618
1646
|
});
|
|
1647
|
+
const generated = rendered.output.files.filter(
|
|
1648
|
+
(file) => !SHARED_CONFIG_PATHS.includes(file.path)
|
|
1649
|
+
);
|
|
1619
1650
|
const config = planUpdateConfigs(root, previous, hookEngines);
|
|
1620
|
-
const
|
|
1651
|
+
const mcp = planMcp(root, settings, rendered.output.files, config.writes);
|
|
1652
|
+
const output = [...generated, ...config.writes, ...mcp.writes].filter(
|
|
1621
1653
|
(file) => !preserved.includes(file.path)
|
|
1622
1654
|
);
|
|
1623
1655
|
const owned = [
|
|
1624
|
-
.../* @__PURE__ */ new Set([...
|
|
1656
|
+
.../* @__PURE__ */ new Set([...generated.map((file) => file.path), ...config.ownedFiles, ...mcp.owned])
|
|
1625
1657
|
].filter((file) => !preserved.includes(file)).sort();
|
|
1626
1658
|
const removals = [
|
|
1627
1659
|
.../* @__PURE__ */ new Set([
|
|
1628
1660
|
...previous.files.filter(
|
|
1629
1661
|
(file) => !owned.includes(file) && !SHARED_CONFIG_PATHS.includes(file)
|
|
1630
1662
|
),
|
|
1631
|
-
...config.removals
|
|
1663
|
+
...config.removals,
|
|
1664
|
+
...mcp.removals
|
|
1632
1665
|
])
|
|
1633
1666
|
].filter((file) => !preserved.includes(file));
|
|
1634
1667
|
assertManagedPaths(root, [...output.map((file) => file.path), ...removals]);
|
|
@@ -1640,6 +1673,7 @@ function planUpdate(args) {
|
|
|
1640
1673
|
return [path, hashContent(content)];
|
|
1641
1674
|
})
|
|
1642
1675
|
);
|
|
1676
|
+
const opencodeConfig = mcp.plannedOpenCode ? mcp.opencodeConfig : config.opencodeConfig;
|
|
1643
1677
|
const manifest = {
|
|
1644
1678
|
version: MANIFEST_VERSION,
|
|
1645
1679
|
harnessVersion: VERSION,
|
|
@@ -1651,7 +1685,8 @@ function planUpdate(args) {
|
|
|
1651
1685
|
stopHookEngines: [...hookEngines].sort(),
|
|
1652
1686
|
...hasOverrides ? { overridesDir: overridesDir === root ? "." : overridesDir } : {},
|
|
1653
1687
|
...preserved.length ? { preservedFiles: preserved } : {},
|
|
1654
|
-
...
|
|
1688
|
+
...opencodeConfig ? { opencodeConfig } : {},
|
|
1689
|
+
...mcp.mcpConfig ? { mcpConfig: mcp.mcpConfig } : {},
|
|
1655
1690
|
...config.hookConfigs ? { hookConfigs: config.hookConfigs } : {}
|
|
1656
1691
|
};
|
|
1657
1692
|
const writes = output.filter((file) => currentContent(root, file.path) !== file.content);
|
|
@@ -1661,25 +1696,33 @@ function planUpdate(args) {
|
|
|
1661
1696
|
writes.push({ path: MANIFEST_FILENAME, content: `${JSON.stringify(manifest, null, 2)}
|
|
1662
1697
|
` });
|
|
1663
1698
|
}
|
|
1664
|
-
const warnings = [...rendered.output.warnings, ...config.warnings];
|
|
1699
|
+
const warnings = [...rendered.output.warnings, ...config.warnings, ...mcp.warnings];
|
|
1700
|
+
if (preserved.length) warnings.push(`preserved user-owned artifacts: ${preserved.join(", ")}`);
|
|
1701
|
+
return { writes, removals: existingRemovals, owned, manifest, warnings };
|
|
1702
|
+
}
|
|
1703
|
+
function planUpdate(args) {
|
|
1704
|
+
const root = resolve5(args.targetDir);
|
|
1705
|
+
const { manifest: previous, sourceVersion, engines, hookEngines } = readUpdateInstallation(root);
|
|
1706
|
+
const plan = planInstallation(args, { previous, sourceVersion, engines, hookEngines });
|
|
1707
|
+
const warnings = [...plan.warnings];
|
|
1665
1708
|
if (!previous.fileHashes) {
|
|
1666
1709
|
warnings.push(
|
|
1667
1710
|
"legacy manifest has no file fingerprints: managed artifacts will be regenerated; backup preserves their previous contents"
|
|
1668
1711
|
);
|
|
1669
1712
|
}
|
|
1670
|
-
if (preserved.length) warnings.push(`preserved user-owned artifacts: ${preserved.join(", ")}`);
|
|
1671
1713
|
if (engines.includes("codex-cli"))
|
|
1672
1714
|
warnings.push("ensure .agents/ is ignored by Git (run harness check)");
|
|
1673
1715
|
return {
|
|
1674
|
-
writes,
|
|
1675
|
-
removals:
|
|
1716
|
+
writes: plan.writes,
|
|
1717
|
+
removals: plan.removals,
|
|
1718
|
+
owned: plan.owned,
|
|
1676
1719
|
result: {
|
|
1677
1720
|
version: VERSION,
|
|
1678
1721
|
fromManifestVersion: sourceVersion,
|
|
1679
1722
|
dryRun: args.dryRun === true,
|
|
1680
|
-
upToDate: writes.length === 0 &&
|
|
1681
|
-
filesToWrite: writes.map((file) => file.path),
|
|
1682
|
-
filesToRemove:
|
|
1723
|
+
upToDate: plan.writes.length === 0 && plan.removals.length === 0,
|
|
1724
|
+
filesToWrite: plan.writes.map((file) => file.path),
|
|
1725
|
+
filesToRemove: plan.removals,
|
|
1683
1726
|
warnings
|
|
1684
1727
|
}
|
|
1685
1728
|
};
|
|
@@ -1695,6 +1738,67 @@ function updateCommand(args) {
|
|
|
1695
1738
|
);
|
|
1696
1739
|
return { ...plan.result, backupDir };
|
|
1697
1740
|
}
|
|
1741
|
+
|
|
1742
|
+
// src/cli/init.ts
|
|
1743
|
+
function writeFilesToTarget(files, targetDir) {
|
|
1744
|
+
assertManagedPaths(
|
|
1745
|
+
targetDir,
|
|
1746
|
+
files.map((file) => file.path)
|
|
1747
|
+
);
|
|
1748
|
+
const written = [];
|
|
1749
|
+
for (const f of files) {
|
|
1750
|
+
const full = join11(targetDir, f.path);
|
|
1751
|
+
mkdirSync2(dirname5(full), { recursive: true });
|
|
1752
|
+
writeFileSync4(full, f.content);
|
|
1753
|
+
written.push(f.path);
|
|
1754
|
+
}
|
|
1755
|
+
return written;
|
|
1756
|
+
}
|
|
1757
|
+
function emptyManifest(engines) {
|
|
1758
|
+
return { version: MANIFEST_VERSION, engines: [...engines], renderedAt: "", files: [] };
|
|
1759
|
+
}
|
|
1760
|
+
function initCommand(args) {
|
|
1761
|
+
const root = resolve6(args.targetDir);
|
|
1762
|
+
const engines = resolveEngineList(args.engines);
|
|
1763
|
+
const stopHookInstalled = args.withStopHook !== false;
|
|
1764
|
+
const previous = readManifest(root) ?? emptyManifest(engines);
|
|
1765
|
+
const plan = planInstallation(
|
|
1766
|
+
{
|
|
1767
|
+
targetDir: root,
|
|
1768
|
+
packageDir: args.packageDir,
|
|
1769
|
+
overridesDir: args.overridesDir,
|
|
1770
|
+
preserveFiles: args.preserveFiles
|
|
1771
|
+
},
|
|
1772
|
+
{
|
|
1773
|
+
previous,
|
|
1774
|
+
sourceVersion: previous.version,
|
|
1775
|
+
engines,
|
|
1776
|
+
hookEngines: stopHookInstalled ? engines : [],
|
|
1777
|
+
mcpServers: args.mcpServers ?? []
|
|
1778
|
+
}
|
|
1779
|
+
);
|
|
1780
|
+
const touchedExisting = [...plan.writes.map((file) => file.path), ...plan.removals].some(
|
|
1781
|
+
(path) => path !== MANIFEST_FILENAME && existsSync9(join11(root, path))
|
|
1782
|
+
);
|
|
1783
|
+
let backupDir;
|
|
1784
|
+
if (plan.writes.length || plan.removals.length) {
|
|
1785
|
+
backupDir = applyUpdateTransaction(root, plan.writes, plan.removals, args.backupDir).backupDir;
|
|
1786
|
+
if (!touchedExisting && !args.backupDir) {
|
|
1787
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
1788
|
+
backupDir = void 0;
|
|
1789
|
+
}
|
|
1790
|
+
pruneEmptyDirs(root, new Set(plan.removals.map((path) => dirname5(join11(root, path)))));
|
|
1791
|
+
}
|
|
1792
|
+
const sharedWritten = plan.writes.map((file) => file.path).filter((path) => SHARED_CONFIG_PATHS.includes(path) && !plan.owned.includes(path));
|
|
1793
|
+
return {
|
|
1794
|
+
filesWritten: [...plan.owned, ...sharedWritten],
|
|
1795
|
+
staleFilesRemoved: plan.removals,
|
|
1796
|
+
warnings: plan.warnings,
|
|
1797
|
+
stopHookInstalled,
|
|
1798
|
+
engines,
|
|
1799
|
+
...backupDir ? { backupDir } : {}
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1698
1802
|
export {
|
|
1699
1803
|
VERSION,
|
|
1700
1804
|
initCommand,
|