trekoon 0.1.6 → 0.1.8

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,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";
@@ -9,10 +9,11 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
9
9
 
10
10
  const SKILLS_USAGE = [
11
11
  "Usage:",
12
- " trekoon skills install [--link --editor opencode|claude|pi] [--to <path>]",
12
+ " trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]",
13
13
  " trekoon skills update",
14
14
  ].join("\n");
15
15
  const EDITOR_NAMES = ["opencode", "claude", "pi"] as const;
16
+ const ALLOW_OUTSIDE_REPO_FLAG = "allow-outside-repo";
16
17
 
17
18
  type EditorName = (typeof EDITOR_NAMES)[number];
18
19
  type LinkStateStatus = "missing" | "valid" | "conflict";
@@ -23,6 +24,12 @@ interface InstallOutcome {
23
24
  readonly installedDir: string;
24
25
  readonly linkPath: string | null;
25
26
  readonly linkTarget: string | null;
27
+ readonly outsideRepoLink: boolean;
28
+ }
29
+
30
+ interface LinkTargetValidation {
31
+ readonly linkRoot: string;
32
+ readonly outsideRepoLink: boolean;
26
33
  }
27
34
 
28
35
  interface LinkState {
@@ -96,6 +103,114 @@ function resolveLinkRoot(cwd: string, editor: EditorName, toOverride: string | u
96
103
  return join(cwd, ".pi", "skills");
97
104
  }
98
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
+
99
214
  function resolveDefaultLinkPath(cwd: string, editor: EditorName): string {
100
215
  return join(resolveLinkRoot(cwd, editor, undefined), "trekoon");
101
216
  }
@@ -146,9 +261,18 @@ function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; i
146
261
  };
147
262
  }
148
263
 
149
- 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 {
150
270
  if (!existsSync(linkPath)) {
151
271
  mkdirSync(dirname(linkPath), { recursive: true });
272
+ const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
273
+ if (boundaryFailure) {
274
+ return boundaryFailure;
275
+ }
152
276
  symlinkSync(targetPath, linkPath, "dir");
153
277
  return null;
154
278
  }
@@ -191,6 +315,10 @@ function replaceOrCreateSymlink(linkPath: string, targetPath: string): CliResult
191
315
  }
192
316
 
193
317
  rmSync(linkPath, { force: true });
318
+ const boundaryFailure = revalidateLinkParentBoundary(repoRoot, linkPath, allowOutsideRepo);
319
+ if (boundaryFailure) {
320
+ return boundaryFailure;
321
+ }
194
322
  symlinkSync(targetPath, linkPath, "dir");
195
323
  return null;
196
324
  }
@@ -259,9 +387,16 @@ function runSkillsInstall(context: CliContext): CliResult {
259
387
  }
260
388
 
261
389
  const wantsLink: boolean = hasFlag(parsed.flags, "link");
390
+ const allowOutsideRepo: boolean = hasFlag(parsed.flags, ALLOW_OUTSIDE_REPO_FLAG);
262
391
  const rawEditor: string | undefined = readOption(parsed.options, "editor");
263
392
  const rawTo: string | undefined = readOption(parsed.options, "to");
264
393
 
394
+ if (allowOutsideRepo && !wantsLink) {
395
+ return invalidInput("skills.install", `--${ALLOW_OUTSIDE_REPO_FLAG} requires --link.`, {
396
+ allowOutsideRepo,
397
+ });
398
+ }
399
+
265
400
  if (!wantsLink && rawEditor !== undefined) {
266
401
  return invalidInput("skills.install", "--editor requires --link.", {
267
402
  editor: rawEditor,
@@ -299,22 +434,42 @@ function runSkillsInstall(context: CliContext): CliResult {
299
434
  let linkTarget: string | null = null;
300
435
 
301
436
  if (wantsLink && editor !== undefined) {
302
- 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;
303
443
  linkPath = join(linkRoot, "trekoon");
304
444
  linkTarget = installResult.installedDir;
305
- const linkFailure = replaceOrCreateSymlink(linkPath, linkTarget);
445
+ const linkFailure = replaceOrCreateSymlink(
446
+ linkPath,
447
+ linkTarget,
448
+ realpathSync(context.cwd),
449
+ allowOutsideRepo,
450
+ );
306
451
  if (linkFailure) {
307
452
  return linkFailure;
308
453
  }
309
- }
310
454
 
311
- outcome = {
312
- sourcePath: installResult.sourcePath,
313
- installedPath: installResult.installedPath,
314
- installedDir: installResult.installedDir,
315
- linkPath,
316
- linkTarget,
317
- };
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
+ }
318
473
  } catch (error: unknown) {
319
474
  const message = error instanceof Error ? error.message : "Unknown skills install failure";
320
475
  return failResult({
@@ -335,6 +490,11 @@ function runSkillsInstall(context: CliContext): CliResult {
335
490
  command: "skills.install",
336
491
  human: outcome.linkPath
337
492
  ? [
493
+ ...(outcome.outsideRepoLink
494
+ ? [
495
+ `WARNING: Linking outside repository root because --${ALLOW_OUTSIDE_REPO_FLAG} was provided.`,
496
+ ]
497
+ : []),
338
498
  "Installed Trekoon skill and linked editor path.",
339
499
  `Source: ${outcome.sourcePath}`,
340
500
  `Installed file: ${outcome.installedPath}`,
@@ -353,6 +513,9 @@ function runSkillsInstall(context: CliContext): CliResult {
353
513
  linked: outcome.linkPath !== null,
354
514
  linkPath: outcome.linkPath,
355
515
  linkTarget: outcome.linkTarget,
516
+ outsideRepoLink: outcome.outsideRepoLink,
517
+ outsideRepoOverrideUsed: outcome.outsideRepoLink,
518
+ outsideRepoOverrideFlag: outcome.outsideRepoLink ? `--${ALLOW_OUTSIDE_REPO_FLAG}` : null,
356
519
  },
357
520
  });
358
521
  }
@@ -1,4 +1,12 @@
1
- import { hasFlag, parseArgs, parseStrictPositiveInt, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
1
+ import {
2
+ hasFlag,
3
+ parseArgs,
4
+ parseStrictNonNegativeInt,
5
+ parseStrictPositiveInt,
6
+ readEnumOption,
7
+ readMissingOptionValue,
8
+ readOption,
9
+ } from "./arg-parser";
2
10
 
3
11
  import { MutationService } from "../domain/mutation-service";
4
12
  import { TrackerDomain } from "../domain/tracker-domain";
@@ -54,16 +62,44 @@ function filterSortAndLimitSubtasks(
54
62
  subtasks: readonly SubtaskRecord[],
55
63
  statuses: readonly string[] | undefined,
56
64
  limit: number | undefined,
57
- ): SubtaskRecord[] {
65
+ cursor: number,
66
+ ): { subtasks: SubtaskRecord[]; pagination: { hasMore: boolean; nextCursor: string | null } } {
58
67
  const allowedStatuses = statuses === undefined ? undefined : new Set(statuses);
59
68
  const filtered = allowedStatuses === undefined ? [...subtasks] : subtasks.filter((subtask) => allowedStatuses.has(subtask.status));
60
- const sorted = [...filtered].sort((left, right) => subtaskStatusPriority(left.status) - subtaskStatusPriority(right.status));
69
+ const sorted = [...filtered].sort((left, right) => {
70
+ const byStatus = subtaskStatusPriority(left.status) - subtaskStatusPriority(right.status);
71
+ if (byStatus !== 0) {
72
+ return byStatus;
73
+ }
74
+
75
+ const byCreatedAt = left.createdAt - right.createdAt;
76
+ if (byCreatedAt !== 0) {
77
+ return byCreatedAt;
78
+ }
79
+
80
+ return left.id.localeCompare(right.id);
81
+ });
61
82
 
62
83
  if (limit === undefined) {
63
- return sorted;
84
+ return {
85
+ subtasks: sorted,
86
+ pagination: {
87
+ hasMore: false,
88
+ nextCursor: null,
89
+ },
90
+ };
64
91
  }
65
92
 
66
- return sorted.slice(0, limit);
93
+ const pagedSubtasks = sorted.slice(cursor, cursor + limit);
94
+ const nextIndex = cursor + pagedSubtasks.length;
95
+ const hasMore = nextIndex < sorted.length;
96
+ return {
97
+ subtasks: pagedSubtasks,
98
+ pagination: {
99
+ hasMore,
100
+ nextCursor: hasMore ? `${nextIndex}` : null,
101
+ },
102
+ };
67
103
  }
68
104
 
69
105
  function appendLine(existing: string, line: string): string {
@@ -161,6 +197,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
161
197
  readMissingOptionValue(parsed.missingOptionValues, "view") ??
162
198
  readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
163
199
  readMissingOptionValue(parsed.missingOptionValues, "limit", "l") ??
200
+ readMissingOptionValue(parsed.missingOptionValues, "cursor") ??
164
201
  readMissingOptionValue(parsed.missingOptionValues, "task", "t");
165
202
  if (missingListOption !== undefined) {
166
203
  return failMissingOptionValue("subtask.list", missingListOption);
@@ -171,6 +208,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
171
208
  const includeAll = hasFlag(parsed.flags, "all");
172
209
  const rawStatuses = readOption(parsed.options, "status", "s");
173
210
  const rawLimit = readOption(parsed.options, "limit", "l");
211
+ const rawCursor = readOption(parsed.options, "cursor");
174
212
 
175
213
  if (rawView !== undefined && view === undefined) {
176
214
  return failResult({
@@ -208,6 +246,18 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
208
246
  });
209
247
  }
210
248
 
249
+ if (includeAll && rawCursor !== undefined) {
250
+ return failResult({
251
+ command: "subtask.list",
252
+ human: "Use either --all or --cursor, not both.",
253
+ data: { code: "invalid_input", flags: ["all", "cursor"] },
254
+ error: {
255
+ code: "invalid_input",
256
+ message: "--all and --cursor are mutually exclusive",
257
+ },
258
+ });
259
+ }
260
+
211
261
  const statuses = parseStatusCsv(rawStatuses);
212
262
  if (rawStatuses !== undefined && statuses !== undefined && statuses.length === 0) {
213
263
  return failResult({
@@ -234,6 +284,19 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
234
284
  });
235
285
  }
236
286
 
287
+ const parsedCursor = parseStrictNonNegativeInt(rawCursor);
288
+ if (Number.isNaN(parsedCursor)) {
289
+ return failResult({
290
+ command: "subtask.list",
291
+ human: "Invalid --cursor value. Use an integer >= 0.",
292
+ data: { code: "invalid_input", cursor: rawCursor },
293
+ error: {
294
+ code: "invalid_input",
295
+ message: "Invalid --cursor value",
296
+ },
297
+ });
298
+ }
299
+
237
300
  const taskId: string | undefined = readOption(parsed.options, "task", "t") ?? parsed.positional[1];
238
301
  const selectedStatuses = includeAll
239
302
  ? undefined
@@ -241,7 +304,13 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
241
304
  const selectedLimit = includeAll
242
305
  ? undefined
243
306
  : parsedLimit ?? DEFAULT_SUBTASK_LIST_LIMIT;
244
- const subtasks = filterSortAndLimitSubtasks(domain.listSubtasks(taskId), selectedStatuses, selectedLimit);
307
+ const listed = filterSortAndLimitSubtasks(
308
+ domain.listSubtasks(taskId),
309
+ selectedStatuses,
310
+ selectedLimit,
311
+ parsedCursor ?? 0,
312
+ );
313
+ const subtasks = listed.subtasks;
245
314
  const listView = view ?? "table";
246
315
  const human =
247
316
  subtasks.length === 0
@@ -254,6 +323,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
254
323
  command: "subtask.list",
255
324
  human,
256
325
  data: { subtasks },
326
+ ...(context.mode === "human" ? {} : { meta: { pagination: listed.pagination } }),
257
327
  });
258
328
  }
259
329
  case "update": {
@@ -113,6 +113,10 @@ export async function runSync(context: CliContext): Promise<CliResult> {
113
113
  `Scanned events: ${summary.scannedEvents}`,
114
114
  `Applied events: ${summary.appliedEvents}`,
115
115
  `Created conflicts: ${summary.createdConflicts}`,
116
+ `Malformed payloads: ${summary.diagnostics.malformedPayloadEvents}`,
117
+ `Quarantined events: ${summary.diagnostics.quarantinedEvents}`,
118
+ `Conflict events: ${summary.diagnostics.conflictEvents}`,
119
+ ...summary.diagnostics.errorHints,
116
120
  ].join("\n"),
117
121
  data: summary,
118
122
  });