trekoon 0.1.8 → 0.2.0

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.
@@ -35,6 +35,9 @@ data:
35
35
  status: in_progress
36
36
  createdAt: 1700000001000
37
37
  updatedAt: 1700000001000
38
+ metadata:
39
+ contractVersion: 1.0.0
40
+ requestId: req-abc12345
38
41
  ```
39
42
 
40
43
  On error:
@@ -43,6 +46,9 @@ On error:
43
46
  ok: false
44
47
  command: task.show
45
48
  data: {}
49
+ metadata:
50
+ contractVersion: 1.0.0
51
+ requestId: req-def67890
46
52
  error:
47
53
  code: not_found
48
54
  message: task not found: invalid-id
@@ -55,10 +61,34 @@ error:
55
61
  | `ok` | `true` if command succeeded, `false` on error |
56
62
  | `command` | The command that was executed (e.g., `task.list`, `epic.create`) |
57
63
  | `data` | The response payload (tasks, epics, dependencies, etc.) |
64
+ | `metadata` | Contract metadata (`contractVersion`, `requestId`) |
65
+ | `meta` | Optional command-specific metadata (pagination/defaults/filters/diagnostics) |
58
66
  | `error` | Present only on failure, contains `code` and `message` |
59
67
 
60
68
  Use long flags (`--status`, `--description`, etc.) and ALWAYS append `--toon` to every command.
61
69
 
70
+ ### Contract details to rely on
71
+
72
+ - Machine responses include `metadata.contractVersion` and `metadata.requestId`.
73
+ - Command IDs are stable and typically dot namespaced (`task.list`, `sync.status`).
74
+ - Some root commands use single-token IDs (`help`, `init`, `quickstart`, `wipe`, `version`).
75
+ - Unknown options fail fast with deterministic `unknown_option` errors and may include:
76
+ - `data.option`
77
+ - `data.allowedOptions`
78
+ - `data.suggestions`
79
+
80
+ ### Compatibility mode (legacy sync consumers)
81
+
82
+ Default behavior is strict canonical IDs (for example `sync.status`).
83
+
84
+ If a legacy consumer still expects underscore sync IDs, compatibility mode can be used:
85
+
86
+ ```bash
87
+ trekoon --toon --compat legacy-sync-command-ids sync status
88
+ ```
89
+
90
+ When enabled, output includes `metadata.compatibility` with migration/deprecation details.
91
+
62
92
  ## 1) Status Management
63
93
 
64
94
  ### Status values
@@ -205,9 +235,21 @@ trekoon task list --all --toon
205
235
  - `--all` cannot be combined with `--cursor`.
206
236
  - Machine pagination contract is in `meta.pagination.hasMore` and
207
237
  `meta.pagination.nextCursor`.
238
+ - Machine list/show responses may also include:
239
+ - `meta.defaults`
240
+ - `meta.filters`
241
+ - `meta.truncation`
208
242
  - `epic show <id> --all --toon`: full epic tree (tasks + subtasks)
209
243
  - `task show <id> --all --toon`: task plus its subtasks
210
244
 
245
+ ### Canonical storage root behavior
246
+
247
+ - In git repos/worktrees, Trekoon resolves storage from repository top-level so
248
+ nested cwd invocations use one canonical `.trekoon/trekoon.db`.
249
+ - In non-git directories, Trekoon falls back to invocation cwd.
250
+ - If invocation cwd differs from canonical root, machine output may include
251
+ `meta.storageRootDiagnostics`.
252
+
211
253
  ### View Options
212
254
 
213
255
  | Command | `--view` options |
@@ -253,7 +295,80 @@ Rules:
253
295
  - In bulk mode, do not pass a positional ID.
254
296
  - Bulk update supports `--append` and/or `--status`.
255
297
 
256
- ## 7) Setup/install/init (if `trekoon` is unavailable)
298
+ ## 7) Scoped search/replace recipes for agents
299
+
300
+ Use scoped search/replace instead of repeated `show` scans when you need to
301
+ locate or migrate repeated text inside one issue tree.
302
+
303
+ ```bash
304
+ trekoon epic search <epic-id> "path/to/somewhere" --toon
305
+ trekoon task search <task-id> "path/to/somewhere" --toon
306
+ trekoon subtask search <subtask-id> "path/to/somewhere" --toon
307
+
308
+ trekoon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path" --toon
309
+ trekoon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path" --apply --toon
310
+ ```
311
+
312
+ Guardrails:
313
+
314
+ - Use `search` first when you only need to confirm whether the text exists.
315
+ - Use preview `replace` next to confirm the exact candidate set.
316
+ - Use `--apply` only after preview matches the intended scope.
317
+ - Prefer the narrowest root that satisfies the task: `subtask` → `task` →
318
+ `epic`.
319
+ - Keep prompts deterministic: literal search text, explicit IDs, no regex
320
+ assumptions.
321
+
322
+ Agent contract for epic-scoped replace:
323
+
324
+ - Exact search command:
325
+ `trekoon epic search <epic-id> "path/to/somewhere" --toon`
326
+ - Exact replace command:
327
+ `trekoon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path" --toon`
328
+ - Apply command:
329
+ `trekoon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path" --apply --toon`
330
+ - Epic scope includes the epic title/description plus every task and subtask
331
+ title/description in that epic tree.
332
+
333
+ Compact TOON fields to expect:
334
+
335
+ ```text
336
+ ok: true
337
+ command: epic.search
338
+ data:
339
+ scope: epic
340
+ query: { search, fields[], mode: preview }
341
+ matches[]: { kind, id, fields[]: { field, count, snippet } }
342
+ summary: { matchedEntities, matchedFields, totalMatches }
343
+ metadata:
344
+ contractVersion: 1.0.0
345
+ requestId: req-<id>
346
+ ```
347
+
348
+ ```text
349
+ ok: true
350
+ command: epic.replace
351
+ data:
352
+ scope: epic
353
+ query: { search, replace, fields[], mode: preview|apply }
354
+ matches[]: { kind, id, fields[]: { field, count, snippet } }
355
+ summary: { matchedEntities, matchedFields, totalMatches, mode }
356
+ metadata:
357
+ contractVersion: 1.0.0
358
+ requestId: req-<id>
359
+ ```
360
+
361
+ Background behavior to assume:
362
+
363
+ - Scope traversal is deterministic: epic first, then descendant tasks, then
364
+ descendant subtasks.
365
+ - Field traversal is deterministic: `title` before `description`.
366
+ - Preview reads and summarizes candidates without mutation.
367
+ - `--apply` reuses the same scoped traversal, mutates only rows with real text
368
+ changes, and returns matched rows with `query.mode` and `summary.mode` set
369
+ to `"apply"`.
370
+
371
+ ## 8) Setup/install/init (if `trekoon` is unavailable)
257
372
 
258
373
  1. Install Trekoon (or make sure it is on `PATH`).
259
374
  2. In the target repository/worktree, initialize tracker state:
@@ -267,7 +382,7 @@ trekoon init --toon
267
382
 
268
383
  If `.trekoon/trekoon.db` is missing, initialize before any create/update commands.
269
384
 
270
- ## 8) Safety
385
+ ## 9) Safety
271
386
 
272
387
  - Never edit `.trekoon/trekoon.db` directly.
273
388
  - `trekoon wipe --yes --toon` is prohibited unless the user explicitly confirms they want a destructive wipe.
package/README.md CHANGED
@@ -46,12 +46,15 @@ npm i -g trekoon
46
46
  ## Command surface
47
47
 
48
48
  - `trekoon init`
49
+ - `trekoon help [command]`
49
50
  - `trekoon quickstart`
50
- - `trekoon epic <create|list|show|update|delete>`
51
- - `trekoon task <create|list|show|ready|next|update|delete>`
52
- - `trekoon subtask <create|list|update|delete>`
51
+ - `trekoon epic <create|list|show|search|replace|update|delete>`
52
+ - `trekoon task <create|list|show|ready|next|search|replace|update|delete>`
53
+ - `trekoon subtask <create|list|search|replace|update|delete>`
53
54
  - `trekoon dep <add|remove|list|reverse>`
54
- - `trekoon sync <status|pull|resolve>`
55
+ - `trekoon events prune [--dry-run] [--archive] [--retention-days <n>]`
56
+ - `trekoon migrate <status|rollback> [--to-version <n>]`
57
+ - `trekoon sync <status|pull|resolve|conflicts>`
55
58
  - `trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]`
56
59
  - `trekoon skills update`
57
60
  - `trekoon wipe --yes`
@@ -60,6 +63,7 @@ Global output modes:
60
63
 
61
64
  - `--json` for structured JSON output
62
65
  - `--toon` for true TOON-encoded output (not JSON text)
66
+ - `--compat <mode>` for explicit machine compatibility behavior
63
67
  - `--help` for root and command help
64
68
  - `--version` for CLI version
65
69
 
@@ -72,7 +76,8 @@ trekoon --json quickstart
72
76
  trekoon quickstart --json
73
77
  ```
74
78
 
75
- Trekoon currently accepts long option form (`--option`).
79
+ Trekoon options use long form (`--option`) for command/subcommand flags.
80
+ Root help/version aliases `-h` and `-v` are also supported.
76
81
 
77
82
  Human view options:
78
83
 
@@ -112,8 +117,15 @@ trekoon epic update --ids <epic-1>,<epic-2> --status done
112
117
 
113
118
  ## Quickstart
114
119
 
115
- Trekoon is local-first: each worktree uses its own `.trekoon/trekoon.db`.
116
- Git does not merge this DB file; Trekoon sync commands merge tracker state.
120
+ Trekoon is local-first: in git repos/worktrees, Trekoon resolves state to one
121
+ canonical repository root (`git rev-parse --show-toplevel`) so nested
122
+ invocations share the same `.trekoon/trekoon.db`.
123
+
124
+ Outside git repos, Trekoon falls back to the invocation cwd.
125
+
126
+ When machine output is enabled (`--json`/`--toon`) and a command resolves
127
+ storage from a non-canonical cwd, Trekoon emits
128
+ `meta.storageRootDiagnostics` to make the divergence explicit for automation.
117
129
 
118
130
  ### 1) Initialize
119
131
 
@@ -174,7 +186,88 @@ trekoon --json epic show <epic-id>
174
186
  trekoon --json task show <task-id>
175
187
  ```
176
188
 
177
- ### 6) Sync workflow for worktrees
189
+ ### 6) Scoped search for repeated text
190
+
191
+ Use scoped search before manual tree reads when you need to locate repeated
192
+ paths, labels, or migration targets.
193
+
194
+ ```bash
195
+ trekoon --toon epic search <epic-id> "path/to/somewhere"
196
+ trekoon --toon task search <task-id> "path/to/somewhere"
197
+ trekoon --toon subtask search <subtask-id> "path/to/somewhere"
198
+ ```
199
+
200
+ Scope rules:
201
+
202
+ - `epic search` scans the epic title/description plus every task and subtask
203
+ title/description in that epic tree.
204
+ - `task search` scans the task title/description plus descendant subtask
205
+ title/description.
206
+ - `subtask search` scans only that subtask's title/description.
207
+ - Add `--fields title`, `--fields description`, or
208
+ `--fields title,description` when you need a narrower scan.
209
+
210
+ ### 7) Preview first, then apply scoped replace
211
+
212
+ Use search first to confirm the scope, then run replace in preview mode, and
213
+ only use `--apply` after the preview matches the intended migration.
214
+
215
+ ```bash
216
+ trekoon --toon epic search <epic-id> "path/to/somewhere"
217
+ trekoon --toon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path"
218
+ trekoon --toon epic replace <epic-id> --search "path/to/somewhere" --replace "path/to/new-path" --apply
219
+ ```
220
+
221
+ Use this loop for low-risk agent workflows:
222
+
223
+ 1. `search` when you need the smallest possible read before deciding whether a
224
+ migration is needed.
225
+ 2. preview `replace` to verify the exact candidate set and changed fields.
226
+ 3. `replace --apply` only after the preview output matches the intended scope.
227
+
228
+ Epic-scoped replace applies across the epic title/description and every task and
229
+ subtask title/description in that epic tree.
230
+
231
+ Compact TOON expectations for agents:
232
+
233
+ ```text
234
+ ok: true
235
+ command: epic.search
236
+ data:
237
+ scope: epic
238
+ query: { search, fields[], mode: preview }
239
+ matches[]: { kind, id, fields[]: { field, count, snippet } }
240
+ summary: { matchedEntities, matchedFields, totalMatches }
241
+ metadata:
242
+ contractVersion: 1.0.0
243
+ requestId: req-<id>
244
+ ```
245
+
246
+ ```text
247
+ ok: true
248
+ command: epic.replace
249
+ data:
250
+ scope: epic
251
+ query: { search, replace, fields[], mode: preview|apply }
252
+ matches[]: { kind, id, fields[]: { field, count, snippet } }
253
+ summary: { matchedEntities, matchedFields, totalMatches, mode }
254
+ metadata:
255
+ contractVersion: 1.0.0
256
+ requestId: req-<id>
257
+ ```
258
+
259
+ Background behavior:
260
+
261
+ - `epic search` and preview `epic replace` traverse the epic first, then
262
+ descendant tasks, then descendant subtasks.
263
+ - Within each record, Trekoon checks `title` before `description` so output stays
264
+ deterministic and low-token.
265
+ - Preview reports the candidate set without mutating records.
266
+ - `--apply` reuses the same scoped traversal, updates only rows with real text
267
+ changes, and returns the matched rows with `query.mode` and `summary.mode`
268
+ set to `"apply"`.
269
+
270
+ ### 8) Sync workflow for worktrees
178
271
 
179
272
  - Run `trekoon sync status` at session start and before PR/merge.
180
273
  - Run `trekoon sync pull --from main` before merge to align tracker state.
@@ -183,6 +276,8 @@ trekoon --json task show <task-id>
183
276
  ```bash
184
277
  trekoon sync status
185
278
  trekoon sync pull --from main
279
+ trekoon sync conflicts list
280
+ trekoon sync conflicts show <conflict-id>
186
281
  trekoon sync resolve <conflict-id> --use ours
187
282
  ```
188
283
 
@@ -195,10 +290,31 @@ react deterministically:
195
290
  - `diagnostics.conflictEvents`
196
291
  - `diagnostics.errorHints`
197
292
 
198
- ### 7) Install project-local Trekoon skill for agents
293
+ Compatibility mode for legacy sync command IDs:
294
+
295
+ ```bash
296
+ trekoon --json --compat legacy-sync-command-ids sync status
297
+ trekoon --toon --compat legacy-sync-command-ids sync pull --from main
298
+ ```
199
299
 
200
- `trekoon skills install` always writes the bundled skill file into the current
201
- repository at:
300
+ Behavior:
301
+
302
+ - Default remains strict canonical IDs (`sync.status`, `sync.pull`, ...).
303
+ - Compatibility mode rewrites sync command IDs to legacy forms
304
+ (`sync_status`, `sync_pull`, ...).
305
+ - Compatibility mode is machine-only and valid only for `sync` commands.
306
+ - Machine output includes `metadata.compatibility` with:
307
+ - deprecation warning code
308
+ - migration guidance
309
+ - canonical + compatibility command IDs
310
+ - removal window (`removalAfter: 2026-09-30`)
311
+ - Migration path: remove `--compat legacy-sync-command-ids` and consume dotted
312
+ command IDs directly.
313
+
314
+ ### 9) Install project-local Trekoon skill for agents
315
+
316
+ `trekoon skills install` always writes the bundled skill file under the current
317
+ working directory at:
202
318
 
203
319
  - `.agents/skills/trekoon/SKILL.md`
204
320
 
@@ -220,7 +336,7 @@ Path behavior:
220
336
  - Default pi link path: `.pi/skills/trekoon`
221
337
  - `--to <path>` overrides the editor root for link creation only.
222
338
  - `--to` does **not** move or copy `SKILL.md` to that path.
223
- - By default, link targets must resolve inside the repository root.
339
+ - By default, link targets must resolve inside the current working directory root.
224
340
  - Use `--allow-outside-repo` only for intentional external links.
225
341
  - When override is used, install prints a warning and includes confirmation
226
342
  fields in machine output.
@@ -237,11 +353,11 @@ Path behavior:
237
353
  How `--to` works (step-by-step):
238
354
 
239
355
  1. Trekoon always installs/copies to:
240
- - `<repo>/.agents/skills/trekoon/SKILL.md`
356
+ - `<cwd>/.agents/skills/trekoon/SKILL.md`
241
357
  2. If `--link` is present, Trekoon creates a `trekoon` symlink directory entry.
242
358
  3. `--to <path>` sets the symlink root directory.
243
359
  4. Final link path is:
244
- - `<resolved-to-path>/trekoon -> <repo>/.agents/skills/trekoon`
360
+ - `<resolved-to-path>/trekoon -> <cwd>/.agents/skills/trekoon`
245
361
 
246
362
  Example:
247
363
 
@@ -251,13 +367,13 @@ trekoon skills install --link --editor opencode --to ./.custom-editor/skills
251
367
 
252
368
  This produces:
253
369
 
254
- - `<repo>/.agents/skills/trekoon/SKILL.md` (copied file)
255
- - `<repo>/.custom-editor/skills/trekoon` (symlink)
256
- - symlink target: `<repo>/.agents/skills/trekoon`
370
+ - `<cwd>/.agents/skills/trekoon/SKILL.md` (copied file)
371
+ - `<cwd>/.custom-editor/skills/trekoon` (symlink)
372
+ - symlink target: `<cwd>/.agents/skills/trekoon`
257
373
 
258
374
  Trekoon does not mutate global editor config directories.
259
375
 
260
- ### 8) Pre-merge checklist
376
+ ### 10) Pre-merge checklist
261
377
 
262
378
  - [ ] `trekoon sync status` shows no unresolved conflicts
263
379
  - [ ] done tasks/subtasks are marked completed
@@ -269,6 +385,27 @@ Trekoon does not mutate global editor config directories.
269
385
  Use `--toon` for production agent loops. The examples below show command +
270
386
  expected envelope fields.
271
387
 
388
+ Base envelope fields (all machine responses):
389
+
390
+ ```text
391
+ ok: true|false
392
+ command: <stable command id>
393
+ data: <payload>
394
+ metadata:
395
+ contractVersion: "1.0.0"
396
+ requestId: req-<stable-id>
397
+ ```
398
+
399
+ Most subcommand identifiers are dot-namespaced (`task.list`, `sync.pull`,
400
+ `epic.show`). Root-level commands may use single-token IDs (`help`, `init`,
401
+ `quickstart`, `wipe`, `version`).
402
+
403
+ Additional metadata can appear when relevant:
404
+
405
+ - `metadata.compatibility` when `--compat` mode is active
406
+ - `meta.storageRootDiagnostics` when a machine-readable command resolves
407
+ storage from a non-canonical cwd
408
+
272
409
  ### Ready queue (deterministic candidates)
273
410
 
274
411
  ```bash
@@ -336,6 +473,9 @@ ok: true
336
473
  command: task.list
337
474
  data:
338
475
  tasks[]: ...
476
+ metadata:
477
+ contractVersion: "1.0.0"
478
+ requestId: req-<stable-id>
339
479
  meta:
340
480
  pagination: { hasMore, nextCursor }
341
481
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "AI-first local issue tracker CLI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,6 +3,22 @@ export interface ParsedArgs {
3
3
  readonly options: ReadonlyMap<string, string>;
4
4
  readonly flags: ReadonlySet<string>;
5
5
  readonly missingOptionValues: ReadonlySet<string>;
6
+ readonly providedOptions: readonly string[];
7
+ }
8
+
9
+ export const SEARCH_REPLACE_FIELDS = ["title", "description"] as const;
10
+
11
+ export type SearchReplaceField = (typeof SEARCH_REPLACE_FIELDS)[number];
12
+
13
+ export interface ParsedCsvEnumOption<T extends string> {
14
+ readonly values: readonly T[];
15
+ readonly invalidValues: readonly string[];
16
+ readonly empty: boolean;
17
+ }
18
+
19
+ export interface PreviewApplyModeSelection {
20
+ readonly mode: "preview" | "apply";
21
+ readonly conflict: boolean;
6
22
  }
7
23
 
8
24
  const LONG_PREFIX = "--";
@@ -12,6 +28,7 @@ export function parseArgs(args: readonly string[]): ParsedArgs {
12
28
  const options = new Map<string, string>();
13
29
  const flags = new Set<string>();
14
30
  const missingOptionValues = new Set<string>();
31
+ const providedOptions: string[] = [];
15
32
 
16
33
  for (let index = 0; index < args.length; index += 1) {
17
34
  const token: string | undefined = args[index];
@@ -25,6 +42,7 @@ export function parseArgs(args: readonly string[]): ParsedArgs {
25
42
  }
26
43
 
27
44
  const key = token.slice(LONG_PREFIX.length);
45
+ providedOptions.push(key);
28
46
  const value = args[index + 1];
29
47
  if (!value || value.startsWith(LONG_PREFIX)) {
30
48
  flags.add(key);
@@ -41,6 +59,7 @@ export function parseArgs(args: readonly string[]): ParsedArgs {
41
59
  options,
42
60
  flags,
43
61
  missingOptionValues,
62
+ providedOptions,
44
63
  };
45
64
  }
46
65
 
@@ -92,6 +111,151 @@ export function parseStrictNonNegativeInt(rawValue: string | undefined): number
92
111
  return parsed;
93
112
  }
94
113
 
114
+ export function parseCsvOption(rawValue: string | undefined): string[] | undefined {
115
+ if (rawValue === undefined) {
116
+ return undefined;
117
+ }
118
+
119
+ return rawValue
120
+ .split(",")
121
+ .map((value) => value.trim())
122
+ .filter((value) => value.length > 0);
123
+ }
124
+
125
+ export function parseCsvEnumOption<const T extends readonly string[]>(
126
+ rawValue: string | undefined,
127
+ allowed: T,
128
+ ): ParsedCsvEnumOption<T[number]> {
129
+ const values = parseCsvOption(rawValue);
130
+ if (values === undefined) {
131
+ return {
132
+ values: [...allowed],
133
+ invalidValues: [],
134
+ empty: false,
135
+ };
136
+ }
137
+
138
+ if (values.length === 0) {
139
+ return {
140
+ values: [...allowed],
141
+ invalidValues: [],
142
+ empty: true,
143
+ };
144
+ }
145
+
146
+ const allowedValues = new Set<string>(allowed);
147
+ const validValues: T[number][] = [];
148
+ const invalidValues: string[] = [];
149
+
150
+ for (const value of values) {
151
+ if (!allowedValues.has(value)) {
152
+ invalidValues.push(value);
153
+ continue;
154
+ }
155
+
156
+ if (!validValues.includes(value as T[number])) {
157
+ validValues.push(value as T[number]);
158
+ }
159
+ }
160
+
161
+ return {
162
+ values: validValues.length > 0 ? validValues : [...allowed],
163
+ invalidValues,
164
+ empty: false,
165
+ };
166
+ }
167
+
168
+ export function resolvePreviewApplyMode(
169
+ flags: ReadonlySet<string>,
170
+ previewKey = "preview",
171
+ applyKey = "apply",
172
+ ): PreviewApplyModeSelection {
173
+ const preview = flags.has(previewKey);
174
+ const apply = flags.has(applyKey);
175
+ return {
176
+ mode: apply ? "apply" : "preview",
177
+ conflict: preview && apply,
178
+ };
179
+ }
180
+
181
+ function levenshteinDistance(source: string, target: string): number {
182
+ const sourceLength = source.length;
183
+ const targetLength = target.length;
184
+ if (sourceLength === 0) {
185
+ return targetLength;
186
+ }
187
+
188
+ if (targetLength === 0) {
189
+ return sourceLength;
190
+ }
191
+
192
+ const previous: number[] = Array.from({ length: targetLength + 1 }, (_, index) => index);
193
+ const current: number[] = new Array<number>(targetLength + 1).fill(0);
194
+
195
+ for (let sourceIndex = 1; sourceIndex <= sourceLength; sourceIndex += 1) {
196
+ current[0] = sourceIndex;
197
+ for (let targetIndex = 1; targetIndex <= targetLength; targetIndex += 1) {
198
+ const replacementCost = source[sourceIndex - 1] === target[targetIndex - 1] ? 0 : 1;
199
+ const insertCost = (current[targetIndex - 1] ?? 0) + 1;
200
+ const deleteCost = (previous[targetIndex] ?? 0) + 1;
201
+ const replaceCost = (previous[targetIndex - 1] ?? 0) + replacementCost;
202
+ current[targetIndex] = Math.min(
203
+ insertCost,
204
+ deleteCost,
205
+ replaceCost,
206
+ );
207
+ }
208
+
209
+ for (let targetIndex = 0; targetIndex <= targetLength; targetIndex += 1) {
210
+ previous[targetIndex] = current[targetIndex] ?? previous[targetIndex] ?? 0;
211
+ }
212
+ }
213
+
214
+ return previous[targetLength] ?? 0;
215
+ }
216
+
217
+ function normalizeOption(option: string): string {
218
+ return option.startsWith(LONG_PREFIX) ? option.slice(LONG_PREFIX.length) : option;
219
+ }
220
+
221
+ export function findUnknownOption(parsed: ParsedArgs, allowedOptions: readonly string[]): string | undefined {
222
+ const allowed = new Set<string>(allowedOptions.map(normalizeOption));
223
+ for (const option of parsed.providedOptions) {
224
+ if (!allowed.has(option)) {
225
+ return option;
226
+ }
227
+ }
228
+
229
+ return undefined;
230
+ }
231
+
232
+ export function suggestOptions(option: string, allowedOptions: readonly string[], limit = 3): string[] {
233
+ const normalizedOption = normalizeOption(option);
234
+ const normalizedAllowed = allowedOptions.map(normalizeOption);
235
+ return normalizedAllowed
236
+ .map((candidate) => {
237
+ const distance =
238
+ candidate.startsWith(normalizedOption) || normalizedOption.startsWith(candidate)
239
+ ? 0
240
+ : levenshteinDistance(normalizedOption, candidate);
241
+ return {
242
+ candidate,
243
+ distance,
244
+ };
245
+ })
246
+ .sort((left, right) => {
247
+ const byDistance = left.distance - right.distance;
248
+ if (byDistance !== 0) {
249
+ return byDistance;
250
+ }
251
+
252
+ return left.candidate.localeCompare(right.candidate);
253
+ })
254
+ .filter((item) => item.distance <= Math.max(2, Math.floor(normalizedOption.length / 2)))
255
+ .slice(0, limit)
256
+ .map((item) => item.candidate);
257
+ }
258
+
95
259
  export function readEnumOption<const T extends readonly string[]>(
96
260
  options: ReadonlyMap<string, string>,
97
261
  allowed: T,