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.
@@ -1,4 +1,5 @@
1
- import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
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
- 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);
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 installedPath: string = join(cwd, ".agents", "skills", "trekoon", "SKILL.md");
258
- 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 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(installedDir, { recursive: true });
262
- copyFileSync(sourcePath, installedPath);
263
- // Copy reference guides if they exist in the bundled source.
264
- const sourceRefDir: string = join(sourceDir, "reference");
265
- if (existsSync(sourceRefDir)) {
266
- const installedRefDir: string = join(installedDir, "reference");
267
- cpSync(sourceRefDir, installedRefDir, { recursive: true });
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
- if (!existsSync(linkPath)) {
301
- mkdirSync(dirname(linkPath), { recursive: true });
302
- const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
303
- if (boundaryFailure) {
304
- return boundaryFailure;
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
- const existing = lstatSync(linkPath);
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
- const existingRawTarget: string = readlinkSync(linkPath);
323
- const existingAbsoluteTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
324
- const expectedTarget: string = resolve(targetPath);
325
- if (existingAbsoluteTarget !== expectedTarget) {
326
- // Replace symlink pointing to a different target.
327
- rmSync(linkPath, { force: true });
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
- 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)) {
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
- editor,
505
- linkPath,
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
- if (!existsSync(linkPath)) {
514
- mkdirSync(dirname(linkPath), { recursive: true });
515
- symlinkSync(symlinkTarget, linkPath, "dir");
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
- editor,
518
- linkPath,
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
- const entry = lstatSync(linkPath);
527
- if (!entry.isSymbolicLink()) {
528
- // Replace stale directory or file with symlink to the canonical location.
529
- rmSync(linkPath, { recursive: true, force: true });
530
- mkdirSync(dirname(linkPath), { recursive: true });
531
- symlinkSync(symlinkTarget, linkPath, "dir");
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
- editor,
534
- linkPath,
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
- const existingRawTarget: string = readlinkSync(linkPath);
543
- const existingTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
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
- if (existingTarget !== expectedTarget) {
546
- // Replace symlink pointing to a different target.
547
- rmSync(linkPath, { force: true });
548
- symlinkSync(symlinkTarget, linkPath, "dir");
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
- editor,
551
- linkPath,
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
- rmSync(linkPath, { force: true });
560
- symlinkSync(symlinkTarget, linkPath, "dir");
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
- editor,
563
- linkPath,
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 installResult = installCanonicalSkill(context.cwd);
582
- if ("ok" in installResult) {
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: installResult.human,
586
- data: installResult.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 links: readonly UpdateLinkEntry[] = EDITOR_NAMES.map((editor) =>
596
- updateEditorLink(context.cwd, editor, installResult.installedDir),
597
- );
920
+ const entries: UpdateEntry[] = [];
921
+ const home: string = homedir();
598
922
 
599
- const outcome: UpdateOutcome = {
600
- sourcePath: installResult.sourcePath,
601
- installedPath: installResult.installedPath,
602
- installedDir: installResult.installedDir,
603
- links,
604
- };
605
-
606
- const linkSummary: string = outcome.links
607
- .map((entry) => {
608
- if (entry.action === "created") {
609
- return `- ${entry.editor}: created (${entry.linkPath} -> ${entry.expectedTarget})`;
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
- if (entry.action === "skipped_no_editor_dir") {
617
- return `- ${entry.editor}: skipped (no editor config dir)`;
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
- if (entry.conflictCode === "non_link") {
621
- return `- ${entry.editor}: conflict (non-link path at ${entry.linkPath})`;
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
- return `- ${entry.editor}: conflict (points to ${entry.existingTarget})`;
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
- sourcePath: outcome.sourcePath,
639
- installedPath: outcome.installedPath,
640
- installedDir: outcome.installedDir,
641
- links: outcome.links,
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
  }