trekoon 0.3.2 → 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,6 +1,6 @@
1
1
  ---
2
2
  name: trekoon
3
- description: Use Trekoon to create issues/tasks, plan backlog and sprints, create epics, update status, track progress, and manage dependencies/sync across repository workflows.
3
+ description: Use Trekoon only for agentic development planning and execution, limited to creating epics with tasks and subtasks, planning backlog/sprints, updating status, tracking progress, and managing dependencies/sync across repository workflows with agents. Only invoke Trekoon when explicitly requested by the user.
4
4
  ---
5
5
 
6
6
  # Trekoon Skill
@@ -61,6 +61,8 @@ This skill ships with bundled reference guides for planning and execution. Read
61
61
  them when the task calls for it — they extend this command reference with
62
62
  methodology and orchestration patterns.
63
63
 
64
+ > **Path note:** Script paths below are relative to this skill's folder (where this SKILL.md lives), not the current project root. Resolve them from this skill folder when invoking Bash.
65
+
64
66
  | When | Read | What it covers |
65
67
  |---|---|---|
66
68
  | User asks to plan, design, or architect a feature | `reference/planning.md` | Decomposition into epic/task/subtask DAGs, writing standard, file scopes, owner assignment, dependency modeling, validation |
@@ -70,9 +72,7 @@ methodology and orchestration patterns.
70
72
  **Typical flow:**
71
73
  1. Read `reference/planning.md` and create the epic with tasks, subtasks, deps,
72
74
  owners.
73
- 2. Read `reference/execution.md` (or `reference/execution-with-team.md` for Agent
74
- Teams), run `session --epic`, build lane groups, dispatch agents, use
75
- `task done` responses to orchestrate waves.
75
+ 2. Read `reference/execution.md` for the regular subagents flow OR `reference/execution-with-team.md` for the Agent Teams flow. Then run `session --epic`, build lane groups, dispatch agents, and use `task done` responses to orchestrate waves.
76
76
  3. This file (SKILL.md) provides the command reference and status machine rules
77
77
  that both planning and execution rely on.
78
78
 
package/README.md CHANGED
@@ -49,13 +49,13 @@ These are the commands most people need to recognize quickly:
49
49
  | --- | --- |
50
50
  | Initialize a repo | `trekoon init` |
51
51
  | Install/open/update the local board | `trekoon board open`, `trekoon board update` |
52
- | Learn the CLI | `trekoon help [command]`, `trekoon quickstart` |
52
+ | Learn the CLI | `trekoon [command] -h`, `trekoon [command] [subcommand] -h`, `trekoon quickstart` |
53
53
  | Plan work | `trekoon epic ...`, `trekoon task ...`, `trekoon subtask ...`, `trekoon dep ...` |
54
54
  | Track epic progress | `trekoon epic progress <id>` |
55
55
  | Start an execution session | `trekoon session`, `trekoon session --epic <id>` |
56
56
  | Get next-action suggestions | `trekoon suggest`, `trekoon suggest --epic <id>` |
57
57
  | Keep worktrees in sync | `trekoon sync ...` |
58
- | Install or refresh the AI skill | `trekoon skills install`, `trekoon skills update` |
58
+ | Install or refresh the AI skill | `trekoon skills install`, `trekoon skills install -g`, `trekoon skills update` |
59
59
  | Maintenance | `trekoon events prune ...`, `trekoon migrate ...`, `trekoon wipe --yes` |
60
60
 
61
61
  Machine output modes:
@@ -141,6 +141,14 @@ covers the full plan-to-completion workflow:
141
141
  - **Agent Teams** — TeamCreate/SendMessage pattern for parallel Claude Code
142
142
  instances (requires `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=true`)
143
143
 
144
+ Install it per-repo or globally:
145
+
146
+ ```bash
147
+ trekoon skills install # repo-local (default)
148
+ trekoon skills install -g # global (~/.agents/skills/trekoon)
149
+ trekoon update # refresh all installed links
150
+ ```
151
+
144
152
  The skill accepts arguments for quick entity-scoped actions:
145
153
 
146
154
  ```
@@ -229,6 +229,25 @@ Valid transitions:
229
229
  | `blocked` | `in_progress`, `todo` |
230
230
  | `done` | `in_progress` |
231
231
 
232
+ ## Install the AI skill
233
+
234
+ Install the Trekoon skill so AI agents can plan and execute against your tracker:
235
+
236
+ ```bash
237
+ trekoon skills install # repo-local (default)
238
+ trekoon skills install -g # global (~/.agents/skills/trekoon)
239
+ trekoon skills install --link --editor claude # repo-local + editor symlink
240
+ ```
241
+
242
+ After upgrading Trekoon, refresh all installed symlinks:
243
+
244
+ ```bash
245
+ trekoon update # alias for: trekoon skills update
246
+ ```
247
+
248
+ For detailed installation, editor linking, and example prompts, read
249
+ [AI agents and the Trekoon skill](ai-agents.md).
250
+
232
251
  ## What to read next
233
252
 
234
253
  - [Command reference](commands.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "AI-first local issue tracker CLI.",
5
5
  "keywords": [
6
6
  "ai",
@@ -45,7 +45,7 @@
45
45
  "lint": "bunx tsc --noEmit"
46
46
  },
47
47
  "devDependencies": {
48
- "@types/bun": "^1.3.9",
48
+ "@types/bun": "^1.3.11",
49
49
  "typescript": "^5.9.3"
50
50
  },
51
51
  "dependencies": {
@@ -143,7 +143,7 @@ export function normalizeSnapshot(rawSnapshot) {
143
143
  id: epicId,
144
144
  title: String(epic.title ?? "Untitled epic"),
145
145
  description: String(epic.description ?? "").replace(/\\n/g, "\n"),
146
- status: String(epic.status ?? "todo"),
146
+ status: normalizeStatus(String(epic.status ?? "todo")),
147
147
  createdAt: Number(epic.createdAt ?? Date.now()),
148
148
  updatedAt: Number(epic.updatedAt ?? epic.createdAt ?? Date.now()),
149
149
  taskIds: epicTasks.map((task) => task.id),
@@ -42,6 +42,7 @@ export interface ParsedCompactFields {
42
42
  }
43
43
 
44
44
  const LONG_PREFIX = "--";
45
+ const SHORT_FLAG_PATTERN = /^-([A-Za-z])$/u;
45
46
 
46
47
  export function parseArgs(args: readonly string[]): ParsedArgs {
47
48
  const positional: string[] = [];
@@ -57,6 +58,15 @@ export function parseArgs(args: readonly string[]): ParsedArgs {
57
58
  continue;
58
59
  }
59
60
 
61
+ // Short flag: single dash + single letter (e.g. -g).
62
+ const shortMatch = SHORT_FLAG_PATTERN.exec(token);
63
+ if (shortMatch) {
64
+ const key: string = shortMatch[1]!;
65
+ flags.add(key);
66
+ providedOptions.push(key);
67
+ continue;
68
+ }
69
+
60
70
  if (!token.startsWith(LONG_PREFIX)) {
61
71
  positional.push(token);
62
72
  continue;
@@ -30,7 +30,8 @@ const ROOT_HELP = [
30
30
  " migrate Migration status and rollback commands",
31
31
  " session One-call agent orientation (diagnostics + sync + next task)",
32
32
  " sync Cross-branch sync commands",
33
- " skills Project-local skill install/update/link",
33
+ " skills Skill install/update/link (local and global)",
34
+ " update Alias for skills update",
34
35
  ].join("\n");
35
36
 
36
37
  const INIT_HELP = [
@@ -385,31 +386,46 @@ const SESSION_HELP = [
385
386
  const SKILLS_HELP = [
386
387
  "Usage:",
387
388
  " trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]",
389
+ " trekoon skills install -g|--global [--editor opencode|claude|pi]",
388
390
  " trekoon skills update",
389
391
  "",
390
392
  "Purpose:",
391
- " Install or refresh the project-local Trekoon skill asset.",
393
+ " Install or refresh the Trekoon skill asset locally or globally.",
392
394
  "",
393
- "Install behavior:",
394
- " - Always installs canonical file to:",
395
- " <cwd>/.agents/skills/trekoon/SKILL.md",
395
+ "Local install behavior (default):",
396
+ " - Creates a directory symlink at <cwd>/.agents/skills/trekoon pointing to",
397
+ " the bundled package source, so the skill always matches the installed version.",
396
398
  " - Use --link to also create an editor symlink named 'trekoon'.",
397
399
  " - --editor is required when --link is used (opencode|claude|pi).",
398
400
  " - --to overrides the symlink root for --link only.",
399
401
  " - Without --allow-outside-repo, link targets must resolve inside repo.",
400
402
  " - --allow-outside-repo requires --link and disables that boundary check.",
401
403
  "",
404
+ "Global install behavior (-g|--global):",
405
+ " - Creates a global anchor symlink at ~/.agents/skills/trekoon pointing to",
406
+ " the bundled package source.",
407
+ " - Creates per-editor symlinks under each editor's global skills directory",
408
+ " (~/.claude/skills/, ~/.config/opencode/skills/, ~/.pi/skills/).",
409
+ " - Use --editor to install for a single editor only.",
410
+ "",
402
411
  "Update behavior:",
403
- " - Refreshes canonical SKILL file in the install path above.",
404
- " - Auto-creates or refreshes editor symlinks when editor config dir exists.",
405
- " - Skips editors with no config dir or conflicting paths.",
412
+ " - Probes and repairs both global and local anchor/editor symlinks.",
413
+ " - Reports per-entry status: ok, repointed, created, migrated, skipped.",
414
+ " - Skips entries that are not installed; creates local editor links when",
415
+ " the editor config dir exists.",
416
+ "",
417
+ "Alias:",
418
+ " trekoon update → trekoon skills update",
406
419
  "",
407
420
  "Examples:",
408
421
  " trekoon skills install",
422
+ " trekoon skills install -g",
423
+ " trekoon skills install --global --editor claude",
409
424
  " trekoon skills install --link --editor opencode",
410
425
  " trekoon skills install --link --editor claude --to .claude/skills",
411
426
  " trekoon skills install --link --editor pi --to ../shared/skills --allow-outside-repo",
412
427
  " trekoon skills update",
428
+ " trekoon update",
413
429
  ].join("\n");
414
430
 
415
431
  const COMMAND_HELP: Record<string, string> = {
@@ -426,6 +442,7 @@ const COMMAND_HELP: Record<string, string> = {
426
442
  migrate: MIGRATE_HELP,
427
443
  sync: SYNC_HELP,
428
444
  skills: SKILLS_HELP,
445
+ update: "Usage: trekoon update [--json|--toon]\n\nAlias for: trekoon skills update\n\nProbes and repairs all installed global and local skill symlinks.",
429
446
  help: "Usage: trekoon help [command] [--json|--toon]",
430
447
  };
431
448
 
@@ -1,4 +1,5 @@
1
- import { copyFileSync, cpSync, 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",
@@ -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,62 @@ 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 create
266
+ // a circular symlink — 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 (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
+ }
268
311
  }
312
+
313
+ const symlinkTarget: string = toRelativeSymlinkTarget(installedDir, resolvedSourceDir);
314
+ symlinkSync(symlinkTarget, installedDir, "dir");
269
315
  } catch (error: unknown) {
270
316
  const message = error instanceof Error ? error.message : "Unknown skills install failure";
271
317
  return failResult({
@@ -295,53 +341,188 @@ function replaceOrCreateSymlink(
295
341
  repoRoot: string,
296
342
  allowOutsideRepo: boolean,
297
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
+
298
354
  const symlinkTarget: string = toRelativeSymlinkTarget(linkPath, targetPath);
299
355
 
300
- if (!existsSync(linkPath)) {
301
- mkdirSync(dirname(linkPath), { recursive: true });
302
- const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
303
- if (boundaryFailure) {
304
- return boundaryFailure;
305
- }
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) {
306
370
  symlinkSync(symlinkTarget, linkPath, "dir");
307
371
  return null;
308
372
  }
309
373
 
310
- const existing = lstatSync(linkPath);
311
- if (!existing.isSymbolicLink()) {
312
- // Replace stale directory or file with symlink to the canonical location.
374
+ if (!isSymlink) {
313
375
  rmSync(linkPath, { recursive: true, force: true });
314
- const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
315
- if (boundaryFailure) {
316
- return boundaryFailure;
317
- }
318
376
  symlinkSync(symlinkTarget, linkPath, "dir");
319
377
  return null;
320
378
  }
321
379
 
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");
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.
333
386
  return null;
334
387
  }
335
388
 
336
389
  rmSync(linkPath, { force: true });
337
- const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
338
- if (boundaryFailure) {
339
- return boundaryFailure;
340
- }
341
390
  symlinkSync(symlinkTarget, linkPath, "dir");
342
391
  return null;
343
392
  }
344
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)) {
465
+ return failResult({
466
+ command: "skills.install",
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" },
470
+ });
471
+ }
472
+
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({
499
+ command: "skills.install",
500
+ human: [
501
+ "Installed Trekoon skill globally.",
502
+ `Global anchor: ${globalAnchorPath} (${globalAnchorAction})`,
503
+ "Editor links:",
504
+ editorSummary,
505
+ ].join("\n"),
506
+ data: {
507
+ global: true,
508
+ sourcePath: outcome.sourcePath,
509
+ sourceDir: outcome.sourceDir,
510
+ globalAnchorPath: outcome.globalAnchorPath,
511
+ globalAnchorAction: outcome.globalAnchorAction,
512
+ editorLinks: outcome.editorLinks,
513
+ },
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
+ });
523
+ }
524
+ }
525
+
345
526
  function runSkillsInstall(context: CliContext): CliResult {
346
527
  const parsed = parseArgs(context.args);
347
528
  const missingValue = readMissingOptionValue(parsed.missingOptionValues, "editor", "to");
@@ -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,83 +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
- // 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");
532
- return {
533
- editor,
534
- linkPath,
535
- expectedTarget,
536
- action: "refreshed",
537
- conflictCode: null,
538
- existingTarget: null,
539
- };
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 };
540
741
  }
742
+ }
541
743
 
542
- const existingRawTarget: string = readlinkSync(linkPath);
543
- const existingTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
744
+ type RepairAction = "ok" | "repointed" | "created" | "migrated" | "skipped";
544
745
 
545
- if (existingTarget !== expectedTarget) {
546
- // Replace symlink pointing to a different target.
547
- rmSync(linkPath, { force: true });
548
- symlinkSync(symlinkTarget, linkPath, "dir");
549
- return {
550
- editor,
551
- linkPath,
552
- expectedTarget,
553
- action: "refreshed",
554
- conflictCode: null,
555
- existingTarget,
556
- };
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" };
557
775
  }
776
+ }
558
777
 
559
- rmSync(linkPath, { force: true });
560
- symlinkSync(symlinkTarget, linkPath, "dir");
561
- return {
562
- editor,
563
- linkPath,
564
- expectedTarget,
565
- action: "refreshed",
566
- conflictCode: null,
567
- existingTarget,
568
- };
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
+ }
569
811
  }
570
812
 
571
813
  function runSkillsUpdate(context: CliContext): CliResult {
@@ -578,67 +820,89 @@ function runSkillsUpdate(context: CliContext): CliResult {
578
820
  return invalidArgs("skills update takes no options.");
579
821
  }
580
822
 
581
- const installResult = installCanonicalSkill(context.cwd);
582
- if ("ok" in installResult) {
823
+ const sourceDir: string = resolveBundledSkillDirPath();
824
+ const sourcePath: string = resolveBundledSkillFilePath();
825
+
826
+ if (!existsSync(sourcePath)) {
583
827
  return failResult({
584
828
  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
- },
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" },
592
832
  });
593
833
  }
594
834
 
595
- const links: readonly UpdateLinkEntry[] = EDITOR_NAMES.map((editor) =>
596
- updateEditorLink(context.cwd, editor, installResult.installedDir),
597
- );
598
-
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
- }
835
+ const entries: UpdateEntry[] = [];
836
+ const home: string = homedir();
611
837
 
612
- if (entry.action === "refreshed") {
613
- return `- ${entry.editor}: refreshed (${entry.linkPath} -> ${entry.expectedTarget})`;
614
- }
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
+ }
615
849
 
616
- if (entry.action === "skipped_no_editor_dir") {
617
- 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;
618
868
  }
619
869
 
620
- if (entry.conflictCode === "non_link") {
621
- 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 });
622
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
+ }
623
889
 
624
- return `- ${entry.editor}: conflict (points to ${entry.existingTarget})`;
625
- })
626
- .join("\n");
890
+ const summary: string = entries.map(formatUpdateEntry).join("\n");
627
891
 
628
892
  return okResult({
629
893
  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"),
894
+ human: ["Trekoon skill update:", summary].join("\n"),
637
895
  data: {
638
- sourcePath: outcome.sourcePath,
639
- installedPath: outcome.installedPath,
640
- installedDir: outcome.installedDir,
641
- 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
+ })),
642
906
  },
643
907
  });
644
908
  }
@@ -34,6 +34,7 @@ const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
34
34
  "sync",
35
35
  "skills",
36
36
  "suggest",
37
+ "update",
37
38
  "wipe",
38
39
  ];
39
40
 
@@ -398,6 +399,10 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
398
399
  case "suggest":
399
400
  result = await runSuggest(context);
400
401
  break;
402
+ case "update":
403
+ // Route `trekoon update` to `trekoon skills update` internally.
404
+ result = await runSkills({ ...context, args: ["update", ...context.args] });
405
+ break;
401
406
  default:
402
407
  result = failResult({
403
408
  command: "shell",