@instruktlabs/kiln 1.0.0 → 1.2.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +60 -3
- package/CONTRIBUTING.md +16 -7
- package/README.md +95 -381
- package/dist/agent-run.mjs +220 -150
- package/dist/build.json +40 -28
- package/dist/cli.mjs +242 -163
- package/dist/mcp-engine.mjs +412 -154
- package/dist/mcp-server.mjs +1 -1
- package/docs/architecture.md +6 -3
- package/docs/chatgpt.md +20 -5
- package/docs/examples.md +40 -170
- package/docs/install.md +100 -35
- package/docs/migration.md +20 -0
- package/docs/sdk.md +49 -5
- package/docs/tools-operations.md +1529 -0
- package/lib/asset-widget-uri.d.ts +9 -0
- package/lib/asset-widget-uri.js +9 -0
- package/lib/cli-render-mode.js +10 -3
- package/lib/discovery/index.d.ts +1 -1
- package/lib/discovery/index.js +2 -2
- package/lib/discovery/portable.d.ts +9 -0
- package/lib/discovery/portable.js +6 -0
- package/lib/discovery/service.d.ts +3 -1
- package/lib/discovery/service.js +4 -2
- package/lib/engine-identity.d.ts +1 -1
- package/lib/engine-identity.js +1 -1
- package/lib/evaluator/isolation.js +19 -5
- package/lib/evaluator/probe-invariants.d.ts +11 -0
- package/lib/evaluator/probe-invariants.js +36 -0
- package/lib/evaluator/probe-worker.js +16 -4
- package/lib/loopback.d.ts +6 -0
- package/lib/loopback.js +6 -0
- package/lib/mcp-core.d.ts +210 -0
- package/lib/mcp-core.js +279 -0
- package/lib/mcp-engine.d.ts +59 -0
- package/lib/mcp-engine.js +289 -0
- package/lib/mcp-manifest.d.ts +15 -0
- package/lib/mcp-manifest.js +65 -0
- package/lib/mcp.d.ts +5 -0
- package/lib/mcp.js +2 -0
- package/lib/render-service-host.js +1 -1
- package/lib/tools/discovery.d.ts +1 -1
- package/lib/tools/discovery.js +11 -9
- package/lib/tools/materials.d.ts +1 -1
- package/lib/tools/materials.js +44 -14
- package/lib/tools/operation-contract.d.ts +8 -0
- package/lib/tools/operation-contract.js +37 -0
- package/lib/tools/operations.d.ts +3 -0
- package/lib/tools/operations.js +154 -0
- package/lib/tools/registry.d.ts +6 -2
- package/lib/tools/registry.js +71 -23
- package/package.json +23 -1
- package/plugin.json +1 -1
- package/plugins/kiln-engine/.claude-plugin/icon.png +0 -0
- package/plugins/kiln-engine/.claude-plugin/plugin.json +9 -3
- package/plugins/kiln-engine/PRIVACY.md +100 -0
- package/plugins/kiln-engine/README.md +103 -18
- package/plugins/kiln-engine/{scripts/setup-workspace.mjs → bin/kiln-setup-workspace.mjs} +37 -9
- package/plugins/kiln-engine/package-provenance.json +15 -8
- package/plugins/kiln-engine/plugin.json +10 -4
- package/plugins/kiln-engine/runtime.json +10 -1
- package/plugins/kiln-engine/skills/kiln-setup-workspace/SKILL.md +17 -18
- package/plugins/kiln-engine/skills/kiln-setup-workspace/references/plugin-install.md +28 -8
- package/plugins/kiln-engine/skills/kiln-setup-workspace/references/renderer-setup.md +11 -0
- package/scripts/create-workspace.mjs +71 -13
- package/scripts/workspace-config.mjs +239 -0
- package/scripts/workspace-project.mjs +384 -0
- package/scripts/workspace-transaction.mjs +504 -0
- package/skills/kiln-setup-workspace/SKILL.md +17 -18
- package/skills/kiln-setup-workspace/references/plugin-install.md +28 -8
- package/skills/kiln-setup-workspace/references/renderer-setup.md +11 -0
- package/src/cli-render-mode.ts +9 -9
- package/src/cli.ts +3 -3
- package/src/discovery/index.ts +2 -0
- package/src/discovery/portable.ts +9 -0
- package/src/discovery/service.ts +3 -1
- package/src/engine-identity.ts +1 -1
- package/src/evaluator/isolation.ts +27 -6
- package/src/evaluator/probe-invariants.ts +50 -0
- package/src/evaluator/probe-worker.ts +22 -4
- package/src/mcp-core.ts +3 -0
- package/src/mcp-engine.ts +11 -1
- package/src/mcp.ts +5 -0
- package/src/render-service-host.ts +1 -1
- package/src/tools/discovery.ts +20 -10
- package/src/tools/materials.ts +59 -15
- package/src/tools/operation-contract.ts +51 -0
- package/src/tools/operations.ts +177 -0
- package/src/tools/registry.ts +100 -20
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/** Shared configuration edits for local project integrations. Never writes files. */
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { applyEdits, getNodeValue, modify, parseTree } from 'jsonc-parser';
|
|
4
|
+
import { parse as parseToml } from 'smol-toml';
|
|
5
|
+
|
|
6
|
+
const maxLength = 2 * 1024 * 1024;
|
|
7
|
+
const forbidden = new Set(['__proto__', 'prototype', 'constructor']);
|
|
8
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
const conflict = (path) =>
|
|
10
|
+
new Error(`Configuration conflict at ${path.join('.')}; no files were changed.`);
|
|
11
|
+
|
|
12
|
+
function checkPath(path) {
|
|
13
|
+
if (
|
|
14
|
+
!Array.isArray(path) ||
|
|
15
|
+
!path.length ||
|
|
16
|
+
path.length > 16 ||
|
|
17
|
+
path.some(
|
|
18
|
+
(part) => typeof part !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(part) || forbidden.has(part),
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
throw new Error('Invalid configuration path.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function checkText(text, kind) {
|
|
25
|
+
if (typeof text !== 'string' || text.length > maxLength)
|
|
26
|
+
throw new Error(`Invalid ${kind} configuration.`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function jsonTree(text, comments) {
|
|
30
|
+
checkText(text, 'JSON');
|
|
31
|
+
try {
|
|
32
|
+
const errors = [];
|
|
33
|
+
const root = parseTree(text, errors, {
|
|
34
|
+
disallowComments: !comments,
|
|
35
|
+
allowTrailingComma: comments,
|
|
36
|
+
});
|
|
37
|
+
if (errors.length || root?.type !== 'object') throw new Error();
|
|
38
|
+
let count = 0;
|
|
39
|
+
const visit = (node, depth = 0) => {
|
|
40
|
+
if (++count > 100000 || depth > 64) throw new Error();
|
|
41
|
+
if (node.type === 'object') {
|
|
42
|
+
const keys = new Set();
|
|
43
|
+
for (const property of node.children ?? []) {
|
|
44
|
+
const key = property.children[0].value;
|
|
45
|
+
if (keys.has(key)) throw new Error();
|
|
46
|
+
keys.add(key);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const child of node.children ?? []) visit(child, depth + 1);
|
|
50
|
+
};
|
|
51
|
+
visit(root);
|
|
52
|
+
return root;
|
|
53
|
+
} catch {
|
|
54
|
+
// Parser messages can include source lines containing user credentials.
|
|
55
|
+
throw new Error('Invalid JSON configuration.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function jsonEntry(root, path) {
|
|
60
|
+
let node = root;
|
|
61
|
+
for (const part of path) {
|
|
62
|
+
if (node === undefined) return undefined;
|
|
63
|
+
if (node.type !== 'object') throw conflict(path);
|
|
64
|
+
node = node.children?.find((property) => property.children[0].value === part)?.children[1];
|
|
65
|
+
}
|
|
66
|
+
return node;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function jsonValue(value) {
|
|
70
|
+
try {
|
|
71
|
+
const serialized = JSON.stringify(value);
|
|
72
|
+
if (serialized === undefined || !isDeepStrictEqual(JSON.parse(serialized), value))
|
|
73
|
+
throw new Error();
|
|
74
|
+
jsonTree(`{"value":${serialized}}`, false);
|
|
75
|
+
return value;
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error('Invalid configuration value.');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function equalJson(a, b) {
|
|
82
|
+
if (Object.is(a, b)) return true;
|
|
83
|
+
if (
|
|
84
|
+
!a ||
|
|
85
|
+
!b ||
|
|
86
|
+
typeof a !== 'object' ||
|
|
87
|
+
typeof b !== 'object' ||
|
|
88
|
+
Array.isArray(a) !== Array.isArray(b)
|
|
89
|
+
)
|
|
90
|
+
return false;
|
|
91
|
+
const keys = Object.keys(a);
|
|
92
|
+
return (
|
|
93
|
+
keys.length === Object.keys(b).length &&
|
|
94
|
+
keys.every((key) => Object.hasOwn(b, key) && equalJson(a[key], b[key]))
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function checkOwnedNumbers(node, path) {
|
|
99
|
+
if (
|
|
100
|
+
node.type === 'number' &&
|
|
101
|
+
(!Number.isFinite(node.value) ||
|
|
102
|
+
(Number.isInteger(node.value) && !Number.isSafeInteger(node.value)))
|
|
103
|
+
)
|
|
104
|
+
throw conflict(path);
|
|
105
|
+
for (const child of node.children ?? []) checkOwnedNumbers(child, path);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Apply non-overlapping owned entries; unrelated JSON bytes are not reserialized. */
|
|
109
|
+
export function mergeJsonConfig(text, changes, { comments = false } = {}) {
|
|
110
|
+
checkText(text, 'JSON');
|
|
111
|
+
if (!Array.isArray(changes) || changes.length > 64)
|
|
112
|
+
throw new Error('Invalid configuration edits.');
|
|
113
|
+
for (const change of changes) {
|
|
114
|
+
checkPath(change?.path);
|
|
115
|
+
jsonValue(change.value);
|
|
116
|
+
if (Object.hasOwn(change, 'previous')) jsonValue(change.previous);
|
|
117
|
+
}
|
|
118
|
+
for (let i = 0; i < changes.length; i++)
|
|
119
|
+
for (let j = i + 1; j < changes.length; j++) {
|
|
120
|
+
const a = changes[i].path,
|
|
121
|
+
b = changes[j].path;
|
|
122
|
+
if (a.slice(0, Math.min(a.length, b.length)).every((part, k) => part === b[k]))
|
|
123
|
+
throw new Error('Overlapping configuration edits.');
|
|
124
|
+
}
|
|
125
|
+
let result = text.trim() ? text : `${text}{}\n`;
|
|
126
|
+
let root = jsonTree(result, comments);
|
|
127
|
+
for (const change of changes) {
|
|
128
|
+
const node = jsonEntry(root, change.path);
|
|
129
|
+
if (node !== undefined) {
|
|
130
|
+
checkOwnedNumbers(node, change.path);
|
|
131
|
+
const current = getNodeValue(node);
|
|
132
|
+
if (equalJson(current, change.value)) continue;
|
|
133
|
+
if (!Object.hasOwn(change, 'previous') || !equalJson(current, change.previous))
|
|
134
|
+
throw conflict(change.path);
|
|
135
|
+
}
|
|
136
|
+
// No whole-document formatter: preserve comments, large numbers and unrelated bytes.
|
|
137
|
+
result = applyEdits(result, modify(result, change.path, change.value, {}));
|
|
138
|
+
root = jsonTree(result, comments);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toml(text) {
|
|
144
|
+
checkText(text, 'TOML');
|
|
145
|
+
try {
|
|
146
|
+
return parseToml(text, { integersAsBigInt: true });
|
|
147
|
+
} catch {
|
|
148
|
+
throw new Error('Invalid TOML configuration.');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function entry(root, path) {
|
|
153
|
+
let value = root;
|
|
154
|
+
for (const part of path) {
|
|
155
|
+
if (!object(value)) throw conflict(path);
|
|
156
|
+
if (!Object.hasOwn(value, part)) return undefined;
|
|
157
|
+
value = value[part];
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function withoutEntry(root, path) {
|
|
163
|
+
const result = { ...root };
|
|
164
|
+
const [key, ...rest] = path;
|
|
165
|
+
if (Object.hasOwn(result, key)) {
|
|
166
|
+
if (!rest.length) delete result[key];
|
|
167
|
+
else {
|
|
168
|
+
result[key] = withoutEntry(result[key], rest);
|
|
169
|
+
if (!Object.keys(result[key]).length) delete result[key];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function fragmentValue(fragment, path) {
|
|
176
|
+
const parsed = toml(fragment);
|
|
177
|
+
const value = entry(parsed, path);
|
|
178
|
+
if (
|
|
179
|
+
!object(value) ||
|
|
180
|
+
!Object.keys(value).length ||
|
|
181
|
+
Object.keys(withoutEntry(parsed, path)).length
|
|
182
|
+
)
|
|
183
|
+
throw new Error('Invalid managed TOML fragment.');
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function markerLines(text, marker) {
|
|
188
|
+
const offsets = [];
|
|
189
|
+
let offset = 0;
|
|
190
|
+
for (const line of text.split('\n')) {
|
|
191
|
+
if (line.replace(/\r$/, '') === marker) offsets.push(offset);
|
|
192
|
+
offset += line.length + 1;
|
|
193
|
+
}
|
|
194
|
+
return offsets;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** TOML is preserved verbatim outside a validated, exclusively owned table block. */
|
|
198
|
+
export function mergeTomlConfig(text, { path, fragment, previous } = {}) {
|
|
199
|
+
checkPath(path);
|
|
200
|
+
const original = toml(text);
|
|
201
|
+
const desired = fragmentValue(fragment, path);
|
|
202
|
+
if (previous !== undefined) fragmentValue(previous, path);
|
|
203
|
+
const start = `# BEGIN KILN ${JSON.stringify(path)}`;
|
|
204
|
+
const end = `# END KILN ${JSON.stringify(path)}`;
|
|
205
|
+
const starts = markerLines(text, start),
|
|
206
|
+
ends = markerLines(text, end);
|
|
207
|
+
const newline = text.includes('\r\n') ? '\r\n' : '\n';
|
|
208
|
+
const block = (value) =>
|
|
209
|
+
`${start}${newline}${value.replace(/\r\n/g, '\n').trim().replace(/\n/g, newline)}${newline}${end}${newline}`;
|
|
210
|
+
let prefix = text,
|
|
211
|
+
suffix = '',
|
|
212
|
+
base = original;
|
|
213
|
+
if (starts.length || ends.length) {
|
|
214
|
+
if (starts.length !== 1 || ends.length !== 1 || starts[0] >= ends[0]) throw conflict(path);
|
|
215
|
+
const afterLine = text.indexOf('\n', ends[0]);
|
|
216
|
+
const after = afterLine < 0 ? text.length : afterLine + 1;
|
|
217
|
+
if (text.slice(starts[0], after) !== block(previous ?? fragment)) throw conflict(path);
|
|
218
|
+
prefix = text.slice(0, starts[0]);
|
|
219
|
+
suffix = text.slice(after);
|
|
220
|
+
base = toml(prefix + suffix);
|
|
221
|
+
if (
|
|
222
|
+
entry(base, path) !== undefined ||
|
|
223
|
+
!isDeepStrictEqual(entry(original, path), fragmentValue(previous ?? fragment, path)) ||
|
|
224
|
+
!isDeepStrictEqual(withoutEntry(original, path), withoutEntry(base, path))
|
|
225
|
+
)
|
|
226
|
+
throw conflict(path);
|
|
227
|
+
} else {
|
|
228
|
+
if (entry(original, path) !== undefined) throw conflict(path);
|
|
229
|
+
if (prefix && !prefix.endsWith('\n')) prefix += newline;
|
|
230
|
+
}
|
|
231
|
+
const result = prefix + block(fragment) + suffix;
|
|
232
|
+
const parsed = toml(result);
|
|
233
|
+
if (
|
|
234
|
+
!isDeepStrictEqual(entry(parsed, path), desired) ||
|
|
235
|
+
!isDeepStrictEqual(withoutEntry(parsed, path), withoutEntry(base, path))
|
|
236
|
+
)
|
|
237
|
+
throw conflict(path);
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/** Shared project adoption and maintenance, independent of any one agent client. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdir } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { getNodeValue, parseTree } from 'jsonc-parser';
|
|
6
|
+
import { mergeJsonConfig, mergeTomlConfig } from './workspace-config.mjs';
|
|
7
|
+
import { applyProjectEdits, readProjectFile } from './workspace-transaction.mjs';
|
|
8
|
+
|
|
9
|
+
const hash = (value) =>
|
|
10
|
+
value === undefined ? undefined : createHash('sha256').update(value).digest('hex');
|
|
11
|
+
const digestPattern = /^[a-f0-9]{64}$/;
|
|
12
|
+
const text = (value, name) => {
|
|
13
|
+
try {
|
|
14
|
+
return value === undefined ? '' : new TextDecoder('utf-8', { fatal: true }).decode(value);
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error(`Invalid UTF-8 project configuration: ${name}`);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const descriptorKey = (value) => JSON.stringify([value.file, value.path]);
|
|
20
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
|
|
22
|
+
function configProjection(file, body) {
|
|
23
|
+
if (file === '.codex/config.toml')
|
|
24
|
+
return [{ file, kind: 'toml', path: ['mcp_servers', 'kiln_workspace'], fragment: body }];
|
|
25
|
+
if (file === 'opencode.json') {
|
|
26
|
+
const value = JSON.parse(body);
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
file,
|
|
30
|
+
kind: 'json',
|
|
31
|
+
path: ['mcp', 'kiln_workspace'],
|
|
32
|
+
value: value.mcp.kiln_workspace,
|
|
33
|
+
comments: true,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
file,
|
|
37
|
+
kind: 'json-list',
|
|
38
|
+
path: ['skills', 'paths'],
|
|
39
|
+
value: value.skills.paths[0],
|
|
40
|
+
comments: true,
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
if (['.mcp.json', '.cursor/mcp.json', '.agents/mcp_config.json'].includes(file))
|
|
45
|
+
return [
|
|
46
|
+
{
|
|
47
|
+
file,
|
|
48
|
+
kind: 'json',
|
|
49
|
+
path: ['mcpServers', 'kiln_workspace'],
|
|
50
|
+
value: JSON.parse(body).mcpServers.kiln_workspace,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validatePrevious(previous, allowedNames, skills, knownConfigs, allowedHarnesses) {
|
|
57
|
+
if (!previous) return;
|
|
58
|
+
if (![1, 2].includes(previous.schemaVersion))
|
|
59
|
+
throw new Error('Unsupported workspace manifest version.');
|
|
60
|
+
if (!allowedHarnesses.includes(previous.harness))
|
|
61
|
+
throw new Error('Invalid workspace harness manifest.');
|
|
62
|
+
const ownedName = (name) => {
|
|
63
|
+
if (
|
|
64
|
+
typeof name !== 'string' ||
|
|
65
|
+
name.includes('\\') ||
|
|
66
|
+
name.includes(':') ||
|
|
67
|
+
name.split('/').some((p) => !p || p === '.' || p === '..')
|
|
68
|
+
)
|
|
69
|
+
return false;
|
|
70
|
+
if (allowedNames.has(name)) return true;
|
|
71
|
+
for (const prefix of ['skills/', '.claude/skills/', '.agents/skills/']) {
|
|
72
|
+
if (name.startsWith(prefix) && skills.includes(name.slice(prefix.length).split('/')[0]))
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
};
|
|
77
|
+
for (const map of [previous.ownedFiles, previous.managedHashes, previous.generated]) {
|
|
78
|
+
if (
|
|
79
|
+
map !== undefined &&
|
|
80
|
+
(!object(map) ||
|
|
81
|
+
Object.entries(map).some(
|
|
82
|
+
([name, value]) =>
|
|
83
|
+
!ownedName(name) || typeof value !== 'string' || !digestPattern.test(value),
|
|
84
|
+
))
|
|
85
|
+
)
|
|
86
|
+
throw new Error('Invalid managed-file manifest.');
|
|
87
|
+
}
|
|
88
|
+
if (previous.schemaVersion === 2) {
|
|
89
|
+
if (!Array.isArray(previous.configEntries) || previous.configEntries.length > 32)
|
|
90
|
+
throw new Error('Invalid configuration ownership manifest.');
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
for (const entry of previous.configEntries) {
|
|
93
|
+
const key = descriptorKey(entry);
|
|
94
|
+
if (!knownConfigs.has(key) || entry.kind !== knownConfigs.get(key).kind || seen.has(key))
|
|
95
|
+
throw new Error('Invalid configuration ownership manifest.');
|
|
96
|
+
seen.add(key);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function listValue(content, path) {
|
|
102
|
+
const errors = [];
|
|
103
|
+
const tree = parseTree(content, errors, { allowTrailingComma: true });
|
|
104
|
+
if (errors.length || tree?.type !== 'object') throw new Error('Invalid JSON configuration.');
|
|
105
|
+
let value = getNodeValue(tree);
|
|
106
|
+
for (const part of path) {
|
|
107
|
+
if (!object(value)) throw new Error('Configuration conflict at skills.paths.');
|
|
108
|
+
value = value[part];
|
|
109
|
+
if (value === undefined) return [];
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string'))
|
|
112
|
+
throw new Error('Configuration conflict at skills.paths.');
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** All side effects go through one validated transaction after the complete plan is built. */
|
|
117
|
+
export async function setupProject({
|
|
118
|
+
root,
|
|
119
|
+
runtime,
|
|
120
|
+
harness,
|
|
121
|
+
previous,
|
|
122
|
+
metadata,
|
|
123
|
+
exists,
|
|
124
|
+
options,
|
|
125
|
+
projectFiles,
|
|
126
|
+
skillFiles,
|
|
127
|
+
guide,
|
|
128
|
+
startGuide,
|
|
129
|
+
allowedHarnesses,
|
|
130
|
+
}) {
|
|
131
|
+
if (
|
|
132
|
+
previous?.schemaVersion === 2 &&
|
|
133
|
+
(!Array.isArray(previous.harnesses) ||
|
|
134
|
+
!previous.harnesses.includes(previous.harness) ||
|
|
135
|
+
previous.harnesses.some((name) => !allowedHarnesses.includes(name)) ||
|
|
136
|
+
new Set(previous.harnesses).size !== previous.harnesses.length)
|
|
137
|
+
)
|
|
138
|
+
throw new Error('Invalid workspace harness manifest.');
|
|
139
|
+
const selected =
|
|
140
|
+
previous?.schemaVersion === 2 ? [...previous.harnesses] : previous ? [previous.harness] : [];
|
|
141
|
+
if (options.adopt || !previous) selected.push(harness);
|
|
142
|
+
const registrations = [...new Set(selected)].sort();
|
|
143
|
+
const projection = new Map();
|
|
144
|
+
const desired = {};
|
|
145
|
+
const runtimeFiles = new Set();
|
|
146
|
+
const knownConfigs = new Map();
|
|
147
|
+
const allowedNames = new Set([
|
|
148
|
+
'AGENTS.md',
|
|
149
|
+
'CLAUDE.md',
|
|
150
|
+
'START.md',
|
|
151
|
+
'.kiln/AGENTS.md',
|
|
152
|
+
'.kiln/START.md',
|
|
153
|
+
'.kiln/.gitignore',
|
|
154
|
+
]);
|
|
155
|
+
for (const name of allowedHarnesses) {
|
|
156
|
+
for (const [file, body] of Object.entries(projectFiles(name))) {
|
|
157
|
+
allowedNames.add(file);
|
|
158
|
+
for (const descriptor of configProjection(file, body))
|
|
159
|
+
knownConfigs.set(descriptorKey(descriptor), descriptor);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
validatePrevious(previous, allowedNames, metadata.skills, knownConfigs, allowedHarnesses);
|
|
163
|
+
const snapshots = new Map();
|
|
164
|
+
const read = async (name) => {
|
|
165
|
+
if (!snapshots.has(name))
|
|
166
|
+
snapshots.set(name, exists ? await readProjectFile(root, name) : undefined);
|
|
167
|
+
return snapshots.get(name);
|
|
168
|
+
};
|
|
169
|
+
for (const name of registrations) {
|
|
170
|
+
for (const [file, body] of Object.entries(projectFiles(name))) {
|
|
171
|
+
const descriptors = configProjection(file, body);
|
|
172
|
+
if (!descriptors.length) {
|
|
173
|
+
desired[file] = body;
|
|
174
|
+
runtimeFiles.add(file);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
for (const descriptor of descriptors) {
|
|
178
|
+
const key = descriptorKey(descriptor);
|
|
179
|
+
if (
|
|
180
|
+
projection.has(key) &&
|
|
181
|
+
JSON.stringify(projection.get(key)) !== JSON.stringify(descriptor)
|
|
182
|
+
)
|
|
183
|
+
throw new Error(
|
|
184
|
+
`The selected clients require conflicting configuration at ${file}. Keep their registrations separate until a compatible adapter is available.`,
|
|
185
|
+
);
|
|
186
|
+
projection.set(key, descriptor);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const configFiles = new Set([...knownConfigs.values()].map((entry) => entry.file));
|
|
191
|
+
const original =
|
|
192
|
+
previous?.schemaVersion === 2
|
|
193
|
+
? { ...previous.ownedFiles }
|
|
194
|
+
: { ...previous?.generated, ...previous?.managedHashes };
|
|
195
|
+
if (previous?.schemaVersion === 1) {
|
|
196
|
+
for (const [name, value] of Object.entries(previous.skillHashes ?? {})) {
|
|
197
|
+
if (typeof value !== 'string' || !digestPattern.test(value))
|
|
198
|
+
throw new Error('Invalid skill manifest.');
|
|
199
|
+
for (const folder of ['skills', '.claude/skills', '.agents/skills']) {
|
|
200
|
+
const path = `${folder}/${name}`;
|
|
201
|
+
// Validate before reading paths from an older manifest.
|
|
202
|
+
validatePrevious(
|
|
203
|
+
{ schemaVersion: 1, harness: previous.harness, generated: { [path]: value } },
|
|
204
|
+
allowedNames,
|
|
205
|
+
metadata.skills,
|
|
206
|
+
knownConfigs,
|
|
207
|
+
allowedHarnesses,
|
|
208
|
+
);
|
|
209
|
+
if ((await read(path)) !== undefined) original[path] ??= value;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const file of configFiles) delete original[file];
|
|
214
|
+
desired['.kiln/AGENTS.md'] = guide;
|
|
215
|
+
desired['.kiln/START.md'] = registrations.map((name) => startGuide(name)).join('\n---\n\n');
|
|
216
|
+
desired['.kiln/.gitignore'] = 'programs/\n';
|
|
217
|
+
const preserved = [];
|
|
218
|
+
for (const [file, body] of Object.entries({
|
|
219
|
+
'AGENTS.md': guide,
|
|
220
|
+
'CLAUDE.md':
|
|
221
|
+
(await read('AGENTS.md')) === undefined || original['AGENTS.md']
|
|
222
|
+
? '@AGENTS.md\n'
|
|
223
|
+
: '@AGENTS.md\n@.kiln/AGENTS.md\n',
|
|
224
|
+
'START.md': desired['.kiln/START.md'],
|
|
225
|
+
})) {
|
|
226
|
+
if ((await read(file)) === undefined || original[file]) desired[file] = body;
|
|
227
|
+
else preserved.push(file);
|
|
228
|
+
}
|
|
229
|
+
const registries = new Set(['skills']);
|
|
230
|
+
for (const name of registrations) {
|
|
231
|
+
if (name === 'claude') registries.add('.claude/skills');
|
|
232
|
+
else if (name !== 'opencode') registries.add('.agents/skills');
|
|
233
|
+
}
|
|
234
|
+
const skillHashes = {};
|
|
235
|
+
for (const [name, body] of Object.entries(await skillFiles()).sort(([a], [b]) =>
|
|
236
|
+
a.localeCompare(b),
|
|
237
|
+
)) {
|
|
238
|
+
skillHashes[name] = hash(body);
|
|
239
|
+
for (const folder of registries) desired[`${folder}/${name}`] = body;
|
|
240
|
+
}
|
|
241
|
+
const edits = [],
|
|
242
|
+
files = [],
|
|
243
|
+
ownedFiles = {};
|
|
244
|
+
for (const name of [...new Set([...Object.keys(original), ...Object.keys(desired)])].sort()) {
|
|
245
|
+
if (options.repair && previous && !runtimeFiles.has(name)) {
|
|
246
|
+
if (original[name] !== undefined) ownedFiles[name] = original[name];
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const before = await read(name);
|
|
250
|
+
const after = desired[name] === undefined ? undefined : Buffer.from(desired[name]);
|
|
251
|
+
const localHash = hash(before),
|
|
252
|
+
desiredHash = hash(after),
|
|
253
|
+
previousHash = original[name];
|
|
254
|
+
const status =
|
|
255
|
+
localHash === desiredHash
|
|
256
|
+
? 'current'
|
|
257
|
+
: before === undefined
|
|
258
|
+
? 'missing'
|
|
259
|
+
: localHash === previousHash
|
|
260
|
+
? after === undefined
|
|
261
|
+
? 'retired'
|
|
262
|
+
: 'outdated'
|
|
263
|
+
: previousHash === desiredHash && after !== undefined
|
|
264
|
+
? 'customized'
|
|
265
|
+
: 'conflict';
|
|
266
|
+
if (after !== undefined) ownedFiles[name] = desiredHash;
|
|
267
|
+
if (status !== 'current') files.push({ path: name, status });
|
|
268
|
+
if (!['current', 'customized', 'conflict'].includes(status))
|
|
269
|
+
edits.push({ path: name, before, after });
|
|
270
|
+
}
|
|
271
|
+
const previousEntries = new Map(
|
|
272
|
+
(previous?.configEntries ?? []).map((value) => [descriptorKey(value), value]),
|
|
273
|
+
);
|
|
274
|
+
const configEntries = [...projection.values()];
|
|
275
|
+
for (const file of [...new Set(configEntries.map((entry) => entry.file))].sort()) {
|
|
276
|
+
const before = await read(file);
|
|
277
|
+
try {
|
|
278
|
+
let merged = text(before, file);
|
|
279
|
+
const legacyOwned =
|
|
280
|
+
previous?.schemaVersion === 1 &&
|
|
281
|
+
before !== undefined &&
|
|
282
|
+
hash(before) === (previous.generated?.[file] ?? previous.managedHashes?.[file]);
|
|
283
|
+
for (const entry of configEntries.filter((value) => value.file === file)) {
|
|
284
|
+
let prior = previousEntries.get(descriptorKey(entry));
|
|
285
|
+
if (entry.kind === 'toml') {
|
|
286
|
+
if (legacyOwned) merged = '';
|
|
287
|
+
merged = mergeTomlConfig(merged, {
|
|
288
|
+
path: entry.path,
|
|
289
|
+
fragment: entry.fragment,
|
|
290
|
+
...(prior ? { previous: prior.fragment } : {}),
|
|
291
|
+
});
|
|
292
|
+
} else if (entry.kind === 'json') {
|
|
293
|
+
if (legacyOwned) {
|
|
294
|
+
let value = JSON.parse(text(before, file));
|
|
295
|
+
for (const part of entry.path) value = value?.[part];
|
|
296
|
+
if (value !== undefined) prior = { value };
|
|
297
|
+
}
|
|
298
|
+
merged = mergeJsonConfig(
|
|
299
|
+
merged,
|
|
300
|
+
[{ path: entry.path, value: entry.value, ...(prior ? { previous: prior.value } : {}) }],
|
|
301
|
+
{ comments: entry.comments === true },
|
|
302
|
+
);
|
|
303
|
+
} else {
|
|
304
|
+
const current = listValue(merged, entry.path);
|
|
305
|
+
// Schema 1 owns the unchanged generated config as a whole. Its single
|
|
306
|
+
// skill path belongs to the original workspace, which may have moved.
|
|
307
|
+
// Never infer this ownership from a modified or adopted user config.
|
|
308
|
+
const oldPath =
|
|
309
|
+
prior?.value ?? (legacyOwned && current.length === 1 ? current[0] : undefined);
|
|
310
|
+
const value = current.filter((item) => item !== oldPath || oldPath === entry.value);
|
|
311
|
+
if (!value.includes(entry.value)) value.push(entry.value);
|
|
312
|
+
merged = mergeJsonConfig(merged, [{ path: entry.path, value, previous: current }], {
|
|
313
|
+
comments: true,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (before === undefined || !before.equals(Buffer.from(merged))) {
|
|
318
|
+
files.push({ path: file, status: before === undefined ? 'missing' : 'outdated' });
|
|
319
|
+
edits.push({ path: file, before, after: merged });
|
|
320
|
+
}
|
|
321
|
+
} catch {
|
|
322
|
+
files.push({ path: file, status: 'conflict' });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const next = {
|
|
326
|
+
schemaVersion: 2,
|
|
327
|
+
root,
|
|
328
|
+
harness: previous?.harness ?? harness,
|
|
329
|
+
harnesses: registrations,
|
|
330
|
+
runtime,
|
|
331
|
+
runtimeVersion: metadata.runtimeVersion,
|
|
332
|
+
buildIdentity: metadata.buildIdentity,
|
|
333
|
+
runtimeHashes: metadata.runtimeHashes,
|
|
334
|
+
node: metadata.node,
|
|
335
|
+
skills: metadata.skills,
|
|
336
|
+
skillHashes: options.repair && previous ? previous.skillHashes : skillHashes,
|
|
337
|
+
ownedFiles,
|
|
338
|
+
configEntries,
|
|
339
|
+
};
|
|
340
|
+
const manifestBefore = await read('.kiln/workspace.json');
|
|
341
|
+
const manifestAfter = JSON.stringify(next);
|
|
342
|
+
const runtimeChanged =
|
|
343
|
+
!!previous &&
|
|
344
|
+
(previous.runtime !== runtime ||
|
|
345
|
+
previous.node !== next.node ||
|
|
346
|
+
previous.runtimeVersion !== next.runtimeVersion ||
|
|
347
|
+
(previous.buildIdentity ?? previous.runtimeIdentity) !== next.buildIdentity ||
|
|
348
|
+
JSON.stringify(previous.runtimeHashes) !== JSON.stringify(next.runtimeHashes) ||
|
|
349
|
+
(previous.schemaVersion === 2 && previous.root !== root));
|
|
350
|
+
const manifestChanged =
|
|
351
|
+
manifestBefore === undefined || !manifestBefore.equals(Buffer.from(manifestAfter));
|
|
352
|
+
if (manifestChanged)
|
|
353
|
+
edits.push({ path: '.kiln/workspace.json', before: manifestBefore, after: manifestAfter });
|
|
354
|
+
const conflicts = files.filter((file) => file.status === 'conflict');
|
|
355
|
+
const report = {
|
|
356
|
+
root,
|
|
357
|
+
runtime,
|
|
358
|
+
harness: next.harness,
|
|
359
|
+
harnesses: registrations,
|
|
360
|
+
store: join(root, '.kiln', 'programs'),
|
|
361
|
+
server: join(runtime, 'dist', 'mcp-server.mjs'),
|
|
362
|
+
instructions: join(root, '.kiln', 'AGENTS.md'),
|
|
363
|
+
status: edits.length || conflicts.length || runtimeChanged ? 'update-required' : 'current',
|
|
364
|
+
runtimeChanged,
|
|
365
|
+
files,
|
|
366
|
+
preserved,
|
|
367
|
+
next: 'Stop active agent/MCP sessions before setup changes. Restart afterward and accept the normal project and MCP trust prompts.',
|
|
368
|
+
};
|
|
369
|
+
if (options.check) {
|
|
370
|
+
if (exists && !conflicts.length)
|
|
371
|
+
await applyProjectEdits(root, edits, { stateDirectory: options.stateDirectory, check: true });
|
|
372
|
+
return report;
|
|
373
|
+
}
|
|
374
|
+
if (conflicts.length)
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Project setup has conflicts; no files were changed: ${conflicts.map((file) => file.path).join(', ')}. Preserve and resolve these files before retrying.`,
|
|
377
|
+
);
|
|
378
|
+
if (!exists) await mkdir(root, { recursive: true });
|
|
379
|
+
await applyProjectEdits(root, edits, {
|
|
380
|
+
stateDirectory: options.stateDirectory,
|
|
381
|
+
onStep: options.onStep,
|
|
382
|
+
});
|
|
383
|
+
return { ...report, status: 'current', changed: edits.map((edit) => edit.path) };
|
|
384
|
+
}
|