trekoon 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,7 +52,8 @@ npm i -g trekoon
52
52
  - `trekoon subtask <create|list|update|delete>`
53
53
  - `trekoon dep <add|remove|list>`
54
54
  - `trekoon sync <status|pull|resolve>`
55
- - `trekoon skills install [--link --editor opencode|claude] [--to <path>]`
55
+ - `trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]`
56
+ - `trekoon skills update`
56
57
  - `trekoon wipe --yes`
57
58
 
58
59
  Global output modes:
@@ -160,6 +161,15 @@ trekoon sync pull --from main
160
161
  trekoon sync resolve <conflict-id> --use ours
161
162
  ```
162
163
 
164
+ `sync pull` machine output includes diagnostics counters and hints so agents can
165
+ react deterministically:
166
+
167
+ - `diagnostics.malformedPayloadEvents`
168
+ - `diagnostics.applyRejectedEvents`
169
+ - `diagnostics.quarantinedEvents`
170
+ - `diagnostics.conflictEvents`
171
+ - `diagnostics.errorHints`
172
+
163
173
  ### 6) Install project-local Trekoon skill for agents
164
174
 
165
175
  `trekoon skills install` always writes the bundled skill file into the current
@@ -173,17 +183,29 @@ You can also create a project-local editor link:
173
183
  trekoon skills install
174
184
  trekoon skills install --link --editor opencode
175
185
  trekoon skills install --link --editor claude
186
+ trekoon skills install --link --editor pi
176
187
  trekoon skills install --link --editor opencode --to ./.custom-editor/skills
188
+ trekoon skills update
177
189
  ```
178
190
 
179
191
  Path behavior:
180
192
 
181
193
  - Default opencode link path: `.opencode/skills/trekoon`
182
194
  - Default claude link path: `.claude/skills/trekoon`
195
+ - Default pi link path: `.pi/skills/trekoon`
183
196
  - `--to <path>` overrides the editor root for link creation only.
184
197
  - `--to` does **not** move or copy `SKILL.md` to that path.
198
+ - By default, link targets must resolve inside the repository root.
199
+ - Use `--allow-outside-repo` only for intentional external links.
200
+ - When override is used, install prints a warning and includes confirmation
201
+ fields in machine output.
185
202
  - Re-running install is idempotent: it refreshes `SKILL.md` and reuses/replaces
186
203
  the same symlink target.
204
+ - `trekoon skills update` is idempotent: it refreshes canonical
205
+ `.agents/skills/trekoon/SKILL.md` and reports default link states for
206
+ opencode/claude/pi as `missing`, `valid`, or `conflict`.
207
+ - Update does not mutate default links; conflicts are reported with actionable
208
+ path context.
187
209
  - If the link destination exists as a non-link path, install fails with an
188
210
  actionable conflict error.
189
211
 
@@ -215,7 +237,7 @@ Trekoon does not mutate global editor config directories.
215
237
  - [ ] `trekoon sync status` shows no unresolved conflicts
216
238
  - [ ] done tasks/subtasks are marked completed
217
239
  - [ ] dependency graph has no stale blockers
218
- - [ ] final AI check: `trekoon --json epic show <epic-id>`
240
+ - [ ] final AI check: `trekoon --toon epic show <epic-id>`
219
241
 
220
242
  ## Implementation principles
221
243
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "AI-first local issue tracker CLI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,7 +1,8 @@
1
1
  import { parseArgs } from "./arg-parser";
2
2
 
3
- import { DomainError } from "../domain/types";
3
+ import { MutationService } from "../domain/mutation-service";
4
4
  import { TrackerDomain } from "../domain/tracker-domain";
5
+ import { DomainError } from "../domain/types";
5
6
  import { failResult, okResult } from "../io/output";
6
7
  import { type CliContext, type CliResult } from "../runtime/command-types";
7
8
  import { openTrekoonDatabase } from "../storage/database";
@@ -42,10 +43,11 @@ export async function runDep(context: CliContext): Promise<CliResult> {
42
43
  const sourceId: string = parsed.positional[1] ?? "";
43
44
  const dependsOnId: string = parsed.positional[2] ?? "";
44
45
  const domain = new TrackerDomain(database.db);
46
+ const mutations = new MutationService(database.db, context.cwd);
45
47
 
46
48
  switch (subcommand) {
47
49
  case "add": {
48
- const dependency = domain.addDependency(sourceId, dependsOnId);
50
+ const dependency = mutations.addDependency(sourceId, dependsOnId);
49
51
 
50
52
  return okResult({
51
53
  command: "dep.add",
@@ -54,7 +56,7 @@ export async function runDep(context: CliContext): Promise<CliResult> {
54
56
  });
55
57
  }
56
58
  case "remove": {
57
- const removed: number = domain.removeDependency(sourceId, dependsOnId);
59
+ const removed: number = mutations.removeDependency(sourceId, dependsOnId);
58
60
 
59
61
  return okResult({
60
62
  command: "dep.remove",
@@ -1,7 +1,8 @@
1
1
  import { hasFlag, parseArgs, parseStrictPositiveInt, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
2
2
 
3
- import { DomainError, type EpicRecord } from "../domain/types";
3
+ import { MutationService } from "../domain/mutation-service";
4
4
  import { TrackerDomain } from "../domain/tracker-domain";
5
+ import { DomainError, type EpicRecord } from "../domain/types";
5
6
  import { formatHumanTable } from "../io/human-table";
6
7
  import { failResult, okResult } from "../io/output";
7
8
  import { type CliContext, type CliResult } from "../runtime/command-types";
@@ -235,6 +236,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
235
236
  const parsed = parseArgs(context.args);
236
237
  const subcommand: string | undefined = parsed.positional[0];
237
238
  const domain = new TrackerDomain(database.db);
239
+ const mutations = new MutationService(database.db, context.cwd);
238
240
 
239
241
  switch (subcommand) {
240
242
  case "create": {
@@ -248,7 +250,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
248
250
  const title: string | undefined = readOption(parsed.options, "title", "t");
249
251
  const description: string | undefined = readOption(parsed.options, "description", "d");
250
252
  const status: string | undefined = readOption(parsed.options, "status", "s");
251
- const epic = domain.createEpic({
253
+ const epic = mutations.createEpic({
252
254
  title: title ?? "",
253
255
  description: description ?? "",
254
256
  status,
@@ -467,7 +469,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
467
469
 
468
470
  const targets = updateAll ? [...domain.listEpics()] : ids.map((id) => domain.getEpicOrThrow(id));
469
471
  const epics = targets.map((target) =>
470
- domain.updateEpic(target.id, {
472
+ mutations.updateEpic(target.id, {
471
473
  status,
472
474
  description: append === undefined ? undefined : appendLine(target.description, append),
473
475
  }),
@@ -500,7 +502,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
500
502
  append === undefined
501
503
  ? description
502
504
  : appendLine(domain.getEpicOrThrow(epicId).description, append);
503
- const epic = domain.updateEpic(epicId, { title, description: nextDescription, status });
505
+ const epic = mutations.updateEpic(epicId, { title, description: nextDescription, status });
504
506
 
505
507
  return okResult({
506
508
  command: "epic.update",
@@ -510,7 +512,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
510
512
  }
511
513
  case "delete": {
512
514
  const epicId: string = parsed.positional[1] ?? "";
513
- domain.deleteEpic(epicId);
515
+ mutations.deleteEpic(epicId);
514
516
 
515
517
  return okResult({
516
518
  command: "epic.delete",
@@ -42,7 +42,7 @@ const COMMAND_HELP: Record<string, string> = {
42
42
  migrate: "Usage: trekoon migrate <status|rollback> [--to-version <n>]",
43
43
  sync: "Usage: trekoon sync <subcommand> [options]",
44
44
  skills:
45
- "Usage: trekoon skills install [--link --editor opencode|claude] [--to <path>] (--to sets symlink root for --link only; install path always <cwd>/.agents/skills/trekoon/SKILL.md)",
45
+ "Usage: trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo] | trekoon skills update (--to sets symlink root for --link only; install path always <cwd>/.agents/skills/trekoon/SKILL.md; links must resolve inside repo unless --allow-outside-repo is set; update refreshes canonical SKILL and reports default link states)",
46
46
  help: "Usage: trekoon help [command] [--json|--toon]",
47
47
  };
48
48
 
@@ -1,5 +1,5 @@
1
- import { copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
2
- import { dirname, isAbsolute, join, resolve } from "node:path";
1
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
5
  import { hasFlag, parseArgs, readMissingOptionValue, readOption } from "./arg-parser";
@@ -7,10 +7,16 @@ import { hasFlag, parseArgs, readMissingOptionValue, readOption } from "./arg-pa
7
7
  import { failResult, okResult } from "../io/output";
8
8
  import { type CliContext, type CliResult } from "../runtime/command-types";
9
9
 
10
- const SKILLS_USAGE = "Usage: trekoon skills install [--link --editor opencode|claude] [--to <path>]";
11
- const EDITOR_NAMES = ["opencode", "claude"] as const;
10
+ const SKILLS_USAGE = [
11
+ "Usage:",
12
+ " trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]",
13
+ " trekoon skills update",
14
+ ].join("\n");
15
+ const EDITOR_NAMES = ["opencode", "claude", "pi"] as const;
16
+ const ALLOW_OUTSIDE_REPO_FLAG = "allow-outside-repo";
12
17
 
13
18
  type EditorName = (typeof EDITOR_NAMES)[number];
19
+ type LinkStateStatus = "missing" | "valid" | "conflict";
14
20
 
15
21
  interface InstallOutcome {
16
22
  readonly sourcePath: string;
@@ -18,6 +24,28 @@ interface InstallOutcome {
18
24
  readonly installedDir: string;
19
25
  readonly linkPath: string | null;
20
26
  readonly linkTarget: string | null;
27
+ readonly outsideRepoLink: boolean;
28
+ }
29
+
30
+ interface LinkTargetValidation {
31
+ readonly linkRoot: string;
32
+ readonly outsideRepoLink: boolean;
33
+ }
34
+
35
+ interface LinkState {
36
+ readonly editor: EditorName;
37
+ readonly linkPath: string;
38
+ readonly expectedTarget: string;
39
+ readonly status: LinkStateStatus;
40
+ readonly existingTarget: string | null;
41
+ readonly conflictCode: "non_link" | "wrong_target" | null;
42
+ }
43
+
44
+ interface UpdateOutcome {
45
+ readonly sourcePath: string;
46
+ readonly installedPath: string;
47
+ readonly installedDir: string;
48
+ readonly links: readonly LinkState[];
21
49
  }
22
50
 
23
51
  function invalidArgs(message: string): CliResult {
@@ -68,12 +96,183 @@ function resolveLinkRoot(cwd: string, editor: EditorName, toOverride: string | u
68
96
  return join(cwd, ".opencode", "skills");
69
97
  }
70
98
 
71
- return join(cwd, ".claude", "skills");
99
+ if (editor === "claude") {
100
+ return join(cwd, ".claude", "skills");
101
+ }
102
+
103
+ return join(cwd, ".pi", "skills");
104
+ }
105
+
106
+ function isPathInsideRoot(pathValue: string, rootPath: string): boolean {
107
+ const normalizedPath: string = resolve(pathValue);
108
+ const normalizedRoot: string = resolve(rootPath);
109
+ const relativePath: string = relative(normalizedRoot, normalizedPath);
110
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
111
+ }
112
+
113
+ function realpathNearestExistingAncestor(pathValue: string): string {
114
+ let cursor: string = resolve(pathValue);
115
+
116
+ while (!existsSync(cursor)) {
117
+ const parent: string = dirname(cursor);
118
+ if (parent === cursor) {
119
+ break;
120
+ }
121
+
122
+ cursor = parent;
123
+ }
124
+
125
+ return realpathSync(cursor);
126
+ }
127
+
128
+ function validateLinkRoot(
129
+ cwd: string,
130
+ editor: EditorName,
131
+ toOverride: string | undefined,
132
+ allowOutsideRepo: boolean,
133
+ ): CliResult | LinkTargetValidation {
134
+ const linkRoot: string = resolveLinkRoot(cwd, editor, toOverride);
135
+ const repoRoot: string = realpathSync(cwd);
136
+ const effectiveTargetRoot: string = realpathNearestExistingAncestor(linkRoot);
137
+ const insideRepo: boolean = isPathInsideRoot(effectiveTargetRoot, repoRoot);
138
+
139
+ if (insideRepo) {
140
+ return {
141
+ linkRoot,
142
+ outsideRepoLink: false,
143
+ };
144
+ }
145
+
146
+ if (!allowOutsideRepo) {
147
+ return failResult({
148
+ command: "skills.install",
149
+ human: [
150
+ "Refusing to link skills outside repository root by default.",
151
+ `Requested link root: ${linkRoot}`,
152
+ `Resolved existing target ancestor: ${effectiveTargetRoot}`,
153
+ `Repository root: ${repoRoot}`,
154
+ `If intentional, re-run with --${ALLOW_OUTSIDE_REPO_FLAG} to override.`,
155
+ ].join("\n"),
156
+ data: {
157
+ code: "outside_repo_target",
158
+ linkRoot,
159
+ effectiveTargetRoot,
160
+ repoRoot,
161
+ overrideFlag: `--${ALLOW_OUTSIDE_REPO_FLAG}`,
162
+ },
163
+ error: {
164
+ code: "outside_repo_target",
165
+ message: "Link target is outside repository root",
166
+ },
167
+ });
168
+ }
169
+
170
+ return {
171
+ linkRoot,
172
+ outsideRepoLink: true,
173
+ };
174
+ }
175
+
176
+ function revalidateLinkParentBoundary(
177
+ repoRoot: string,
178
+ linkPath: string,
179
+ allowOutsideRepo: boolean,
180
+ ): CliResult | null {
181
+ if (allowOutsideRepo) {
182
+ return null;
183
+ }
184
+
185
+ const linkParentRealpath: string = realpathSync(dirname(linkPath));
186
+ const insideRepo: boolean = isPathInsideRoot(linkParentRealpath, repoRoot);
187
+ if (insideRepo) {
188
+ return null;
189
+ }
190
+
191
+ return failResult({
192
+ command: "skills.install",
193
+ human: [
194
+ "Refusing to link skills outside repository root by default.",
195
+ `Requested link root: ${dirname(linkPath)}`,
196
+ `Resolved existing target ancestor: ${linkParentRealpath}`,
197
+ `Repository root: ${repoRoot}`,
198
+ `If intentional, re-run with --${ALLOW_OUTSIDE_REPO_FLAG} to override.`,
199
+ ].join("\n"),
200
+ data: {
201
+ code: "outside_repo_target",
202
+ linkRoot: dirname(linkPath),
203
+ effectiveTargetRoot: linkParentRealpath,
204
+ repoRoot,
205
+ overrideFlag: `--${ALLOW_OUTSIDE_REPO_FLAG}`,
206
+ },
207
+ error: {
208
+ code: "outside_repo_target",
209
+ message: "Link target is outside repository root",
210
+ },
211
+ });
212
+ }
213
+
214
+ function resolveDefaultLinkPath(cwd: string, editor: EditorName): string {
215
+ return join(resolveLinkRoot(cwd, editor, undefined), "trekoon");
216
+ }
217
+
218
+ function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; installedPath: string; installedDir: string } {
219
+ const sourcePath: string = resolveBundledSkillFilePath();
220
+ if (!existsSync(sourcePath)) {
221
+ return failResult({
222
+ command: "skills.install",
223
+ human: `Bundled skill asset not found at ${sourcePath}`,
224
+ data: {
225
+ code: "missing_asset",
226
+ sourcePath,
227
+ },
228
+ error: {
229
+ code: "missing_asset",
230
+ message: "Bundled skill asset not found",
231
+ },
232
+ });
233
+ }
234
+
235
+ const installedPath: string = join(cwd, ".agents", "skills", "trekoon", "SKILL.md");
236
+ const installedDir: string = dirname(installedPath);
237
+
238
+ try {
239
+ mkdirSync(installedDir, { recursive: true });
240
+ copyFileSync(sourcePath, installedPath);
241
+ } catch (error: unknown) {
242
+ const message = error instanceof Error ? error.message : "Unknown skills install failure";
243
+ return failResult({
244
+ command: "skills.install",
245
+ human: `Failed to install skill: ${message}`,
246
+ data: {
247
+ code: "install_failed",
248
+ message,
249
+ },
250
+ error: {
251
+ code: "install_failed",
252
+ message,
253
+ },
254
+ });
255
+ }
256
+
257
+ return {
258
+ sourcePath,
259
+ installedPath,
260
+ installedDir,
261
+ };
72
262
  }
73
263
 
74
- function replaceOrCreateSymlink(linkPath: string, targetPath: string): CliResult | null {
264
+ function replaceOrCreateSymlink(
265
+ linkPath: string,
266
+ targetPath: string,
267
+ repoRoot: string,
268
+ allowOutsideRepo: boolean,
269
+ ): CliResult | null {
75
270
  if (!existsSync(linkPath)) {
76
271
  mkdirSync(dirname(linkPath), { recursive: true });
272
+ const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
273
+ if (boundaryFailure) {
274
+ return boundaryFailure;
275
+ }
77
276
  symlinkSync(targetPath, linkPath, "dir");
78
277
  return null;
79
278
  }
@@ -116,10 +315,64 @@ function replaceOrCreateSymlink(linkPath: string, targetPath: string): CliResult
116
315
  }
117
316
 
118
317
  rmSync(linkPath, { force: true });
318
+ const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
319
+ if (boundaryFailure) {
320
+ return boundaryFailure;
321
+ }
119
322
  symlinkSync(targetPath, linkPath, "dir");
120
323
  return null;
121
324
  }
122
325
 
326
+ function inspectDefaultLink(cwd: string, editor: EditorName, installedDir: string): LinkState {
327
+ const linkPath: string = resolveDefaultLinkPath(cwd, editor);
328
+ const expectedTarget: string = resolve(installedDir);
329
+
330
+ if (!existsSync(linkPath)) {
331
+ return {
332
+ editor,
333
+ linkPath,
334
+ expectedTarget,
335
+ status: "missing",
336
+ existingTarget: null,
337
+ conflictCode: null,
338
+ };
339
+ }
340
+
341
+ const entry = lstatSync(linkPath);
342
+ if (!entry.isSymbolicLink()) {
343
+ return {
344
+ editor,
345
+ linkPath,
346
+ expectedTarget,
347
+ status: "conflict",
348
+ existingTarget: null,
349
+ conflictCode: "non_link",
350
+ };
351
+ }
352
+
353
+ const existingRawTarget: string = readlinkSync(linkPath);
354
+ const existingTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
355
+ if (existingTarget !== expectedTarget) {
356
+ return {
357
+ editor,
358
+ linkPath,
359
+ expectedTarget,
360
+ status: "conflict",
361
+ existingTarget,
362
+ conflictCode: "wrong_target",
363
+ };
364
+ }
365
+
366
+ return {
367
+ editor,
368
+ linkPath,
369
+ expectedTarget,
370
+ status: "valid",
371
+ existingTarget,
372
+ conflictCode: null,
373
+ };
374
+ }
375
+
123
376
  function runSkillsInstall(context: CliContext): CliResult {
124
377
  const parsed = parseArgs(context.args);
125
378
  const missingValue = readMissingOptionValue(parsed.missingOptionValues, "editor", "to");
@@ -134,9 +387,16 @@ function runSkillsInstall(context: CliContext): CliResult {
134
387
  }
135
388
 
136
389
  const wantsLink: boolean = hasFlag(parsed.flags, "link");
390
+ const allowOutsideRepo: boolean = hasFlag(parsed.flags, ALLOW_OUTSIDE_REPO_FLAG);
137
391
  const rawEditor: string | undefined = readOption(parsed.options, "editor");
138
392
  const rawTo: string | undefined = readOption(parsed.options, "to");
139
393
 
394
+ if (allowOutsideRepo && !wantsLink) {
395
+ return invalidInput("skills.install", `--${ALLOW_OUTSIDE_REPO_FLAG} requires --link.`, {
396
+ allowOutsideRepo,
397
+ });
398
+ }
399
+
140
400
  if (!wantsLink && rawEditor !== undefined) {
141
401
  return invalidInput("skills.install", "--editor requires --link.", {
142
402
  editor: rawEditor,
@@ -150,11 +410,11 @@ function runSkillsInstall(context: CliContext): CliResult {
150
410
  }
151
411
 
152
412
  if (wantsLink && rawEditor === undefined) {
153
- return invalidArgs("skills install --link requires --editor opencode|claude.");
413
+ return invalidArgs("skills install --link requires --editor opencode|claude|pi.");
154
414
  }
155
415
 
156
416
  if (rawEditor !== undefined && !EDITOR_NAMES.includes(rawEditor as EditorName)) {
157
- return invalidInput("skills.install", "Invalid --editor value. Use: opencode, claude", {
417
+ return invalidInput("skills.install", "Invalid --editor value. Use: opencode, claude, pi", {
158
418
  editor: rawEditor,
159
419
  allowedEditors: EDITOR_NAMES,
160
420
  });
@@ -162,51 +422,54 @@ function runSkillsInstall(context: CliContext): CliResult {
162
422
 
163
423
  const editor: EditorName | undefined = rawEditor as EditorName | undefined;
164
424
 
165
- const sourcePath: string = resolveBundledSkillFilePath();
166
- if (!existsSync(sourcePath)) {
167
- return failResult({
168
- command: "skills.install",
169
- human: `Bundled skill asset not found at ${sourcePath}`,
170
- data: {
171
- code: "missing_asset",
172
- sourcePath,
173
- },
174
- error: {
175
- code: "missing_asset",
176
- message: "Bundled skill asset not found",
177
- },
178
- });
425
+ const installResult = installCanonicalSkill(context.cwd);
426
+ if ("ok" in installResult) {
427
+ return installResult;
179
428
  }
180
429
 
181
- const installPath = join(context.cwd, ".agents", "skills", "trekoon", "SKILL.md");
182
- const installDir = dirname(installPath);
183
-
184
430
  let outcome: InstallOutcome;
185
431
 
186
432
  try {
187
- mkdirSync(installDir, { recursive: true });
188
- copyFileSync(sourcePath, installPath);
189
-
190
433
  let linkPath: string | null = null;
191
434
  let linkTarget: string | null = null;
192
435
 
193
436
  if (wantsLink && editor !== undefined) {
194
- const linkRoot: string = resolveLinkRoot(context.cwd, editor, rawTo);
437
+ const validation = validateLinkRoot(context.cwd, editor, rawTo, allowOutsideRepo);
438
+ if ("ok" in validation) {
439
+ return validation;
440
+ }
441
+
442
+ const linkRoot: string = validation.linkRoot;
195
443
  linkPath = join(linkRoot, "trekoon");
196
- linkTarget = installDir;
197
- const linkFailure = replaceOrCreateSymlink(linkPath, linkTarget);
444
+ linkTarget = installResult.installedDir;
445
+ const linkFailure = replaceOrCreateSymlink(
446
+ linkPath,
447
+ linkTarget,
448
+ realpathSync(context.cwd),
449
+ allowOutsideRepo,
450
+ );
198
451
  if (linkFailure) {
199
452
  return linkFailure;
200
453
  }
201
- }
202
454
 
203
- outcome = {
204
- sourcePath,
205
- installedPath: installPath,
206
- installedDir: installDir,
207
- linkPath,
208
- linkTarget,
209
- };
455
+ outcome = {
456
+ sourcePath: installResult.sourcePath,
457
+ installedPath: installResult.installedPath,
458
+ installedDir: installResult.installedDir,
459
+ linkPath,
460
+ linkTarget,
461
+ outsideRepoLink: validation.outsideRepoLink,
462
+ };
463
+ } else {
464
+ outcome = {
465
+ sourcePath: installResult.sourcePath,
466
+ installedPath: installResult.installedPath,
467
+ installedDir: installResult.installedDir,
468
+ linkPath,
469
+ linkTarget,
470
+ outsideRepoLink: false,
471
+ };
472
+ }
210
473
  } catch (error: unknown) {
211
474
  const message = error instanceof Error ? error.message : "Unknown skills install failure";
212
475
  return failResult({
@@ -227,6 +490,11 @@ function runSkillsInstall(context: CliContext): CliResult {
227
490
  command: "skills.install",
228
491
  human: outcome.linkPath
229
492
  ? [
493
+ ...(outcome.outsideRepoLink
494
+ ? [
495
+ `WARNING: Linking outside repository root because --${ALLOW_OUTSIDE_REPO_FLAG} was provided.`,
496
+ ]
497
+ : []),
230
498
  "Installed Trekoon skill and linked editor path.",
231
499
  `Source: ${outcome.sourcePath}`,
232
500
  `Installed file: ${outcome.installedPath}`,
@@ -245,6 +513,80 @@ function runSkillsInstall(context: CliContext): CliResult {
245
513
  linked: outcome.linkPath !== null,
246
514
  linkPath: outcome.linkPath,
247
515
  linkTarget: outcome.linkTarget,
516
+ outsideRepoLink: outcome.outsideRepoLink,
517
+ outsideRepoOverrideUsed: outcome.outsideRepoLink,
518
+ outsideRepoOverrideFlag: outcome.outsideRepoLink ? `--${ALLOW_OUTSIDE_REPO_FLAG}` : null,
519
+ },
520
+ });
521
+ }
522
+
523
+ function runSkillsUpdate(context: CliContext): CliResult {
524
+ const parsed = parseArgs(context.args);
525
+ if (parsed.positional.length > 1) {
526
+ return invalidArgs("Unexpected positional arguments for skills update.");
527
+ }
528
+
529
+ if (parsed.flags.size > 0 || parsed.options.size > 0) {
530
+ return invalidArgs("skills update takes no options.");
531
+ }
532
+
533
+ const installResult = installCanonicalSkill(context.cwd);
534
+ if ("ok" in installResult) {
535
+ return failResult({
536
+ command: "skills.update",
537
+ human: installResult.human,
538
+ data: installResult.data,
539
+ error:
540
+ installResult.error ?? {
541
+ code: "install_failed",
542
+ message: "Failed to refresh canonical skill",
543
+ },
544
+ });
545
+ }
546
+
547
+ const links: readonly LinkState[] = EDITOR_NAMES.map((editor) =>
548
+ inspectDefaultLink(context.cwd, editor, installResult.installedDir),
549
+ );
550
+
551
+ const outcome: UpdateOutcome = {
552
+ sourcePath: installResult.sourcePath,
553
+ installedPath: installResult.installedPath,
554
+ installedDir: installResult.installedDir,
555
+ links,
556
+ };
557
+
558
+ const linkSummary: string = outcome.links
559
+ .map((entry) => {
560
+ if (entry.status === "missing") {
561
+ return `- ${entry.editor}: missing (${entry.linkPath})`;
562
+ }
563
+
564
+ if (entry.status === "valid") {
565
+ return `- ${entry.editor}: valid (${entry.linkPath} -> ${entry.expectedTarget})`;
566
+ }
567
+
568
+ if (entry.conflictCode === "non_link") {
569
+ return `- ${entry.editor}: conflict (non-link path at ${entry.linkPath})`;
570
+ }
571
+
572
+ return `- ${entry.editor}: conflict (points to ${entry.existingTarget})`;
573
+ })
574
+ .join("\n");
575
+
576
+ return okResult({
577
+ command: "skills.update",
578
+ human: [
579
+ "Updated Trekoon skill in canonical path.",
580
+ `Source: ${outcome.sourcePath}`,
581
+ `Installed file: ${outcome.installedPath}`,
582
+ "Default link states:",
583
+ linkSummary,
584
+ ].join("\n"),
585
+ data: {
586
+ sourcePath: outcome.sourcePath,
587
+ installedPath: outcome.installedPath,
588
+ installedDir: outcome.installedDir,
589
+ links: outcome.links,
248
590
  },
249
591
  });
250
592
  }
@@ -259,6 +601,8 @@ export async function runSkills(context: CliContext): Promise<CliResult> {
259
601
  switch (subcommand) {
260
602
  case "install":
261
603
  return runSkillsInstall(context);
604
+ case "update":
605
+ return runSkillsUpdate(context);
262
606
  default:
263
607
  return invalidArgs(`Unknown skills subcommand '${subcommand}'.`);
264
608
  }