@vaubaan/harness 1.3.0 → 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/NOTICE +1 -1
- package/README.md +229 -112
- package/dist/agents/coding.agent.md +1 -2
- package/dist/agents/merge.agent.md +2 -2
- package/dist/agents/planning.agent.md +1 -1
- package/dist/agents/review.agent.md +1 -1
- package/dist/cli/index.js +1438 -1287
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +804 -667
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +29 -6
- package/dist/init.js +1145 -470
- package/dist/init.js.map +1 -1
- package/dist/linters/index.js +3 -2
- package/dist/linters/index.js.map +1 -1
- package/dist/render.d.ts +1 -1
- package/dist/render.js +41 -8
- package/dist/render.js.map +1 -1
- package/dist/skills/spec-writer.skill.md +1 -1
- package/dist/skills/test-plan-writer.skill.md +1 -1
- package/package.json +9 -4
- 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 = [];
|
|
@@ -546,18 +577,26 @@ ${body}` });
|
|
|
546
577
|
renderStopHook() {
|
|
547
578
|
const content = `import { spawnSync } from "node:child_process";
|
|
548
579
|
|
|
549
|
-
|
|
580
|
+
// Runs the harness lint and returns the report to inject, or "" when the
|
|
581
|
+
// turn may end.
|
|
582
|
+
const lintReport = (directory, sessionID) => {
|
|
583
|
+
const r = spawnSync(
|
|
584
|
+
"npx",
|
|
585
|
+
["-y", "${STOP_HOOK_PACKAGE}", "stop-hook", "--engine", "opencode", "--cwd", directory],
|
|
586
|
+
{ input: JSON.stringify({ sessionID }), encoding: "utf-8" },
|
|
587
|
+
);
|
|
588
|
+
const report = (r.stderr ?? "").trim();
|
|
589
|
+
return r.status === 2 ? report : "";
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
// OpenCode 1.x entry point, exposed as server() below.
|
|
593
|
+
const HarnessStopHook = async ({ client, directory }) => ({
|
|
550
594
|
event: async ({ event }) => {
|
|
551
595
|
if (event.type !== "session.idle") return;
|
|
552
596
|
const sessionID = event.properties?.sessionID;
|
|
553
597
|
if (!sessionID) return;
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
["-y", "${STOP_HOOK_PACKAGE}", "stop-hook", "--engine", "opencode", "--cwd", directory],
|
|
557
|
-
{ input: JSON.stringify({ sessionID }), encoding: "utf-8" },
|
|
558
|
-
);
|
|
559
|
-
const report = (r.stderr ?? "").trim();
|
|
560
|
-
if (r.status !== 2 || report === "") return;
|
|
598
|
+
const report = lintReport(directory, sessionID);
|
|
599
|
+
if (report === "") return;
|
|
561
600
|
// OpenCode defaults to its build agent when a prompt omits agent.
|
|
562
601
|
// Preserve the last conversation persona, excluding compaction summaries.
|
|
563
602
|
const history = await client.session.messages({ path: { id: sessionID } }).catch(() => undefined);
|
|
@@ -573,6 +612,31 @@ export const HarnessStopHook = async ({ client, directory }) => ({
|
|
|
573
612
|
});
|
|
574
613
|
},
|
|
575
614
|
});
|
|
615
|
+
|
|
616
|
+
// One default export for both majors: OpenCode 1.x calls server(),
|
|
617
|
+
// OpenCode 2.x calls setup(). A V2 session keeps its agent, so the report
|
|
618
|
+
// is sent without naming the persona.
|
|
619
|
+
export default {
|
|
620
|
+
id: "vaubaan-harness-stop-hook",
|
|
621
|
+
server: HarnessStopHook,
|
|
622
|
+
setup(context) {
|
|
623
|
+
const directory = context.location.directory;
|
|
624
|
+
const controller = new AbortController();
|
|
625
|
+
const listen = async () => {
|
|
626
|
+
for await (const event of context.event.subscribe({ signal: controller.signal })) {
|
|
627
|
+
if (event.type !== "session.idle") continue;
|
|
628
|
+
if (event.location?.directory && event.location.directory !== directory) continue;
|
|
629
|
+
const sessionID = event.data?.sessionID;
|
|
630
|
+
if (!sessionID) continue;
|
|
631
|
+
const report = lintReport(directory, sessionID);
|
|
632
|
+
if (report === "") continue;
|
|
633
|
+
await context.session.prompt({ sessionID, text: report }).catch(() => undefined);
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
listen().catch(() => undefined);
|
|
637
|
+
return () => controller.abort();
|
|
638
|
+
},
|
|
639
|
+
};
|
|
576
640
|
`;
|
|
577
641
|
return {
|
|
578
642
|
files: [{ path: `${BASE3}/plugins/harness-stop-hook.ts`, content }],
|
|
@@ -591,274 +655,7 @@ function getRenderer(engine) {
|
|
|
591
655
|
return RENDERERS[engine];
|
|
592
656
|
}
|
|
593
657
|
|
|
594
|
-
// src/render.ts
|
|
595
|
-
var RenderError = class extends Error {
|
|
596
|
-
constructor(message) {
|
|
597
|
-
super(message);
|
|
598
|
-
this.name = "RenderError";
|
|
599
|
-
}
|
|
600
|
-
};
|
|
601
|
-
function mergeById(stock, added) {
|
|
602
|
-
const result = /* @__PURE__ */ new Map();
|
|
603
|
-
for (const item of stock) result.set(item.meta.id, item);
|
|
604
|
-
if (added) {
|
|
605
|
-
for (const item of added) result.set(item.meta.id, item);
|
|
606
|
-
}
|
|
607
|
-
return Array.from(result.values());
|
|
608
|
-
}
|
|
609
|
-
function removeByIds(items, disabled) {
|
|
610
|
-
if (disabled.length === 0) return [...items];
|
|
611
|
-
const disabledSet = new Set(disabled);
|
|
612
|
-
return items.filter((item) => !disabledSet.has(item.meta.id));
|
|
613
|
-
}
|
|
614
|
-
function applyOverrides(harness, overrides) {
|
|
615
|
-
const disabled = overrides?.disable ?? [];
|
|
616
|
-
return {
|
|
617
|
-
agents: removeByIds(mergeById(harness.agents, overrides?.add?.agents), disabled),
|
|
618
|
-
skills: removeByIds(mergeById(harness.skills, overrides?.add?.skills), disabled),
|
|
619
|
-
cmds: removeByIds(mergeById(harness.cmds, overrides?.add?.cmds), disabled)
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
var CLAUDE_MD_STUB = "@AGENTS.md\n";
|
|
623
|
-
function opencodeLast(engines) {
|
|
624
|
-
return [...engines].sort((a, b) => Number(a === "opencode") - Number(b === "opencode"));
|
|
625
|
-
}
|
|
626
|
-
function render(input) {
|
|
627
|
-
const { engines } = input;
|
|
628
|
-
if (engines.length === 0) {
|
|
629
|
-
throw new RenderError("at least one engine must be selected");
|
|
630
|
-
}
|
|
631
|
-
const base = input.projectSkills?.length ? { ...input.harness, skills: mergeById(input.harness.skills, input.projectSkills) } : input.harness;
|
|
632
|
-
const harness = applyOverrides(base, input.overrides);
|
|
633
|
-
if (harness.agents.length === 0) {
|
|
634
|
-
throw new RenderError("No agents found in harness");
|
|
635
|
-
}
|
|
636
|
-
const files = [];
|
|
637
|
-
const warnings = [];
|
|
638
|
-
const exposedSkills = /* @__PURE__ */ new Map();
|
|
639
|
-
const exposedCmds = /* @__PURE__ */ new Map();
|
|
640
|
-
for (const engine of opencodeLast(engines)) {
|
|
641
|
-
const renderer = getRenderer(engine);
|
|
642
|
-
const renderedSkills = [];
|
|
643
|
-
for (const skill of harness.skills) {
|
|
644
|
-
const result = renderer.renderSkill(skill);
|
|
645
|
-
warnings.push(...result.warnings);
|
|
646
|
-
if (result.files.length > 0) {
|
|
647
|
-
const alreadyVisible = engine === "opencode" && exposedSkills.has(skill.meta.id);
|
|
648
|
-
if (!alreadyVisible) files.push(...result.files);
|
|
649
|
-
renderedSkills.push(skill);
|
|
650
|
-
exposedSkills.set(skill.meta.id, skill);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
const renderedSkillIds = new Set(renderedSkills.map((s) => s.meta.id));
|
|
654
|
-
const renderedCmds = [];
|
|
655
|
-
for (const cmd of harness.cmds) {
|
|
656
|
-
if (!renderedSkillIds.has(cmd.meta.invokes_skill)) continue;
|
|
657
|
-
const result = renderer.renderCmd(cmd);
|
|
658
|
-
warnings.push(...result.warnings);
|
|
659
|
-
if (result.files.length > 0) {
|
|
660
|
-
files.push(...result.files);
|
|
661
|
-
renderedCmds.push(cmd);
|
|
662
|
-
exposedCmds.set(cmd.meta.id, cmd);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
const agentsResult = renderer.renderAgents(harness.agents);
|
|
666
|
-
warnings.push(...agentsResult.warnings);
|
|
667
|
-
files.push(...agentsResult.files);
|
|
668
|
-
if (input.mcpServers && input.mcpServers.length > 0) {
|
|
669
|
-
const specs = input.mcpServers.map((id) => MCP_SERVERS[id]);
|
|
670
|
-
const mcpResult = renderer.renderMcp(specs);
|
|
671
|
-
warnings.push(...mcpResult.warnings);
|
|
672
|
-
files.push(...mcpResult.files);
|
|
673
|
-
}
|
|
674
|
-
if (input.withStopHook !== false) {
|
|
675
|
-
const hookResult = renderer.renderStopHook();
|
|
676
|
-
warnings.push(...hookResult.warnings);
|
|
677
|
-
files.push(...hookResult.files);
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
const hasProjectSkills = (input.projectSkills?.length ?? 0) > 0;
|
|
681
|
-
const umbrellaContent = buildUmbrellaBody(harness.agents, {
|
|
682
|
-
availableSkills: Array.from(exposedSkills.values()),
|
|
683
|
-
availableCommands: Array.from(exposedCmds.values()),
|
|
684
|
-
hasProjectSkills,
|
|
685
|
-
hasQualityDoc: input.hasQualityDoc === true
|
|
686
|
-
});
|
|
687
|
-
files.push({ path: "AGENTS.md", content: umbrellaContent });
|
|
688
|
-
if (engines.includes("claude-code")) {
|
|
689
|
-
files.push({ path: "CLAUDE.md", content: CLAUDE_MD_STUB });
|
|
690
|
-
}
|
|
691
|
-
return { files, warnings };
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// src/cli/managed-paths.ts
|
|
695
|
-
import { lstatSync, realpathSync, statSync } from "fs";
|
|
696
|
-
import { basename, dirname, isAbsolute, join as join2, resolve, win32 } from "path";
|
|
697
|
-
var MANAGED_FILES = /* @__PURE__ */ new Set([
|
|
698
|
-
"AGENTS.md",
|
|
699
|
-
"CLAUDE.md",
|
|
700
|
-
".mcp.json",
|
|
701
|
-
".harness-manifest.json",
|
|
702
|
-
".claude/settings.json",
|
|
703
|
-
".codex/config.toml",
|
|
704
|
-
".codex/hooks.json",
|
|
705
|
-
".opencode/opencode.json",
|
|
706
|
-
".opencode/mcp.json"
|
|
707
|
-
]);
|
|
708
|
-
var MANAGED_DIRECTORIES = [
|
|
709
|
-
".claude/agents/",
|
|
710
|
-
".claude/commands/",
|
|
711
|
-
".claude/skills/",
|
|
712
|
-
".codex/agents/",
|
|
713
|
-
".codex/commands/",
|
|
714
|
-
".codex/skills/",
|
|
715
|
-
".agents/skills/",
|
|
716
|
-
".opencode/agents/",
|
|
717
|
-
".opencode/commands/",
|
|
718
|
-
".opencode/skills/",
|
|
719
|
-
".opencode/plugins/"
|
|
720
|
-
];
|
|
721
|
-
function canonicalTargetRoot(root) {
|
|
722
|
-
let ancestor = resolve(root);
|
|
723
|
-
const missing = [];
|
|
724
|
-
while (!lstatSync(ancestor, { throwIfNoEntry: false })) {
|
|
725
|
-
missing.unshift(basename(ancestor));
|
|
726
|
-
ancestor = dirname(ancestor);
|
|
727
|
-
}
|
|
728
|
-
const canonical = realpathSync(ancestor);
|
|
729
|
-
if (!statSync(canonical).isDirectory()) {
|
|
730
|
-
throw new Error(`harness target is not a directory: ${root}`);
|
|
731
|
-
}
|
|
732
|
-
return join2(canonical, ...missing);
|
|
733
|
-
}
|
|
734
|
-
function assertManagedLocation(path) {
|
|
735
|
-
const invalidSegment = path.split("/").some((part) => part === "" || part === "." || part === "..");
|
|
736
|
-
if (isAbsolute(path) || win32.isAbsolute(path) || path.includes("\\") || path.includes("\0") || invalidSegment) {
|
|
737
|
-
throw new Error(`unsafe harness path: ${path}`);
|
|
738
|
-
}
|
|
739
|
-
if (!MANAGED_FILES.has(path) && !MANAGED_DIRECTORIES.some((prefix) => path.startsWith(prefix))) {
|
|
740
|
-
throw new Error(`path is outside harness-managed locations: ${path}`);
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
function assertNoSymbolicComponents(root, path) {
|
|
744
|
-
const parts = path.split("/");
|
|
745
|
-
let current = root;
|
|
746
|
-
for (const [index, part] of parts.entries()) {
|
|
747
|
-
current = join2(current, part);
|
|
748
|
-
const info = lstatSync(current, { throwIfNoEntry: false });
|
|
749
|
-
if (!info) return;
|
|
750
|
-
if (info.isSymbolicLink()) {
|
|
751
|
-
throw new Error(`symbolic link in harness path: ${path}`);
|
|
752
|
-
}
|
|
753
|
-
const isLeaf = index === parts.length - 1;
|
|
754
|
-
if (isLeaf ? !info.isFile() : !info.isDirectory()) {
|
|
755
|
-
throw new Error(`unexpected file type in harness path: ${path}`);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
function assertManagedPaths(root, files) {
|
|
760
|
-
for (const path of files) assertManagedLocation(path);
|
|
761
|
-
const canonicalRoot2 = canonicalTargetRoot(root);
|
|
762
|
-
for (const path of files) assertNoSymbolicComponents(canonicalRoot2, path);
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// src/cli/manifest.ts
|
|
766
|
-
import { createHash } from "crypto";
|
|
767
|
-
import { chmodSync, existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync } from "fs";
|
|
768
|
-
import { join as join5 } from "path";
|
|
769
|
-
import { z as z5 } from "zod";
|
|
770
|
-
|
|
771
|
-
// src/cli/opencode-config.ts
|
|
772
|
-
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
773
|
-
import { join as join3 } from "path";
|
|
774
|
-
import { isDeepStrictEqual } from "util";
|
|
775
|
-
import { z as z3 } from "zod";
|
|
776
|
-
var OPENCODE_CONFIG_PATH = ".opencode/opencode.json";
|
|
777
|
-
var OPENCODE_SCHEMA = "https://opencode.ai/config.json";
|
|
778
|
-
var ObjectSchema = z3.record(z3.unknown());
|
|
779
|
-
var OpenCodeConfigOwnershipSchema = z3.object({
|
|
780
|
-
createdFile: z3.boolean(),
|
|
781
|
-
addedMcp: z3.boolean(),
|
|
782
|
-
addedSchema: z3.boolean(),
|
|
783
|
-
entries: z3.record(ObjectSchema)
|
|
784
|
-
});
|
|
785
|
-
function parseConfig(content) {
|
|
786
|
-
try {
|
|
787
|
-
const config = ObjectSchema.parse(JSON.parse(content));
|
|
788
|
-
if (Object.hasOwn(config, "mcp")) ObjectSchema.parse(config.mcp);
|
|
789
|
-
return config;
|
|
790
|
-
} catch {
|
|
791
|
-
throw new Error(`invalid ${OPENCODE_CONFIG_PATH}: expected a JSON object with an MCP object`);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
function removeOwnedEntries(config, ownership, warnings) {
|
|
795
|
-
if (Object.hasOwn(config, "mcp")) {
|
|
796
|
-
const mcp = ObjectSchema.parse(config.mcp);
|
|
797
|
-
for (const [id, installed] of Object.entries(ownership.entries)) {
|
|
798
|
-
if (!Object.hasOwn(mcp, id)) continue;
|
|
799
|
-
if (isDeepStrictEqual(mcp[id], installed)) delete mcp[id];
|
|
800
|
-
else warnings.push(`preserved modified OpenCode MCP server: ${id}`);
|
|
801
|
-
}
|
|
802
|
-
config.mcp = mcp;
|
|
803
|
-
if (ownership.addedMcp && Object.keys(mcp).length === 0) Reflect.deleteProperty(config, "mcp");
|
|
804
|
-
}
|
|
805
|
-
if (ownership.addedSchema && config.$schema === OPENCODE_SCHEMA)
|
|
806
|
-
Reflect.deleteProperty(config, "$schema");
|
|
807
|
-
}
|
|
808
|
-
function addMissingEntries(config, desired, createdFile, warnings) {
|
|
809
|
-
const requested = ObjectSchema.parse(parseConfig(desired.content).mcp);
|
|
810
|
-
const mcp = ObjectSchema.parse(config.mcp ?? {});
|
|
811
|
-
const entries = {};
|
|
812
|
-
for (const [id, entry] of Object.entries(requested)) {
|
|
813
|
-
if (Object.hasOwn(mcp, id)) {
|
|
814
|
-
if (!isDeepStrictEqual(mcp[id], entry)) {
|
|
815
|
-
warnings.push(`preserved existing OpenCode MCP server instead of replacing it: ${id}`);
|
|
816
|
-
}
|
|
817
|
-
continue;
|
|
818
|
-
}
|
|
819
|
-
entries[id] = ObjectSchema.parse(entry);
|
|
820
|
-
mcp[id] = entry;
|
|
821
|
-
}
|
|
822
|
-
if (Object.keys(entries).length === 0) return void 0;
|
|
823
|
-
const ownership = {
|
|
824
|
-
createdFile,
|
|
825
|
-
addedMcp: !Object.hasOwn(config, "mcp"),
|
|
826
|
-
addedSchema: !Object.hasOwn(config, "$schema"),
|
|
827
|
-
entries
|
|
828
|
-
};
|
|
829
|
-
config.mcp = mcp;
|
|
830
|
-
if (ownership.addedSchema) config.$schema = OPENCODE_SCHEMA;
|
|
831
|
-
return ownership;
|
|
832
|
-
}
|
|
833
|
-
function planOpenCodeConfig(root, desired, previous) {
|
|
834
|
-
const warnings = [];
|
|
835
|
-
if (!desired && !previous) return { remove: false, warnings };
|
|
836
|
-
const path = join3(root, OPENCODE_CONFIG_PATH);
|
|
837
|
-
const existed = existsSync2(path);
|
|
838
|
-
if (!existed && !desired) return { remove: false, warnings };
|
|
839
|
-
const original = existed ? readFileSync3(path, "utf8") : void 0;
|
|
840
|
-
const config = original === void 0 ? {} : parseConfig(original);
|
|
841
|
-
const before = structuredClone(config);
|
|
842
|
-
if (previous) removeOwnedEntries(config, previous, warnings);
|
|
843
|
-
const createdFile = !existed || previous?.createdFile === true;
|
|
844
|
-
const ownership = desired ? addMissingEntries(config, desired, createdFile, warnings) : void 0;
|
|
845
|
-
if (!ownership && createdFile && Object.keys(config).length === 0) {
|
|
846
|
-
return { remove: existed, warnings };
|
|
847
|
-
}
|
|
848
|
-
return {
|
|
849
|
-
remove: false,
|
|
850
|
-
ownership,
|
|
851
|
-
warnings,
|
|
852
|
-
...!existed || !isDeepStrictEqual(before, config) ? { write: { path: OPENCODE_CONFIG_PATH, content: `${JSON.stringify(config, null, 2)}
|
|
853
|
-
` } } : {}
|
|
854
|
-
};
|
|
855
|
-
}
|
|
856
|
-
|
|
857
658
|
// src/cli/update-config.ts
|
|
858
|
-
import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
|
|
859
|
-
import { join as join4 } from "path";
|
|
860
|
-
import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
|
|
861
|
-
import { z as z4 } from "zod";
|
|
862
659
|
var LEGACY_OPENCODE_CONFIG = ".opencode/mcp.json";
|
|
863
660
|
var SHARED_CONFIG_PATHS = [
|
|
864
661
|
".mcp.json",
|
|
@@ -868,15 +665,15 @@ var SHARED_CONFIG_PATHS = [
|
|
|
868
665
|
".claude/settings.json",
|
|
869
666
|
".codex/hooks.json"
|
|
870
667
|
];
|
|
871
|
-
var ObjectSchema2 =
|
|
872
|
-
var HookEntrySchema =
|
|
873
|
-
var HookConfigOwnershipSchema =
|
|
874
|
-
createdFile:
|
|
875
|
-
addedHooks:
|
|
876
|
-
addedStop:
|
|
877
|
-
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)
|
|
878
675
|
});
|
|
879
|
-
var HookConfigsOwnershipSchema =
|
|
676
|
+
var HookConfigsOwnershipSchema = z2.object({
|
|
880
677
|
".claude/settings.json": HookConfigOwnershipSchema.optional(),
|
|
881
678
|
".codex/hooks.json": HookConfigOwnershipSchema.optional()
|
|
882
679
|
}).strict();
|
|
@@ -1000,8 +797,8 @@ function planHookConfigCleanup(root, ownership) {
|
|
|
1000
797
|
assertManagedPaths(root, Object.keys(validated));
|
|
1001
798
|
for (const path of Object.keys(HOOK_ENGINES)) {
|
|
1002
799
|
const owned = validated[path];
|
|
1003
|
-
if (!owned || !
|
|
1004
|
-
const before = parseObject(
|
|
800
|
+
if (!owned || !existsSync2(join3(root, path))) continue;
|
|
801
|
+
const before = parseObject(readFileSync2(join3(root, path), "utf8"), path);
|
|
1005
802
|
const config = structuredClone(before);
|
|
1006
803
|
removeOwnedHooks(config, owned, path, plan.warnings);
|
|
1007
804
|
recordMutation(plan, path, before, config, owned.createdFile);
|
|
@@ -1010,12 +807,12 @@ function planHookConfigCleanup(root, ownership) {
|
|
|
1010
807
|
}
|
|
1011
808
|
function planActiveHook(root, path, previous, claimExisting, plan) {
|
|
1012
809
|
assertManagedPaths(root, [path]);
|
|
1013
|
-
const existed =
|
|
810
|
+
const existed = existsSync2(join3(root, path));
|
|
1014
811
|
if (!existed && previous?.entries.length) {
|
|
1015
812
|
plan.warnings.push(`preserved removed harness Stop hook configuration: ${path}`);
|
|
1016
813
|
return previous;
|
|
1017
814
|
}
|
|
1018
|
-
const before = existed ? parseObject(
|
|
815
|
+
const before = existed ? parseObject(readFileSync2(join3(root, path), "utf8"), path) : void 0;
|
|
1019
816
|
const config = structuredClone(before ?? {});
|
|
1020
817
|
const groups = stopHooks(config, path);
|
|
1021
818
|
const hooks = asObject(config.hooks) ?? {};
|
|
@@ -1027,10 +824,11 @@ function planActiveHook(root, path, previous, claimExisting, plan) {
|
|
|
1027
824
|
plan.warnings,
|
|
1028
825
|
path
|
|
1029
826
|
);
|
|
827
|
+
const legacyWholeFile = !previous && claimExisting && existed;
|
|
1030
828
|
const ownership = {
|
|
1031
|
-
createdFile: !existed || previous?.createdFile === true,
|
|
1032
|
-
addedHooks: previous?.addedHooks ?? !Object.hasOwn(config, "hooks"),
|
|
1033
|
-
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")),
|
|
1034
832
|
entries: normalized.entries
|
|
1035
833
|
};
|
|
1036
834
|
if (!isDeepStrictEqual2(groups, normalized.groups)) {
|
|
@@ -1041,10 +839,10 @@ function planActiveHook(root, path, previous, claimExisting, plan) {
|
|
|
1041
839
|
}
|
|
1042
840
|
function planLegacyOpenCode(root, previous, plan) {
|
|
1043
841
|
if (!previous.files.includes(LEGACY_OPENCODE_CONFIG)) return;
|
|
1044
|
-
const legacyPath =
|
|
1045
|
-
if (!
|
|
842
|
+
const legacyPath = join3(root, LEGACY_OPENCODE_CONFIG);
|
|
843
|
+
if (!existsSync2(legacyPath)) return;
|
|
1046
844
|
assertManagedPaths(root, [LEGACY_OPENCODE_CONFIG, OPENCODE_CONFIG_PATH]);
|
|
1047
|
-
const legacy = parseObject(
|
|
845
|
+
const legacy = parseObject(readFileSync2(legacyPath, "utf8"), LEGACY_OPENCODE_CONFIG);
|
|
1048
846
|
if (!asObject(legacy.mcp)) {
|
|
1049
847
|
throw new Error(
|
|
1050
848
|
`invalid shared configuration ${LEGACY_OPENCODE_CONFIG}: expected an MCP object`
|
|
@@ -1108,45 +906,24 @@ function planUpdateConfigs(root, previous, hookEngines) {
|
|
|
1108
906
|
// src/cli/manifest.ts
|
|
1109
907
|
var MANIFEST_FILENAME = ".harness-manifest.json";
|
|
1110
908
|
var MANIFEST_VERSION = "6";
|
|
1111
|
-
var ManifestSchema =
|
|
1112
|
-
version:
|
|
1113
|
-
engines:
|
|
1114
|
-
renderedAt:
|
|
1115
|
-
files:
|
|
1116
|
-
stopHookInstalled:
|
|
1117
|
-
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(),
|
|
1118
916
|
opencodeConfig: OpenCodeConfigOwnershipSchema.optional(),
|
|
917
|
+
/** Entries the harness added to `.mcp.json` (Claude Code). */
|
|
918
|
+
mcpConfig: OpenCodeConfigOwnershipSchema.optional(),
|
|
1119
919
|
hookConfigs: HookConfigsOwnershipSchema.optional(),
|
|
1120
|
-
harnessVersion:
|
|
1121
|
-
overridesDir:
|
|
1122
|
-
fileHashes:
|
|
1123
|
-
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()
|
|
1124
924
|
});
|
|
1125
925
|
function manifestPath(targetDir) {
|
|
1126
|
-
return
|
|
1127
|
-
}
|
|
1128
|
-
function writeManifest(targetDir, engines, files, extras = {}) {
|
|
1129
|
-
assertManagedPaths(targetDir, [MANIFEST_FILENAME]);
|
|
1130
|
-
const manifest = {
|
|
1131
|
-
version: MANIFEST_VERSION,
|
|
1132
|
-
harnessVersion: VERSION,
|
|
1133
|
-
engines: [...engines].sort(),
|
|
1134
|
-
renderedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1135
|
-
files: [...files].sort(),
|
|
1136
|
-
fileHashes: Object.fromEntries(
|
|
1137
|
-
files.filter((file) => existsSync4(join5(targetDir, file))).sort().map((file) => [file, hashContent(readFileSync5(join5(targetDir, file)))])
|
|
1138
|
-
),
|
|
1139
|
-
...extras.overridesDir ? { overridesDir: extras.overridesDir } : {},
|
|
1140
|
-
...extras.preservedFiles ? { preservedFiles: extras.preservedFiles } : {},
|
|
1141
|
-
...extras.hookConfigs ? { hookConfigs: extras.hookConfigs } : {},
|
|
1142
|
-
...extras.opencodeConfig ? { opencodeConfig: extras.opencodeConfig } : {},
|
|
1143
|
-
...extras.stopHookInstalled !== void 0 ? { stopHookInstalled: extras.stopHookInstalled } : {},
|
|
1144
|
-
...extras.stopHookEngines !== void 0 ? { stopHookEngines: [...extras.stopHookEngines].sort() } : {}
|
|
1145
|
-
};
|
|
1146
|
-
const path = manifestPath(targetDir);
|
|
1147
|
-
if (existsSync4(path)) chmodSync(path, 384);
|
|
1148
|
-
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}
|
|
1149
|
-
`, { mode: 384 });
|
|
926
|
+
return join4(targetDir, MANIFEST_FILENAME);
|
|
1150
927
|
}
|
|
1151
928
|
function hashContent(content) {
|
|
1152
929
|
return createHash("sha256").update(content).digest("hex");
|
|
@@ -1154,66 +931,291 @@ function hashContent(content) {
|
|
|
1154
931
|
function readManifest(targetDir) {
|
|
1155
932
|
assertManagedPaths(targetDir, [MANIFEST_FILENAME]);
|
|
1156
933
|
const path = manifestPath(targetDir);
|
|
1157
|
-
if (!
|
|
1158
|
-
const raw = JSON.parse(
|
|
934
|
+
if (!existsSync3(path)) return null;
|
|
935
|
+
const raw = JSON.parse(readFileSync3(path, "utf8"));
|
|
1159
936
|
return ManifestSchema.parse(raw);
|
|
1160
937
|
}
|
|
1161
938
|
|
|
1162
|
-
// src/cli/
|
|
1163
|
-
import { existsSync as existsSync5
|
|
1164
|
-
import { dirname as dirname2, join as join6,
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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 {
|
|
1178
1078
|
}
|
|
1179
1079
|
}
|
|
1180
|
-
return {
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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(", ")})`);
|
|
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");
|
|
1143
|
+
}
|
|
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);
|
|
1189
1186
|
}
|
|
1190
1187
|
}
|
|
1191
|
-
const
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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 });
|
|
1198
1198
|
}
|
|
1199
|
-
return
|
|
1199
|
+
return { files, warnings };
|
|
1200
1200
|
}
|
|
1201
1201
|
|
|
1202
|
-
// src/cli/
|
|
1202
|
+
// src/cli/plan-render.ts
|
|
1203
1203
|
var QUALITY_DOC_PATH = "docs/quality/code-quality.md";
|
|
1204
1204
|
function resolveEngineList(requested) {
|
|
1205
|
-
if (!requested
|
|
1206
|
-
|
|
1205
|
+
if (!requested) return [...ENGINES];
|
|
1206
|
+
if (requested.length === 0) throw new Error("no engine selected");
|
|
1207
|
+
const engines = requested.map((e) => {
|
|
1207
1208
|
if (!ENGINES.includes(e)) {
|
|
1208
1209
|
throw new Error(`unknown engine: ${e} (expected one of ${ENGINES.join(", ")})`);
|
|
1209
1210
|
}
|
|
1210
1211
|
return e;
|
|
1211
1212
|
});
|
|
1213
|
+
return [...new Set(engines)];
|
|
1212
1214
|
}
|
|
1213
1215
|
function defaultPackageDir() {
|
|
1214
|
-
const here =
|
|
1215
|
-
if (
|
|
1216
|
-
return
|
|
1216
|
+
const here = dirname2(fileURLToPath(import.meta.url));
|
|
1217
|
+
if (existsSync5(join6(here, "agents"))) return here;
|
|
1218
|
+
return resolve2(here, "..");
|
|
1217
1219
|
}
|
|
1218
1220
|
function planInit(args) {
|
|
1219
1221
|
const engines = resolveEngineList(args.engines);
|
|
@@ -1221,7 +1223,7 @@ function planInit(args) {
|
|
|
1221
1223
|
const harness = loadHarness(packageDir);
|
|
1222
1224
|
const overrides = args.overridesDir ? loadOverrides(args.overridesDir) : void 0;
|
|
1223
1225
|
const projectSkills = loadProjectSkills(args.targetDir);
|
|
1224
|
-
const hasQualityDoc =
|
|
1226
|
+
const hasQualityDoc = existsSync5(join6(args.targetDir, QUALITY_DOC_PATH));
|
|
1225
1227
|
const mcpServers = args.mcpServers ? resolveMcpIds(args.mcpServers) : void 0;
|
|
1226
1228
|
const stopHookInstalled = args.withStopHook !== false;
|
|
1227
1229
|
const output = render({
|
|
@@ -1235,107 +1237,35 @@ function planInit(args) {
|
|
|
1235
1237
|
});
|
|
1236
1238
|
return { engines, output, stopHookInstalled };
|
|
1237
1239
|
}
|
|
1238
|
-
function writeFilesToTarget(files, targetDir) {
|
|
1239
|
-
assertManagedPaths(
|
|
1240
|
-
targetDir,
|
|
1241
|
-
files.map((file) => file.path)
|
|
1242
|
-
);
|
|
1243
|
-
const written = [];
|
|
1244
|
-
for (const f of files) {
|
|
1245
|
-
const full = join7(targetDir, f.path);
|
|
1246
|
-
mkdirSync(dirname3(full), { recursive: true });
|
|
1247
|
-
writeFileSync3(full, f.content);
|
|
1248
|
-
written.push(f.path);
|
|
1249
|
-
}
|
|
1250
|
-
return written;
|
|
1251
|
-
}
|
|
1252
|
-
function initCommand(args) {
|
|
1253
|
-
const { engines, output, stopHookInstalled } = planInit(args);
|
|
1254
|
-
const previous = readManifest(args.targetDir);
|
|
1255
|
-
assertManagedPaths(args.targetDir, [
|
|
1256
|
-
MANIFEST_FILENAME,
|
|
1257
|
-
...previous?.files ?? [],
|
|
1258
|
-
...output.files.map((file) => file.path),
|
|
1259
|
-
...previous?.opencodeConfig ? [OPENCODE_CONFIG_PATH] : []
|
|
1260
|
-
]);
|
|
1261
|
-
const desiredConfig = output.files.find((file) => file.path === OPENCODE_CONFIG_PATH);
|
|
1262
|
-
const config = planOpenCodeConfig(args.targetDir, desiredConfig, previous?.opencodeConfig);
|
|
1263
|
-
const next = new Set(output.files.map((f) => f.path));
|
|
1264
|
-
const stale = (previous?.files ?? []).filter((f) => !next.has(f) && f !== OPENCODE_CONFIG_PATH);
|
|
1265
|
-
if (config.remove) stale.push(OPENCODE_CONFIG_PATH);
|
|
1266
|
-
const staleFilesRemoved = removeRenderedFiles(resolve3(args.targetDir), stale).filesRemoved;
|
|
1267
|
-
const ownedFiles = output.files.filter((file) => file.path !== OPENCODE_CONFIG_PATH);
|
|
1268
|
-
const filesWritten = writeFilesToTarget(ownedFiles, args.targetDir);
|
|
1269
|
-
if (config.write) writeFilesToTarget([config.write], args.targetDir);
|
|
1270
|
-
writeManifest(args.targetDir, engines, filesWritten, {
|
|
1271
|
-
stopHookInstalled,
|
|
1272
|
-
stopHookEngines: stopHookInstalled ? engines : void 0,
|
|
1273
|
-
opencodeConfig: config.ownership,
|
|
1274
|
-
overridesDir: args.overridesDir ? resolve3(args.overridesDir) === resolve3(args.targetDir) ? "." : resolve3(args.overridesDir) : void 0
|
|
1275
|
-
});
|
|
1276
|
-
if (config.write) filesWritten.push(OPENCODE_CONFIG_PATH);
|
|
1277
|
-
const warnings = [...output.warnings, ...config.warnings];
|
|
1278
|
-
if (previous?.files.includes(OPENCODE_CONFIG_PATH) && !previous.opencodeConfig) {
|
|
1279
|
-
warnings.push(`preserved legacy shared configuration: ${OPENCODE_CONFIG_PATH}`);
|
|
1280
|
-
}
|
|
1281
|
-
return { filesWritten, staleFilesRemoved, warnings, stopHookInstalled };
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
// src/cli/update.ts
|
|
1285
|
-
import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
|
|
1286
|
-
import { join as join10, resolve as resolve5 } from "path";
|
|
1287
|
-
import { isDeepStrictEqual as isDeepStrictEqual3 } from "util";
|
|
1288
1240
|
|
|
1289
|
-
// src/cli/
|
|
1290
|
-
import {
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
]
|
|
1308
|
-
};
|
|
1309
|
-
function readUpdateInstallation(root) {
|
|
1310
|
-
assertManagedPaths(root, [MANIFEST_FILENAME]);
|
|
1311
|
-
if (!existsSync7(join8(root, MANIFEST_FILENAME))) {
|
|
1312
|
-
throw new Error("no harness manifest found; use init for a new installation");
|
|
1313
|
-
}
|
|
1314
|
-
const raw = JSON.parse(readFileSync6(join8(root, MANIFEST_FILENAME), "utf8"));
|
|
1315
|
-
const sourceVersion = String(raw?.version);
|
|
1316
|
-
let manifest;
|
|
1317
|
-
if (["1", "2", "3"].includes(sourceVersion)) {
|
|
1318
|
-
const legacy = LegacySchema.parse(raw);
|
|
1319
|
-
const engines2 = legacy.version === "1" ? [EngineSchema.parse(legacy.engine)] : legacy.engines;
|
|
1320
|
-
manifest = {
|
|
1321
|
-
version: "4",
|
|
1322
|
-
engines: z6.array(EngineSchema).nonempty().parse(engines2),
|
|
1323
|
-
files: legacy.files,
|
|
1324
|
-
renderedAt: legacy.renderedAt
|
|
1325
|
-
};
|
|
1326
|
-
} else {
|
|
1327
|
-
const current = readManifest(root);
|
|
1328
|
-
if (!current) throw new Error("harness manifest disappeared during planning");
|
|
1329
|
-
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
|
+
}
|
|
1330
1259
|
}
|
|
1331
|
-
const
|
|
1332
|
-
const
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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));
|
|
1337
1267
|
}
|
|
1338
|
-
return
|
|
1268
|
+
return pruned;
|
|
1339
1269
|
}
|
|
1340
1270
|
|
|
1341
1271
|
// src/cli/update-transaction.ts
|
|
@@ -1345,17 +1275,17 @@ import {
|
|
|
1345
1275
|
closeSync,
|
|
1346
1276
|
fsyncSync,
|
|
1347
1277
|
lstatSync as lstatSync2,
|
|
1348
|
-
mkdirSync
|
|
1278
|
+
mkdirSync,
|
|
1349
1279
|
openSync,
|
|
1350
1280
|
readFileSync as readFileSync7,
|
|
1351
1281
|
readdirSync as readdirSync2,
|
|
1352
1282
|
realpathSync as realpathSync2,
|
|
1353
1283
|
rmdirSync as rmdirSync2,
|
|
1354
1284
|
unlinkSync as unlinkSync2,
|
|
1355
|
-
writeFileSync as
|
|
1285
|
+
writeFileSync as writeFileSync3
|
|
1356
1286
|
} from "fs";
|
|
1357
1287
|
import { homedir } from "os";
|
|
1358
|
-
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";
|
|
1359
1289
|
var MANIFEST_PATH = ".harness-manifest.json";
|
|
1360
1290
|
var PRIVATE_FILES = /* @__PURE__ */ new Set([
|
|
1361
1291
|
MANIFEST_PATH,
|
|
@@ -1373,7 +1303,7 @@ function canonicalRoot(root) {
|
|
|
1373
1303
|
missing.unshift(basename2(ancestor));
|
|
1374
1304
|
ancestor = dirname4(ancestor);
|
|
1375
1305
|
}
|
|
1376
|
-
return
|
|
1306
|
+
return join8(realpathSync2(ancestor), ...missing);
|
|
1377
1307
|
}
|
|
1378
1308
|
function contains(parent, child) {
|
|
1379
1309
|
const path = relative2(parent, child);
|
|
@@ -1405,7 +1335,7 @@ function createDirectories(path, created, mode = 511) {
|
|
|
1405
1335
|
missing.unshift(current);
|
|
1406
1336
|
}
|
|
1407
1337
|
for (const directory of missing) {
|
|
1408
|
-
|
|
1338
|
+
mkdirSync(directory, { mode });
|
|
1409
1339
|
created.push(directory);
|
|
1410
1340
|
}
|
|
1411
1341
|
}
|
|
@@ -1413,7 +1343,7 @@ function writeBackupFile(path, content) {
|
|
|
1413
1343
|
createDirectories(dirname4(path), [], 448);
|
|
1414
1344
|
const fd = openSync(path, "wx", 384);
|
|
1415
1345
|
try {
|
|
1416
|
-
|
|
1346
|
+
writeFileSync3(fd, content);
|
|
1417
1347
|
fsyncSync(fd);
|
|
1418
1348
|
} finally {
|
|
1419
1349
|
closeSync(fd);
|
|
@@ -1422,16 +1352,16 @@ function writeBackupFile(path, content) {
|
|
|
1422
1352
|
function saveSnapshot(root, paths, backup) {
|
|
1423
1353
|
const entries = [];
|
|
1424
1354
|
createDirectories(dirname4(backup), [], 448);
|
|
1425
|
-
|
|
1355
|
+
mkdirSync(backup, { mode: 448 });
|
|
1426
1356
|
for (const path of paths) {
|
|
1427
|
-
const full =
|
|
1357
|
+
const full = join8(root, path);
|
|
1428
1358
|
const info = lstatSync2(full, { throwIfNoEntry: false });
|
|
1429
1359
|
if (!info) {
|
|
1430
1360
|
entries.push({ path, exists: false });
|
|
1431
1361
|
continue;
|
|
1432
1362
|
}
|
|
1433
1363
|
const content = readFileSync7(full);
|
|
1434
|
-
writeBackupFile(
|
|
1364
|
+
writeBackupFile(join8(backup, "files", path), content);
|
|
1435
1365
|
entries.push({
|
|
1436
1366
|
path,
|
|
1437
1367
|
exists: true,
|
|
@@ -1440,7 +1370,7 @@ function saveSnapshot(root, paths, backup) {
|
|
|
1440
1370
|
});
|
|
1441
1371
|
}
|
|
1442
1372
|
writeBackupFile(
|
|
1443
|
-
|
|
1373
|
+
join8(backup, "snapshot.json"),
|
|
1444
1374
|
`${JSON.stringify({ version: 1, root, createdAt: (/* @__PURE__ */ new Date()).toISOString(), entries }, null, 2)}
|
|
1445
1375
|
`
|
|
1446
1376
|
);
|
|
@@ -1455,16 +1385,16 @@ function rollback(root, backup, entries, touched, createdDirectories) {
|
|
|
1455
1385
|
try {
|
|
1456
1386
|
assertManagedPaths(root, [path]);
|
|
1457
1387
|
const entry = entries.get(path);
|
|
1458
|
-
const full =
|
|
1388
|
+
const full = join8(root, path);
|
|
1459
1389
|
const current = lstatSync2(full, { throwIfNoEntry: false });
|
|
1460
1390
|
if (!entry?.exists) {
|
|
1461
1391
|
if (current) unlinkSync2(full);
|
|
1462
1392
|
continue;
|
|
1463
1393
|
}
|
|
1464
|
-
const original = readFileSync7(
|
|
1394
|
+
const original = readFileSync7(join8(backup, "files", path));
|
|
1465
1395
|
if (!current || !readFileSync7(full).equals(original)) {
|
|
1466
1396
|
if (current) unlinkSync2(full);
|
|
1467
|
-
|
|
1397
|
+
writeFileSync3(full, original, { mode: 384 });
|
|
1468
1398
|
}
|
|
1469
1399
|
chmodSync2(full, entry.mode);
|
|
1470
1400
|
} catch (error) {
|
|
@@ -1485,7 +1415,7 @@ function applyUpdateTransaction(root, writes, removals, backupDir) {
|
|
|
1485
1415
|
assertManagedPaths(root, paths);
|
|
1486
1416
|
const target = canonicalRoot(root);
|
|
1487
1417
|
const backup = resolve4(
|
|
1488
|
-
backupDir ??
|
|
1418
|
+
backupDir ?? join8(homedir(), ".local", "state", "vaubaan-harness", "backups", randomUUID())
|
|
1489
1419
|
);
|
|
1490
1420
|
assertBackupLocation(target, backup);
|
|
1491
1421
|
let snapshot;
|
|
@@ -1503,18 +1433,18 @@ function applyUpdateTransaction(root, writes, removals, backupDir) {
|
|
|
1503
1433
|
try {
|
|
1504
1434
|
assertManagedPaths(target, paths);
|
|
1505
1435
|
for (const path of removals) {
|
|
1506
|
-
if (!lstatSync2(
|
|
1436
|
+
if (!lstatSync2(join8(target, path), { throwIfNoEntry: false })) continue;
|
|
1507
1437
|
touched.add(path);
|
|
1508
|
-
unlinkSync2(
|
|
1438
|
+
unlinkSync2(join8(target, path));
|
|
1509
1439
|
}
|
|
1510
1440
|
for (const file of writes) {
|
|
1511
|
-
const full =
|
|
1441
|
+
const full = join8(target, file.path);
|
|
1512
1442
|
createDirectories(dirname4(full), createdDirectories);
|
|
1513
1443
|
touched.add(file.path);
|
|
1514
1444
|
const entry = entries.get(file.path);
|
|
1515
1445
|
const mode = file.path === MANIFEST_PATH ? 384 : entry?.exists ? entry.mode : PRIVATE_FILES.has(file.path) ? 384 : 420;
|
|
1516
1446
|
if (entry?.exists && file.path === MANIFEST_PATH) chmodSync2(full, mode);
|
|
1517
|
-
|
|
1447
|
+
writeFileSync3(full, file.content, { mode });
|
|
1518
1448
|
if (entry?.exists) chmodSync2(full, mode);
|
|
1519
1449
|
}
|
|
1520
1450
|
} catch (error) {
|
|
@@ -1528,29 +1458,159 @@ function applyUpdateTransaction(root, writes, removals, backupDir) {
|
|
|
1528
1458
|
}
|
|
1529
1459
|
|
|
1530
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";
|
|
1531
1519
|
function currentContent(root, path) {
|
|
1532
|
-
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);
|
|
1533
1527
|
}
|
|
1534
1528
|
function checkArtifactConflicts(root, previous, writes, removals) {
|
|
1535
1529
|
const desired = new Map(writes.map((file) => [file.path, file.content]));
|
|
1530
|
+
const conflicts = [];
|
|
1536
1531
|
for (const path of [...desired.keys(), ...removals]) {
|
|
1537
1532
|
if (SHARED_CONFIG_PATHS.includes(path)) continue;
|
|
1538
1533
|
const content = currentContent(root, path);
|
|
1539
|
-
if (content === void 0) continue;
|
|
1534
|
+
if (content === void 0 || content === desired.get(path)) continue;
|
|
1540
1535
|
if (!previous.files.includes(path)) {
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
);
|
|
1536
|
+
conflicts.push(`existing file outside harness manifest: ${path}`);
|
|
1537
|
+
continue;
|
|
1544
1538
|
}
|
|
1545
1539
|
const hash = previous.fileHashes?.[path];
|
|
1546
|
-
if (hash && hash !== hashContent(content)
|
|
1547
|
-
|
|
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}`);
|
|
1548
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);
|
|
1549
1608
|
}
|
|
1609
|
+
return plan;
|
|
1550
1610
|
}
|
|
1551
|
-
function
|
|
1611
|
+
function planInstallation(args, settings) {
|
|
1552
1612
|
const root = resolve5(args.targetDir);
|
|
1553
|
-
const {
|
|
1613
|
+
const { previous, engines, hookEngines } = settings;
|
|
1554
1614
|
const preserved = [
|
|
1555
1615
|
.../* @__PURE__ */ new Set([...previous.preservedFiles ?? [], ...args.preserveFiles ?? []])
|
|
1556
1616
|
].sort();
|
|
@@ -1571,7 +1631,7 @@ function planUpdate(args) {
|
|
|
1571
1631
|
const disablePath = join10(overridesDir, ".harness-overrides", "disable.yaml");
|
|
1572
1632
|
if (existsSync8(disablePath)) {
|
|
1573
1633
|
try {
|
|
1574
|
-
DisableYamlSchema.parse(parseYAML(
|
|
1634
|
+
DisableYamlSchema.parse(parseYAML(readFileSync9(disablePath, "utf8")));
|
|
1575
1635
|
} catch {
|
|
1576
1636
|
throw new Error(`invalid override selection: ${disablePath}`);
|
|
1577
1637
|
}
|
|
@@ -1581,21 +1641,27 @@ function planUpdate(args) {
|
|
|
1581
1641
|
packageDir: args.packageDir,
|
|
1582
1642
|
engines,
|
|
1583
1643
|
overridesDir: hasOverrides ? overridesDir : void 0,
|
|
1644
|
+
mcpServers: settings.mcpServers,
|
|
1584
1645
|
withStopHook: false
|
|
1585
1646
|
});
|
|
1647
|
+
const generated = rendered.output.files.filter(
|
|
1648
|
+
(file) => !SHARED_CONFIG_PATHS.includes(file.path)
|
|
1649
|
+
);
|
|
1586
1650
|
const config = planUpdateConfigs(root, previous, hookEngines);
|
|
1587
|
-
const
|
|
1651
|
+
const mcp = planMcp(root, settings, rendered.output.files, config.writes);
|
|
1652
|
+
const output = [...generated, ...config.writes, ...mcp.writes].filter(
|
|
1588
1653
|
(file) => !preserved.includes(file.path)
|
|
1589
1654
|
);
|
|
1590
1655
|
const owned = [
|
|
1591
|
-
.../* @__PURE__ */ new Set([...
|
|
1656
|
+
.../* @__PURE__ */ new Set([...generated.map((file) => file.path), ...config.ownedFiles, ...mcp.owned])
|
|
1592
1657
|
].filter((file) => !preserved.includes(file)).sort();
|
|
1593
1658
|
const removals = [
|
|
1594
1659
|
.../* @__PURE__ */ new Set([
|
|
1595
1660
|
...previous.files.filter(
|
|
1596
1661
|
(file) => !owned.includes(file) && !SHARED_CONFIG_PATHS.includes(file)
|
|
1597
1662
|
),
|
|
1598
|
-
...config.removals
|
|
1663
|
+
...config.removals,
|
|
1664
|
+
...mcp.removals
|
|
1599
1665
|
])
|
|
1600
1666
|
].filter((file) => !preserved.includes(file));
|
|
1601
1667
|
assertManagedPaths(root, [...output.map((file) => file.path), ...removals]);
|
|
@@ -1607,6 +1673,7 @@ function planUpdate(args) {
|
|
|
1607
1673
|
return [path, hashContent(content)];
|
|
1608
1674
|
})
|
|
1609
1675
|
);
|
|
1676
|
+
const opencodeConfig = mcp.plannedOpenCode ? mcp.opencodeConfig : config.opencodeConfig;
|
|
1610
1677
|
const manifest = {
|
|
1611
1678
|
version: MANIFEST_VERSION,
|
|
1612
1679
|
harnessVersion: VERSION,
|
|
@@ -1618,7 +1685,8 @@ function planUpdate(args) {
|
|
|
1618
1685
|
stopHookEngines: [...hookEngines].sort(),
|
|
1619
1686
|
...hasOverrides ? { overridesDir: overridesDir === root ? "." : overridesDir } : {},
|
|
1620
1687
|
...preserved.length ? { preservedFiles: preserved } : {},
|
|
1621
|
-
...
|
|
1688
|
+
...opencodeConfig ? { opencodeConfig } : {},
|
|
1689
|
+
...mcp.mcpConfig ? { mcpConfig: mcp.mcpConfig } : {},
|
|
1622
1690
|
...config.hookConfigs ? { hookConfigs: config.hookConfigs } : {}
|
|
1623
1691
|
};
|
|
1624
1692
|
const writes = output.filter((file) => currentContent(root, file.path) !== file.content);
|
|
@@ -1628,25 +1696,33 @@ function planUpdate(args) {
|
|
|
1628
1696
|
writes.push({ path: MANIFEST_FILENAME, content: `${JSON.stringify(manifest, null, 2)}
|
|
1629
1697
|
` });
|
|
1630
1698
|
}
|
|
1631
|
-
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];
|
|
1632
1708
|
if (!previous.fileHashes) {
|
|
1633
1709
|
warnings.push(
|
|
1634
1710
|
"legacy manifest has no file fingerprints: managed artifacts will be regenerated; backup preserves their previous contents"
|
|
1635
1711
|
);
|
|
1636
1712
|
}
|
|
1637
|
-
if (preserved.length) warnings.push(`preserved user-owned artifacts: ${preserved.join(", ")}`);
|
|
1638
1713
|
if (engines.includes("codex-cli"))
|
|
1639
1714
|
warnings.push("ensure .agents/ is ignored by Git (run harness check)");
|
|
1640
1715
|
return {
|
|
1641
|
-
writes,
|
|
1642
|
-
removals:
|
|
1716
|
+
writes: plan.writes,
|
|
1717
|
+
removals: plan.removals,
|
|
1718
|
+
owned: plan.owned,
|
|
1643
1719
|
result: {
|
|
1644
1720
|
version: VERSION,
|
|
1645
1721
|
fromManifestVersion: sourceVersion,
|
|
1646
1722
|
dryRun: args.dryRun === true,
|
|
1647
|
-
upToDate: writes.length === 0 &&
|
|
1648
|
-
filesToWrite: writes.map((file) => file.path),
|
|
1649
|
-
filesToRemove:
|
|
1723
|
+
upToDate: plan.writes.length === 0 && plan.removals.length === 0,
|
|
1724
|
+
filesToWrite: plan.writes.map((file) => file.path),
|
|
1725
|
+
filesToRemove: plan.removals,
|
|
1650
1726
|
warnings
|
|
1651
1727
|
}
|
|
1652
1728
|
};
|
|
@@ -1662,6 +1738,67 @@ function updateCommand(args) {
|
|
|
1662
1738
|
);
|
|
1663
1739
|
return { ...plan.result, backupDir };
|
|
1664
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
|
+
}
|
|
1665
1802
|
export {
|
|
1666
1803
|
VERSION,
|
|
1667
1804
|
initCommand,
|