@microck/canonfig 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli/cli.js +1 -1
- package/dist/harness-configuration/adapters/amp.js +87 -0
- package/dist/harness-configuration/adapters/antigravity.js +50 -0
- package/dist/harness-configuration/adapters/claude.js +43 -0
- package/dist/harness-configuration/adapters/codex.js +60 -0
- package/dist/harness-configuration/adapters/copilot.js +79 -0
- package/dist/harness-configuration/adapters/cursor.js +67 -0
- package/dist/harness-configuration/adapters/descriptor.js +10 -0
- package/dist/harness-configuration/adapters/devin.js +66 -0
- package/dist/harness-configuration/adapters/droid.js +32 -0
- package/dist/harness-configuration/adapters/grok.js +44 -0
- package/dist/harness-configuration/adapters/hermes.js +105 -0
- package/dist/harness-configuration/adapters/index.js +50 -0
- package/dist/harness-configuration/adapters/kilo.js +18 -0
- package/dist/harness-configuration/adapters/kimi.js +148 -0
- package/dist/harness-configuration/adapters/omp.js +64 -0
- package/dist/harness-configuration/adapters/open-code-family.js +94 -0
- package/dist/harness-configuration/adapters/opencode.js +18 -0
- package/dist/harness-configuration/adapters/pi.js +93 -0
- package/dist/harness-configuration/adapters/qwen.js +164 -0
- package/dist/harness-configuration/adapters/shared-common.js +66 -0
- package/dist/harness-configuration/adapters/shared-documents.js +117 -0
- package/dist/harness-configuration/adapters/shared-hooks.js +186 -0
- package/dist/harness-configuration/adapters/shared-mcp.js +214 -0
- package/dist/harness-configuration/adapters/shared.js +4 -0
- package/dist/harness-configuration/adapters/tools.js +24 -0
- package/dist/harness-configuration/cli-arguments.js +105 -0
- package/dist/harness-configuration/cli-output.js +77 -0
- package/dist/harness-configuration/cli.js +196 -0
- package/dist/harness-configuration/core/compiler.js +175 -0
- package/dist/harness-configuration/core/config.js +74 -0
- package/dist/harness-configuration/core/diff.js +60 -0
- package/dist/harness-configuration/core/doctor.js +40 -0
- package/dist/harness-configuration/core/errors.js +13 -0
- package/dist/harness-configuration/core/filesystem.js +172 -0
- package/dist/harness-configuration/core/frontmatter.js +49 -0
- package/dist/harness-configuration/core/hash.js +4 -0
- package/dist/harness-configuration/core/path.js +50 -0
- package/dist/harness-configuration/core/planner.js +255 -0
- package/dist/harness-configuration/core/render-cleanup.js +91 -0
- package/dist/harness-configuration/core/render-json.js +195 -0
- package/dist/harness-configuration/core/render-text.js +134 -0
- package/dist/harness-configuration/core/render-utils.js +202 -0
- package/dist/harness-configuration/core/render.js +54 -0
- package/dist/harness-configuration/core/scaffold.js +103 -0
- package/dist/harness-configuration/core/schema-components.js +167 -0
- package/dist/harness-configuration/core/schema-config.js +98 -0
- package/dist/harness-configuration/core/schema-runtime.js +143 -0
- package/dist/harness-configuration/core/schema-types.js +8 -0
- package/dist/harness-configuration/core/schema.js +13 -0
- package/dist/harness-configuration/core/state.js +42 -0
- package/dist/harness-configuration/core/types.js +5 -0
- package/dist/harness-configuration/core/validation.js +113 -0
- package/dist/harness-configuration/templates/runtime.js +212 -0
- package/dist/runtime/main.js +20 -14
- package/package.json +5 -5
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
export function secretValue(value) {
|
|
2
|
+
return typeof value === "string" ? value : `\${${value.fromEnv}}`;
|
|
3
|
+
}
|
|
4
|
+
export function enabledMcpServerEntries(context) {
|
|
5
|
+
return Object.entries(context.config.mcp.servers).filter(([, server]) => server.enabled);
|
|
6
|
+
}
|
|
7
|
+
export function hasEnabledMcpServers(context) {
|
|
8
|
+
return enabledMcpServerEntries(context).length > 0;
|
|
9
|
+
}
|
|
10
|
+
export function standardMcpProjectionDiagnostics(context, target, includeType = true) {
|
|
11
|
+
const diagnostics = [];
|
|
12
|
+
for (const [name, server] of enabledMcpServerEntries(context)) {
|
|
13
|
+
const omitted = [];
|
|
14
|
+
if (server.timeoutMs !== undefined)
|
|
15
|
+
omitted.push("timeoutMs");
|
|
16
|
+
if (server.enabledTools?.length)
|
|
17
|
+
omitted.push("enabledTools");
|
|
18
|
+
if (server.disabledTools?.length)
|
|
19
|
+
omitted.push("disabledTools");
|
|
20
|
+
if (!includeType && server.transport === "sse")
|
|
21
|
+
omitted.push("sse transport discriminator");
|
|
22
|
+
if (omitted.length > 0) {
|
|
23
|
+
diagnostics.push({
|
|
24
|
+
level: "warning",
|
|
25
|
+
code: "MCP_OPTION_UNSUPPORTED",
|
|
26
|
+
target,
|
|
27
|
+
message: `${target} cannot represent ${omitted.join(", ")} for MCP server ${name} in its standard JSON projection; those options were omitted.`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return diagnostics;
|
|
32
|
+
}
|
|
33
|
+
export function codexMcpDiagnostics(context) {
|
|
34
|
+
return enabledMcpServerEntries(context).flatMap(([name, server]) => server.transport === "sse"
|
|
35
|
+
? [{
|
|
36
|
+
level: "warning",
|
|
37
|
+
code: "MCP_TRANSPORT_UNSUPPORTED",
|
|
38
|
+
target: "codex",
|
|
39
|
+
message: `Codex project MCP config supports streamable HTTP URLs but cannot preserve legacy SSE transport for server ${name}; the URL is emitted as streamable HTTP.`,
|
|
40
|
+
}]
|
|
41
|
+
: []);
|
|
42
|
+
}
|
|
43
|
+
export function standardMcpServer(server, includeType = true) {
|
|
44
|
+
if (server.transport === "stdio") {
|
|
45
|
+
return {
|
|
46
|
+
...(includeType ? { type: "stdio" } : {}),
|
|
47
|
+
command: server.command,
|
|
48
|
+
args: server.args,
|
|
49
|
+
...(Object.keys(server.env).length > 0
|
|
50
|
+
? { env: Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, secretValue(value)])) }
|
|
51
|
+
: {}),
|
|
52
|
+
...(server.cwd ? { cwd: server.cwd } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
...(includeType ? { type: server.transport === "sse" ? "sse" : "http" } : {}),
|
|
57
|
+
url: server.url,
|
|
58
|
+
...(Object.keys(server.headers).length > 0
|
|
59
|
+
? { headers: Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, secretValue(value)])) }
|
|
60
|
+
: {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function standardMcpMap(context, includeType = true) {
|
|
64
|
+
return Object.fromEntries(enabledMcpServerEntries(context)
|
|
65
|
+
.map(([name, server]) => [name, standardMcpServer(server, includeType)]));
|
|
66
|
+
}
|
|
67
|
+
export function piMcpMap(context) {
|
|
68
|
+
return Object.fromEntries(enabledMcpServerEntries(context)
|
|
69
|
+
.map(([name, server]) => {
|
|
70
|
+
if (server.transport === "stdio") {
|
|
71
|
+
return [name, {
|
|
72
|
+
command: server.command,
|
|
73
|
+
args: server.args,
|
|
74
|
+
...(server.cwd ? { cwd: server.cwd } : {}),
|
|
75
|
+
...(Object.keys(server.env).length
|
|
76
|
+
? { env: Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, secretValue(value)])) }
|
|
77
|
+
: {}),
|
|
78
|
+
}];
|
|
79
|
+
}
|
|
80
|
+
return [name, {
|
|
81
|
+
transport: server.transport,
|
|
82
|
+
url: server.url,
|
|
83
|
+
...(Object.keys(server.headers).length
|
|
84
|
+
? { headers: Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, secretValue(value)])) }
|
|
85
|
+
: {}),
|
|
86
|
+
}];
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
export function antigravityMcpMap(context) {
|
|
90
|
+
return Object.fromEntries(enabledMcpServerEntries(context)
|
|
91
|
+
.map(([name, server]) => {
|
|
92
|
+
if (server.transport === "stdio") {
|
|
93
|
+
return [name, {
|
|
94
|
+
command: server.command,
|
|
95
|
+
args: server.args,
|
|
96
|
+
...(Object.keys(server.env).length
|
|
97
|
+
? { env: Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, secretValue(value)])) }
|
|
98
|
+
: {}),
|
|
99
|
+
}];
|
|
100
|
+
}
|
|
101
|
+
return [name, {
|
|
102
|
+
serverUrl: server.url,
|
|
103
|
+
...(Object.keys(server.headers).length
|
|
104
|
+
? { headers: Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, secretValue(value)])) }
|
|
105
|
+
: {}),
|
|
106
|
+
}];
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
export function openCodeMcpMap(context) {
|
|
110
|
+
return Object.fromEntries(Object.entries(context.config.mcp.servers).map(([name, server]) => {
|
|
111
|
+
if (server.transport === "stdio") {
|
|
112
|
+
return [name, {
|
|
113
|
+
type: "local",
|
|
114
|
+
command: [server.command, ...server.args],
|
|
115
|
+
enabled: server.enabled,
|
|
116
|
+
...(Object.keys(server.env).length
|
|
117
|
+
? { environment: Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, secretValue(value)])) }
|
|
118
|
+
: {}),
|
|
119
|
+
...(server.timeoutMs ? { timeout: server.timeoutMs } : {}),
|
|
120
|
+
}];
|
|
121
|
+
}
|
|
122
|
+
return [name, {
|
|
123
|
+
type: "remote",
|
|
124
|
+
url: server.url,
|
|
125
|
+
enabled: server.enabled,
|
|
126
|
+
...(Object.keys(server.headers).length
|
|
127
|
+
? { headers: Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, secretValue(value)])) }
|
|
128
|
+
: {}),
|
|
129
|
+
...(server.timeoutMs ? { timeout: server.timeoutMs } : {}),
|
|
130
|
+
}];
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
function tomlString(value) {
|
|
134
|
+
return JSON.stringify(value);
|
|
135
|
+
}
|
|
136
|
+
function tomlKey(value) {
|
|
137
|
+
return /^[A-Za-z0-9_-]+$/.test(value) ? value : JSON.stringify(value);
|
|
138
|
+
}
|
|
139
|
+
function tomlArray(values) {
|
|
140
|
+
return `[${values.map(tomlString).join(", ")}]`;
|
|
141
|
+
}
|
|
142
|
+
function tomlInlineTable(entries) {
|
|
143
|
+
return `{ ${Object.entries(entries).map(([key, value]) => `${tomlKey(key)} = ${tomlString(value)}`).join(", ")} }`;
|
|
144
|
+
}
|
|
145
|
+
export function mcpToml(context, remoteHeadersKey = "http_headers") {
|
|
146
|
+
const sections = [];
|
|
147
|
+
for (const [name, server] of enabledMcpServerEntries(context)) {
|
|
148
|
+
const lines = [`[mcp_servers.${tomlKey(name)}]`];
|
|
149
|
+
if (server.transport === "stdio") {
|
|
150
|
+
lines.push(`command = ${tomlString(server.command)}`);
|
|
151
|
+
if (server.args.length)
|
|
152
|
+
lines.push(`args = ${tomlArray(server.args)}`);
|
|
153
|
+
if (server.cwd)
|
|
154
|
+
lines.push(`cwd = ${tomlString(server.cwd)}`);
|
|
155
|
+
if (Object.keys(server.env).length) {
|
|
156
|
+
lines.push(`env = ${tomlInlineTable(Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, secretValue(value)])))}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
lines.push(`url = ${tomlString(server.url)}`);
|
|
161
|
+
if (Object.keys(server.headers).length) {
|
|
162
|
+
lines.push(`${remoteHeadersKey} = ${tomlInlineTable(Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, secretValue(value)])))}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (server.enabledTools?.length)
|
|
166
|
+
lines.push(`enabled_tools = ${tomlArray(server.enabledTools)}`);
|
|
167
|
+
if (server.disabledTools?.length)
|
|
168
|
+
lines.push(`disabled_tools = ${tomlArray(server.disabledTools)}`);
|
|
169
|
+
sections.push(lines.join("\n"));
|
|
170
|
+
}
|
|
171
|
+
return sections.join("\n\n");
|
|
172
|
+
}
|
|
173
|
+
export function jsonMcpArtifact(pathname, owner, context, pathSegments = ["mcpServers"], includeType = true) {
|
|
174
|
+
return {
|
|
175
|
+
kind: "json",
|
|
176
|
+
path: pathname,
|
|
177
|
+
owner,
|
|
178
|
+
operations: [{
|
|
179
|
+
kind: "managed-map",
|
|
180
|
+
path: pathSegments,
|
|
181
|
+
entries: standardMcpMap(context, includeType),
|
|
182
|
+
collision: "error",
|
|
183
|
+
}],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
export function grokMcpToml(context) {
|
|
187
|
+
const sections = [];
|
|
188
|
+
for (const [name, server] of enabledMcpServerEntries(context)) {
|
|
189
|
+
const lines = [`[mcp_servers.${tomlKey(name)}]`];
|
|
190
|
+
if (server.transport === "stdio") {
|
|
191
|
+
lines.push(`command = ${tomlString(server.command)}`);
|
|
192
|
+
if (server.args.length)
|
|
193
|
+
lines.push(`args = ${tomlArray(server.args)}`);
|
|
194
|
+
if (server.cwd)
|
|
195
|
+
lines.push(`cwd = ${tomlString(server.cwd)}`);
|
|
196
|
+
if (Object.keys(server.env).length) {
|
|
197
|
+
lines.push(`env = ${tomlInlineTable(Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, secretValue(value)])))}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
lines.push(`url = ${tomlString(server.url)}`);
|
|
202
|
+
if (Object.keys(server.headers).length) {
|
|
203
|
+
lines.push(`headers = ${tomlInlineTable(Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, secretValue(value)])))}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (server.timeoutMs) {
|
|
207
|
+
const seconds = Math.max(1, Math.ceil(server.timeoutMs / 1000));
|
|
208
|
+
lines.push(`startup_timeout_sec = ${seconds}`);
|
|
209
|
+
lines.push(`tool_timeout_sec = ${seconds}`);
|
|
210
|
+
}
|
|
211
|
+
sections.push(lines.join("\n"));
|
|
212
|
+
}
|
|
213
|
+
return sections.join("\n\n");
|
|
214
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const maps = {
|
|
2
|
+
codex: { read: ["read_file"], write: ["apply_patch"], search: ["grep", "glob"], shell: ["shell"], web: ["web_search"], mcp: ["mcp"], subagent: ["spawn_agent"], test: ["shell"], git: ["shell"] },
|
|
3
|
+
"claude-code": { read: ["Read"], write: ["Edit", "Write"], search: ["Grep", "Glob"], shell: ["Bash"], web: ["WebFetch", "WebSearch"], mcp: [], subagent: ["Task"], test: ["Bash"], git: ["Bash"] },
|
|
4
|
+
amp: { read: ["read"], write: ["edit", "write"], search: ["grep", "glob"], shell: ["Bash"], web: ["web"], mcp: ["mcp"], subagent: ["agent"], test: ["Bash"], git: ["Bash"] },
|
|
5
|
+
"oh-my-pi": { read: ["read"], write: ["edit", "write"], search: ["search", "find"], shell: ["bash"], web: ["web_search"], mcp: ["mcp"], subagent: ["task"], test: ["bash"], git: ["bash"] },
|
|
6
|
+
pi: { read: ["read"], write: ["edit", "write"], search: ["grep", "find"], shell: ["bash"], web: ["web"], mcp: ["mcp"], subagent: ["task"], test: ["bash"], git: ["bash"] },
|
|
7
|
+
"factory-droid": { read: ["Read"], write: ["Edit", "Create", "ApplyPatch"], search: ["Grep", "Glob", "LS"], shell: ["Execute"], web: ["FetchUrl", "WebSearch"], mcp: ["mcp__.*"], subagent: ["Task"], test: ["Execute"], git: ["Execute"] },
|
|
8
|
+
cursor: { read: ["Read"], write: ["Edit", "Write"], search: ["Grep", "Glob"], shell: ["Shell"], web: ["WebFetch", "WebSearch"], mcp: ["MCP"], subagent: ["Agent"], test: ["Shell"], git: ["Shell"] },
|
|
9
|
+
devin: { read: ["read"], write: ["edit", "write"], search: ["grep", "glob"], shell: ["exec"], web: ["web"], mcp: ["mcp"], subagent: ["subagent"], test: ["exec"], git: ["exec"] },
|
|
10
|
+
opencode: { read: ["read"], write: ["edit", "write"], search: ["grep", "glob"], shell: ["bash"], web: ["webfetch"], mcp: [], subagent: ["task"], test: ["bash"], git: ["bash"] },
|
|
11
|
+
"grok-build": { read: ["Read"], write: ["Edit", "Write"], search: ["Grep", "Glob"], shell: ["Bash"], web: ["WebFetch", "WebSearch"], mcp: ["mcp__.*"], subagent: ["Agent"], test: ["Bash"], git: ["Bash"] },
|
|
12
|
+
antigravity: { read: ["view_file"], write: ["write_to_file", "replace_file_content", "multi_replace_file_content"], search: ["grep_search", "find_by_name", "list_dir"], shell: ["run_command"], web: ["browser_*", "search_web"], mcp: ["mcp_*"], subagent: ["task"], test: ["run_command"], git: ["run_command"] },
|
|
13
|
+
"copilot-cli": { read: ["Read"], write: ["Edit", "Write"], search: ["Grep", "Glob"], shell: ["Bash"], web: ["WebFetch", "WebSearch"], mcp: ["mcp__*"], subagent: ["Task"], test: ["Bash"], git: ["Bash"] },
|
|
14
|
+
kimi: { read: ["Read"], write: ["Edit", "Write"], search: ["Grep", "Glob"], shell: ["Bash"], web: ["WebSearch", "FetchURL"], mcp: ["mcp__*"], subagent: ["Agent", "AgentSwarm"], test: ["Bash"], git: ["Bash"] },
|
|
15
|
+
kilo: { read: ["read"], write: ["edit", "write"], search: ["grep", "glob"], shell: ["bash"], web: ["webfetch"], mcp: [], subagent: ["task"], test: ["bash"], git: ["bash"] },
|
|
16
|
+
hermes: { read: ["read_file"], write: ["write_file", "patch"], search: ["search_files"], shell: ["terminal"], web: ["web_search", "web_extract"], mcp: ["mcp"], subagent: ["delegate_task"], test: ["terminal"], git: ["terminal"] },
|
|
17
|
+
qwen: { read: ["read_file"], write: ["edit", "write_file"], search: ["grep_search", "glob", "list_directory"], shell: ["run_shell_command"], web: ["web_fetch", "web_search"], mcp: ["mcp__*"], subagent: ["agent"], test: ["run_shell_command"], git: ["run_shell_command"] },
|
|
18
|
+
};
|
|
19
|
+
export function nativeToolsForCapabilities(target, capabilities) {
|
|
20
|
+
return [...new Set(capabilities.flatMap((capability) => maps[target][capability]))];
|
|
21
|
+
}
|
|
22
|
+
export function nativeTools(target, agent) {
|
|
23
|
+
return nativeToolsForCapabilities(target, agent.tools);
|
|
24
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { parseTargetList } from "./core/config.js";
|
|
3
|
+
import { CanonfigError } from "./core/errors.js";
|
|
4
|
+
export const harnessHelpText = `Canonfig harness configuration
|
|
5
|
+
|
|
6
|
+
Usage: canonfig harness <command> [options]
|
|
7
|
+
|
|
8
|
+
Commands:
|
|
9
|
+
init Create .canonfig/harness.yaml and canonical source directories
|
|
10
|
+
validate Validate canonical sources and selected adapter translations
|
|
11
|
+
targets List built-in harness adapters and support levels
|
|
12
|
+
plan Show native files that would change
|
|
13
|
+
apply Apply the current plan atomically
|
|
14
|
+
sync Alias for apply
|
|
15
|
+
status Report pending changes, conflicts, and diagnostics
|
|
16
|
+
diff Print a unified-style diff for pending changes
|
|
17
|
+
clean Remove only configuration currently owned by Canonfig
|
|
18
|
+
doctor Probe selected harness executables
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--root <path> Repository root or descendant working directory
|
|
22
|
+
--target <id> Select one target; repeatable
|
|
23
|
+
--targets <ids> Select comma-separated targets
|
|
24
|
+
--strict Reject shim, lossy, and unsupported mappings
|
|
25
|
+
--force Take ownership of explicit collisions or managed edits
|
|
26
|
+
--all Include unchanged files in plan output
|
|
27
|
+
--dry-run Do not write during apply or clean
|
|
28
|
+
--no-input Never prompt; accepted for scheduled invocations
|
|
29
|
+
--json Emit the stable canonfig.cli/v1 envelope
|
|
30
|
+
`;
|
|
31
|
+
export const parseHarnessArguments = (arguments_) => {
|
|
32
|
+
const [command = "help", ...rest] = arguments_;
|
|
33
|
+
let root = process.cwd();
|
|
34
|
+
let json = false;
|
|
35
|
+
let strict = false;
|
|
36
|
+
let force = false;
|
|
37
|
+
let all = false;
|
|
38
|
+
let dryRun = false;
|
|
39
|
+
const requestedTargets = [];
|
|
40
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
41
|
+
const argument = rest[index];
|
|
42
|
+
if (argument === "--json") {
|
|
43
|
+
json = true;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (argument === "--strict") {
|
|
47
|
+
strict = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (argument === "--force") {
|
|
51
|
+
force = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (argument === "--all") {
|
|
55
|
+
all = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (argument === "--dry-run") {
|
|
59
|
+
dryRun = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (argument === "--no-input")
|
|
63
|
+
continue;
|
|
64
|
+
if (argument === "--help" || argument === "-h") {
|
|
65
|
+
return {
|
|
66
|
+
command: "help",
|
|
67
|
+
root: path.resolve(root),
|
|
68
|
+
json,
|
|
69
|
+
strict,
|
|
70
|
+
force,
|
|
71
|
+
all,
|
|
72
|
+
dryRun,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (argument === "--root"
|
|
76
|
+
|| argument === "--cwd"
|
|
77
|
+
|| argument === "--target"
|
|
78
|
+
|| argument === "--targets") {
|
|
79
|
+
const value = rest[index + 1];
|
|
80
|
+
if (value === undefined || value.startsWith("-")) {
|
|
81
|
+
throw new CanonfigError("HARNESS_OPTION_VALUE_REQUIRED", `${argument} requires a value.`);
|
|
82
|
+
}
|
|
83
|
+
index += 1;
|
|
84
|
+
if (argument === "--root" || argument === "--cwd")
|
|
85
|
+
root = path.resolve(value);
|
|
86
|
+
else
|
|
87
|
+
requestedTargets.push(value);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
throw new CanonfigError("HARNESS_OPTION_UNKNOWN", `Unknown harness option: ${argument}`);
|
|
91
|
+
}
|
|
92
|
+
const targets = requestedTargets.length === 0
|
|
93
|
+
? undefined
|
|
94
|
+
: parseTargetList(requestedTargets.join(","));
|
|
95
|
+
return {
|
|
96
|
+
command,
|
|
97
|
+
root: path.resolve(root),
|
|
98
|
+
json,
|
|
99
|
+
strict,
|
|
100
|
+
force,
|
|
101
|
+
all,
|
|
102
|
+
dryRun,
|
|
103
|
+
...(targets === undefined ? {} : { targets }),
|
|
104
|
+
};
|
|
105
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { CliExitCode } from "../cli/exit-codes.js";
|
|
3
|
+
import { renderCliResult } from "../cli/render.js";
|
|
4
|
+
import { CanonfigError } from "./core/errors.js";
|
|
5
|
+
const actionSymbol = (entry) => {
|
|
6
|
+
switch (entry.action) {
|
|
7
|
+
case "create": return "+";
|
|
8
|
+
case "update": return "~";
|
|
9
|
+
case "delete": return "-";
|
|
10
|
+
case "conflict": return "!";
|
|
11
|
+
case "unchanged": return "=";
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
export const renderHumanPlan = (plan, includeUnchanged) => {
|
|
15
|
+
const lines = [];
|
|
16
|
+
for (const entry of plan.entries) {
|
|
17
|
+
if (!includeUnchanged && entry.action === "unchanged")
|
|
18
|
+
continue;
|
|
19
|
+
lines.push(`${actionSymbol(entry)} ${entry.action.padEnd(9)} ${entry.path}`);
|
|
20
|
+
if (entry.reason !== undefined)
|
|
21
|
+
lines.push(` ${entry.reason}`);
|
|
22
|
+
}
|
|
23
|
+
for (const diagnostic of plan.diagnostics) {
|
|
24
|
+
const target = diagnostic.target === undefined ? "" : `[${diagnostic.target}] `;
|
|
25
|
+
const location = diagnostic.path === undefined ? "" : ` (${diagnostic.path})`;
|
|
26
|
+
lines.push(`${diagnostic.level.toUpperCase()} ${target}${diagnostic.code}: ${diagnostic.message}${location}`);
|
|
27
|
+
}
|
|
28
|
+
const counts = ["create", "update", "delete", "unchanged", "conflict"]
|
|
29
|
+
.map((action) => `${plan.entries.filter((entry) => entry.action === action).length} ${action}`)
|
|
30
|
+
.join(", ");
|
|
31
|
+
lines.push(counts);
|
|
32
|
+
return `${lines.join("\n")}\n`;
|
|
33
|
+
};
|
|
34
|
+
export const toCliPayload = (value) => Schema.decodeUnknownSync(Schema.MutableJson)(JSON.parse(JSON.stringify(value)));
|
|
35
|
+
export const planPayload = (plan) => toCliPayload({
|
|
36
|
+
root: plan.root,
|
|
37
|
+
targets: plan.targets,
|
|
38
|
+
entries: plan.entries.map(({ content: _content, before: _before, after: _after, nextState: _nextState, ...entry }) => entry),
|
|
39
|
+
diagnostics: plan.diagnostics,
|
|
40
|
+
});
|
|
41
|
+
export const diagnosticsPayload = (diagnostics) => toCliPayload(diagnostics);
|
|
42
|
+
export const isHarnessPlanBlocked = (plan) => plan.entries.some((entry) => entry.action === "conflict")
|
|
43
|
+
|| plan.diagnostics.some((diagnostic) => diagnostic.level === "error");
|
|
44
|
+
export const renderHarnessResult = (io, input) => {
|
|
45
|
+
const rendered = input.json
|
|
46
|
+
? renderCliResult({
|
|
47
|
+
command: input.command,
|
|
48
|
+
message: input.message,
|
|
49
|
+
data: input.data,
|
|
50
|
+
exitCode: input.exitCode,
|
|
51
|
+
}, "json")
|
|
52
|
+
: (input.human ?? renderCliResult({
|
|
53
|
+
command: input.command,
|
|
54
|
+
message: input.message,
|
|
55
|
+
data: input.data,
|
|
56
|
+
exitCode: input.exitCode,
|
|
57
|
+
}, "human"));
|
|
58
|
+
if (input.exitCode === CliExitCode.success)
|
|
59
|
+
io.writeStdout(rendered);
|
|
60
|
+
else
|
|
61
|
+
io.writeStderr(rendered);
|
|
62
|
+
io.setExitCode(input.exitCode);
|
|
63
|
+
};
|
|
64
|
+
export const harnessFailureExitCode = (error) => {
|
|
65
|
+
if (!(error instanceof CanonfigError))
|
|
66
|
+
return CliExitCode.internal;
|
|
67
|
+
if (/CONFLICT|COLLISION|EDITED|ESCAPE|STALE/u.test(error.code)) {
|
|
68
|
+
return CliExitCode.conflictOrDrift;
|
|
69
|
+
}
|
|
70
|
+
if (/APPLY|WRITE|ROLLBACK/u.test(error.code)) {
|
|
71
|
+
return CliExitCode.verificationOrApplyFailure;
|
|
72
|
+
}
|
|
73
|
+
if (/INVALID|NOT_FOUND|UNKNOWN|REQUIRED|EMPTY|PARSE/u.test(error.code)) {
|
|
74
|
+
return CliExitCode.usageOrConfiguration;
|
|
75
|
+
}
|
|
76
|
+
return CliExitCode.internal;
|
|
77
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { CliExitCode } from "../cli/exit-codes.js";
|
|
2
|
+
import { harnessHelpText, parseHarnessArguments, } from "./cli-arguments.js";
|
|
3
|
+
import { diagnosticsPayload, harnessFailureExitCode, isHarnessPlanBlocked, planPayload, renderHarnessResult, renderHumanPlan, toCliPayload, } from "./cli-output.js";
|
|
4
|
+
import { HarnessConfigurationCompiler, createDefaultRegistry, } from "./core/compiler.js";
|
|
5
|
+
import { findRepositoryRoot } from "./core/config.js";
|
|
6
|
+
import { formatPlanDiff } from "./core/diff.js";
|
|
7
|
+
import { doctorTargets } from "./core/doctor.js";
|
|
8
|
+
import { CanonfigError, errorMessage } from "./core/errors.js";
|
|
9
|
+
import { applyPlan, createPlan } from "./core/planner.js";
|
|
10
|
+
import { scaffoldProject } from "./core/scaffold.js";
|
|
11
|
+
import { TARGET_IDS } from "./core/types.js";
|
|
12
|
+
export const isHarnessConfigurationCommand = (arguments_) => arguments_[0] === "harness";
|
|
13
|
+
const renderHelp = (parsed, io) => {
|
|
14
|
+
renderHarnessResult(io, {
|
|
15
|
+
command: "harness.help",
|
|
16
|
+
message: "Harness configuration help",
|
|
17
|
+
data: {
|
|
18
|
+
commands: [
|
|
19
|
+
"init",
|
|
20
|
+
"validate",
|
|
21
|
+
"targets",
|
|
22
|
+
"plan",
|
|
23
|
+
"apply",
|
|
24
|
+
"status",
|
|
25
|
+
"diff",
|
|
26
|
+
"clean",
|
|
27
|
+
"doctor",
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
exitCode: CliExitCode.success,
|
|
31
|
+
json: parsed.json,
|
|
32
|
+
human: harnessHelpText,
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
export const runHarnessConfigurationCli = async (arguments_, io) => {
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = parseHarnessArguments(arguments_);
|
|
39
|
+
const registry = createDefaultRegistry();
|
|
40
|
+
const compiler = new HarnessConfigurationCompiler(registry);
|
|
41
|
+
const commandName = `harness.${parsed.command}`;
|
|
42
|
+
if (parsed.command === "help") {
|
|
43
|
+
renderHelp(parsed, io);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (parsed.command === "init") {
|
|
47
|
+
const written = await scaffoldProject(parsed.root, {
|
|
48
|
+
targets: parsed.targets,
|
|
49
|
+
force: parsed.force,
|
|
50
|
+
});
|
|
51
|
+
renderHarnessResult(io, {
|
|
52
|
+
command: commandName,
|
|
53
|
+
message: written.length === 0
|
|
54
|
+
? "No harness source files changed"
|
|
55
|
+
: "Harness source initialized",
|
|
56
|
+
data: { root: parsed.root, written },
|
|
57
|
+
exitCode: CliExitCode.success,
|
|
58
|
+
json: parsed.json,
|
|
59
|
+
human: written.length === 0
|
|
60
|
+
? "No files changed.\n"
|
|
61
|
+
: `${written.map((file) => `+ ${file}`).join("\n")}\n`,
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (parsed.command === "targets") {
|
|
66
|
+
const descriptors = registry.list().map((adapter) => adapter.descriptor);
|
|
67
|
+
renderHarnessResult(io, {
|
|
68
|
+
command: commandName,
|
|
69
|
+
message: "Harness targets listed",
|
|
70
|
+
data: toCliPayload(descriptors),
|
|
71
|
+
exitCode: CliExitCode.success,
|
|
72
|
+
json: parsed.json,
|
|
73
|
+
human: `${descriptors.map((descriptor) => [
|
|
74
|
+
`${descriptor.id.padEnd(16)} ${descriptor.name}`,
|
|
75
|
+
` ${Object.entries(descriptor.capabilities)
|
|
76
|
+
.map(([feature, level]) => `${feature}:${level}`)
|
|
77
|
+
.join(" ")}`,
|
|
78
|
+
].join("\n")).join("\n")}\n`,
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (parsed.command === "doctor") {
|
|
83
|
+
const results = doctorTargets(registry, parsed.targets);
|
|
84
|
+
renderHarnessResult(io, {
|
|
85
|
+
command: commandName,
|
|
86
|
+
message: "Harness probes completed",
|
|
87
|
+
data: toCliPayload(results),
|
|
88
|
+
exitCode: CliExitCode.success,
|
|
89
|
+
json: parsed.json,
|
|
90
|
+
human: `${results.map((result) => `${result.id.padEnd(16)} ${(result.found ? "found" : "missing").padEnd(8)} ${result.executable ?? result.error ?? ""}`).join("\n")}\n`,
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const root = await findRepositoryRoot(parsed.root);
|
|
95
|
+
if (parsed.command === "clean") {
|
|
96
|
+
const plan = await createPlan(root, [...TARGET_IDS], [], [], { force: parsed.force });
|
|
97
|
+
const exitCode = isHarnessPlanBlocked(plan)
|
|
98
|
+
? CliExitCode.conflictOrDrift
|
|
99
|
+
: CliExitCode.success;
|
|
100
|
+
if (exitCode === CliExitCode.success && !parsed.dryRun)
|
|
101
|
+
await applyPlan(plan);
|
|
102
|
+
renderHarnessResult(io, {
|
|
103
|
+
command: commandName,
|
|
104
|
+
message: exitCode !== CliExitCode.success
|
|
105
|
+
? "Harness cleanup blocked"
|
|
106
|
+
: parsed.dryRun
|
|
107
|
+
? "Harness cleanup planned"
|
|
108
|
+
: "Harness-owned configuration cleaned",
|
|
109
|
+
data: planPayload(plan),
|
|
110
|
+
exitCode,
|
|
111
|
+
json: parsed.json,
|
|
112
|
+
human: renderHumanPlan(plan, parsed.all),
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const plan = await compiler.plan({
|
|
117
|
+
root,
|
|
118
|
+
targets: parsed.targets,
|
|
119
|
+
strict: parsed.strict,
|
|
120
|
+
force: parsed.force,
|
|
121
|
+
});
|
|
122
|
+
const exitCode = isHarnessPlanBlocked(plan)
|
|
123
|
+
? CliExitCode.conflictOrDrift
|
|
124
|
+
: CliExitCode.success;
|
|
125
|
+
if (parsed.command === "validate") {
|
|
126
|
+
renderHarnessResult(io, {
|
|
127
|
+
command: commandName,
|
|
128
|
+
message: exitCode === CliExitCode.success
|
|
129
|
+
? "Harness configuration is valid"
|
|
130
|
+
: "Harness configuration validation failed",
|
|
131
|
+
data: diagnosticsPayload(plan.diagnostics),
|
|
132
|
+
exitCode,
|
|
133
|
+
json: parsed.json,
|
|
134
|
+
human: exitCode === CliExitCode.success
|
|
135
|
+
? "Harness configuration is valid.\n"
|
|
136
|
+
: renderHumanPlan(plan, false),
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (parsed.command === "plan" || parsed.command === "status") {
|
|
141
|
+
renderHarnessResult(io, {
|
|
142
|
+
command: commandName,
|
|
143
|
+
message: parsed.command === "status"
|
|
144
|
+
? (exitCode === CliExitCode.success
|
|
145
|
+
? "Harness configuration status computed"
|
|
146
|
+
: "Harness configuration has conflicts")
|
|
147
|
+
: "Harness configuration plan computed",
|
|
148
|
+
data: planPayload(plan),
|
|
149
|
+
exitCode,
|
|
150
|
+
json: parsed.json,
|
|
151
|
+
human: renderHumanPlan(plan, parsed.all),
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (parsed.command === "diff") {
|
|
156
|
+
const diff = formatPlanDiff(plan);
|
|
157
|
+
renderHarnessResult(io, {
|
|
158
|
+
command: commandName,
|
|
159
|
+
message: "Harness configuration diff computed",
|
|
160
|
+
data: planPayload(plan),
|
|
161
|
+
exitCode,
|
|
162
|
+
json: parsed.json,
|
|
163
|
+
human: diff === "" ? "No pending changes.\n" : `${diff}\n`,
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (parsed.command === "apply" || parsed.command === "sync") {
|
|
168
|
+
if (exitCode === CliExitCode.success && !parsed.dryRun)
|
|
169
|
+
await applyPlan(plan);
|
|
170
|
+
renderHarnessResult(io, {
|
|
171
|
+
command: commandName,
|
|
172
|
+
message: exitCode !== CliExitCode.success
|
|
173
|
+
? "Harness configuration apply blocked"
|
|
174
|
+
: parsed.dryRun
|
|
175
|
+
? "Harness configuration apply planned"
|
|
176
|
+
: "Harness configuration applied",
|
|
177
|
+
data: planPayload(plan),
|
|
178
|
+
exitCode,
|
|
179
|
+
json: parsed.json,
|
|
180
|
+
human: renderHumanPlan(plan, parsed.all),
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
throw new CanonfigError("HARNESS_COMMAND_UNKNOWN", `Unknown harness command: ${parsed.command}`);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
const exitCode = harnessFailureExitCode(error);
|
|
188
|
+
renderHarnessResult(io, {
|
|
189
|
+
command: `harness.${parsed?.command ?? "unknown"}`,
|
|
190
|
+
message: errorMessage(error),
|
|
191
|
+
data: error instanceof CanonfigError ? { code: error.code } : undefined,
|
|
192
|
+
exitCode,
|
|
193
|
+
json: parsed?.json ?? arguments_.includes("--json"),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
};
|