trekoon 0.3.2 → 0.3.4
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/.agents/skills/trekoon/SKILL.md +55 -6
- package/README.md +156 -122
- package/docs/ai-agents.md +105 -104
- package/docs/commands.md +137 -167
- package/docs/machine-contracts.md +67 -68
- package/docs/quickstart.md +87 -143
- package/package.json +2 -2
- package/src/board/assets/state/utils.js +1 -1
- package/src/commands/arg-parser.ts +10 -0
- package/src/commands/help.ts +243 -239
- package/src/commands/quickstart.ts +67 -77
- package/src/commands/skills.ts +517 -168
- package/src/commands/sync.ts +93 -5
- package/src/domain/tracker-domain.ts +210 -37
- package/src/runtime/cli-shell.ts +5 -0
- package/src/storage/events-retention.ts +72 -0
- package/src/storage/migrations.ts +28 -0
- package/src/sync/event-writes.ts +8 -6
- package/src/sync/service.ts +80 -52
- package/src/sync/types.ts +12 -0
package/src/commands/skills.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
|
|
@@ -10,6 +11,7 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
|
10
11
|
const SKILLS_USAGE = [
|
|
11
12
|
"Usage:",
|
|
12
13
|
" trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]",
|
|
14
|
+
" trekoon skills install -g|--global [--editor opencode|claude|pi]",
|
|
13
15
|
" trekoon skills update",
|
|
14
16
|
].join("\n");
|
|
15
17
|
const EDITOR_NAMES = ["opencode", "claude", "pi"] as const;
|
|
@@ -30,24 +32,6 @@ interface LinkTargetValidation {
|
|
|
30
32
|
readonly outsideRepoLink: boolean;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
type UpdateLinkAction = "created" | "refreshed" | "skipped_conflict" | "skipped_no_editor_dir";
|
|
34
|
-
|
|
35
|
-
interface UpdateLinkEntry {
|
|
36
|
-
readonly editor: EditorName;
|
|
37
|
-
readonly linkPath: string;
|
|
38
|
-
readonly expectedTarget: string;
|
|
39
|
-
readonly action: UpdateLinkAction;
|
|
40
|
-
readonly conflictCode: "non_link" | "wrong_target" | null;
|
|
41
|
-
readonly existingTarget: string | null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface UpdateOutcome {
|
|
45
|
-
readonly sourcePath: string;
|
|
46
|
-
readonly installedPath: string;
|
|
47
|
-
readonly installedDir: string;
|
|
48
|
-
readonly links: readonly UpdateLinkEntry[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
35
|
function invalidArgs(message: string): CliResult {
|
|
52
36
|
return failResult({
|
|
53
37
|
command: "skills",
|
|
@@ -220,7 +204,12 @@ function resolveDefaultLinkPath(cwd: string, editor: EditorName): string {
|
|
|
220
204
|
}
|
|
221
205
|
|
|
222
206
|
function toRelativeSymlinkTarget(linkPath: string, targetPath: string): string {
|
|
223
|
-
|
|
207
|
+
// Use realpathNearestExistingAncestor for the link parent so the relative
|
|
208
|
+
// path is correct even when parts of the path are OS-level symlinks (e.g.
|
|
209
|
+
// macOS /var → /private/var).
|
|
210
|
+
const linkParent: string = realpathNearestExistingAncestor(dirname(linkPath));
|
|
211
|
+
const resolvedTarget: string = resolve(targetPath);
|
|
212
|
+
const relativeTarget: string = relative(linkParent, resolvedTarget);
|
|
224
213
|
return relativeTarget === "" ? "." : relativeTarget;
|
|
225
214
|
}
|
|
226
215
|
|
|
@@ -236,6 +225,19 @@ function resolveEditorConfigDir(cwd: string, editor: EditorName): string {
|
|
|
236
225
|
return join(cwd, ".pi");
|
|
237
226
|
}
|
|
238
227
|
|
|
228
|
+
function resolveGlobalEditorSkillsDir(editor: EditorName): string {
|
|
229
|
+
const home: string = homedir();
|
|
230
|
+
if (editor === "opencode") {
|
|
231
|
+
return join(home, ".config", "opencode", "skills");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (editor === "claude") {
|
|
235
|
+
return join(home, ".claude", "skills");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return join(home, ".pi", "skills");
|
|
239
|
+
}
|
|
240
|
+
|
|
239
241
|
function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; installedPath: string; installedDir: string } {
|
|
240
242
|
const sourcePath: string = resolveBundledSkillFilePath();
|
|
241
243
|
const sourceDir: string = resolveBundledSkillDirPath();
|
|
@@ -254,18 +256,60 @@ function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; i
|
|
|
254
256
|
});
|
|
255
257
|
}
|
|
256
258
|
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
+
const installedDir: string = join(cwd, ".agents", "skills", "trekoon");
|
|
260
|
+
const installedPath: string = join(installedDir, "SKILL.md");
|
|
261
|
+
const parentDir: string = dirname(installedDir);
|
|
262
|
+
const resolvedSourceDir: string = resolve(sourceDir);
|
|
263
|
+
|
|
264
|
+
// Self-reference guard: when cwd IS the package dir (e.g. developing Trekoon
|
|
265
|
+
// itself), the source dir and installed dir are the same path. Do not copy
|
|
266
|
+
// over itself — the directory already contains the bundled files.
|
|
267
|
+
if (resolve(installedDir) === resolvedSourceDir) {
|
|
268
|
+
return { sourcePath, installedPath, installedDir };
|
|
269
|
+
}
|
|
259
270
|
|
|
260
271
|
try {
|
|
261
|
-
mkdirSync(
|
|
262
|
-
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
272
|
+
mkdirSync(parentDir, { recursive: true });
|
|
273
|
+
|
|
274
|
+
// Check what currently occupies the install path (lstat does not follow symlinks).
|
|
275
|
+
let existingIsSymlink = false;
|
|
276
|
+
let existingIsDir = false;
|
|
277
|
+
let pathOccupied = false;
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const stat = lstatSync(installedDir);
|
|
281
|
+
pathOccupied = true;
|
|
282
|
+
existingIsSymlink = stat.isSymbolicLink();
|
|
283
|
+
existingIsDir = stat.isDirectory();
|
|
284
|
+
} catch {
|
|
285
|
+
// Nothing at the path — proceed to create.
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (pathOccupied) {
|
|
289
|
+
if (existingIsDir && !existingIsSymlink) {
|
|
290
|
+
// Real directory — check whether contents are already up to date.
|
|
291
|
+
try {
|
|
292
|
+
const sourceContents = readFileSync(sourcePath, "utf8");
|
|
293
|
+
const installedContents = readFileSync(installedPath, "utf8");
|
|
294
|
+
if (sourceContents === installedContents) {
|
|
295
|
+
// Already up to date; idempotent success.
|
|
296
|
+
return { sourcePath, installedPath, installedDir };
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// Can't read installed file — refresh below.
|
|
300
|
+
}
|
|
301
|
+
// Stale copy — remove and recopy.
|
|
302
|
+
rmSync(installedDir, { recursive: true, force: true });
|
|
303
|
+
} else if (existingIsSymlink) {
|
|
304
|
+
// Legacy symlink (pre-copy era) — remove and replace with copy.
|
|
305
|
+
rmSync(installedDir, { force: true });
|
|
306
|
+
} else {
|
|
307
|
+
// Unexpected file — remove.
|
|
308
|
+
rmSync(installedDir, { force: true });
|
|
309
|
+
}
|
|
268
310
|
}
|
|
311
|
+
|
|
312
|
+
cpSync(resolvedSourceDir, installedDir, { recursive: true });
|
|
269
313
|
} catch (error: unknown) {
|
|
270
314
|
const message = error instanceof Error ? error.message : "Unknown skills install failure";
|
|
271
315
|
return failResult({
|
|
@@ -295,53 +339,188 @@ function replaceOrCreateSymlink(
|
|
|
295
339
|
repoRoot: string,
|
|
296
340
|
allowOutsideRepo: boolean,
|
|
297
341
|
): CliResult | null {
|
|
342
|
+
// Ensure parent dirs exist before computing the relative target so that
|
|
343
|
+
// realpathNearestExistingAncestor resolves correctly (avoids macOS
|
|
344
|
+
// /var → /private/var mismatch when parent chain is missing).
|
|
345
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
346
|
+
|
|
347
|
+
const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
|
|
348
|
+
if (boundaryFailure) {
|
|
349
|
+
return boundaryFailure;
|
|
350
|
+
}
|
|
351
|
+
|
|
298
352
|
const symlinkTarget: string = toRelativeSymlinkTarget(linkPath, targetPath);
|
|
299
353
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
354
|
+
// Use lstatSync to detect broken symlinks that existsSync would miss
|
|
355
|
+
// (existsSync follows symlinks, so a broken symlink returns false).
|
|
356
|
+
let occupied = false;
|
|
357
|
+
let isSymlink = false;
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const stat = lstatSync(linkPath);
|
|
361
|
+
occupied = true;
|
|
362
|
+
isSymlink = stat.isSymbolicLink();
|
|
363
|
+
} catch {
|
|
364
|
+
// Nothing at the path — proceed to create.
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!occupied) {
|
|
306
368
|
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
307
369
|
return null;
|
|
308
370
|
}
|
|
309
371
|
|
|
310
|
-
|
|
311
|
-
if (!existing.isSymbolicLink()) {
|
|
312
|
-
// Replace stale directory or file with symlink to the canonical location.
|
|
372
|
+
if (!isSymlink) {
|
|
313
373
|
rmSync(linkPath, { recursive: true, force: true });
|
|
314
|
-
const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
|
|
315
|
-
if (boundaryFailure) {
|
|
316
|
-
return boundaryFailure;
|
|
317
|
-
}
|
|
318
374
|
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
319
375
|
return null;
|
|
320
376
|
}
|
|
321
377
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
|
|
329
|
-
if (boundaryFailure) {
|
|
330
|
-
return boundaryFailure;
|
|
331
|
-
}
|
|
332
|
-
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
378
|
+
// Use realpathSync to resolve OS-level symlinks (macOS /var → /private/var)
|
|
379
|
+
// for a consistent comparison with the target.
|
|
380
|
+
const resolvedExisting: string = realpathSync(linkPath);
|
|
381
|
+
const resolvedExpected: string = realpathSync(resolve(targetPath));
|
|
382
|
+
if (resolvedExisting === resolvedExpected) {
|
|
383
|
+
// Already correct — avoid needless tear-down and recreation.
|
|
333
384
|
return null;
|
|
334
385
|
}
|
|
335
386
|
|
|
336
387
|
rmSync(linkPath, { force: true });
|
|
337
|
-
const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
|
|
338
|
-
if (boundaryFailure) {
|
|
339
|
-
return boundaryFailure;
|
|
340
|
-
}
|
|
341
388
|
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
342
389
|
return null;
|
|
343
390
|
}
|
|
344
391
|
|
|
392
|
+
interface GlobalEditorLinkEntry {
|
|
393
|
+
readonly editor: EditorName;
|
|
394
|
+
readonly linkPath: string;
|
|
395
|
+
readonly linkTarget: string;
|
|
396
|
+
readonly action: "created" | "refreshed" | "already_ok";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
interface GlobalInstallOutcome {
|
|
400
|
+
readonly sourcePath: string;
|
|
401
|
+
readonly sourceDir: string;
|
|
402
|
+
readonly globalAnchorPath: string;
|
|
403
|
+
readonly globalAnchorAction: "created" | "refreshed" | "already_ok";
|
|
404
|
+
readonly editorLinks: readonly GlobalEditorLinkEntry[];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function ensureSymlink(
|
|
408
|
+
linkPath: string,
|
|
409
|
+
targetPath: string,
|
|
410
|
+
): "created" | "refreshed" | "already_ok" {
|
|
411
|
+
const resolvedTarget: string = resolve(targetPath);
|
|
412
|
+
|
|
413
|
+
// Self-reference guard: source and target are the same path (dev mode).
|
|
414
|
+
if (resolve(linkPath) === resolvedTarget) {
|
|
415
|
+
return "already_ok";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Ensure parent dirs exist before computing the relative target so that
|
|
419
|
+
// realpathNearestExistingAncestor resolves correctly.
|
|
420
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
421
|
+
const symlinkTarget: string = toRelativeSymlinkTarget(linkPath, resolvedTarget);
|
|
422
|
+
|
|
423
|
+
let existingIsSymlink = false;
|
|
424
|
+
let pathOccupied = false;
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const stat = lstatSync(linkPath);
|
|
428
|
+
pathOccupied = true;
|
|
429
|
+
existingIsSymlink = stat.isSymbolicLink();
|
|
430
|
+
} catch {
|
|
431
|
+
// Nothing at the path.
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (pathOccupied) {
|
|
435
|
+
if (existingIsSymlink) {
|
|
436
|
+
// Use realpathSync so OS-level symlinks (macOS /var → /private/var) do
|
|
437
|
+
// not cause false mismatches.
|
|
438
|
+
try {
|
|
439
|
+
const resolvedExisting: string = realpathSync(linkPath);
|
|
440
|
+
const resolvedExpectedReal: string = realpathSync(resolvedTarget);
|
|
441
|
+
if (resolvedExisting === resolvedExpectedReal) {
|
|
442
|
+
return "already_ok";
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
// Broken symlink — fall through to remove and recreate.
|
|
446
|
+
}
|
|
447
|
+
rmSync(linkPath, { force: true });
|
|
448
|
+
} else {
|
|
449
|
+
rmSync(linkPath, { recursive: true, force: true });
|
|
450
|
+
}
|
|
451
|
+
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
452
|
+
return "refreshed";
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
symlinkSync(symlinkTarget, linkPath, "dir");
|
|
456
|
+
return "created";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function runGlobalInstall(editors: readonly EditorName[]): CliResult {
|
|
460
|
+
const sourcePath: string = resolveBundledSkillFilePath();
|
|
461
|
+
const sourceDir: string = resolveBundledSkillDirPath();
|
|
462
|
+
if (!existsSync(sourcePath)) {
|
|
463
|
+
return failResult({
|
|
464
|
+
command: "skills.install",
|
|
465
|
+
human: `Bundled skill asset not found at ${sourcePath}`,
|
|
466
|
+
data: { code: "missing_asset", sourcePath },
|
|
467
|
+
error: { code: "missing_asset", message: "Bundled skill asset not found" },
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
// Step 1: Global anchor ~/.agents/skills/trekoon → bundled package dir.
|
|
473
|
+
const globalAnchorPath: string = join(homedir(), ".agents", "skills", "trekoon");
|
|
474
|
+
const globalAnchorAction = ensureSymlink(globalAnchorPath, sourceDir);
|
|
475
|
+
|
|
476
|
+
// Step 2: Editor links <editor-global-skills>/trekoon → global anchor.
|
|
477
|
+
const editorLinks: GlobalEditorLinkEntry[] = editors.map((editor) => {
|
|
478
|
+
const editorSkillsDir: string = resolveGlobalEditorSkillsDir(editor);
|
|
479
|
+
const linkPath: string = join(editorSkillsDir, "trekoon");
|
|
480
|
+
const action = ensureSymlink(linkPath, globalAnchorPath);
|
|
481
|
+
return { editor, linkPath, linkTarget: globalAnchorPath, action };
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const outcome: GlobalInstallOutcome = {
|
|
485
|
+
sourcePath,
|
|
486
|
+
sourceDir,
|
|
487
|
+
globalAnchorPath,
|
|
488
|
+
globalAnchorAction,
|
|
489
|
+
editorLinks,
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const editorSummary: string = editorLinks
|
|
493
|
+
.map((entry) => `- ${entry.editor}: ${entry.action} (${entry.linkPath})`)
|
|
494
|
+
.join("\n");
|
|
495
|
+
|
|
496
|
+
return okResult({
|
|
497
|
+
command: "skills.install",
|
|
498
|
+
human: [
|
|
499
|
+
"Installed Trekoon skill globally.",
|
|
500
|
+
`Global anchor: ${globalAnchorPath} (${globalAnchorAction})`,
|
|
501
|
+
"Editor links:",
|
|
502
|
+
editorSummary,
|
|
503
|
+
].join("\n"),
|
|
504
|
+
data: {
|
|
505
|
+
global: true,
|
|
506
|
+
sourcePath: outcome.sourcePath,
|
|
507
|
+
sourceDir: outcome.sourceDir,
|
|
508
|
+
globalAnchorPath: outcome.globalAnchorPath,
|
|
509
|
+
globalAnchorAction: outcome.globalAnchorAction,
|
|
510
|
+
editorLinks: outcome.editorLinks,
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
} catch (error: unknown) {
|
|
514
|
+
const message = error instanceof Error ? error.message : "Unknown global install failure";
|
|
515
|
+
return failResult({
|
|
516
|
+
command: "skills.install",
|
|
517
|
+
human: `Failed to install skill globally: ${message}`,
|
|
518
|
+
data: { code: "install_failed", message },
|
|
519
|
+
error: { code: "install_failed", message },
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
345
524
|
function runSkillsInstall(context: CliContext): CliResult {
|
|
346
525
|
const parsed = parseArgs(context.args);
|
|
347
526
|
const missingValue = readMissingOptionValue(parsed.missingOptionValues, "editor", "to");
|
|
@@ -355,11 +534,41 @@ function runSkillsInstall(context: CliContext): CliResult {
|
|
|
355
534
|
return invalidArgs("Unexpected positional arguments for skills install.");
|
|
356
535
|
}
|
|
357
536
|
|
|
537
|
+
const wantsGlobal: boolean = hasFlag(parsed.flags, "global", "g");
|
|
358
538
|
const wantsLink: boolean = hasFlag(parsed.flags, "link");
|
|
359
539
|
const allowOutsideRepo: boolean = hasFlag(parsed.flags, ALLOW_OUTSIDE_REPO_FLAG);
|
|
360
540
|
const rawEditor: string | undefined = readOption(parsed.options, "editor");
|
|
361
541
|
const rawTo: string | undefined = readOption(parsed.options, "to");
|
|
362
542
|
|
|
543
|
+
// Validate editor early (shared by both modes).
|
|
544
|
+
if (rawEditor !== undefined && !EDITOR_NAMES.includes(rawEditor as EditorName)) {
|
|
545
|
+
return invalidInput("skills.install", "Invalid --editor value. Use: opencode, claude, pi", {
|
|
546
|
+
editor: rawEditor,
|
|
547
|
+
allowedEditors: EDITOR_NAMES,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Global mode validation.
|
|
552
|
+
if (wantsGlobal) {
|
|
553
|
+
if (rawTo !== undefined) {
|
|
554
|
+
return invalidInput("skills.install", "--to is not supported with --global.", { to: rawTo });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (wantsLink) {
|
|
558
|
+
return invalidInput("skills.install", "--link is not supported with --global.", {});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (allowOutsideRepo) {
|
|
562
|
+
return invalidInput("skills.install", `--${ALLOW_OUTSIDE_REPO_FLAG} is not supported with --global.`, {});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const editors: readonly EditorName[] = rawEditor
|
|
566
|
+
? [rawEditor as EditorName]
|
|
567
|
+
: EDITOR_NAMES;
|
|
568
|
+
return runGlobalInstall(editors);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Local mode validation.
|
|
363
572
|
if (allowOutsideRepo && !wantsLink) {
|
|
364
573
|
return invalidInput("skills.install", `--${ALLOW_OUTSIDE_REPO_FLAG} requires --link.`, {
|
|
365
574
|
allowOutsideRepo,
|
|
@@ -382,13 +591,6 @@ function runSkillsInstall(context: CliContext): CliResult {
|
|
|
382
591
|
return invalidArgs("skills install --link requires --editor opencode|claude|pi.");
|
|
383
592
|
}
|
|
384
593
|
|
|
385
|
-
if (rawEditor !== undefined && !EDITOR_NAMES.includes(rawEditor as EditorName)) {
|
|
386
|
-
return invalidInput("skills.install", "Invalid --editor value. Use: opencode, claude, pi", {
|
|
387
|
-
editor: rawEditor,
|
|
388
|
-
allowedEditors: EDITOR_NAMES,
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
|
|
392
594
|
const editor: EditorName | undefined = rawEditor as EditorName | undefined;
|
|
393
595
|
|
|
394
596
|
const installResult = installCanonicalSkill(context.cwd);
|
|
@@ -489,85 +691,210 @@ function runSkillsInstall(context: CliContext): CliResult {
|
|
|
489
691
|
});
|
|
490
692
|
}
|
|
491
693
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
694
|
+
type ProbeStatus = "ok" | "stale" | "broken" | "legacy" | "not_installed";
|
|
695
|
+
|
|
696
|
+
interface ProbeResult {
|
|
697
|
+
readonly path: string;
|
|
698
|
+
readonly expectedTarget: string;
|
|
699
|
+
readonly status: ProbeStatus;
|
|
700
|
+
readonly currentTarget: string | null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function probeSymlink(linkPath: string, expectedTarget: string): ProbeResult {
|
|
704
|
+
const resolvedExpected: string = resolve(expectedTarget);
|
|
705
|
+
|
|
706
|
+
// Self-reference guard: source and install are the same path (dev mode).
|
|
707
|
+
if (resolve(linkPath) === resolvedExpected) {
|
|
708
|
+
return { path: linkPath, expectedTarget: resolvedExpected, status: "ok", currentTarget: resolvedExpected };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
const stat = lstatSync(linkPath);
|
|
713
|
+
|
|
714
|
+
if (stat.isSymbolicLink()) {
|
|
715
|
+
const rawTarget: string = readlinkSync(linkPath);
|
|
716
|
+
const resolvedCurrent: string = resolve(dirname(linkPath), rawTarget);
|
|
717
|
+
|
|
718
|
+
// Check if symlink target actually exists on disk.
|
|
719
|
+
const targetExists: boolean = existsSync(linkPath);
|
|
720
|
+
if (!targetExists) {
|
|
721
|
+
return { path: linkPath, expectedTarget: resolvedExpected, status: "broken", currentTarget: resolvedCurrent };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (resolvedCurrent === resolvedExpected) {
|
|
725
|
+
return { path: linkPath, expectedTarget: resolvedExpected, status: "ok", currentTarget: resolvedCurrent };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return { path: linkPath, expectedTarget: resolvedExpected, status: "stale", currentTarget: resolvedCurrent };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (stat.isDirectory()) {
|
|
732
|
+
return { path: linkPath, expectedTarget: resolvedExpected, status: "legacy", currentTarget: null };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Unexpected file type — treat as legacy.
|
|
736
|
+
return { path: linkPath, expectedTarget: resolvedExpected, status: "legacy", currentTarget: null };
|
|
737
|
+
} catch {
|
|
738
|
+
return { path: linkPath, expectedTarget: resolvedExpected, status: "not_installed", currentTarget: null };
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
type RepairAction = "ok" | "repointed" | "created" | "migrated" | "skipped";
|
|
743
|
+
|
|
744
|
+
interface RepairResult {
|
|
745
|
+
readonly probe: ProbeResult;
|
|
746
|
+
readonly action: RepairAction;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function repairSymlink(probe: ProbeResult): RepairResult {
|
|
750
|
+
switch (probe.status) {
|
|
751
|
+
case "ok":
|
|
752
|
+
return { probe, action: "ok" };
|
|
753
|
+
|
|
754
|
+
case "stale":
|
|
755
|
+
case "broken": {
|
|
756
|
+
rmSync(probe.path, { force: true });
|
|
757
|
+
mkdirSync(dirname(probe.path), { recursive: true });
|
|
758
|
+
const target: string = toRelativeSymlinkTarget(probe.path, probe.expectedTarget);
|
|
759
|
+
symlinkSync(target, probe.path, "dir");
|
|
760
|
+
return { probe, action: "repointed" };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
case "legacy": {
|
|
764
|
+
rmSync(probe.path, { recursive: true, force: true });
|
|
765
|
+
mkdirSync(dirname(probe.path), { recursive: true });
|
|
766
|
+
const target: string = toRelativeSymlinkTarget(probe.path, probe.expectedTarget);
|
|
767
|
+
symlinkSync(target, probe.path, "dir");
|
|
768
|
+
return { probe, action: "migrated" };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
case "not_installed":
|
|
772
|
+
return { probe, action: "skipped" };
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Probe and repair, but skip (don't create) when not already installed. */
|
|
777
|
+
function probeAndRepairIfInstalled(linkPath: string, expectedTarget: string): RepairResult {
|
|
778
|
+
const probe = probeSymlink(linkPath, expectedTarget);
|
|
779
|
+
if (probe.status === "not_installed") {
|
|
780
|
+
return { probe, action: "skipped" };
|
|
781
|
+
}
|
|
782
|
+
return repairSymlink(probe);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Probe and repair local anchor using directory copy instead of symlinks. */
|
|
786
|
+
function probeAndRepairLocalAnchor(installedDir: string, sourceDir: string): RepairResult {
|
|
787
|
+
const resolvedExpected: string = resolve(sourceDir);
|
|
788
|
+
const sourcePath: string = join(sourceDir, "SKILL.md");
|
|
789
|
+
const installedPath: string = join(installedDir, "SKILL.md");
|
|
790
|
+
|
|
791
|
+
// Self-reference guard: source and install are the same path (dev mode).
|
|
792
|
+
if (resolve(installedDir) === resolvedExpected) {
|
|
503
793
|
return {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
expectedTarget,
|
|
507
|
-
action: "skipped_no_editor_dir",
|
|
508
|
-
conflictCode: null,
|
|
509
|
-
existingTarget: null,
|
|
794
|
+
probe: { path: installedDir, expectedTarget: resolvedExpected, status: "ok", currentTarget: resolvedExpected },
|
|
795
|
+
action: "ok",
|
|
510
796
|
};
|
|
511
797
|
}
|
|
512
798
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
799
|
+
let existingIsSymlink = false;
|
|
800
|
+
let existingIsDir = false;
|
|
801
|
+
let pathOccupied = false;
|
|
802
|
+
|
|
803
|
+
try {
|
|
804
|
+
const stat = lstatSync(installedDir);
|
|
805
|
+
pathOccupied = true;
|
|
806
|
+
existingIsSymlink = stat.isSymbolicLink();
|
|
807
|
+
existingIsDir = stat.isDirectory();
|
|
808
|
+
} catch {
|
|
809
|
+
// Not installed.
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (!pathOccupied) {
|
|
516
813
|
return {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
expectedTarget,
|
|
520
|
-
action: "created",
|
|
521
|
-
conflictCode: null,
|
|
522
|
-
existingTarget: null,
|
|
814
|
+
probe: { path: installedDir, expectedTarget: resolvedExpected, status: "not_installed", currentTarget: null },
|
|
815
|
+
action: "skipped",
|
|
523
816
|
};
|
|
524
817
|
}
|
|
525
818
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
819
|
+
if (existingIsSymlink) {
|
|
820
|
+
// Legacy symlink — migrate to directory copy.
|
|
821
|
+
let currentTarget: string | null = null;
|
|
822
|
+
try {
|
|
823
|
+
const rawTarget = readlinkSync(installedDir);
|
|
824
|
+
currentTarget = resolve(dirname(installedDir), rawTarget);
|
|
825
|
+
} catch {
|
|
826
|
+
// Broken symlink.
|
|
827
|
+
}
|
|
828
|
+
rmSync(installedDir, { force: true });
|
|
829
|
+
mkdirSync(dirname(installedDir), { recursive: true });
|
|
830
|
+
cpSync(resolvedExpected, installedDir, { recursive: true });
|
|
532
831
|
return {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
expectedTarget,
|
|
536
|
-
action: "refreshed",
|
|
537
|
-
conflictCode: null,
|
|
538
|
-
existingTarget: null,
|
|
832
|
+
probe: { path: installedDir, expectedTarget: resolvedExpected, status: "legacy", currentTarget },
|
|
833
|
+
action: "migrated",
|
|
539
834
|
};
|
|
540
835
|
}
|
|
541
836
|
|
|
542
|
-
|
|
543
|
-
|
|
837
|
+
if (existingIsDir) {
|
|
838
|
+
// Real directory — check if contents match.
|
|
839
|
+
try {
|
|
840
|
+
const sourceContents = readFileSync(sourcePath, "utf8");
|
|
841
|
+
const installedContents = readFileSync(installedPath, "utf8");
|
|
842
|
+
if (sourceContents === installedContents) {
|
|
843
|
+
return {
|
|
844
|
+
probe: { path: installedDir, expectedTarget: resolvedExpected, status: "ok", currentTarget: null },
|
|
845
|
+
action: "ok",
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
} catch {
|
|
849
|
+
// Can't read — treat as stale.
|
|
850
|
+
}
|
|
544
851
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
852
|
+
// Stale copy — refresh.
|
|
853
|
+
rmSync(installedDir, { recursive: true, force: true });
|
|
854
|
+
mkdirSync(dirname(installedDir), { recursive: true });
|
|
855
|
+
cpSync(resolvedExpected, installedDir, { recursive: true });
|
|
549
856
|
return {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
expectedTarget,
|
|
553
|
-
action: "refreshed",
|
|
554
|
-
conflictCode: null,
|
|
555
|
-
existingTarget,
|
|
857
|
+
probe: { path: installedDir, expectedTarget: resolvedExpected, status: "stale", currentTarget: null },
|
|
858
|
+
action: "repointed",
|
|
556
859
|
};
|
|
557
860
|
}
|
|
558
861
|
|
|
559
|
-
|
|
560
|
-
|
|
862
|
+
// Unexpected file type — remove and copy.
|
|
863
|
+
rmSync(installedDir, { force: true });
|
|
864
|
+
mkdirSync(dirname(installedDir), { recursive: true });
|
|
865
|
+
cpSync(resolvedExpected, installedDir, { recursive: true });
|
|
561
866
|
return {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
expectedTarget,
|
|
565
|
-
action: "refreshed",
|
|
566
|
-
conflictCode: null,
|
|
567
|
-
existingTarget,
|
|
867
|
+
probe: { path: installedDir, expectedTarget: resolvedExpected, status: "legacy", currentTarget: null },
|
|
868
|
+
action: "migrated",
|
|
568
869
|
};
|
|
569
870
|
}
|
|
570
871
|
|
|
872
|
+
type UpdateScope = "global" | "local";
|
|
873
|
+
|
|
874
|
+
interface UpdateEntry {
|
|
875
|
+
readonly scope: UpdateScope;
|
|
876
|
+
readonly label: string;
|
|
877
|
+
readonly repair: RepairResult;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function formatUpdateEntry(entry: UpdateEntry): string {
|
|
881
|
+
const { scope, label, repair } = entry;
|
|
882
|
+
const prefix = `${scope} ${label}`;
|
|
883
|
+
|
|
884
|
+
switch (repair.action) {
|
|
885
|
+
case "ok":
|
|
886
|
+
return ` ok ${prefix}`;
|
|
887
|
+
case "repointed":
|
|
888
|
+
return ` fix ${prefix} repointed`;
|
|
889
|
+
case "created":
|
|
890
|
+
return ` new ${prefix} created`;
|
|
891
|
+
case "migrated":
|
|
892
|
+
return ` fix ${prefix} migrated from legacy dir`;
|
|
893
|
+
case "skipped":
|
|
894
|
+
return ` -- ${prefix} not installed`;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
571
898
|
function runSkillsUpdate(context: CliContext): CliResult {
|
|
572
899
|
const parsed = parseArgs(context.args);
|
|
573
900
|
if (parsed.positional.length > 1) {
|
|
@@ -578,67 +905,89 @@ function runSkillsUpdate(context: CliContext): CliResult {
|
|
|
578
905
|
return invalidArgs("skills update takes no options.");
|
|
579
906
|
}
|
|
580
907
|
|
|
581
|
-
const
|
|
582
|
-
|
|
908
|
+
const sourceDir: string = resolveBundledSkillDirPath();
|
|
909
|
+
const sourcePath: string = resolveBundledSkillFilePath();
|
|
910
|
+
|
|
911
|
+
if (!existsSync(sourcePath)) {
|
|
583
912
|
return failResult({
|
|
584
913
|
command: "skills.update",
|
|
585
|
-
human:
|
|
586
|
-
data:
|
|
587
|
-
error:
|
|
588
|
-
installResult.error ?? {
|
|
589
|
-
code: "install_failed",
|
|
590
|
-
message: "Failed to refresh canonical skill",
|
|
591
|
-
},
|
|
914
|
+
human: `Bundled skill asset not found at ${sourcePath}`,
|
|
915
|
+
data: { code: "missing_asset", sourcePath },
|
|
916
|
+
error: { code: "missing_asset", message: "Bundled skill asset not found" },
|
|
592
917
|
});
|
|
593
918
|
}
|
|
594
919
|
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
);
|
|
920
|
+
const entries: UpdateEntry[] = [];
|
|
921
|
+
const home: string = homedir();
|
|
598
922
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
if (entry.action === "refreshed") {
|
|
613
|
-
return `- ${entry.editor}: refreshed (${entry.linkPath} -> ${entry.expectedTarget})`;
|
|
614
|
-
}
|
|
923
|
+
try {
|
|
924
|
+
// Global anchor: ~/.agents/skills/trekoon → bundled package dir.
|
|
925
|
+
const globalAnchorPath: string = join(home, ".agents", "skills", "trekoon");
|
|
926
|
+
entries.push({ scope: "global", label: "anchor", repair: probeAndRepairIfInstalled(globalAnchorPath, sourceDir) });
|
|
927
|
+
|
|
928
|
+
// Global editor links: <editor-global-skills>/trekoon → global anchor.
|
|
929
|
+
for (const editor of EDITOR_NAMES) {
|
|
930
|
+
const editorSkillsDir: string = resolveGlobalEditorSkillsDir(editor);
|
|
931
|
+
const linkPath: string = join(editorSkillsDir, "trekoon");
|
|
932
|
+
entries.push({ scope: "global", label: editor, repair: probeAndRepairIfInstalled(linkPath, globalAnchorPath) });
|
|
933
|
+
}
|
|
615
934
|
|
|
616
|
-
|
|
617
|
-
|
|
935
|
+
// Local anchor: <cwd>/.agents/skills/trekoon — directory copy of bundled source.
|
|
936
|
+
const localAnchorPath: string = join(context.cwd, ".agents", "skills", "trekoon");
|
|
937
|
+
entries.push({ scope: "local", label: "anchor", repair: probeAndRepairLocalAnchor(localAnchorPath, sourceDir) });
|
|
938
|
+
|
|
939
|
+
// Local editor links: <cwd>/.<editor>/skills/trekoon → local anchor.
|
|
940
|
+
for (const editor of EDITOR_NAMES) {
|
|
941
|
+
const editorConfigDir: string = resolveEditorConfigDir(context.cwd, editor);
|
|
942
|
+
const linkPath: string = resolveDefaultLinkPath(context.cwd, editor);
|
|
943
|
+
|
|
944
|
+
if (!existsSync(editorConfigDir)) {
|
|
945
|
+
const probe: ProbeResult = {
|
|
946
|
+
path: linkPath,
|
|
947
|
+
expectedTarget: resolve(localAnchorPath),
|
|
948
|
+
status: "not_installed",
|
|
949
|
+
currentTarget: null,
|
|
950
|
+
};
|
|
951
|
+
entries.push({ scope: "local", label: editor, repair: { probe, action: "skipped" } });
|
|
952
|
+
continue;
|
|
618
953
|
}
|
|
619
954
|
|
|
620
|
-
|
|
621
|
-
|
|
955
|
+
const probe = probeSymlink(linkPath, localAnchorPath);
|
|
956
|
+
if (probe.status === "not_installed") {
|
|
957
|
+
// Editor config dir exists but no link yet — create it.
|
|
958
|
+
const action = ensureSymlink(linkPath, localAnchorPath);
|
|
959
|
+
entries.push({ scope: "local", label: editor, repair: { probe, action: action === "already_ok" ? "ok" : "created" } });
|
|
960
|
+
} else {
|
|
961
|
+
const repair = repairSymlink(probe);
|
|
962
|
+
entries.push({ scope: "local", label: editor, repair });
|
|
622
963
|
}
|
|
964
|
+
}
|
|
965
|
+
} catch (error: unknown) {
|
|
966
|
+
const message = error instanceof Error ? error.message : "Unknown update failure";
|
|
967
|
+
return failResult({
|
|
968
|
+
command: "skills.update",
|
|
969
|
+
human: `Failed to update skill: ${message}`,
|
|
970
|
+
data: { code: "update_failed", message },
|
|
971
|
+
error: { code: "update_failed", message },
|
|
972
|
+
});
|
|
973
|
+
}
|
|
623
974
|
|
|
624
|
-
|
|
625
|
-
})
|
|
626
|
-
.join("\n");
|
|
975
|
+
const summary: string = entries.map(formatUpdateEntry).join("\n");
|
|
627
976
|
|
|
628
977
|
return okResult({
|
|
629
978
|
command: "skills.update",
|
|
630
|
-
human: [
|
|
631
|
-
"Updated Trekoon skill in canonical path.",
|
|
632
|
-
`Source: ${outcome.sourcePath}`,
|
|
633
|
-
`Installed file: ${outcome.installedPath}`,
|
|
634
|
-
"Editor links:",
|
|
635
|
-
linkSummary,
|
|
636
|
-
].join("\n"),
|
|
979
|
+
human: ["Trekoon skill update:", summary].join("\n"),
|
|
637
980
|
data: {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
981
|
+
sourceDir,
|
|
982
|
+
entries: entries.map((e) => ({
|
|
983
|
+
scope: e.scope,
|
|
984
|
+
label: e.label,
|
|
985
|
+
path: e.repair.probe.path,
|
|
986
|
+
expectedTarget: e.repair.probe.expectedTarget,
|
|
987
|
+
status: e.repair.probe.status,
|
|
988
|
+
action: e.repair.action,
|
|
989
|
+
currentTarget: e.repair.probe.currentTarget,
|
|
990
|
+
})),
|
|
642
991
|
},
|
|
643
992
|
});
|
|
644
993
|
}
|