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