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 +24 -2
- package/package.json +1 -1
- package/src/commands/dep.ts +5 -3
- package/src/commands/epic.ts +7 -5
- package/src/commands/help.ts +1 -1
- package/src/commands/skills.ts +383 -39
- package/src/commands/subtask.ts +7 -5
- package/src/commands/sync.ts +102 -3
- package/src/commands/task.ts +7 -5
- package/src/domain/mutation-operations.ts +27 -0
- package/src/domain/mutation-service.ts +169 -0
- package/src/storage/migrations.ts +48 -1
- package/src/sync/service.ts +417 -73
- package/src/sync/types.ts +44 -0
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 --
|
|
240
|
+
- [ ] final AI check: `trekoon --toon epic show <epic-id>`
|
|
219
241
|
|
|
220
242
|
## Implementation principles
|
|
221
243
|
|
package/package.json
CHANGED
package/src/commands/dep.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { parseArgs } from "./arg-parser";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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 =
|
|
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 =
|
|
59
|
+
const removed: number = mutations.removeDependency(sourceId, dependsOnId);
|
|
58
60
|
|
|
59
61
|
return okResult({
|
|
60
62
|
command: "dep.remove",
|
package/src/commands/epic.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { hasFlag, parseArgs, parseStrictPositiveInt, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
515
|
+
mutations.deleteEpic(epicId);
|
|
514
516
|
|
|
515
517
|
return okResult({
|
|
516
518
|
command: "epic.delete",
|
package/src/commands/help.ts
CHANGED
|
@@ -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
|
|
package/src/commands/skills.ts
CHANGED
|
@@ -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 =
|
|
11
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
166
|
-
if (
|
|
167
|
-
return
|
|
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
|
|
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 =
|
|
197
|
-
const linkFailure = replaceOrCreateSymlink(
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
}
|