trekoon 0.1.8 → 0.1.9

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,22 +1,48 @@
1
+ import { findUnknownOption, parseArgs, readMissingOptionValue, readOption, suggestOptions } from "./arg-parser";
2
+
1
3
  import { failResult, okResult } from "../io/output";
2
4
  import { type CliContext, type CliResult } from "../runtime/command-types";
3
5
  import { MissingBranchDatabaseError } from "../sync/branch-db";
4
6
  import { getSyncConflict, listSyncConflicts, syncPull, syncResolve, syncStatus } from "../sync/service";
5
- import { type SyncConflictMode, type SyncResolution } from "../sync/types";
7
+ import { type SyncResolution } from "../sync/types";
8
+
9
+ const STATUS_OPTIONS = ["from"] as const;
10
+ const PULL_OPTIONS = ["from"] as const;
11
+ const RESOLVE_OPTIONS = ["use"] as const;
12
+ const CONFLICTS_LIST_OPTIONS = ["mode"] as const;
13
+ const CONFLICTS_SHOW_OPTIONS: readonly string[] = [];
14
+
15
+ function resolveSyncCommandId(subcommand: string | undefined, conflictsSubcommand: string | undefined): string {
16
+ if (subcommand === "status") {
17
+ return "sync.status";
18
+ }
19
+
20
+ if (subcommand === "pull") {
21
+ return "sync.pull";
22
+ }
23
+
24
+ if (subcommand === "resolve") {
25
+ return "sync.resolve";
26
+ }
27
+
28
+ if (subcommand !== "conflicts") {
29
+ return "sync";
30
+ }
31
+
32
+ if (conflictsSubcommand === "list") {
33
+ return "sync.conflicts.list";
34
+ }
6
35
 
7
- function parseOption(args: readonly string[], option: string): string | null {
8
- const index: number = args.indexOf(option);
9
- if (index < 0) {
10
- return null;
36
+ if (conflictsSubcommand === "show") {
37
+ return "sync.conflicts.show";
11
38
  }
12
39
 
13
- const value: string | undefined = args[index + 1];
14
- return value && !value.startsWith("--") ? value : null;
40
+ return "sync.conflicts";
15
41
  }
16
42
 
17
- function usage(message: string): CliResult {
43
+ function usage(message: string, command = "sync"): CliResult {
18
44
  return failResult({
19
- command: "sync",
45
+ command,
20
46
  human: `${message}\nUsage: trekoon sync <status|pull|resolve|conflicts> [options]`,
21
47
  data: { message },
22
48
  error: {
@@ -26,6 +52,28 @@ function usage(message: string): CliResult {
26
52
  });
27
53
  }
28
54
 
55
+ function prefixedOptions(options: readonly string[]): string[] {
56
+ return options.map((option) => `--${option}`);
57
+ }
58
+
59
+ function unknownOption(command: string, option: string, allowedOptions: readonly string[]): CliResult {
60
+ const suggestions = suggestOptions(option, allowedOptions).map((suggestion) => `--${suggestion}`);
61
+ const suggestionMessage = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
62
+ return failResult({
63
+ command,
64
+ human: `Unknown option --${option}.${suggestionMessage}`,
65
+ data: {
66
+ option: `--${option}`,
67
+ allowedOptions: prefixedOptions(allowedOptions),
68
+ suggestions,
69
+ },
70
+ error: {
71
+ code: "unknown_option",
72
+ message: `Unknown option --${option}`,
73
+ },
74
+ });
75
+ }
76
+
29
77
  function statusMessage(sourceBranch: string, ahead: number, behind: number, conflicts: number): string {
30
78
  return [
31
79
  `Sync status against '${sourceBranch}'`,
@@ -61,26 +109,11 @@ function formatConflictList(
61
109
  .join("\n");
62
110
  }
63
111
 
64
- function parseConflictMode(args: readonly string[]): SyncConflictMode | null {
65
- const modeIndex = args.indexOf("--mode");
66
- if (modeIndex < 0) {
67
- return "pending";
68
- }
69
-
70
- const explicitMode = args[modeIndex + 1];
71
- if (!explicitMode || explicitMode.startsWith("--")) {
72
- return null;
73
- }
74
-
75
- if (explicitMode === "pending" || explicitMode === "all") {
76
- return explicitMode;
77
- }
78
-
79
- return null;
80
- }
81
-
82
112
  export async function runSync(context: CliContext): Promise<CliResult> {
83
- const subcommand: string | undefined = context.args[0];
113
+ const parsed = parseArgs(context.args);
114
+ const subcommand: string | undefined = parsed.positional[0];
115
+ const conflictsSubcommand: string | undefined = subcommand === "conflicts" ? parsed.positional[1] : undefined;
116
+ const resolvedCommand: string = resolveSyncCommandId(subcommand, conflictsSubcommand);
84
117
 
85
118
  if (!subcommand) {
86
119
  return usage("Missing sync subcommand.");
@@ -88,26 +121,46 @@ export async function runSync(context: CliContext): Promise<CliResult> {
88
121
 
89
122
  try {
90
123
  if (subcommand === "status") {
91
- const sourceBranch: string = parseOption(context.args, "--from") ?? "main";
124
+ const statusUnknownOption = findUnknownOption(parsed, STATUS_OPTIONS);
125
+ if (statusUnknownOption !== undefined) {
126
+ return unknownOption("sync.status", statusUnknownOption, STATUS_OPTIONS);
127
+ }
128
+
129
+ const missingFromOption = readMissingOptionValue(parsed.missingOptionValues, "from");
130
+ if (missingFromOption !== undefined) {
131
+ return usage("sync status requires --from <branch> when provided.", "sync.status");
132
+ }
133
+
134
+ const sourceBranch: string = readOption(parsed.options, "from") ?? "main";
92
135
  const summary = syncStatus(context.cwd, sourceBranch);
93
136
 
94
137
  return okResult({
95
- command: "sync status",
138
+ command: "sync.status",
96
139
  human: statusMessage(summary.sourceBranch, summary.ahead, summary.behind, summary.pendingConflicts),
97
140
  data: summary,
98
141
  });
99
142
  }
100
143
 
101
144
  if (subcommand === "pull") {
102
- const sourceBranch: string | null = parseOption(context.args, "--from");
103
- if (!sourceBranch) {
104
- return usage("sync pull requires --from <branch>.");
145
+ const pullUnknownOption = findUnknownOption(parsed, PULL_OPTIONS);
146
+ if (pullUnknownOption !== undefined) {
147
+ return unknownOption("sync.pull", pullUnknownOption, PULL_OPTIONS);
148
+ }
149
+
150
+ const missingFromOption = readMissingOptionValue(parsed.missingOptionValues, "from");
151
+ if (missingFromOption !== undefined) {
152
+ return usage("sync pull requires --from <branch>.", "sync.pull");
153
+ }
154
+
155
+ const sourceBranch: string | undefined = readOption(parsed.options, "from");
156
+ if (sourceBranch === undefined) {
157
+ return usage("sync pull requires --from <branch>.", "sync.pull");
105
158
  }
106
159
 
107
160
  const summary = syncPull(context.cwd, sourceBranch);
108
161
 
109
162
  return okResult({
110
- command: "sync pull",
163
+ command: "sync.pull",
111
164
  human: [
112
165
  `Pulled from '${summary.sourceBranch}'`,
113
166
  `Scanned events: ${summary.scannedEvents}`,
@@ -123,42 +176,62 @@ export async function runSync(context: CliContext): Promise<CliResult> {
123
176
  }
124
177
 
125
178
  if (subcommand === "resolve") {
126
- const conflictId: string | undefined = context.args[1];
127
- const rawResolution: string | null = parseOption(context.args, "--use");
179
+ const resolveUnknownOption = findUnknownOption(parsed, RESOLVE_OPTIONS);
180
+ if (resolveUnknownOption !== undefined) {
181
+ return unknownOption("sync.resolve", resolveUnknownOption, RESOLVE_OPTIONS);
182
+ }
183
+
184
+ const conflictId: string | undefined = parsed.positional[1];
185
+ const missingResolutionOption = readMissingOptionValue(parsed.missingOptionValues, "use");
186
+ if (missingResolutionOption !== undefined) {
187
+ return usage("sync resolve requires <conflict-id> --use ours|theirs.", "sync.resolve");
188
+ }
189
+
190
+ const rawResolution: string | undefined = readOption(parsed.options, "use");
128
191
 
129
192
  if (!conflictId || !rawResolution) {
130
- return usage("sync resolve requires <conflict-id> --use ours|theirs.");
193
+ return usage("sync resolve requires <conflict-id> --use ours|theirs.", "sync.resolve");
131
194
  }
132
195
 
133
196
  if (rawResolution !== "ours" && rawResolution !== "theirs") {
134
- return usage("sync resolve --use only accepts ours|theirs.");
197
+ return usage("sync resolve --use only accepts ours|theirs.", "sync.resolve");
135
198
  }
136
199
 
137
200
  const summary = syncResolve(context.cwd, conflictId, rawResolution as SyncResolution);
138
201
 
139
202
  return okResult({
140
- command: "sync resolve",
203
+ command: "sync.resolve",
141
204
  human: `Resolved ${summary.conflictId} using ${summary.resolution}.`,
142
205
  data: summary,
143
206
  });
144
207
  }
145
208
 
146
209
  if (subcommand === "conflicts") {
147
- const conflictsCommand: string | undefined = context.args[1];
210
+ const conflictsCommand: string | undefined = parsed.positional[1];
148
211
  if (!conflictsCommand) {
149
- return usage("sync conflicts requires list|show.");
212
+ return usage("sync conflicts requires list|show.", "sync.conflicts");
150
213
  }
151
214
 
152
215
  if (conflictsCommand === "list") {
153
- const mode = parseConflictMode(context.args);
154
- if (!mode) {
155
- return usage("sync conflicts list --mode only accepts pending|all.");
216
+ const listUnknownOption = findUnknownOption(parsed, CONFLICTS_LIST_OPTIONS);
217
+ if (listUnknownOption !== undefined) {
218
+ return unknownOption("sync.conflicts.list", listUnknownOption, CONFLICTS_LIST_OPTIONS);
219
+ }
220
+
221
+ const missingModeOption = readMissingOptionValue(parsed.missingOptionValues, "mode");
222
+ if (missingModeOption !== undefined) {
223
+ return usage("sync conflicts list --mode only accepts pending|all.", "sync.conflicts.list");
224
+ }
225
+
226
+ const mode = readOption(parsed.options, "mode") ?? "pending";
227
+ if (mode !== "pending" && mode !== "all") {
228
+ return usage("sync conflicts list --mode only accepts pending|all.", "sync.conflicts.list");
156
229
  }
157
230
 
158
231
  const conflicts = listSyncConflicts(context.cwd, mode);
159
232
 
160
233
  return okResult({
161
- command: "sync conflicts list",
234
+ command: "sync.conflicts.list",
162
235
  human: formatConflictList(conflicts),
163
236
  data: {
164
237
  mode,
@@ -168,15 +241,20 @@ export async function runSync(context: CliContext): Promise<CliResult> {
168
241
  }
169
242
 
170
243
  if (conflictsCommand === "show") {
171
- const conflictId: string | undefined = context.args[2];
172
- if (!conflictId) {
173
- return usage("sync conflicts show requires <conflict-id>.");
244
+ const showUnknownOption = findUnknownOption(parsed, CONFLICTS_SHOW_OPTIONS);
245
+ if (showUnknownOption !== undefined) {
246
+ return unknownOption("sync.conflicts.show", showUnknownOption, CONFLICTS_SHOW_OPTIONS);
174
247
  }
175
248
 
249
+ const conflictId: string | undefined = parsed.positional[2];
250
+ if (!conflictId) {
251
+ return usage("sync conflicts show requires <conflict-id>.", "sync.conflicts.show");
252
+ }
253
+
176
254
  const conflict = getSyncConflict(context.cwd, conflictId);
177
255
 
178
256
  return okResult({
179
- command: "sync conflicts show",
257
+ command: "sync.conflicts.show",
180
258
  human: [
181
259
  `Conflict: ${conflict.id}`,
182
260
  `Entity: ${conflict.entityKind} ${conflict.entityId}`,
@@ -191,14 +269,14 @@ export async function runSync(context: CliContext): Promise<CliResult> {
191
269
  });
192
270
  }
193
271
 
194
- return usage(`Unknown sync conflicts subcommand '${conflictsCommand}'.`);
272
+ return usage(`Unknown sync conflicts subcommand '${conflictsCommand}'.`, "sync.conflicts");
195
273
  }
196
274
 
197
275
  return usage(`Unknown sync subcommand '${subcommand}'.`);
198
276
  } catch (error) {
199
277
  if (error instanceof MissingBranchDatabaseError) {
200
278
  return failResult({
201
- command: "sync",
279
+ command: resolvedCommand,
202
280
  human: error.message,
203
281
  data: {
204
282
  reason: "missing_branch_db",
@@ -213,7 +291,7 @@ export async function runSync(context: CliContext): Promise<CliResult> {
213
291
  const message = error instanceof Error ? error.message : "Unknown sync error.";
214
292
 
215
293
  return failResult({
216
- command: "sync",
294
+ command: resolvedCommand,
217
295
  human: message,
218
296
  data: {
219
297
  reason: "sync_failed",
@@ -537,7 +537,29 @@ export async function runTask(context: CliContext): Promise<CliResult> {
537
537
  command: "task.list",
538
538
  human,
539
539
  data: { tasks },
540
- ...(context.mode === "human" ? {} : { meta: { pagination: listed.pagination } }),
540
+ ...(context.mode === "human"
541
+ ? {}
542
+ : {
543
+ meta: {
544
+ pagination: listed.pagination,
545
+ defaults: {
546
+ statuses: !includeAll && statuses === undefined ? [...DEFAULT_OPEN_TASK_STATUSES] : null,
547
+ limit: !includeAll && parsedLimit === undefined ? DEFAULT_TASK_LIST_LIMIT : null,
548
+ cursor: parsedCursor === undefined ? 0 : null,
549
+ view: view === undefined ? "table" : null,
550
+ },
551
+ filters: {
552
+ epicId: epicId ?? null,
553
+ statuses: selectedStatuses ?? null,
554
+ includeAll,
555
+ },
556
+ truncation: {
557
+ applied: listed.pagination.hasMore,
558
+ returned: tasks.length,
559
+ limit: selectedLimit ?? null,
560
+ },
561
+ },
562
+ }),
541
563
  });
542
564
  }
543
565
  case "show": {
@@ -593,6 +615,22 @@ export async function runTask(context: CliContext): Promise<CliResult> {
593
615
  command: "task.show",
594
616
  human: formatTask(task),
595
617
  data: { task, includeAll: false },
618
+ ...(context.mode === "human"
619
+ ? {}
620
+ : {
621
+ meta: {
622
+ defaults: {
623
+ view: view === undefined ? effectiveView : null,
624
+ },
625
+ filters: {
626
+ includeAll: false,
627
+ },
628
+ truncation: {
629
+ applied: true,
630
+ scope: "compact",
631
+ },
632
+ },
633
+ }),
596
634
  });
597
635
  }
598
636
 
@@ -603,6 +641,22 @@ export async function runTask(context: CliContext): Promise<CliResult> {
603
641
  command: "task.show",
604
642
  human: formatTaskShowTree(taskTree),
605
643
  data: { task: taskTree, includeAll: true, subtasksCount: taskTree.subtasks.length },
644
+ ...(context.mode === "human"
645
+ ? {}
646
+ : {
647
+ meta: {
648
+ defaults: {
649
+ view: view === undefined ? effectiveView : null,
650
+ },
651
+ filters: {
652
+ includeAll: true,
653
+ },
654
+ truncation: {
655
+ applied: effectiveView === "tree",
656
+ scope: "tree",
657
+ },
658
+ },
659
+ }),
606
660
  });
607
661
  }
608
662
 
@@ -610,6 +664,22 @@ export async function runTask(context: CliContext): Promise<CliResult> {
610
664
  command: "task.show",
611
665
  human: effectiveView === "table" ? formatTaskShowTable(taskTree) : formatTaskShowDetail(taskTree),
612
666
  data: { task: taskTree, includeAll: true, subtasksCount: taskTree.subtasks.length },
667
+ ...(context.mode === "human"
668
+ ? {}
669
+ : {
670
+ meta: {
671
+ defaults: {
672
+ view: view === undefined ? effectiveView : null,
673
+ },
674
+ filters: {
675
+ includeAll: true,
676
+ },
677
+ truncation: {
678
+ applied: false,
679
+ scope: "full",
680
+ },
681
+ },
682
+ }),
613
683
  });
614
684
  }
615
685
  case "ready": {
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ import { executeShell, parseInvocation, renderShellResult } from "./runtime/cli-
5
5
  export async function run(argv: readonly string[] = process.argv.slice(2)): Promise<void> {
6
6
  const parsed = parseInvocation(argv);
7
7
  const result = await executeShell(parsed);
8
- const rendered: string = renderShellResult(result, parsed.mode);
8
+ const rendered: string = renderShellResult(result, parsed.mode, parsed.compatibilityMode);
9
9
 
10
10
  if (result.ok) {
11
11
  process.stdout.write(`${rendered}\n`);
package/src/io/output.ts CHANGED
@@ -1,5 +1,94 @@
1
1
  import { encode } from "@toon-format/toon";
2
- import { type CliResult, type OutputMode, type ToonEnvelope, type ToonError } from "../runtime/command-types";
2
+ import {
3
+ type CliResult,
4
+ type CompatibilityMetadata,
5
+ type CompatibilityMode,
6
+ type ContractMetadata,
7
+ type OutputMode,
8
+ type ToonEnvelope,
9
+ type ToonError,
10
+ } from "../runtime/command-types";
11
+
12
+ const CONTRACT_VERSION = "1.0.0";
13
+ const COMPATIBILITY_DEPRECATED_SINCE = "0.1.8";
14
+ const COMPATIBILITY_REMOVAL_AFTER = "2026-09-30";
15
+
16
+ interface RenderOptions {
17
+ readonly compatibilityMode?: CompatibilityMode | null;
18
+ }
19
+
20
+ function toLegacySyncCommandId(command: string): string {
21
+ const mapping: Record<string, string> = {
22
+ "sync.status": "sync_status",
23
+ "sync.pull": "sync_pull",
24
+ "sync.resolve": "sync_resolve",
25
+ "sync.conflicts": "sync_conflicts",
26
+ "sync.conflicts.list": "sync_conflicts_list",
27
+ "sync.conflicts.show": "sync_conflicts_show",
28
+ };
29
+
30
+ return mapping[command] ?? command;
31
+ }
32
+
33
+ function resolveCompatibilityCommand(command: string, compatibilityMode: CompatibilityMode | null): string {
34
+ if (compatibilityMode === "legacy-sync-command-ids") {
35
+ return toLegacySyncCommandId(command);
36
+ }
37
+
38
+ return command;
39
+ }
40
+
41
+ function createCompatibilityMetadata(command: string, compatibilityMode: CompatibilityMode | null): CompatibilityMetadata | undefined {
42
+ if (compatibilityMode !== "legacy-sync-command-ids") {
43
+ return undefined;
44
+ }
45
+
46
+ const compatibilityCommand: string = toLegacySyncCommandId(command);
47
+ return {
48
+ mode: compatibilityMode,
49
+ warningCode: "compatibility_mode_deprecated",
50
+ deprecatedSince: COMPATIBILITY_DEPRECATED_SINCE,
51
+ removalAfter: COMPATIBILITY_REMOVAL_AFTER,
52
+ migration: "Drop --compat legacy-sync-command-ids and parse canonical dotted command IDs.",
53
+ canonicalCommand: command,
54
+ compatibilityCommand,
55
+ };
56
+ }
57
+
58
+ function hashString(value: string): string {
59
+ let hash = 2166136261;
60
+ for (let index = 0; index < value.length; index += 1) {
61
+ hash ^= value.charCodeAt(index);
62
+ hash = Math.imul(hash, 16777619);
63
+ }
64
+
65
+ return (hash >>> 0).toString(16).padStart(8, "0");
66
+ }
67
+
68
+ function createContractMetadata(result: CliResult, compatibilityMode: CompatibilityMode | null): ContractMetadata {
69
+ const requestSignature = JSON.stringify({
70
+ ok: result.ok,
71
+ command: result.command,
72
+ data: result.data,
73
+ error: result.error ?? null,
74
+ meta: result.meta ?? null,
75
+ });
76
+
77
+ const base: ContractMetadata = {
78
+ contractVersion: CONTRACT_VERSION,
79
+ requestId: `req-${hashString(requestSignature)}`,
80
+ };
81
+
82
+ const compatibility = createCompatibilityMetadata(result.command, compatibilityMode);
83
+ if (!compatibility) {
84
+ return base;
85
+ }
86
+
87
+ return {
88
+ ...base,
89
+ compatibility,
90
+ };
91
+ }
3
92
 
4
93
  export interface ResultInput {
5
94
  readonly command: string;
@@ -45,18 +134,22 @@ export function failResult(input: ResultInput & { readonly error: ToonError }):
45
134
  };
46
135
  }
47
136
 
48
- export function toToonEnvelope(result: CliResult): ToonEnvelope {
137
+ export function toToonEnvelope(result: CliResult, options: RenderOptions = {}): ToonEnvelope {
138
+ const compatibilityMode: CompatibilityMode | null = options.compatibilityMode ?? null;
139
+ const command: string = resolveCompatibilityCommand(result.command, compatibilityMode);
140
+
49
141
  return {
50
142
  ok: result.ok,
51
- command: result.command,
143
+ command,
52
144
  data: result.data,
145
+ metadata: createContractMetadata(result, compatibilityMode),
53
146
  ...(result.error ? { error: result.error } : {}),
54
147
  ...(result.meta ? { meta: result.meta } : {}),
55
148
  };
56
149
  }
57
150
 
58
- export function renderResult(result: CliResult, mode: OutputMode): string {
59
- const envelope: ToonEnvelope = toToonEnvelope(result);
151
+ export function renderResult(result: CliResult, mode: OutputMode, options: RenderOptions = {}): string {
152
+ const envelope: ToonEnvelope = toToonEnvelope(result, options);
60
153
 
61
154
  if (mode === "json") {
62
155
  return JSON.stringify(envelope);