trekoon 0.3.1 → 0.3.3

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.
@@ -1,4 +1,5 @@
1
- import { copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, 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",
@@ -79,6 +63,10 @@ function resolveBundledSkillFilePath(): string {
79
63
  return fileURLToPath(new URL("../../.agents/skills/trekoon/SKILL.md", import.meta.url));
80
64
  }
81
65
 
66
+ function resolveBundledSkillDirPath(): string {
67
+ return fileURLToPath(new URL("../../.agents/skills/trekoon", import.meta.url));
68
+ }
69
+
82
70
  function toAbsolutePath(cwd: string, pathValue: string): string {
83
71
  if (isAbsolute(pathValue)) {
84
72
  return pathValue;
@@ -216,7 +204,12 @@ function resolveDefaultLinkPath(cwd: string, editor: EditorName): string {
216
204
  }
217
205
 
218
206
  function toRelativeSymlinkTarget(linkPath: string, targetPath: string): string {
219
- const relativeTarget: string = relative(dirname(linkPath), resolve(targetPath));
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);
220
213
  return relativeTarget === "" ? "." : relativeTarget;
221
214
  }
222
215
 
@@ -232,8 +225,22 @@ function resolveEditorConfigDir(cwd: string, editor: EditorName): string {
232
225
  return join(cwd, ".pi");
233
226
  }
234
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
+
235
241
  function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; installedPath: string; installedDir: string } {
236
242
  const sourcePath: string = resolveBundledSkillFilePath();
243
+ const sourceDir: string = resolveBundledSkillDirPath();
237
244
  if (!existsSync(sourcePath)) {
238
245
  return failResult({
239
246
  command: "skills.install",
@@ -249,12 +256,62 @@ function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; i
249
256
  });
250
257
  }
251
258
 
252
- const installedPath: string = join(cwd, ".agents", "skills", "trekoon", "SKILL.md");
253
- const installedDir: string = dirname(installedPath);
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 create
266
+ // a circular symlink — the directory already contains the bundled files.
267
+ if (resolve(installedDir) === resolvedSourceDir) {
268
+ return { sourcePath, installedPath, installedDir };
269
+ }
254
270
 
255
271
  try {
256
- mkdirSync(installedDir, { recursive: true });
257
- copyFileSync(sourcePath, installedPath);
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 (existingIsSymlink) {
290
+ // Already a symlink — check whether it points to the correct target.
291
+ // Use realpathSync so OS-level symlinks (macOS /var → /private/var)
292
+ // do not cause false mismatches.
293
+ try {
294
+ const resolvedExisting: string = realpathSync(installedDir);
295
+ if (resolvedExisting === realpathSync(resolvedSourceDir)) {
296
+ // Symlink is already correct; idempotent success.
297
+ return { sourcePath, installedPath, installedDir };
298
+ }
299
+ } catch {
300
+ // Broken symlink — fall through to remove and recreate.
301
+ }
302
+ // Stale or broken symlink — remove and recreate.
303
+ rmSync(installedDir, { force: true });
304
+ } else if (existingIsDir) {
305
+ // Legacy directory install (file-copy era) — migrate by removing.
306
+ rmSync(installedDir, { recursive: true, force: true });
307
+ } else {
308
+ // Unexpected file — remove.
309
+ rmSync(installedDir, { force: true });
310
+ }
311
+ }
312
+
313
+ const symlinkTarget: string = toRelativeSymlinkTarget(installedDir, resolvedSourceDir);
314
+ symlinkSync(symlinkTarget, installedDir, "dir");
258
315
  } catch (error: unknown) {
259
316
  const message = error instanceof Error ? error.message : "Unknown skills install failure";
260
317
  return failResult({
@@ -284,62 +341,186 @@ function replaceOrCreateSymlink(
284
341
  repoRoot: string,
285
342
  allowOutsideRepo: boolean,
286
343
  ): CliResult | null {
344
+ // Ensure parent dirs exist before computing the relative target so that
345
+ // realpathNearestExistingAncestor resolves correctly (avoids macOS
346
+ // /var → /private/var mismatch when parent chain is missing).
347
+ mkdirSync(dirname(linkPath), { recursive: true });
348
+
349
+ const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
350
+ if (boundaryFailure) {
351
+ return boundaryFailure;
352
+ }
353
+
287
354
  const symlinkTarget: string = toRelativeSymlinkTarget(linkPath, targetPath);
288
355
 
289
- if (!existsSync(linkPath)) {
290
- mkdirSync(dirname(linkPath), { recursive: true });
291
- const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
292
- if (boundaryFailure) {
293
- return boundaryFailure;
294
- }
356
+ // Use lstatSync to detect broken symlinks that existsSync would miss
357
+ // (existsSync follows symlinks, so a broken symlink returns false).
358
+ let occupied = false;
359
+ let isSymlink = false;
360
+
361
+ try {
362
+ const stat = lstatSync(linkPath);
363
+ occupied = true;
364
+ isSymlink = stat.isSymbolicLink();
365
+ } catch {
366
+ // Nothing at the path — proceed to create.
367
+ }
368
+
369
+ if (!occupied) {
370
+ symlinkSync(symlinkTarget, linkPath, "dir");
371
+ return null;
372
+ }
373
+
374
+ if (!isSymlink) {
375
+ rmSync(linkPath, { recursive: true, force: true });
295
376
  symlinkSync(symlinkTarget, linkPath, "dir");
296
377
  return null;
297
378
  }
298
379
 
299
- const existing = lstatSync(linkPath);
300
- if (!existing.isSymbolicLink()) {
380
+ // Use realpathSync to resolve OS-level symlinks (macOS /var → /private/var)
381
+ // for a consistent comparison with the target.
382
+ const resolvedExisting: string = realpathSync(linkPath);
383
+ const resolvedExpected: string = realpathSync(resolve(targetPath));
384
+ if (resolvedExisting === resolvedExpected) {
385
+ // Already correct — avoid needless tear-down and recreation.
386
+ return null;
387
+ }
388
+
389
+ rmSync(linkPath, { force: true });
390
+ symlinkSync(symlinkTarget, linkPath, "dir");
391
+ return null;
392
+ }
393
+
394
+ interface GlobalEditorLinkEntry {
395
+ readonly editor: EditorName;
396
+ readonly linkPath: string;
397
+ readonly linkTarget: string;
398
+ readonly action: "created" | "refreshed" | "already_ok";
399
+ }
400
+
401
+ interface GlobalInstallOutcome {
402
+ readonly sourcePath: string;
403
+ readonly sourceDir: string;
404
+ readonly globalAnchorPath: string;
405
+ readonly globalAnchorAction: "created" | "refreshed" | "already_ok";
406
+ readonly editorLinks: readonly GlobalEditorLinkEntry[];
407
+ }
408
+
409
+ function ensureSymlink(
410
+ linkPath: string,
411
+ targetPath: string,
412
+ ): "created" | "refreshed" | "already_ok" {
413
+ const resolvedTarget: string = resolve(targetPath);
414
+
415
+ // Self-reference guard: source and target are the same path (dev mode).
416
+ if (resolve(linkPath) === resolvedTarget) {
417
+ return "already_ok";
418
+ }
419
+
420
+ // Ensure parent dirs exist before computing the relative target so that
421
+ // realpathNearestExistingAncestor resolves correctly.
422
+ mkdirSync(dirname(linkPath), { recursive: true });
423
+ const symlinkTarget: string = toRelativeSymlinkTarget(linkPath, resolvedTarget);
424
+
425
+ let existingIsSymlink = false;
426
+ let pathOccupied = false;
427
+
428
+ try {
429
+ const stat = lstatSync(linkPath);
430
+ pathOccupied = true;
431
+ existingIsSymlink = stat.isSymbolicLink();
432
+ } catch {
433
+ // Nothing at the path.
434
+ }
435
+
436
+ if (pathOccupied) {
437
+ if (existingIsSymlink) {
438
+ // Use realpathSync so OS-level symlinks (macOS /var → /private/var) do
439
+ // not cause false mismatches.
440
+ try {
441
+ const resolvedExisting: string = realpathSync(linkPath);
442
+ const resolvedExpectedReal: string = realpathSync(resolvedTarget);
443
+ if (resolvedExisting === resolvedExpectedReal) {
444
+ return "already_ok";
445
+ }
446
+ } catch {
447
+ // Broken symlink — fall through to remove and recreate.
448
+ }
449
+ rmSync(linkPath, { force: true });
450
+ } else {
451
+ rmSync(linkPath, { recursive: true, force: true });
452
+ }
453
+ symlinkSync(symlinkTarget, linkPath, "dir");
454
+ return "refreshed";
455
+ }
456
+
457
+ symlinkSync(symlinkTarget, linkPath, "dir");
458
+ return "created";
459
+ }
460
+
461
+ function runGlobalInstall(editors: readonly EditorName[]): CliResult {
462
+ const sourcePath: string = resolveBundledSkillFilePath();
463
+ const sourceDir: string = resolveBundledSkillDirPath();
464
+ if (!existsSync(sourcePath)) {
301
465
  return failResult({
302
466
  command: "skills.install",
303
- human: `Cannot create symlink: path exists and is not a link (${linkPath}).`,
304
- data: {
305
- code: "path_conflict",
306
- linkPath,
307
- targetPath,
308
- },
309
- error: {
310
- code: "path_conflict",
311
- message: "Symlink destination exists as a non-link path",
312
- },
467
+ human: `Bundled skill asset not found at ${sourcePath}`,
468
+ data: { code: "missing_asset", sourcePath },
469
+ error: { code: "missing_asset", message: "Bundled skill asset not found" },
313
470
  });
314
471
  }
315
472
 
316
- const existingRawTarget: string = readlinkSync(linkPath);
317
- const existingAbsoluteTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
318
- const expectedTarget: string = resolve(targetPath);
319
- if (existingAbsoluteTarget !== expectedTarget) {
320
- return failResult({
473
+ try {
474
+ // Step 1: Global anchor ~/.agents/skills/trekoon bundled package dir.
475
+ const globalAnchorPath: string = join(homedir(), ".agents", "skills", "trekoon");
476
+ const globalAnchorAction = ensureSymlink(globalAnchorPath, sourceDir);
477
+
478
+ // Step 2: Editor links <editor-global-skills>/trekoon → global anchor.
479
+ const editorLinks: GlobalEditorLinkEntry[] = editors.map((editor) => {
480
+ const editorSkillsDir: string = resolveGlobalEditorSkillsDir(editor);
481
+ const linkPath: string = join(editorSkillsDir, "trekoon");
482
+ const action = ensureSymlink(linkPath, globalAnchorPath);
483
+ return { editor, linkPath, linkTarget: globalAnchorPath, action };
484
+ });
485
+
486
+ const outcome: GlobalInstallOutcome = {
487
+ sourcePath,
488
+ sourceDir,
489
+ globalAnchorPath,
490
+ globalAnchorAction,
491
+ editorLinks,
492
+ };
493
+
494
+ const editorSummary: string = editorLinks
495
+ .map((entry) => `- ${entry.editor}: ${entry.action} (${entry.linkPath})`)
496
+ .join("\n");
497
+
498
+ return okResult({
321
499
  command: "skills.install",
322
- human: `Cannot replace existing link at ${linkPath}; it points to ${existingAbsoluteTarget}.`,
500
+ human: [
501
+ "Installed Trekoon skill globally.",
502
+ `Global anchor: ${globalAnchorPath} (${globalAnchorAction})`,
503
+ "Editor links:",
504
+ editorSummary,
505
+ ].join("\n"),
323
506
  data: {
324
- code: "path_conflict",
325
- linkPath,
326
- existingTarget: existingAbsoluteTarget,
327
- expectedTarget,
328
- },
329
- error: {
330
- code: "path_conflict",
331
- message: "Symlink destination points to a different target",
507
+ global: true,
508
+ sourcePath: outcome.sourcePath,
509
+ sourceDir: outcome.sourceDir,
510
+ globalAnchorPath: outcome.globalAnchorPath,
511
+ globalAnchorAction: outcome.globalAnchorAction,
512
+ editorLinks: outcome.editorLinks,
332
513
  },
333
514
  });
515
+ } catch (error: unknown) {
516
+ const message = error instanceof Error ? error.message : "Unknown global install failure";
517
+ return failResult({
518
+ command: "skills.install",
519
+ human: `Failed to install skill globally: ${message}`,
520
+ data: { code: "install_failed", message },
521
+ error: { code: "install_failed", message },
522
+ });
334
523
  }
335
-
336
- rmSync(linkPath, { force: true });
337
- const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
338
- if (boundaryFailure) {
339
- return boundaryFailure;
340
- }
341
- symlinkSync(symlinkTarget, linkPath, "dir");
342
- return null;
343
524
  }
344
525
 
345
526
  function runSkillsInstall(context: CliContext): CliResult {
@@ -355,11 +536,41 @@ function runSkillsInstall(context: CliContext): CliResult {
355
536
  return invalidArgs("Unexpected positional arguments for skills install.");
356
537
  }
357
538
 
539
+ const wantsGlobal: boolean = hasFlag(parsed.flags, "global", "g");
358
540
  const wantsLink: boolean = hasFlag(parsed.flags, "link");
359
541
  const allowOutsideRepo: boolean = hasFlag(parsed.flags, ALLOW_OUTSIDE_REPO_FLAG);
360
542
  const rawEditor: string | undefined = readOption(parsed.options, "editor");
361
543
  const rawTo: string | undefined = readOption(parsed.options, "to");
362
544
 
545
+ // Validate editor early (shared by both modes).
546
+ if (rawEditor !== undefined && !EDITOR_NAMES.includes(rawEditor as EditorName)) {
547
+ return invalidInput("skills.install", "Invalid --editor value. Use: opencode, claude, pi", {
548
+ editor: rawEditor,
549
+ allowedEditors: EDITOR_NAMES,
550
+ });
551
+ }
552
+
553
+ // Global mode validation.
554
+ if (wantsGlobal) {
555
+ if (rawTo !== undefined) {
556
+ return invalidInput("skills.install", "--to is not supported with --global.", { to: rawTo });
557
+ }
558
+
559
+ if (wantsLink) {
560
+ return invalidInput("skills.install", "--link is not supported with --global.", {});
561
+ }
562
+
563
+ if (allowOutsideRepo) {
564
+ return invalidInput("skills.install", `--${ALLOW_OUTSIDE_REPO_FLAG} is not supported with --global.`, {});
565
+ }
566
+
567
+ const editors: readonly EditorName[] = rawEditor
568
+ ? [rawEditor as EditorName]
569
+ : EDITOR_NAMES;
570
+ return runGlobalInstall(editors);
571
+ }
572
+
573
+ // Local mode validation.
363
574
  if (allowOutsideRepo && !wantsLink) {
364
575
  return invalidInput("skills.install", `--${ALLOW_OUTSIDE_REPO_FLAG} requires --link.`, {
365
576
  allowOutsideRepo,
@@ -382,13 +593,6 @@ function runSkillsInstall(context: CliContext): CliResult {
382
593
  return invalidArgs("skills install --link requires --editor opencode|claude|pi.");
383
594
  }
384
595
 
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
596
  const editor: EditorName | undefined = rawEditor as EditorName | undefined;
393
597
 
394
598
  const installResult = installCanonicalSkill(context.cwd);
@@ -489,76 +693,121 @@ function runSkillsInstall(context: CliContext): CliResult {
489
693
  });
490
694
  }
491
695
 
492
- function updateEditorLink(
493
- cwd: string,
494
- editor: EditorName,
495
- installedDir: string,
496
- ): UpdateLinkEntry {
497
- const linkPath: string = resolveDefaultLinkPath(cwd, editor);
498
- const expectedTarget: string = resolve(installedDir);
499
- const symlinkTarget: string = toRelativeSymlinkTarget(linkPath, expectedTarget);
500
- const editorConfigDir: string = resolveEditorConfigDir(cwd, editor);
501
-
502
- if (!existsSync(editorConfigDir)) {
503
- return {
504
- editor,
505
- linkPath,
506
- expectedTarget,
507
- action: "skipped_no_editor_dir",
508
- conflictCode: null,
509
- existingTarget: null,
510
- };
511
- }
696
+ type ProbeStatus = "ok" | "stale" | "broken" | "legacy" | "not_installed";
512
697
 
513
- if (!existsSync(linkPath)) {
514
- mkdirSync(dirname(linkPath), { recursive: true });
515
- symlinkSync(symlinkTarget, linkPath, "dir");
516
- return {
517
- editor,
518
- linkPath,
519
- expectedTarget,
520
- action: "created",
521
- conflictCode: null,
522
- existingTarget: null,
523
- };
698
+ interface ProbeResult {
699
+ readonly path: string;
700
+ readonly expectedTarget: string;
701
+ readonly status: ProbeStatus;
702
+ readonly currentTarget: string | null;
703
+ }
704
+
705
+ function probeSymlink(linkPath: string, expectedTarget: string): ProbeResult {
706
+ const resolvedExpected: string = resolve(expectedTarget);
707
+
708
+ // Self-reference guard: source and install are the same path (dev mode).
709
+ if (resolve(linkPath) === resolvedExpected) {
710
+ return { path: linkPath, expectedTarget: resolvedExpected, status: "ok", currentTarget: resolvedExpected };
524
711
  }
525
712
 
526
- const entry = lstatSync(linkPath);
527
- if (!entry.isSymbolicLink()) {
528
- return {
529
- editor,
530
- linkPath,
531
- expectedTarget,
532
- action: "skipped_conflict",
533
- conflictCode: "non_link",
534
- existingTarget: null,
535
- };
713
+ try {
714
+ const stat = lstatSync(linkPath);
715
+
716
+ if (stat.isSymbolicLink()) {
717
+ const rawTarget: string = readlinkSync(linkPath);
718
+ const resolvedCurrent: string = resolve(dirname(linkPath), rawTarget);
719
+
720
+ // Check if symlink target actually exists on disk.
721
+ const targetExists: boolean = existsSync(linkPath);
722
+ if (!targetExists) {
723
+ return { path: linkPath, expectedTarget: resolvedExpected, status: "broken", currentTarget: resolvedCurrent };
724
+ }
725
+
726
+ if (resolvedCurrent === resolvedExpected) {
727
+ return { path: linkPath, expectedTarget: resolvedExpected, status: "ok", currentTarget: resolvedCurrent };
728
+ }
729
+
730
+ return { path: linkPath, expectedTarget: resolvedExpected, status: "stale", currentTarget: resolvedCurrent };
731
+ }
732
+
733
+ if (stat.isDirectory()) {
734
+ return { path: linkPath, expectedTarget: resolvedExpected, status: "legacy", currentTarget: null };
735
+ }
736
+
737
+ // Unexpected file type — treat as legacy.
738
+ return { path: linkPath, expectedTarget: resolvedExpected, status: "legacy", currentTarget: null };
739
+ } catch {
740
+ return { path: linkPath, expectedTarget: resolvedExpected, status: "not_installed", currentTarget: null };
536
741
  }
742
+ }
537
743
 
538
- const existingRawTarget: string = readlinkSync(linkPath);
539
- const existingTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
744
+ type RepairAction = "ok" | "repointed" | "created" | "migrated" | "skipped";
540
745
 
541
- if (existingTarget !== expectedTarget) {
542
- return {
543
- editor,
544
- linkPath,
545
- expectedTarget,
546
- action: "skipped_conflict",
547
- conflictCode: "wrong_target",
548
- existingTarget,
549
- };
746
+ interface RepairResult {
747
+ readonly probe: ProbeResult;
748
+ readonly action: RepairAction;
749
+ }
750
+
751
+ function repairSymlink(probe: ProbeResult): RepairResult {
752
+ switch (probe.status) {
753
+ case "ok":
754
+ return { probe, action: "ok" };
755
+
756
+ case "stale":
757
+ case "broken": {
758
+ rmSync(probe.path, { force: true });
759
+ mkdirSync(dirname(probe.path), { recursive: true });
760
+ const target: string = toRelativeSymlinkTarget(probe.path, probe.expectedTarget);
761
+ symlinkSync(target, probe.path, "dir");
762
+ return { probe, action: "repointed" };
763
+ }
764
+
765
+ case "legacy": {
766
+ rmSync(probe.path, { recursive: true, force: true });
767
+ mkdirSync(dirname(probe.path), { recursive: true });
768
+ const target: string = toRelativeSymlinkTarget(probe.path, probe.expectedTarget);
769
+ symlinkSync(target, probe.path, "dir");
770
+ return { probe, action: "migrated" };
771
+ }
772
+
773
+ case "not_installed":
774
+ return { probe, action: "skipped" };
550
775
  }
776
+ }
551
777
 
552
- rmSync(linkPath, { force: true });
553
- symlinkSync(symlinkTarget, linkPath, "dir");
554
- return {
555
- editor,
556
- linkPath,
557
- expectedTarget,
558
- action: "refreshed",
559
- conflictCode: null,
560
- existingTarget,
561
- };
778
+ /** Probe and repair, but skip (don't create) when not already installed. */
779
+ function probeAndRepairIfInstalled(linkPath: string, expectedTarget: string): RepairResult {
780
+ const probe = probeSymlink(linkPath, expectedTarget);
781
+ if (probe.status === "not_installed") {
782
+ return { probe, action: "skipped" };
783
+ }
784
+ return repairSymlink(probe);
785
+ }
786
+
787
+ type UpdateScope = "global" | "local";
788
+
789
+ interface UpdateEntry {
790
+ readonly scope: UpdateScope;
791
+ readonly label: string;
792
+ readonly repair: RepairResult;
793
+ }
794
+
795
+ function formatUpdateEntry(entry: UpdateEntry): string {
796
+ const { scope, label, repair } = entry;
797
+ const prefix = `${scope} ${label}`;
798
+
799
+ switch (repair.action) {
800
+ case "ok":
801
+ return ` ok ${prefix}`;
802
+ case "repointed":
803
+ return ` fix ${prefix} repointed`;
804
+ case "created":
805
+ return ` new ${prefix} created`;
806
+ case "migrated":
807
+ return ` fix ${prefix} migrated from legacy dir`;
808
+ case "skipped":
809
+ return ` -- ${prefix} not installed`;
810
+ }
562
811
  }
563
812
 
564
813
  function runSkillsUpdate(context: CliContext): CliResult {
@@ -571,67 +820,89 @@ function runSkillsUpdate(context: CliContext): CliResult {
571
820
  return invalidArgs("skills update takes no options.");
572
821
  }
573
822
 
574
- const installResult = installCanonicalSkill(context.cwd);
575
- if ("ok" in installResult) {
823
+ const sourceDir: string = resolveBundledSkillDirPath();
824
+ const sourcePath: string = resolveBundledSkillFilePath();
825
+
826
+ if (!existsSync(sourcePath)) {
576
827
  return failResult({
577
828
  command: "skills.update",
578
- human: installResult.human,
579
- data: installResult.data,
580
- error:
581
- installResult.error ?? {
582
- code: "install_failed",
583
- message: "Failed to refresh canonical skill",
584
- },
829
+ human: `Bundled skill asset not found at ${sourcePath}`,
830
+ data: { code: "missing_asset", sourcePath },
831
+ error: { code: "missing_asset", message: "Bundled skill asset not found" },
585
832
  });
586
833
  }
587
834
 
588
- const links: readonly UpdateLinkEntry[] = EDITOR_NAMES.map((editor) =>
589
- updateEditorLink(context.cwd, editor, installResult.installedDir),
590
- );
835
+ const entries: UpdateEntry[] = [];
836
+ const home: string = homedir();
591
837
 
592
- const outcome: UpdateOutcome = {
593
- sourcePath: installResult.sourcePath,
594
- installedPath: installResult.installedPath,
595
- installedDir: installResult.installedDir,
596
- links,
597
- };
598
-
599
- const linkSummary: string = outcome.links
600
- .map((entry) => {
601
- if (entry.action === "created") {
602
- return `- ${entry.editor}: created (${entry.linkPath} -> ${entry.expectedTarget})`;
603
- }
604
-
605
- if (entry.action === "refreshed") {
606
- return `- ${entry.editor}: refreshed (${entry.linkPath} -> ${entry.expectedTarget})`;
607
- }
838
+ try {
839
+ // Global anchor: ~/.agents/skills/trekoon → bundled package dir.
840
+ const globalAnchorPath: string = join(home, ".agents", "skills", "trekoon");
841
+ entries.push({ scope: "global", label: "anchor", repair: probeAndRepairIfInstalled(globalAnchorPath, sourceDir) });
842
+
843
+ // Global editor links: <editor-global-skills>/trekoon → global anchor.
844
+ for (const editor of EDITOR_NAMES) {
845
+ const editorSkillsDir: string = resolveGlobalEditorSkillsDir(editor);
846
+ const linkPath: string = join(editorSkillsDir, "trekoon");
847
+ entries.push({ scope: "global", label: editor, repair: probeAndRepairIfInstalled(linkPath, globalAnchorPath) });
848
+ }
608
849
 
609
- if (entry.action === "skipped_no_editor_dir") {
610
- return `- ${entry.editor}: skipped (no editor config dir)`;
850
+ // Local anchor: <cwd>/.agents/skills/trekoon → bundled package dir.
851
+ const localAnchorPath: string = join(context.cwd, ".agents", "skills", "trekoon");
852
+ entries.push({ scope: "local", label: "anchor", repair: probeAndRepairIfInstalled(localAnchorPath, sourceDir) });
853
+
854
+ // Local editor links: <cwd>/.<editor>/skills/trekoon → local anchor.
855
+ for (const editor of EDITOR_NAMES) {
856
+ const editorConfigDir: string = resolveEditorConfigDir(context.cwd, editor);
857
+ const linkPath: string = resolveDefaultLinkPath(context.cwd, editor);
858
+
859
+ if (!existsSync(editorConfigDir)) {
860
+ const probe: ProbeResult = {
861
+ path: linkPath,
862
+ expectedTarget: resolve(localAnchorPath),
863
+ status: "not_installed",
864
+ currentTarget: null,
865
+ };
866
+ entries.push({ scope: "local", label: editor, repair: { probe, action: "skipped" } });
867
+ continue;
611
868
  }
612
869
 
613
- if (entry.conflictCode === "non_link") {
614
- return `- ${entry.editor}: conflict (non-link path at ${entry.linkPath})`;
870
+ const probe = probeSymlink(linkPath, localAnchorPath);
871
+ if (probe.status === "not_installed") {
872
+ // Editor config dir exists but no link yet — create it.
873
+ const action = ensureSymlink(linkPath, localAnchorPath);
874
+ entries.push({ scope: "local", label: editor, repair: { probe, action: action === "already_ok" ? "ok" : "created" } });
875
+ } else {
876
+ const repair = repairSymlink(probe);
877
+ entries.push({ scope: "local", label: editor, repair });
615
878
  }
879
+ }
880
+ } catch (error: unknown) {
881
+ const message = error instanceof Error ? error.message : "Unknown update failure";
882
+ return failResult({
883
+ command: "skills.update",
884
+ human: `Failed to update skill: ${message}`,
885
+ data: { code: "update_failed", message },
886
+ error: { code: "update_failed", message },
887
+ });
888
+ }
616
889
 
617
- return `- ${entry.editor}: conflict (points to ${entry.existingTarget})`;
618
- })
619
- .join("\n");
890
+ const summary: string = entries.map(formatUpdateEntry).join("\n");
620
891
 
621
892
  return okResult({
622
893
  command: "skills.update",
623
- human: [
624
- "Updated Trekoon skill in canonical path.",
625
- `Source: ${outcome.sourcePath}`,
626
- `Installed file: ${outcome.installedPath}`,
627
- "Editor links:",
628
- linkSummary,
629
- ].join("\n"),
894
+ human: ["Trekoon skill update:", summary].join("\n"),
630
895
  data: {
631
- sourcePath: outcome.sourcePath,
632
- installedPath: outcome.installedPath,
633
- installedDir: outcome.installedDir,
634
- links: outcome.links,
896
+ sourceDir,
897
+ entries: entries.map((e) => ({
898
+ scope: e.scope,
899
+ label: e.label,
900
+ path: e.repair.probe.path,
901
+ expectedTarget: e.repair.probe.expectedTarget,
902
+ status: e.repair.probe.status,
903
+ action: e.repair.action,
904
+ currentTarget: e.repair.probe.currentTarget,
905
+ })),
635
906
  },
636
907
  });
637
908
  }