arkgate 4.1.0 → 4.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/CHANGELOG.md +124 -2
- package/README.md +24 -14
- package/bin/ark-check-runtime.mjs +16 -5
- package/bin/ark-mcp-runtime.mjs +766 -64
- package/bin/lib/agent-gates.mjs +1 -0
- package/bin/lib/ark-gitignore.mjs +88 -0
- package/bin/lib/ci-and-commands.mjs +33 -8
- package/bin/lib/codex-home.mjs +90 -8
- package/bin/lib/design-smells.mjs +71 -9
- package/bin/lib/doctor-plan.mjs +47 -39
- package/bin/lib/effective-contract-load.mjs +73 -9
- package/bin/lib/enforcement-honesty.mjs +78 -22
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/gate-files.mjs +441 -9
- package/bin/lib/github-enforcement.mjs +168 -7
- package/bin/lib/hook-templates.mjs +12 -11
- package/bin/lib/host-support-matrix.mjs +91 -17
- package/bin/lib/html-report-depth.mjs +13 -2
- package/bin/lib/html-report-evolution.mjs +114 -0
- package/bin/lib/html-report.mjs +18 -97
- package/bin/lib/import-resolve.mjs +33 -11
- package/bin/lib/install-activation.mjs +87 -0
- package/bin/lib/install-migrate.mjs +66 -50
- package/bin/lib/managed-upgrade.mjs +10 -41
- package/bin/lib/mcp-adoption.mjs +15 -5
- package/bin/lib/pilot-loop.mjs +25 -8
- package/bin/lib/project-identity.mjs +103 -0
- package/bin/lib/report-snapshot-context.mjs +28 -0
- package/bin/lib/resident-hook.mjs +33 -9
- package/bin/lib/rules-inventory.mjs +100 -8
- package/bin/lib/skill-install.mjs +272 -22
- package/bin/lib/skill-write.mjs +899 -0
- package/bin/lib/start-preview.mjs +84 -1
- package/bin/lib/upgrade-command.mjs +2 -5
- package/bin/lib/write-path-detect.mjs +2 -2
- package/dist/index.cjs +13 -13
- package/dist/index.d.ts +194 -2
- package/dist/index.js +13 -13
- package/docs/README.md +6 -4
- package/docs/agent-guide.md +115 -17
- package/docs/ai-gates.md +133 -25
- package/docs/assets/ark-write-gate.svg +2 -2
- package/docs/develop.md +16 -6
- package/docs/enthusiast/how-to-agent-gates.md +6 -0
- package/docs/package-surface.md +16 -9
- package/docs/product-voice.md +22 -4
- package/docs/use.md +3 -1
- package/package.json +3 -1
- package/schemas/ark.project-identity.schema.json +116 -0
- package/server.json +2 -2
- package/templates/skills/ark-adopt.md +9 -0
- package/templates/skills/ark-architect.md +12 -2
- package/templates/skills/ark-autopilot.md +9 -0
- package/templates/skills/ark-contract.md +11 -1
- package/templates/skills/ark-coverage.md +9 -0
- package/templates/skills/ark-explain.md +13 -1
- package/templates/skills/ark-explore.md +9 -0
- package/templates/skills/ark-fix.md +10 -1
- package/templates/skills/ark-loop.md +11 -2
- package/templates/skills/ark-place.md +17 -6
- package/templates/skills/ark-runtime.md +8 -0
- package/templates/skills/ark-think.md +14 -2
- package/templates/skills/ark-upgrade.md +9 -0
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed skill file IO shared by repo and Codex-home installation.
|
|
3
|
+
*/
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
isValidSemver,
|
|
9
|
+
isVersionOlder,
|
|
10
|
+
planSkillInstall,
|
|
11
|
+
skillContentIdentity,
|
|
12
|
+
} from './skill-install.mjs';
|
|
13
|
+
|
|
14
|
+
export const HOME_SKILL_CATALOG = '.arkgate-catalog.json';
|
|
15
|
+
export const HOME_SKILL_PENDING_CATALOG = '.arkgate-catalog.pending.json';
|
|
16
|
+
const HOME_SKILL_LOCK = '.arkgate-install.lock';
|
|
17
|
+
const HOME_SKILL_CATALOG_SCHEMA = '1.0';
|
|
18
|
+
const HOME_SKILL_LOCK_STALE_MS = 5 * 60 * 1000;
|
|
19
|
+
const HOME_SKILL_LOCK_ATTEMPTS = 100;
|
|
20
|
+
const HOME_SKILL_LOCK_RETRY_MS = 25;
|
|
21
|
+
const UUID_PATTERN =
|
|
22
|
+
/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
|
|
23
|
+
|
|
24
|
+
function messageOf(error) {
|
|
25
|
+
return error instanceof Error ? error.message : String(error);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isWithin(root, candidate) {
|
|
29
|
+
const relative = path.relative(root, candidate);
|
|
30
|
+
return (
|
|
31
|
+
relative === '' ||
|
|
32
|
+
(!path.isAbsolute(relative) &&
|
|
33
|
+
relative !== '..' &&
|
|
34
|
+
!relative.startsWith(`..${path.sep}`))
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function rootContext(root) {
|
|
39
|
+
const resolved = path.resolve(root);
|
|
40
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
41
|
+
const stat = fs.lstatSync(resolved);
|
|
42
|
+
if (
|
|
43
|
+
!stat.isDirectory() &&
|
|
44
|
+
!(stat.isSymbolicLink() && fs.statSync(resolved).isDirectory())
|
|
45
|
+
) {
|
|
46
|
+
throw new Error(`Managed skill root is not a directory: ${resolved}`);
|
|
47
|
+
}
|
|
48
|
+
return { path: resolved, real: fs.realpathSync(resolved) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function targetWithinRoot(context, file) {
|
|
52
|
+
const resolved = path.resolve(file);
|
|
53
|
+
if (!isWithin(context.path, resolved) || resolved === context.path) {
|
|
54
|
+
throw new Error(`Managed skill path escapes its catalog root: ${file}`);
|
|
55
|
+
}
|
|
56
|
+
return resolved;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validateParents(context, file, create) {
|
|
60
|
+
const resolved = targetWithinRoot(context, file);
|
|
61
|
+
const rootStat = fs.lstatSync(context.path);
|
|
62
|
+
if (
|
|
63
|
+
!rootStat.isDirectory() &&
|
|
64
|
+
!(rootStat.isSymbolicLink() && fs.statSync(context.path).isDirectory())
|
|
65
|
+
) {
|
|
66
|
+
throw new Error(`Managed skill root is no longer a directory: ${context.path}`);
|
|
67
|
+
}
|
|
68
|
+
if (fs.realpathSync(context.path) !== context.real) {
|
|
69
|
+
throw new Error(`Managed skill root changed during installation: ${context.path}`);
|
|
70
|
+
}
|
|
71
|
+
const relativeParent = path.relative(context.path, path.dirname(resolved));
|
|
72
|
+
const segments = relativeParent === '' ? [] : relativeParent.split(path.sep);
|
|
73
|
+
let cursor = context.path;
|
|
74
|
+
for (const segment of segments) {
|
|
75
|
+
cursor = path.join(cursor, segment);
|
|
76
|
+
let stat = fs.lstatSync(cursor, { throwIfNoEntry: false });
|
|
77
|
+
if (!stat) {
|
|
78
|
+
if (!create) return false;
|
|
79
|
+
fs.mkdirSync(cursor);
|
|
80
|
+
stat = fs.lstatSync(cursor);
|
|
81
|
+
}
|
|
82
|
+
if (stat.isSymbolicLink()) {
|
|
83
|
+
throw new Error(`Managed skill parent must not be a symlink or junction: ${cursor}`);
|
|
84
|
+
}
|
|
85
|
+
if (!stat.isDirectory()) {
|
|
86
|
+
throw new Error(`Managed skill parent is not a directory: ${cursor}`);
|
|
87
|
+
}
|
|
88
|
+
const real = fs.realpathSync(cursor);
|
|
89
|
+
if (!isWithin(context.real, real)) {
|
|
90
|
+
throw new Error(`Managed skill parent resolves outside its catalog root: ${cursor}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sameFileState(left, right) {
|
|
97
|
+
if (!left || !right) return false;
|
|
98
|
+
if (
|
|
99
|
+
left.dev !== undefined &&
|
|
100
|
+
left.ino !== undefined &&
|
|
101
|
+
(left.dev !== 0 || left.ino !== 0) &&
|
|
102
|
+
(left.dev !== right.dev || left.ino !== right.ino)
|
|
103
|
+
) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return (
|
|
107
|
+
left.size === right.size &&
|
|
108
|
+
left.mtimeMs === right.mtimeMs &&
|
|
109
|
+
left.ctimeMs === right.ctimeMs
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sameFileIdentity(left, right) {
|
|
114
|
+
if (!left || !right) return false;
|
|
115
|
+
if (left.dev !== undefined && left.ino !== undefined && (left.dev !== 0 || left.ino !== 0)) {
|
|
116
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
117
|
+
}
|
|
118
|
+
return left.size === right.size && left.birthtimeMs === right.birthtimeMs;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readSafeFile(context, file) {
|
|
122
|
+
const resolved = targetWithinRoot(context, file);
|
|
123
|
+
if (!validateParents(context, resolved, false)) return null;
|
|
124
|
+
const before = fs.lstatSync(resolved, { throwIfNoEntry: false });
|
|
125
|
+
if (!before) return null;
|
|
126
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
|
|
127
|
+
throw new Error(`Managed target is not a single-link regular file: ${resolved}`);
|
|
128
|
+
}
|
|
129
|
+
let content;
|
|
130
|
+
try {
|
|
131
|
+
content = fs.readFileSync(resolved, 'utf8');
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new Error(`Existing managed target is unreadable (${resolved}): ${messageOf(error)}`);
|
|
134
|
+
}
|
|
135
|
+
validateParents(context, resolved, false);
|
|
136
|
+
const after = fs.lstatSync(resolved, { throwIfNoEntry: false });
|
|
137
|
+
if (
|
|
138
|
+
!after ||
|
|
139
|
+
!after.isFile() ||
|
|
140
|
+
after.isSymbolicLink() ||
|
|
141
|
+
after.nlink !== 1 ||
|
|
142
|
+
!sameFileState(before, after)
|
|
143
|
+
) {
|
|
144
|
+
throw new Error(`Managed target changed while it was being read: ${resolved}`);
|
|
145
|
+
}
|
|
146
|
+
return { content, stat: after };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fsyncDirectory(directory) {
|
|
150
|
+
let descriptor;
|
|
151
|
+
try {
|
|
152
|
+
descriptor = fs.openSync(directory, 'r');
|
|
153
|
+
fs.fsyncSync(descriptor);
|
|
154
|
+
} catch {
|
|
155
|
+
// Some filesystems/platforms do not support syncing directory handles.
|
|
156
|
+
} finally {
|
|
157
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function cleanupOwnedTemp(context, file, createdStat) {
|
|
162
|
+
try {
|
|
163
|
+
const current = readSafeFile(context, file);
|
|
164
|
+
if (current && sameFileIdentity(current.stat, createdStat)) {
|
|
165
|
+
fs.unlinkSync(file);
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// Never delete a temp path that no longer identifies as the file we created.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function atomicReplaceFile(context, file, content, expectedContent) {
|
|
173
|
+
const resolved = targetWithinRoot(context, file);
|
|
174
|
+
validateParents(context, resolved, true);
|
|
175
|
+
const observed = readSafeFile(context, resolved);
|
|
176
|
+
if ((observed?.content ?? null) !== expectedContent) {
|
|
177
|
+
throw new Error(`Managed target changed concurrently before write: ${resolved}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const token = randomUUID();
|
|
181
|
+
const temp = path.join(
|
|
182
|
+
path.dirname(resolved),
|
|
183
|
+
`.${path.basename(resolved)}.arkgate-${token}.tmp`
|
|
184
|
+
);
|
|
185
|
+
let descriptor;
|
|
186
|
+
let createdStat;
|
|
187
|
+
try {
|
|
188
|
+
validateParents(context, temp, false);
|
|
189
|
+
descriptor = fs.openSync(temp, 'wx', observed ? observed.stat.mode & 0o777 : 0o666);
|
|
190
|
+
createdStat = fs.fstatSync(descriptor);
|
|
191
|
+
fs.writeFileSync(descriptor, content, 'utf8');
|
|
192
|
+
fs.fsyncSync(descriptor);
|
|
193
|
+
fs.closeSync(descriptor);
|
|
194
|
+
descriptor = undefined;
|
|
195
|
+
|
|
196
|
+
const beforeReplace = readSafeFile(context, resolved);
|
|
197
|
+
if ((beforeReplace?.content ?? null) !== expectedContent) {
|
|
198
|
+
throw new Error(`Managed target changed concurrently during write: ${resolved}`);
|
|
199
|
+
}
|
|
200
|
+
const staged = readSafeFile(context, temp);
|
|
201
|
+
if (staged?.content !== content) {
|
|
202
|
+
throw new Error(`Atomic managed write staging verification failed: ${resolved}`);
|
|
203
|
+
}
|
|
204
|
+
validateParents(context, resolved, false);
|
|
205
|
+
fs.renameSync(temp, resolved);
|
|
206
|
+
fsyncDirectory(path.dirname(resolved));
|
|
207
|
+
} finally {
|
|
208
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
209
|
+
cleanupOwnedTemp(context, temp, createdStat);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function sleepSync(milliseconds) {
|
|
214
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseLock(content) {
|
|
218
|
+
try {
|
|
219
|
+
const value = JSON.parse(content);
|
|
220
|
+
if (
|
|
221
|
+
typeof value?.token !== 'string' ||
|
|
222
|
+
!UUID_PATTERN.test(value.token) ||
|
|
223
|
+
!Number.isInteger(value.pid) ||
|
|
224
|
+
value.pid <= 0 ||
|
|
225
|
+
!Number.isFinite(value.createdAtMs) ||
|
|
226
|
+
value.createdAtMs <= 0
|
|
227
|
+
) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function processIsAlive(pid) {
|
|
237
|
+
try {
|
|
238
|
+
process.kill(pid, 0);
|
|
239
|
+
return true;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return error?.code !== 'ESRCH';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function restoreClaimedFile(context, claimed, original) {
|
|
246
|
+
try {
|
|
247
|
+
validateParents(context, claimed, false);
|
|
248
|
+
validateParents(context, original, false);
|
|
249
|
+
fs.linkSync(claimed, original);
|
|
250
|
+
fs.unlinkSync(claimed);
|
|
251
|
+
return true;
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function removeOwnedTokenFile(context, file, token, parseMetadata, claimKind) {
|
|
258
|
+
const claimed = `${file}.${claimKind}-${token}`;
|
|
259
|
+
try {
|
|
260
|
+
const current = readSafeFile(context, file);
|
|
261
|
+
if (parseMetadata(current?.content)?.token !== token) return false;
|
|
262
|
+
validateParents(context, file, false);
|
|
263
|
+
const confirmed = readSafeFile(context, file);
|
|
264
|
+
if (parseMetadata(confirmed?.content)?.token !== token) return false;
|
|
265
|
+
validateParents(context, claimed, false);
|
|
266
|
+
fs.renameSync(file, claimed);
|
|
267
|
+
const moved = readSafeFile(context, claimed);
|
|
268
|
+
if (parseMetadata(moved?.content)?.token !== token) {
|
|
269
|
+
restoreClaimedFile(context, claimed, file);
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
fs.unlinkSync(claimed);
|
|
273
|
+
fsyncDirectory(path.dirname(file));
|
|
274
|
+
return true;
|
|
275
|
+
} catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function removeOwnedLock(context, file, token) {
|
|
281
|
+
return removeOwnedTokenFile(context, file, token, parseLock, 'release');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function createOwnedLock(context, file) {
|
|
285
|
+
const token = randomUUID();
|
|
286
|
+
const content = `${JSON.stringify({
|
|
287
|
+
token,
|
|
288
|
+
pid: process.pid,
|
|
289
|
+
createdAtMs: Date.now(),
|
|
290
|
+
})}\n`;
|
|
291
|
+
validateParents(context, file, true);
|
|
292
|
+
let descriptor;
|
|
293
|
+
let createdStat;
|
|
294
|
+
try {
|
|
295
|
+
descriptor = fs.openSync(file, 'wx', 0o600);
|
|
296
|
+
createdStat = fs.fstatSync(descriptor);
|
|
297
|
+
fs.writeFileSync(descriptor, content, 'utf8');
|
|
298
|
+
fs.fsyncSync(descriptor);
|
|
299
|
+
fs.closeSync(descriptor);
|
|
300
|
+
descriptor = undefined;
|
|
301
|
+
fsyncDirectory(path.dirname(file));
|
|
302
|
+
return { file, token };
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
305
|
+
if (createdStat) {
|
|
306
|
+
try {
|
|
307
|
+
validateParents(context, file, false);
|
|
308
|
+
const current = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
309
|
+
if (sameFileIdentity(createdStat, current)) fs.unlinkSync(file);
|
|
310
|
+
} catch {
|
|
311
|
+
// Preserve anything that no longer identifies as the inode we created.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function recoverStaleLock(context, file) {
|
|
319
|
+
const current = readSafeFile(context, file);
|
|
320
|
+
if (!current) return true;
|
|
321
|
+
const metadata = parseLock(current.content);
|
|
322
|
+
const timestamp = metadata?.createdAtMs ?? current.stat.mtimeMs;
|
|
323
|
+
if (Date.now() - timestamp <= HOME_SKILL_LOCK_STALE_MS) return false;
|
|
324
|
+
if (metadata && processIsAlive(metadata.pid)) return false;
|
|
325
|
+
|
|
326
|
+
const recoveryToken = randomUUID();
|
|
327
|
+
const claimed = path.join(
|
|
328
|
+
path.dirname(file),
|
|
329
|
+
`${HOME_SKILL_LOCK}.recovery-${recoveryToken}`
|
|
330
|
+
);
|
|
331
|
+
validateParents(context, claimed, false);
|
|
332
|
+
validateParents(context, file, false);
|
|
333
|
+
const confirmed = readSafeFile(context, file);
|
|
334
|
+
if (
|
|
335
|
+
!confirmed ||
|
|
336
|
+
confirmed.content !== current.content ||
|
|
337
|
+
!sameFileState(confirmed.stat, current.stat)
|
|
338
|
+
) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
fs.renameSync(file, claimed);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (error?.code === 'ENOENT') return true;
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
const recovered = readSafeFile(context, claimed);
|
|
348
|
+
if (
|
|
349
|
+
!recovered ||
|
|
350
|
+
recovered.content !== current.content ||
|
|
351
|
+
!sameFileIdentity(recovered.stat, current.stat)
|
|
352
|
+
) {
|
|
353
|
+
restoreClaimedFile(context, claimed, file);
|
|
354
|
+
throw new Error(`Stale home skill lock changed during recovery: ${HOME_SKILL_LOCK}`);
|
|
355
|
+
}
|
|
356
|
+
const ownedContent = `${JSON.stringify({
|
|
357
|
+
token: recoveryToken,
|
|
358
|
+
pid: process.pid,
|
|
359
|
+
createdAtMs: Date.now(),
|
|
360
|
+
})}\n`;
|
|
361
|
+
atomicReplaceFile(context, claimed, ownedContent, current.content);
|
|
362
|
+
if (!removeOwnedLock(context, claimed, recoveryToken)) {
|
|
363
|
+
throw new Error(`Could not release recovered home skill lock: ${HOME_SKILL_LOCK}`);
|
|
364
|
+
}
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function acquireHomeLock(context) {
|
|
369
|
+
const file = path.join(context.path, HOME_SKILL_LOCK);
|
|
370
|
+
let lastConflict = null;
|
|
371
|
+
for (let attempt = 0; attempt < HOME_SKILL_LOCK_ATTEMPTS; attempt += 1) {
|
|
372
|
+
try {
|
|
373
|
+
return createOwnedLock(context, file);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
376
|
+
lastConflict = error;
|
|
377
|
+
if (recoverStaleLock(context, file)) continue;
|
|
378
|
+
if (attempt + 1 < HOME_SKILL_LOCK_ATTEMPTS) {
|
|
379
|
+
sleepSync(HOME_SKILL_LOCK_RETRY_MS);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
throw new Error(
|
|
384
|
+
`another home skill install is active (${HOME_SKILL_LOCK}); retry after it finishes` +
|
|
385
|
+
(lastConflict?.message ? `: ${lastConflict.message}` : '')
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function skillInstallNote(plan) {
|
|
390
|
+
const scope = plan.scope === 'home' ? 'home-shared' : 'repo';
|
|
391
|
+
const source = plan.sourceVersion ? `arkgate@${plan.sourceVersion}` : 'arkgate@unknown';
|
|
392
|
+
const installed = plan.installedVersion ?? 'no stamp';
|
|
393
|
+
if (plan.reason === 'content-current') {
|
|
394
|
+
return `scope=${scope}; body current; installed=${installed}; source=${source}; no write`;
|
|
395
|
+
}
|
|
396
|
+
if (plan.reason === 'newer-home-version') {
|
|
397
|
+
return `scope=${scope}; CONFLICT installed=${installed} newer than source=${source}; downgrade blocked`;
|
|
398
|
+
}
|
|
399
|
+
if (plan.reason === 'unknown-source-version') {
|
|
400
|
+
return `scope=${scope}; CONFLICT installed=${installed}; source version unknown; overwrite blocked`;
|
|
401
|
+
}
|
|
402
|
+
if (plan.reason === 'existing-preserved') {
|
|
403
|
+
return `scope=${scope}; CONFLICT body differs; installed=${installed}; source=${source}; preserved without --force`;
|
|
404
|
+
}
|
|
405
|
+
return `scope=${scope}; source=${source}; ${plan.reason === 'missing' ? 'missing' : 'body update'}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* @param {{
|
|
410
|
+
* root: string,
|
|
411
|
+
* file: string,
|
|
412
|
+
* relativePath: string,
|
|
413
|
+
* targetContent: string,
|
|
414
|
+
* packageVersion: string|null,
|
|
415
|
+
* force: boolean,
|
|
416
|
+
* scope: 'repo'|'home',
|
|
417
|
+
* }} input
|
|
418
|
+
*/
|
|
419
|
+
export function installSkillFile(input) {
|
|
420
|
+
if (input.scope === 'home' && !isValidSemver(input.packageVersion)) {
|
|
421
|
+
return {
|
|
422
|
+
relativePath: input.relativePath,
|
|
423
|
+
status: 'failed',
|
|
424
|
+
message: 'Shared-home skill writes require a valid SemVer package version.',
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
let context;
|
|
428
|
+
let existingContent = null;
|
|
429
|
+
try {
|
|
430
|
+
context = rootContext(input.root);
|
|
431
|
+
existingContent = readSafeFile(context, input.file)?.content ?? null;
|
|
432
|
+
} catch (error) {
|
|
433
|
+
return {
|
|
434
|
+
relativePath: input.relativePath,
|
|
435
|
+
status: 'failed',
|
|
436
|
+
message: messageOf(error),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
const skillPlan = planSkillInstall({
|
|
440
|
+
existingContent,
|
|
441
|
+
targetContent: input.targetContent,
|
|
442
|
+
packageVersion: input.packageVersion,
|
|
443
|
+
force: input.force,
|
|
444
|
+
scope: input.scope,
|
|
445
|
+
});
|
|
446
|
+
if (skillPlan.action === 'skip') {
|
|
447
|
+
return { relativePath: input.relativePath, status: 'skipped', skillPlan };
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
atomicReplaceFile(context, input.file, input.targetContent, existingContent);
|
|
451
|
+
return { relativePath: input.relativePath, status: 'written', skillPlan };
|
|
452
|
+
} catch (error) {
|
|
453
|
+
return {
|
|
454
|
+
relativePath: input.relativePath,
|
|
455
|
+
status: 'failed',
|
|
456
|
+
skillPlan,
|
|
457
|
+
message: messageOf(error),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function installRepoSkillFile(root, relativePath, targetContent, packageVersion, force) {
|
|
463
|
+
return installSkillFile({
|
|
464
|
+
root,
|
|
465
|
+
file: path.join(root, relativePath),
|
|
466
|
+
relativePath,
|
|
467
|
+
targetContent,
|
|
468
|
+
packageVersion,
|
|
469
|
+
force,
|
|
470
|
+
scope: 'repo',
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function failedCatalog(directory, message, displayPath = HOME_SKILL_CATALOG) {
|
|
475
|
+
return [{
|
|
476
|
+
relativePath: path.join(directory, displayPath),
|
|
477
|
+
displayPath,
|
|
478
|
+
status: 'failed',
|
|
479
|
+
message,
|
|
480
|
+
}];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function readHomeCatalog(context) {
|
|
484
|
+
const file = path.join(context.path, HOME_SKILL_CATALOG);
|
|
485
|
+
const current = readSafeFile(context, file);
|
|
486
|
+
if (!current) return { file, value: null, content: null };
|
|
487
|
+
let value;
|
|
488
|
+
try {
|
|
489
|
+
value = JSON.parse(current.content);
|
|
490
|
+
} catch {
|
|
491
|
+
throw new Error(
|
|
492
|
+
`${HOME_SKILL_CATALOG} is unreadable or invalid JSON; no home skills changed. ` +
|
|
493
|
+
'Repair it or move it aside after verifying no newer ArkGate catalog is active.'
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
const valid =
|
|
497
|
+
value?.schemaVersion === HOME_SKILL_CATALOG_SCHEMA &&
|
|
498
|
+
isValidSemver(value.packageVersion) &&
|
|
499
|
+
Array.isArray(value.skills);
|
|
500
|
+
if (!valid) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
`${HOME_SKILL_CATALOG} has an unsupported shape; no home skills changed. ` +
|
|
503
|
+
'Expected schemaVersion, packageVersion, and managed skill identities.'
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const seen = new Set();
|
|
507
|
+
for (const skill of value.skills) {
|
|
508
|
+
if (
|
|
509
|
+
!skill ||
|
|
510
|
+
typeof skill.name !== 'string' ||
|
|
511
|
+
!/^ark-[a-z0-9-]+$/.test(skill.name) ||
|
|
512
|
+
typeof skill.contentIdentity !== 'string' ||
|
|
513
|
+
!/^sha256:[a-f0-9]{64}$/.test(skill.contentIdentity) ||
|
|
514
|
+
seen.has(skill.name)
|
|
515
|
+
) {
|
|
516
|
+
throw new Error(
|
|
517
|
+
`${HOME_SKILL_CATALOG} has invalid managed skill identities; no home skills changed.`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
seen.add(skill.name);
|
|
521
|
+
}
|
|
522
|
+
return { file, value, content: current.content };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function parsePendingCatalog(content) {
|
|
526
|
+
try {
|
|
527
|
+
const value = JSON.parse(content);
|
|
528
|
+
const keys =
|
|
529
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
530
|
+
? Object.keys(value).sort()
|
|
531
|
+
: [];
|
|
532
|
+
if (
|
|
533
|
+
keys.join(',') !== 'packageVersion,schemaVersion,token' ||
|
|
534
|
+
value.schemaVersion !== HOME_SKILL_CATALOG_SCHEMA ||
|
|
535
|
+
!isValidSemver(value.packageVersion) ||
|
|
536
|
+
typeof value.token !== 'string' ||
|
|
537
|
+
!UUID_PATTERN.test(value.token)
|
|
538
|
+
) {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
return value;
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function readPendingCatalog(context) {
|
|
548
|
+
const file = path.join(context.path, HOME_SKILL_PENDING_CATALOG);
|
|
549
|
+
const current = readSafeFile(context, file);
|
|
550
|
+
if (!current) return { file, value: null, content: null };
|
|
551
|
+
const value = parsePendingCatalog(current.content);
|
|
552
|
+
if (!value) {
|
|
553
|
+
throw new Error(
|
|
554
|
+
`${HOME_SKILL_PENDING_CATALOG} is unreadable or invalid; no home skills changed. ` +
|
|
555
|
+
'Repair it only after verifying the newest ArkGate version that may have started an install.'
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
return { file, value, content: current.content };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function newestInstalledVersion(context, catalogVersion, pendingVersion) {
|
|
562
|
+
let newest = null;
|
|
563
|
+
for (const version of [catalogVersion, pendingVersion]) {
|
|
564
|
+
if (isValidSemver(version) && (!newest || isVersionOlder(newest, version))) {
|
|
565
|
+
newest = version;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const entries = fs.readdirSync(context.path, { withFileTypes: true });
|
|
569
|
+
for (const entry of entries) {
|
|
570
|
+
if (!entry.isDirectory() || !/^ark-[a-z0-9-]+$/.test(entry.name)) continue;
|
|
571
|
+
const current = readSafeFile(
|
|
572
|
+
context,
|
|
573
|
+
path.join(context.path, entry.name, 'SKILL.md')
|
|
574
|
+
);
|
|
575
|
+
const version = current?.content.match(/^arkVersion:\s*(.+)$/m)?.[1]?.trim() ?? null;
|
|
576
|
+
if (
|
|
577
|
+
isValidSemver(version) &&
|
|
578
|
+
(!newest || isVersionOlder(newest, version))
|
|
579
|
+
) {
|
|
580
|
+
newest = version;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return newest;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function pendingCatalogContent(packageVersion, token) {
|
|
587
|
+
return `${JSON.stringify({
|
|
588
|
+
schemaVersion: HOME_SKILL_CATALOG_SCHEMA,
|
|
589
|
+
packageVersion,
|
|
590
|
+
token,
|
|
591
|
+
}, null, 2)}\n`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function removeOwnedPendingCatalog(context, file, token) {
|
|
595
|
+
try {
|
|
596
|
+
if (!readSafeFile(context, file)) return true;
|
|
597
|
+
} catch {
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
return removeOwnedTokenFile(
|
|
601
|
+
context,
|
|
602
|
+
file,
|
|
603
|
+
token,
|
|
604
|
+
parsePendingCatalog,
|
|
605
|
+
'commit'
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function catalogContent(packageVersion, entries) {
|
|
610
|
+
return `${JSON.stringify({
|
|
611
|
+
schemaVersion: HOME_SKILL_CATALOG_SCHEMA,
|
|
612
|
+
packageVersion,
|
|
613
|
+
skills: entries.sort((left, right) => left.name.localeCompare(right.name)),
|
|
614
|
+
}, null, 2)}\n`;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function retireManagedHomeSkills(context, priorEntries, targetNames, force) {
|
|
618
|
+
const results = [];
|
|
619
|
+
const retainedEntries = [];
|
|
620
|
+
for (const entry of priorEntries) {
|
|
621
|
+
if (targetNames.has(entry.name)) continue;
|
|
622
|
+
const file = path.join(context.path, entry.name, 'SKILL.md');
|
|
623
|
+
let current;
|
|
624
|
+
try {
|
|
625
|
+
current = readSafeFile(context, file);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
results.push({
|
|
628
|
+
relativePath: file,
|
|
629
|
+
displayPath: `${entry.name}/SKILL.md`,
|
|
630
|
+
status: 'failed',
|
|
631
|
+
message: messageOf(error),
|
|
632
|
+
});
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (!current) continue;
|
|
636
|
+
if (skillContentIdentity(current.content) !== entry.contentIdentity) {
|
|
637
|
+
results.push({
|
|
638
|
+
relativePath: file,
|
|
639
|
+
displayPath: `${entry.name}/SKILL.md`,
|
|
640
|
+
status: 'skipped',
|
|
641
|
+
note: 'scope=home-shared; retired but customized; preserved and ownership released',
|
|
642
|
+
});
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (!force) {
|
|
646
|
+
retainedEntries.push(entry);
|
|
647
|
+
results.push({
|
|
648
|
+
relativePath: file,
|
|
649
|
+
displayPath: `${entry.name}/SKILL.md`,
|
|
650
|
+
status: 'skipped',
|
|
651
|
+
note: 'scope=home-shared; retired Ark-owned skill preserved without --force',
|
|
652
|
+
});
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
try {
|
|
656
|
+
const confirmed = readSafeFile(context, file);
|
|
657
|
+
if (
|
|
658
|
+
!confirmed ||
|
|
659
|
+
skillContentIdentity(confirmed.content) !== entry.contentIdentity
|
|
660
|
+
) {
|
|
661
|
+
throw new Error(`Retired managed skill changed before removal: ${file}`);
|
|
662
|
+
}
|
|
663
|
+
validateParents(context, file, false);
|
|
664
|
+
fs.unlinkSync(file);
|
|
665
|
+
try {
|
|
666
|
+
const parent = path.dirname(file);
|
|
667
|
+
validateParents(context, path.join(parent, '.arkgate-parent-check'), false);
|
|
668
|
+
const parentStat = fs.lstatSync(parent);
|
|
669
|
+
if (!parentStat.isSymbolicLink() && parentStat.isDirectory()) fs.rmdirSync(parent);
|
|
670
|
+
} catch {
|
|
671
|
+
// Keep a non-empty directory and any user-owned sibling files.
|
|
672
|
+
}
|
|
673
|
+
results.push({
|
|
674
|
+
relativePath: file,
|
|
675
|
+
displayPath: `${entry.name}/SKILL.md`,
|
|
676
|
+
status: 'removed',
|
|
677
|
+
note: 'scope=home-shared; retired Ark-owned skill removed',
|
|
678
|
+
});
|
|
679
|
+
} catch (error) {
|
|
680
|
+
results.push({
|
|
681
|
+
relativePath: file,
|
|
682
|
+
displayPath: `${entry.name}/SKILL.md`,
|
|
683
|
+
status: 'failed',
|
|
684
|
+
message: error instanceof Error ? error.message : String(error),
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return { results, retainedEntries };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function installHomeSkillCatalog({ directory, skills, packageVersion, force }) {
|
|
692
|
+
if (!isValidSemver(packageVersion)) {
|
|
693
|
+
return failedCatalog(
|
|
694
|
+
directory,
|
|
695
|
+
'ArkGate package version is unavailable or not valid SemVer; refusing to mutate the shared home catalog.'
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
const seenNames = new Set();
|
|
699
|
+
let validSkills = Array.isArray(skills);
|
|
700
|
+
if (validSkills) {
|
|
701
|
+
for (const entry of skills) {
|
|
702
|
+
if (
|
|
703
|
+
!Array.isArray(entry) ||
|
|
704
|
+
entry.length !== 2 ||
|
|
705
|
+
typeof entry[0] !== 'string' ||
|
|
706
|
+
!/^ark-[a-z0-9-]+$/.test(entry[0]) ||
|
|
707
|
+
typeof entry[1] !== 'string' ||
|
|
708
|
+
seenNames.has(entry[0])
|
|
709
|
+
) {
|
|
710
|
+
validSkills = false;
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
seenNames.add(entry[0]);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (!validSkills) {
|
|
717
|
+
return failedCatalog(
|
|
718
|
+
directory,
|
|
719
|
+
'Home skill input contains an invalid or duplicate managed skill name; no home skills changed.'
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
let context;
|
|
723
|
+
try {
|
|
724
|
+
context = rootContext(directory);
|
|
725
|
+
} catch (error) {
|
|
726
|
+
return failedCatalog(directory, messageOf(error));
|
|
727
|
+
}
|
|
728
|
+
let lock;
|
|
729
|
+
try {
|
|
730
|
+
lock = acquireHomeLock(context);
|
|
731
|
+
} catch (error) {
|
|
732
|
+
return failedCatalog(directory, messageOf(error));
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
const catalog = readHomeCatalog(context);
|
|
736
|
+
let pending;
|
|
737
|
+
try {
|
|
738
|
+
pending = readPendingCatalog(context);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
return failedCatalog(
|
|
741
|
+
directory,
|
|
742
|
+
messageOf(error),
|
|
743
|
+
HOME_SKILL_PENDING_CATALOG
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
const installedVersion = newestInstalledVersion(
|
|
747
|
+
context,
|
|
748
|
+
catalog.value?.packageVersion ?? null,
|
|
749
|
+
pending.value?.packageVersion ?? null
|
|
750
|
+
);
|
|
751
|
+
if (installedVersion && isVersionOlder(packageVersion, installedVersion)) {
|
|
752
|
+
return [{
|
|
753
|
+
relativePath: catalog.file,
|
|
754
|
+
displayPath: HOME_SKILL_CATALOG,
|
|
755
|
+
status: 'skipped',
|
|
756
|
+
note:
|
|
757
|
+
`scope=home-shared; CONFLICT catalog=${installedVersion} newer than ` +
|
|
758
|
+
`source=arkgate@${packageVersion}; entire home update and retirements blocked`,
|
|
759
|
+
}];
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const pendingToken = randomUUID();
|
|
763
|
+
const nextPendingContent = pendingCatalogContent(packageVersion, pendingToken);
|
|
764
|
+
try {
|
|
765
|
+
atomicReplaceFile(
|
|
766
|
+
context,
|
|
767
|
+
pending.file,
|
|
768
|
+
nextPendingContent,
|
|
769
|
+
pending.content
|
|
770
|
+
);
|
|
771
|
+
} catch (error) {
|
|
772
|
+
return [{
|
|
773
|
+
relativePath: pending.file,
|
|
774
|
+
displayPath: HOME_SKILL_PENDING_CATALOG,
|
|
775
|
+
status: 'failed',
|
|
776
|
+
message:
|
|
777
|
+
`${messageOf(error)}; no home skills changed because the durable ` +
|
|
778
|
+
'catalog journal could not be committed.',
|
|
779
|
+
}];
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const installed = skills.map(([name, targetContent]) => ({
|
|
783
|
+
...installSkillFile({
|
|
784
|
+
root: context.path,
|
|
785
|
+
file: path.join(context.path, name, 'SKILL.md'),
|
|
786
|
+
relativePath: path.join(context.path, name, 'SKILL.md'),
|
|
787
|
+
targetContent,
|
|
788
|
+
packageVersion,
|
|
789
|
+
force,
|
|
790
|
+
scope: 'home',
|
|
791
|
+
}),
|
|
792
|
+
name,
|
|
793
|
+
targetContent,
|
|
794
|
+
displayPath: `${name}/SKILL.md`,
|
|
795
|
+
}));
|
|
796
|
+
if (installed.some((result) => result.status === 'failed')) return installed;
|
|
797
|
+
|
|
798
|
+
const targetNames = new Set(skills.map(([name]) => name));
|
|
799
|
+
const retired = retireManagedHomeSkills(
|
|
800
|
+
context,
|
|
801
|
+
catalog.value?.skills ?? [],
|
|
802
|
+
targetNames,
|
|
803
|
+
force
|
|
804
|
+
);
|
|
805
|
+
if (retired.results.some((result) => result.status === 'failed')) {
|
|
806
|
+
return [...installed, ...retired.results];
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const managedEntries = installed
|
|
810
|
+
.filter(
|
|
811
|
+
(result) =>
|
|
812
|
+
result.status === 'written' || result.skillPlan?.reason === 'content-current'
|
|
813
|
+
)
|
|
814
|
+
.map((result) => ({
|
|
815
|
+
name: result.name,
|
|
816
|
+
contentIdentity: skillContentIdentity(result.targetContent),
|
|
817
|
+
}))
|
|
818
|
+
.concat(retired.retainedEntries);
|
|
819
|
+
const nextContent = catalogContent(packageVersion, managedEntries);
|
|
820
|
+
const catalogResult =
|
|
821
|
+
nextContent === catalog.content
|
|
822
|
+
? {
|
|
823
|
+
relativePath: catalog.file,
|
|
824
|
+
displayPath: HOME_SKILL_CATALOG,
|
|
825
|
+
status: 'skipped',
|
|
826
|
+
note: `scope=home-shared; catalog current at arkgate@${packageVersion}; no write`,
|
|
827
|
+
}
|
|
828
|
+
: (() => {
|
|
829
|
+
try {
|
|
830
|
+
atomicReplaceFile(context, catalog.file, nextContent, catalog.content);
|
|
831
|
+
return {
|
|
832
|
+
relativePath: catalog.file,
|
|
833
|
+
displayPath: HOME_SKILL_CATALOG,
|
|
834
|
+
status: 'written',
|
|
835
|
+
note: `scope=home-shared; catalog advanced to arkgate@${packageVersion}`,
|
|
836
|
+
};
|
|
837
|
+
} catch (error) {
|
|
838
|
+
return {
|
|
839
|
+
relativePath: catalog.file,
|
|
840
|
+
displayPath: HOME_SKILL_CATALOG,
|
|
841
|
+
status: 'failed',
|
|
842
|
+
message: messageOf(error),
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
})();
|
|
846
|
+
const results = [...installed, ...retired.results, catalogResult];
|
|
847
|
+
if (catalogResult.status === 'failed') return results;
|
|
848
|
+
if (!removeOwnedPendingCatalog(context, pending.file, pendingToken)) {
|
|
849
|
+
return [
|
|
850
|
+
...results,
|
|
851
|
+
{
|
|
852
|
+
relativePath: pending.file,
|
|
853
|
+
displayPath: HOME_SKILL_PENDING_CATALOG,
|
|
854
|
+
status: 'failed',
|
|
855
|
+
message:
|
|
856
|
+
'The shared catalog committed, but its durable journal changed or could not be ' +
|
|
857
|
+
'removed; it was preserved for a safe same-or-newer retry.',
|
|
858
|
+
},
|
|
859
|
+
];
|
|
860
|
+
}
|
|
861
|
+
return results;
|
|
862
|
+
} catch (error) {
|
|
863
|
+
return failedCatalog(directory, messageOf(error));
|
|
864
|
+
} finally {
|
|
865
|
+
if (lock) removeOwnedLock(context, lock.file, lock.token);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
export function installSkillCatalog(input) {
|
|
870
|
+
if (input.scope === 'home') return installHomeSkillCatalog(input);
|
|
871
|
+
return input.skills.map(([name, targetContent]) => ({
|
|
872
|
+
...installSkillFile({
|
|
873
|
+
root: input.directory,
|
|
874
|
+
file: path.join(input.directory, name, 'SKILL.md'),
|
|
875
|
+
relativePath: path.join(input.directory, name, 'SKILL.md'),
|
|
876
|
+
targetContent,
|
|
877
|
+
packageVersion: input.packageVersion,
|
|
878
|
+
force: input.force,
|
|
879
|
+
scope: input.scope,
|
|
880
|
+
}),
|
|
881
|
+
displayPath: `${name}/SKILL.md`,
|
|
882
|
+
}));
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export function skillInstallLine(result) {
|
|
886
|
+
const marker =
|
|
887
|
+
result.status === 'written'
|
|
888
|
+
? 'wrote'
|
|
889
|
+
: result.status === 'removed'
|
|
890
|
+
? 'removed'
|
|
891
|
+
: result.status === 'failed'
|
|
892
|
+
? 'FAILED'
|
|
893
|
+
: 'skipped';
|
|
894
|
+
const detail =
|
|
895
|
+
result.status === 'failed'
|
|
896
|
+
? ` (${result.message})`
|
|
897
|
+
: ` (${result.note ?? skillInstallNote(result.skillPlan)})`;
|
|
898
|
+
return ` ${marker.padEnd(7)} ${result.displayPath}${detail}`;
|
|
899
|
+
}
|