@skrr-ai/cli 0.1.9 → 0.1.11

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.
Files changed (109) hide show
  1. package/dist/base-command.d.ts +1 -13
  2. package/dist/base-command.js +78 -1
  3. package/dist/commands/browser/skill/show.js +7 -1
  4. package/dist/commands/code/index.d.ts +1 -0
  5. package/dist/commands/code/index.js +9 -1
  6. package/dist/commands/commitments/analytics/index.js +2 -0
  7. package/dist/commands/daemon/byok.d.ts +1 -0
  8. package/dist/commands/daemon/byok.js +2 -1
  9. package/dist/commands/daemon/install.d.ts +1 -0
  10. package/dist/commands/daemon/install.js +2 -1
  11. package/dist/commands/daemon/login.d.ts +31 -0
  12. package/dist/commands/daemon/login.js +56 -0
  13. package/dist/commands/daemon/restart.d.ts +9 -0
  14. package/dist/commands/daemon/restart.js +36 -0
  15. package/dist/commands/daemon/start.d.ts +1 -0
  16. package/dist/commands/daemon/start.js +2 -1
  17. package/dist/commands/daemon/status.d.ts +1 -0
  18. package/dist/commands/daemon/status.js +2 -1
  19. package/dist/commands/daemon/stop.d.ts +1 -0
  20. package/dist/commands/daemon/stop.js +2 -1
  21. package/dist/commands/daemon/uninstall.d.ts +1 -0
  22. package/dist/commands/daemon/uninstall.js +2 -1
  23. package/dist/commands/daemon/unlock.d.ts +9 -0
  24. package/dist/commands/daemon/unlock.js +33 -0
  25. package/dist/commands/goals/key-results/create.js +32 -1
  26. package/dist/commands/goals/key-results/update.d.ts +11 -0
  27. package/dist/commands/goals/key-results/update.js +80 -2
  28. package/dist/commands/goals/plan-now.d.ts +54 -2
  29. package/dist/commands/goals/plan-now.js +175 -18
  30. package/dist/commands/goals/planner-config.d.ts +60 -9
  31. package/dist/commands/goals/planner-config.js +82 -34
  32. package/dist/commands/goals/revisions.js +17 -0
  33. package/dist/commands/goals/show.d.ts +17 -0
  34. package/dist/commands/goals/show.js +90 -3
  35. package/dist/commands/login.js +36 -3
  36. package/dist/commands/spaces/create.js +2 -1
  37. package/dist/commands/spaces/index.js +9 -1
  38. package/dist/commands/spaces/list.d.ts +18 -0
  39. package/dist/commands/spaces/list.js +57 -7
  40. package/dist/commands/spaces/show.js +4 -1
  41. package/dist/commands/spaces/summary.d.ts +4 -0
  42. package/dist/commands/spaces/summary.js +77 -1
  43. package/dist/commands/spaces/update.d.ts +4 -0
  44. package/dist/commands/spaces/update.js +38 -1
  45. package/dist/commands/tasks/actionability.js +40 -1
  46. package/dist/commands/tasks/activity.d.ts +29 -0
  47. package/dist/commands/tasks/activity.js +47 -0
  48. package/dist/commands/tasks/complete.d.ts +47 -0
  49. package/dist/commands/tasks/complete.js +159 -12
  50. package/dist/commands/tasks/create.d.ts +26 -0
  51. package/dist/commands/tasks/create.js +60 -1
  52. package/dist/commands/tasks/events/append.d.ts +2 -0
  53. package/dist/commands/tasks/events/append.js +41 -10
  54. package/dist/commands/tasks/events/list.js +60 -9
  55. package/dist/commands/tasks/output.js +22 -2
  56. package/dist/commands/tasks/ready.d.ts +38 -0
  57. package/dist/commands/tasks/ready.js +37 -0
  58. package/dist/commands/tasks/runs.d.ts +22 -0
  59. package/dist/commands/tasks/runs.js +110 -2
  60. package/dist/commands/tasks/show.d.ts +28 -0
  61. package/dist/commands/tasks/show.js +61 -0
  62. package/dist/commands/tasks/timeline.d.ts +7 -0
  63. package/dist/commands/tasks/timeline.js +34 -3
  64. package/dist/commands/tasks/update.d.ts +20 -0
  65. package/dist/commands/tasks/update.js +38 -1
  66. package/dist/commands/whoami.d.ts +40 -0
  67. package/dist/commands/whoami.js +61 -10
  68. package/dist/commands/wiki/ls.d.ts +23 -0
  69. package/dist/commands/wiki/ls.js +63 -10
  70. package/dist/commands/wiki/mv.d.ts +70 -0
  71. package/dist/commands/wiki/mv.js +198 -4
  72. package/dist/commands/wiki/rm.js +12 -1
  73. package/dist/commands/wiki/write.js +35 -3
  74. package/dist/help.d.ts +27 -0
  75. package/dist/help.js +48 -0
  76. package/dist/hooks/command-not-found.d.ts +31 -0
  77. package/dist/hooks/command-not-found.js +12 -80
  78. package/dist/lib/command-miss.d.ts +60 -0
  79. package/dist/lib/command-miss.js +128 -0
  80. package/dist/lib/commitment-analytics.d.ts +10 -0
  81. package/dist/lib/commitment-analytics.js +10 -0
  82. package/dist/lib/config.d.ts +40 -0
  83. package/dist/lib/config.js +72 -9
  84. package/dist/lib/daemonBroker.d.ts +7 -1
  85. package/dist/lib/daemonBroker.js +71 -0
  86. package/dist/lib/daemonHandoff.d.ts +38 -0
  87. package/dist/lib/daemonHandoff.js +297 -0
  88. package/dist/lib/exec-oversky.d.ts +30 -0
  89. package/dist/lib/exec-oversky.js +41 -0
  90. package/dist/lib/format.d.ts +7 -0
  91. package/dist/lib/format.js +27 -5
  92. package/dist/lib/login.js +22 -2
  93. package/dist/lib/sky-code.js +9 -4
  94. package/dist/lib/task-transcript.d.ts +9 -0
  95. package/dist/lib/task-transcript.js +13 -2
  96. package/dist/lib/tasks.d.ts +34 -0
  97. package/dist/lib/tasks.js +70 -1
  98. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/configRoot.d.ts +50 -0
  99. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/configRoot.js +67 -0
  100. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +1 -0
  101. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +7 -1
  102. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/configRoot.d.ts +50 -0
  103. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/configRoot.js +59 -0
  104. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -0
  105. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +3 -0
  106. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  107. package/dist/node_modules/@skrr-ai/data-provider/index.js +15 -4
  108. package/oclif.manifest.json +15713 -15480
  109. package/package.json +5 -3
@@ -1,10 +1,69 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.destinationConflict = destinationConflict;
4
+ exports.serverRenamedOnMove = serverRenamedOnMove;
3
5
  const core_1 = require("@oclif/core");
4
6
  const data_provider_1 = require("@skrr-ai/data-provider");
5
7
  const base_command_1 = require("../../base-command");
6
8
  const http_error_1 = require("../../lib/http-error");
7
9
  const wiki_1 = require("../../lib/wiki");
10
+ const write_1 = require("./write");
11
+ /**
12
+ * Why this move must not proceed — or `null` when it may.
13
+ *
14
+ * Pure, so the decision is testable without a server: everything it needs is
15
+ * the occupant, the kinds involved, and whether the caller authorised a
16
+ * replacement. The messages say the word the old output never did — "already
17
+ * exists" — because a human watching the terminal could see the `-2` in the
18
+ * success line and still not read it as a collision.
19
+ */
20
+ function destinationConflict(opts) {
21
+ const { occupant, sourceKind, source, destination, force, binName, spaceId } = opts;
22
+ if (occupant.kind === 'free' || occupant.kind === 'self')
23
+ return null;
24
+ if (occupant.kind === 'folder') {
25
+ // No `--force` escape here even for a folder source: replacing a folder
26
+ // means deleting a subtree, and `rmdir` is the verb that asks about that.
27
+ return {
28
+ message: `'${destination}' already exists — it is a folder, and \`mv\` will not replace one.\n` +
29
+ `Move '${source}' somewhere else, or remove the folder first with ` +
30
+ `\`${binName} wiki rmdir ${spaceId} ${destination}\`.`,
31
+ code: 'WIKI_DESTINATION_EXISTS',
32
+ };
33
+ }
34
+ if (sourceKind === 'folder') {
35
+ return {
36
+ message: `'${destination}' already exists — it is a page, so a folder cannot take that path.\n` +
37
+ `Pick another destination, or remove the page first with ` +
38
+ `\`${binName} wiki rm ${spaceId} ${destination}\`.`,
39
+ code: 'WIKI_DESTINATION_EXISTS',
40
+ };
41
+ }
42
+ if (!force) {
43
+ return {
44
+ message: `'${destination}' already exists (page ${occupant.pageId}, "${occupant.title}"). ` +
45
+ `Nothing was moved.\n` +
46
+ `Pass --force to replace it with '${source}', or choose a different destination. ` +
47
+ `Without it \`mv\` would land '${source}' beside the page rather than on it, and ` +
48
+ `\`${binName} wiki cat ${spaceId} ${destination}\` would still read the OLD content.`,
49
+ code: 'WIKI_DESTINATION_EXISTS',
50
+ };
51
+ }
52
+ return null;
53
+ }
54
+ /**
55
+ * Did the server disambiguate the slug anyway?
56
+ *
57
+ * The pre-flight check above closes the ordinary case, but it cannot close the
58
+ * race — a page can appear on the destination between the check and the move —
59
+ * and an older server has no notion of refusing. `allocateSlug` suffixes (`y` ->
60
+ * `y-2`), which is precisely the outcome that must never read as success, so the
61
+ * post-check reuses `write.ts`'s detector rather than a second copy of the
62
+ * server's slug rules.
63
+ */
64
+ function serverRenamedOnMove(requestedSlug, movedSlug) {
65
+ return (0, write_1.createdSlugCollided)(requestedSlug, movedSlug);
66
+ }
8
67
  /**
9
68
  * `skrr wiki mv <space> <source> <destination>` — move or rename a page OR a
10
69
  * folder.
@@ -19,6 +78,16 @@ const wiki_1 = require("../../lib/wiki");
19
78
  * through `PATCH /pages/:pageId/move` (which creates missing destination
20
79
  * folders); folders go through `PATCH /folders/:folder_id`, whose server-side
21
80
  * guard refuses to move a folder into its own descendant.
81
+ *
82
+ * OCCUPIED DESTINATIONS ARE REFUSED, not renamed around. The move endpoint runs
83
+ * the same `allocateSlug` de-duplication a CREATE does, so `mv X Y` onto an
84
+ * existing `Y` landed the source at `Y-2`, reported success, and left `Y`
85
+ * holding its OLD content — and `mv X Y; cat Y` then reads the stale page while
86
+ * believing it read the new one. Moving something onto a canonical path is the
87
+ * entire reason to run `mv`, so "I could not do that, so I did something
88
+ * adjacent" is the one outcome this command must not present as success.
89
+ * `--force` gives the filesystem's answer (replace the destination); the
90
+ * default gives the safe one (refuse and change nothing).
22
91
  */
23
92
  class WikiMv extends base_command_1.BaseCommand {
24
93
  static description = 'Move or rename a wiki page or folder';
@@ -26,6 +95,7 @@ class WikiMv extends base_command_1.BaseCommand {
26
95
  '<%= config.bin %> wiki mv <space-id> draft Engineering/Runbooks/deploy',
27
96
  '<%= config.bin %> wiki mv <space-id> Engineering/old-name Engineering/new-name',
28
97
  '<%= config.bin %> wiki mv <space-id> Notes Engineering/Notes',
98
+ '<%= config.bin %> wiki mv <space-id> new-runbook Engineering/deploy --force',
29
99
  '<%= config.bin %> wiki mv <space-id> Engineering/Runbooks Archive/Runbooks --json',
30
100
  ];
31
101
  static args = {
@@ -36,13 +106,23 @@ class WikiMv extends base_command_1.BaseCommand {
36
106
  ignoreStdin: true,
37
107
  }),
38
108
  destination: core_1.Args.string({
39
- description: 'Destination path (missing parent folders are created for pages)',
109
+ description: 'Destination path (missing parent folders are created for pages). ' +
110
+ 'An occupied destination is refused unless --force.',
40
111
  required: true,
41
112
  ignoreStdin: true,
42
113
  }),
43
114
  };
44
115
  static flags = {
45
116
  json: core_1.Flags.boolean({ description: 'Output as JSON' }),
117
+ force: core_1.Flags.boolean({
118
+ char: 'f',
119
+ // Named for what it destroys, not for what it skips: unlike `wiki rm`'s
120
+ // --force this suppresses no prompt, it authorises deleting whatever is
121
+ // already on the destination path.
122
+ description: 'Replace an existing page at the destination (deletes it first)',
123
+ aliases: ['yes'],
124
+ charAliases: ['y'],
125
+ }),
46
126
  };
47
127
  async run() {
48
128
  this.requireAuth();
@@ -79,6 +159,45 @@ class WikiMv extends base_command_1.BaseCommand {
79
159
  this.handleApiError(err);
80
160
  }
81
161
  if (page) {
162
+ const occupant = await this.resolveDestination(args.space, args.destination, {
163
+ pageId: page.pageId,
164
+ });
165
+ const conflict = destinationConflict({
166
+ occupant,
167
+ sourceKind: 'page',
168
+ source,
169
+ destination: args.destination,
170
+ force: flags.force,
171
+ binName: this.config.bin,
172
+ spaceId: args.space,
173
+ });
174
+ if (conflict) {
175
+ this.failWithCliError({ ...conflict, exit: 1, retryable: false });
176
+ }
177
+ // `--force` on an occupied destination: clear the path before moving, so
178
+ // the move lands ON it rather than beside it. Archive, not purge —
179
+ // `wiki rm --purge` is where destroying edit history is asked for
180
+ // explicitly, and it should not be a side effect of a move.
181
+ //
182
+ // The order is forced (moving first would just hit the collision again),
183
+ // so a move that fails after the delete leaves the destination archived
184
+ // and the source where it was. That is recoverable — the row survives —
185
+ // and it is the lesser failure: the alternative is the behaviour this
186
+ // whole guard exists to remove, where both pages end up present and the
187
+ // canonical path quietly holds the wrong one. A server-side atomic
188
+ // replace (`conflict: 'replace'` on the move endpoint, mirroring the one
189
+ // create already takes) is the real fix and is not the CLI's to make.
190
+ if (occupant.kind === 'page') {
191
+ try {
192
+ await data_provider_1.dataService.deleteSpaceWikiPage({
193
+ spaceId: args.space,
194
+ pageId: occupant.pageId,
195
+ });
196
+ }
197
+ catch (err) {
198
+ this.handleApiError(err);
199
+ }
200
+ }
82
201
  let moved;
83
202
  try {
84
203
  moved = await data_provider_1.dataService.moveSpaceWikiPage({
@@ -90,11 +209,35 @@ class WikiMv extends base_command_1.BaseCommand {
90
209
  catch (err) {
91
210
  this.handleApiError(err);
92
211
  }
212
+ // The check above cannot close the window between it and the move, and an
213
+ // older server refuses nothing. Landing at `y-2` is a FAILED move, so say
214
+ // so rather than printing a success line naming a path nobody asked for.
215
+ if (serverRenamedOnMove(name, moved.slug)) {
216
+ this.failWithCliError({
217
+ message: `'${args.destination}' was already taken, so the server moved '${source}' to ` +
218
+ `'${moved.path ?? moved.slug}' instead. '${args.destination}' still holds its ` +
219
+ `ORIGINAL content — the move you asked for did NOT happen.\n` +
220
+ `Re-run with --force to replace the destination, or move ` +
221
+ `'${moved.path ?? moved.slug}' where you want it.`,
222
+ code: 'WIKI_DESTINATION_EXISTS',
223
+ exit: 1,
224
+ retryable: true,
225
+ details: {
226
+ requestedPath: args.destination,
227
+ requestedSlug: name,
228
+ actualSlug: moved.slug,
229
+ movedPageId: moved.pageId,
230
+ },
231
+ });
232
+ }
93
233
  if (flags.json) {
94
234
  this.log(JSON.stringify({ kind: 'page', ...moved }, null, 2));
95
235
  return;
96
236
  }
97
237
  this.log(`Moved page ${source} → ${moved.path ?? args.destination}.`);
238
+ if (occupant.kind === 'page') {
239
+ this.log(`Replaced the page that was there ("${occupant.title}", now deleted).`);
240
+ }
98
241
  // A rename moved the path but not the title. When the title was never
99
242
  // authored (it still equals the old slug), point the user at the one
100
243
  // command that finishes the rename rather than leaving them to notice the
@@ -111,7 +254,43 @@ class WikiMv extends base_command_1.BaseCommand {
111
254
  this.log(hint);
112
255
  return;
113
256
  }
114
- await this.moveFolder(args.space, source, dir, name, flags.json);
257
+ await this.moveFolder(args.space, source, args.destination, dir, name, flags);
258
+ }
259
+ /**
260
+ * What already occupies `destination` — a page, a folder, us, or nothing.
261
+ *
262
+ * A page and a folder cannot share a path, so this asks in the same order the
263
+ * source resolution does. Both lookups treat a 404 as an answer rather than a
264
+ * failure; anything else still surfaces, so an unreadable destination never
265
+ * degrades into "looks free, go ahead and clobber".
266
+ */
267
+ async resolveDestination(spaceId, destination,
268
+ // The thing being moved. `mv x x` and a rename that changes nothing both
269
+ // resolve the destination to the SOURCE, and refusing those would turn a
270
+ // harmless no-op into an error.
271
+ self = {}) {
272
+ try {
273
+ const existing = await data_provider_1.dataService.getSpaceWikiPageByPath(spaceId, destination);
274
+ if (existing) {
275
+ if (self.pageId && existing.pageId === self.pageId)
276
+ return { kind: 'self' };
277
+ return { kind: 'page', pageId: existing.pageId, title: existing.title };
278
+ }
279
+ }
280
+ catch (err) {
281
+ if (!(0, http_error_1.isHttpNotFound)(err))
282
+ this.handleApiError(err);
283
+ }
284
+ let folderId = null;
285
+ try {
286
+ folderId = await (0, wiki_1.resolveWikiFolderId)(spaceId, destination, data_provider_1.dataService.listSpaceWikiDirectory, http_error_1.isHttpNotFound);
287
+ }
288
+ catch (err) {
289
+ this.handleApiError(err);
290
+ }
291
+ if (!folderId)
292
+ return { kind: 'free' };
293
+ return folderId === self.folderId ? { kind: 'self' } : { kind: 'folder' };
115
294
  }
116
295
  /**
117
296
  * Reparent and/or rename a folder.
@@ -121,7 +300,7 @@ class WikiMv extends base_command_1.BaseCommand {
121
300
  * would mean a typo in the destination silently builds a new tree instead of
122
301
  * failing — `mkdir -p` is a separate decision the caller should make.
123
302
  */
124
- async moveFolder(spaceId, source, destinationDir, name, json) {
303
+ async moveFolder(spaceId, source, destination, destinationDir, name, flags) {
125
304
  let folderId;
126
305
  try {
127
306
  folderId = await (0, wiki_1.resolveWikiFolderId)(spaceId, source, data_provider_1.dataService.listSpaceWikiDirectory, http_error_1.isHttpNotFound);
@@ -157,6 +336,21 @@ class WikiMv extends base_command_1.BaseCommand {
157
336
  });
158
337
  }
159
338
  }
339
+ // Same refusal the page branch makes, for the same reason: a folder that
340
+ // silently lands beside its destination instead of on it is a move that
341
+ // reports success without happening.
342
+ const conflict = destinationConflict({
343
+ occupant: await this.resolveDestination(spaceId, destination, { folderId }),
344
+ sourceKind: 'folder',
345
+ source,
346
+ destination,
347
+ force: flags.force,
348
+ binName: this.config.bin,
349
+ spaceId,
350
+ });
351
+ if (conflict) {
352
+ this.failWithCliError({ ...conflict, exit: 1, retryable: false });
353
+ }
160
354
  let updated;
161
355
  try {
162
356
  updated = await data_provider_1.dataService.updateSpaceWikiFolder({
@@ -168,7 +362,7 @@ class WikiMv extends base_command_1.BaseCommand {
168
362
  catch (err) {
169
363
  this.handleApiError(err);
170
364
  }
171
- if (json) {
365
+ if (flags.json) {
172
366
  this.log(JSON.stringify({ kind: 'folder', ...updated }, null, 2));
173
367
  return;
174
368
  }
@@ -90,7 +90,18 @@ class WikiRm extends base_command_1.BaseCommand {
90
90
  this.log(JSON.stringify(result, null, 2));
91
91
  return;
92
92
  }
93
- this.log(flags.purge ? `Purged ${args.path}.` : `Deleted ${args.path}.`);
93
+ if (flags.purge) {
94
+ this.log(`Purged ${args.path}.`);
95
+ return;
96
+ }
97
+ // The page id is the ONLY handle left that reaches this content, and it was
98
+ // the one thing not printed. `rm` archives rather than destroys — the help
99
+ // says so — and `wiki cat --page-id <id>` still returns the body. But `ls`
100
+ // drops the page and `rm` said only "Deleted <path>.", so the handle was
101
+ // discarded at exactly the moment it became the sole way back. An honest
102
+ // "archived, not destroyed" then behaves like "destroyed".
103
+ this.log(`Deleted ${args.path} (${page.pageId}).`);
104
+ this.log(` Archived, not destroyed — read it with \`${this.config.bin} wiki cat ${args.space} --page-id ${page.pageId}\`.`);
94
105
  }
95
106
  }
96
107
  exports.default = WikiRm;
@@ -94,9 +94,25 @@ class WikiWrite extends base_command_1.BaseCommand {
94
94
  // one place a CLI user (or an agent with no docs) looks before writing, and
95
95
  // `__x__` silently staying literal is exactly the kind of surprise that has to
96
96
  // reach them there. (OSK-4844)
97
+ //
98
+ // It has to be SELF-CONTAINED, which the first cut was not: it named the cost
99
+ // of the escape rule ("a backslash before punctuation escapes it") and then
100
+ // pointed at `docs/cli/SKRR_WIKI.md` for what that actually costs you — a path
101
+ // inside the git checkout, which someone who installed `skrr` from npm does
102
+ // not have, with no URL and no `skrr docs` to reach it by. So the CLI's own
103
+ // answer to "what does that mean for my regex?" was a file the reader cannot
104
+ // open. It is two sentences; they belong here.
105
+ //
106
+ // Stated as CONSUMED rather than as "escaped", because the wiki stores
107
+ // rendered HTML: those bytes are gone on the FIRST write, not merely hidden
108
+ // on read, and no `wiki cat --html` gets them back.
97
109
  static description = 'Write a wiki page, creating it (and any missing folders) if needed. ' +
98
- 'Markdown by default; `__x__` stays literal (use `**x**` for bold) and a ' +
99
- 'backslash before punctuation escapes it see docs/cli/SKRR_WIKI.md.';
110
+ 'Markdown by default: `__x__` stays literal (use `**x**` for bold), and a ' +
111
+ 'backslash before ASCII punctuation is an escape, so the backslash itself ' +
112
+ 'is CONSUMED and not stored — `\\d+\\.\\d+` becomes `\\d+.\\d+`. Double it ' +
113
+ '(`\\\\.`) to keep a literal backslash, or put the text in a code span or ' +
114
+ 'fence, where nothing is interpreted. A backslash before anything else ' +
115
+ '(a letter, a space) is kept as written.';
100
116
  static examples = [
101
117
  '<%= config.bin %> wiki write <space-id> Engineering/deploy --file runbook.md',
102
118
  'cat notes.md | <%= config.bin %> wiki write <space-id> notes --stdin',
@@ -113,7 +129,9 @@ class WikiWrite extends base_command_1.BaseCommand {
113
129
  stdin: core_1.Flags.boolean({ description: 'Read content from stdin' }),
114
130
  html: core_1.Flags.boolean({ description: 'Treat the input as HTML instead of Markdown' }),
115
131
  append: core_1.Flags.boolean({ description: 'Append instead of replacing (>> rather than >)' }),
116
- title: core_1.Flags.string({ description: 'Title for a newly-created page (defaults to the slug)' }),
132
+ title: core_1.Flags.string({
133
+ description: 'Title for a NEWLY-CREATED page (defaults to the slug). Retitle an existing page with `wiki update --title`',
134
+ }),
117
135
  };
118
136
  async run() {
119
137
  this.requireAuth();
@@ -216,6 +234,20 @@ class WikiWrite extends base_command_1.BaseCommand {
216
234
  },
217
235
  });
218
236
  }
237
+ // `--title` is create-only, which the help now says — but the command still
238
+ // ACCEPTED it, reported success, and changed nothing about the one thing
239
+ // the flag names. `wiki update`'s own help already argues this case: it
240
+ // refuses an all-empty patch precisely so "a mistyped flag name [does not]
241
+ // look like success". Same reasoning, and the write itself succeeded, so
242
+ // this is a warning naming the command that does the job rather than a
243
+ // failure. To stderr, so `--json` stays a clean contract — and BEFORE the
244
+ // `--json` early return, or the caller most likely to be scripting this is
245
+ // the one caller who never hears it.
246
+ if (wroteIntoExisting && flags.title !== undefined && flags.title !== page.title) {
247
+ this.warn(`--title was ignored: '${page.path ?? args.path}' already exists, and \`wiki write\` only titles pages ` +
248
+ `it creates. Retitle it with \`${this.config.bin} wiki update ${args.space} ${page.path ?? args.path} ` +
249
+ `--title ${JSON.stringify(flags.title)}\` (the slug is kept, so stored paths stay valid).`);
250
+ }
219
251
  if (flags.json) {
220
252
  this.log(JSON.stringify(page, null, 2));
221
253
  return;
package/dist/help.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { Help } from '@oclif/core';
2
+ /**
3
+ * Make `--help` on a command that does not exist say the same thing running it
4
+ * says.
5
+ *
6
+ * oclif's help command resolves the id itself and never reaches the
7
+ * `command_not_found` hook, so the two paths answered differently:
8
+ *
9
+ * $ skrr daemon reload
10
+ * Error: `skrr daemon` exists, but does not take the positional
11
+ * argument `reload`.
12
+ * See `skrr daemon --help`.
13
+ *
14
+ * $ skrr daemon reload --help
15
+ * › Error: Command daemon:reload not found.
16
+ *
17
+ * The second names an internal colon-form id the user never typed and cannot
18
+ * type, and offers no next step — and it is the one a reader exploring the CLI
19
+ * hits FIRST, because asking for help on a guessed subcommand is what people do
20
+ * before running it. The audience most likely to need the good message was the
21
+ * audience reliably denied it.
22
+ *
23
+ * Only the not-found path is overridden. Real help output is untouched.
24
+ */
25
+ export default class SkrrHelp extends Help {
26
+ showHelp(argv: string[]): Promise<void>;
27
+ }
package/dist/help.js ADDED
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const core_1 = require("@oclif/core");
4
+ const command_miss_1 = require("./lib/command-miss");
5
+ /**
6
+ * Make `--help` on a command that does not exist say the same thing running it
7
+ * says.
8
+ *
9
+ * oclif's help command resolves the id itself and never reaches the
10
+ * `command_not_found` hook, so the two paths answered differently:
11
+ *
12
+ * $ skrr daemon reload
13
+ * Error: `skrr daemon` exists, but does not take the positional
14
+ * argument `reload`.
15
+ * See `skrr daemon --help`.
16
+ *
17
+ * $ skrr daemon reload --help
18
+ * › Error: Command daemon:reload not found.
19
+ *
20
+ * The second names an internal colon-form id the user never typed and cannot
21
+ * type, and offers no next step — and it is the one a reader exploring the CLI
22
+ * hits FIRST, because asking for help on a guessed subcommand is what people do
23
+ * before running it. The audience most likely to need the good message was the
24
+ * audience reliably denied it.
25
+ *
26
+ * Only the not-found path is overridden. Real help output is untouched.
27
+ */
28
+ class SkrrHelp extends core_1.Help {
29
+ async showHelp(argv) {
30
+ // Mirror oclif's own id resolution: the topic separator is a space here, so
31
+ // the id is the leading non-flag tokens joined with `:`.
32
+ const tokens = [];
33
+ for (const token of argv) {
34
+ if (token.startsWith('-'))
35
+ break;
36
+ tokens.push(token);
37
+ }
38
+ const id = tokens.join(':');
39
+ // A bare topic (`skrr daemon`) has no command but DOES have help to show,
40
+ // so it must fall through — only an id that is neither a command nor a
41
+ // topic is a miss.
42
+ if (id && !this.config.findCommand(id) && !this.config.findTopic(id)) {
43
+ throw new Error((0, command_miss_1.describeCommandMiss)((0, command_miss_1.lookupFromConfig)(this.config), id));
44
+ }
45
+ return super.showHelp(argv);
46
+ }
47
+ }
48
+ exports.default = SkrrHelp;
@@ -1,3 +1,34 @@
1
1
  import { Hook } from '@oclif/core';
2
+ /**
3
+ * Turn "command not found" back into the truth when the command DOES exist.
4
+ *
5
+ * ── The failure ───────────────────────────────────────────────────────────
6
+ *
7
+ * oclif resolves a command by joining argv with `:`, so an unexpected
8
+ * positional becomes part of the command id:
9
+ *
10
+ * $ skrr shortcuts list 4a167054-e70e-4b1c-ab43-88de52d3d4a2
11
+ * › Error: command shortcuts:list:4a167054-… not found
12
+ *
13
+ * `skrr shortcuts list` exists and is correct — only the argument form was
14
+ * wrong; it wants `--space`. But the message says the COMMAND is missing, which
15
+ * is the one conclusion that sends a reader looking in the wrong place. I hit
16
+ * the identical shape on `skrr commitments archive <id>` and briefly believed
17
+ * the subcommand had been removed.
18
+ *
19
+ * It is easy to hit because the CLI has no rule about how a space is passed:
20
+ * 65 commands take it as a positional and 61 as `--space`, and `projects
21
+ * create <SPACE>` sits beside `projects archive --space`. Picking one is a
22
+ * separate, larger change (OSK-4527). This makes guessing wrong cheap either
23
+ * way, which is worth having even after that lands.
24
+ *
25
+ * ── What it does ──────────────────────────────────────────────────────────
26
+ *
27
+ * Walks the id's prefixes longest-first. If one resolves to a real command, the
28
+ * remaining tokens are arguments that command did not want, and the message
29
+ * says so — naming the command, the leftover tokens, and the flag to try when
30
+ * the command has an obvious one. Otherwise it falls through to oclif's own
31
+ * not-found behaviour, because then the command really is missing.
32
+ */
2
33
  declare const hook: Hook<'command_not_found'>;
3
34
  export default hook;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const command_manifest_1 = require("../lib/command-manifest");
4
4
  const usage_discovery_1 = require("../lib/usage-discovery");
5
+ const command_miss_1 = require("../lib/command-miss");
5
6
  /**
6
7
  * Turn "command not found" back into the truth when the command DOES exist.
7
8
  *
@@ -33,8 +34,6 @@ const usage_discovery_1 = require("../lib/usage-discovery");
33
34
  * the command has an obvious one. Otherwise it falls through to oclif's own
34
35
  * not-found behaviour, because then the command really is missing.
35
36
  */
36
- /** A uuid-ish token, which is what a stray space id looks like. */
37
- const LOOKS_LIKE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
38
37
  const hook = async function (opts) {
39
38
  const id = String(opts.id ?? '');
40
39
  const parts = id.split(':').filter(Boolean);
@@ -66,86 +65,19 @@ const hook = async function (opts) {
66
65
  throw err;
67
66
  }
68
67
  }
69
- // Longest prefix first: `a:b:c` should report `a b`, not `a`.
70
- for (let take = parts.length - 1; take >= 1; take -= 1) {
71
- const candidateId = parts.slice(0, take).join(':');
72
- const found = this.config.findCommand(candidateId);
73
- if (!found)
74
- continue;
75
- const extras = parts.slice(take);
76
- const spoken = `${this.config.bin} ${parts.slice(0, take).join(' ')}`;
77
- // Suggest the flag only when the command actually has it, so the hint can
78
- // never point at something that does not exist.
79
- const flags = Object.keys(found.flags ?? {});
80
- const hint = extras.some((e) => LOOKS_LIKE_ID.test(e)) && flags.includes('space')
81
- ? ` Pass it as \`--space ${extras[0]}\`.`
82
- : flags.includes('space')
83
- ? ' It takes `--space <id>`.'
84
- : '';
85
- const plural = extras.length === 1 ? 'argument' : 'arguments';
86
- throw new Error(`\`${spoken}\` exists, but does not take the positional ${plural} ` +
87
- `${extras.map((e) => `\`${e}\``).join(' ')}.${hint}\n` +
88
- `See \`${spoken} --help\`.`);
89
- }
90
- // No prefix resolved — the command genuinely is not there.
68
+ // The message itself lives in `lib/command-miss`, shared with the help
69
+ // class `skrr daemon reload` reaches this hook and `skrr daemon reload
70
+ // --help` does not, so the two surfaces have to read from one description or
71
+ // they drift, which is exactly how the `--help` path ended up with oclif's
72
+ // raw boilerplate while this path had a good message.
91
73
  //
92
- // This branch MUST throw. A `command_not_found` hook that returns without
93
- // throwing is treated as having handled the miss, so oclif prints nothing and
94
- // exits 0 — a typo would silently look like success, which is far worse than
95
- // the message this hook was written to improve. (I shipped exactly that for
96
- // one build; `skrr shortcuts lst` said nothing and exited 0.)
97
- const allIds = this.config.commandIDs.map((c) => String(c));
98
- // Topic names are candidates too, so a near-miss on a bare topic gets routed
99
- // to the topic rather than nothing (OSK-4869): `skrr harness` (singular) is
100
- // one edit from the `harnesses` topic, but that topic owns no command whose
101
- // id is close to `harness`, so a command-id-only search suggests nothing.
102
- const topics = this.config.topics;
103
- const topicNames = Array.isArray(topics)
104
- ? topics.map((t) => String(t?.name ?? '')).filter(Boolean)
105
- : [];
106
- // Prefer siblings under the same topic. `commitments:archive:<id>` is a long
107
- // way from every real command id, so a whole-string comparison finds nothing
108
- // and the reader is told only that they are wrong — when the useful answer is
109
- // "that topic exists and here is what it does have".
110
- const topic = parts[0];
111
- const siblings = allIds.filter((c) => c.startsWith(`${topic}:`));
112
- const suggestions = siblings.length
113
- ? nearestCommands(parts.slice(0, 2).join(':'), siblings)
114
- : nearestCommands(id, [...allIds, ...topicNames]);
115
- throw new Error(`\`${this.config.bin} ${parts.join(' ')}\` is not a command.` +
116
- (suggestions.length
117
- ? `\nDid you mean: ${suggestions.map((s) => `\`${this.config.bin} ${s.split(':').join(' ')}\``).join(', ')}?`
118
- : `\nRun \`${this.config.bin} --help\` to see the topics.`));
74
+ // This MUST throw. A `command_not_found` hook that returns without throwing
75
+ // is treated as having handled the miss, so oclif prints nothing and exits 0
76
+ // — a typo would silently look like success, which is far worse than the
77
+ // message this hook was written to improve. (I shipped exactly that for one
78
+ // build; `skrr shortcuts lst` said nothing and exited 0.)
79
+ throw new Error((0, command_miss_1.describeCommandMiss)((0, command_miss_1.lookupFromConfig)(this.config), id));
119
80
  };
120
- /** Cheap edit distance, bounded — this runs only on a miss. */
121
- function editDistance(a, b) {
122
- const rows = a.length + 1;
123
- const cols = b.length + 1;
124
- let prev = Array.from({ length: cols }, (_, j) => j);
125
- for (let i = 1; i < rows; i += 1) {
126
- const cur = [i, ...Array(cols - 1).fill(0)];
127
- for (let j = 1; j < cols; j += 1) {
128
- cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
129
- }
130
- prev = cur;
131
- }
132
- return prev[cols - 1];
133
- }
134
- /**
135
- * The closest real command ids, so a typo gets a route forward rather than
136
- * only a verdict.
137
- */
138
- function nearestCommands(id, all, limit = 3) {
139
- return (all
140
- .map((candidate) => ({ candidate, d: editDistance(id, candidate) }))
141
- // A third of the length keeps this to genuine near-misses; without a bound
142
- // every miss suggests three unrelated commands, which is noise wearing the
143
- // shape of help.
144
- .filter(({ d }) => d <= Math.max(2, Math.floor(id.length / 3)))
145
- .sort((x, y) => x.d - y.d)
146
- .slice(0, limit)
147
- .map(({ candidate }) => candidate));
148
- }
149
81
  exports.default = hook;
150
82
  class VirtualUsageFlagError extends Error {
151
83
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * What to say when a command id does not resolve — ONE description, shared by
3
+ * every surface that can produce a miss.
4
+ *
5
+ * There are two such surfaces, and they used to disagree. `skrr daemon reload`
6
+ * goes through the `command_not_found` hook and gets a message naming the real
7
+ * command, the mistake, and the next step. `skrr daemon reload --help` goes
8
+ * through oclif's HELP command, which does its own lookup and never reaches
9
+ * that hook — so it answered `Command daemon:reload not found.`: raw
10
+ * boilerplate naming an internal colon-form id the user never typed and cannot
11
+ * type, with no route forward.
12
+ *
13
+ * The audience is what makes that the wrong way round. Someone exploring a
14
+ * service-manager CLI types `--help` on a guessed subcommand FIRST. So the
15
+ * readers most likely to hit a miss were exactly the readers the good message
16
+ * was written for, and they systematically got the bad one.
17
+ *
18
+ * Keeping the text here rather than in either caller is the point: a fix to one
19
+ * phrasing reaches both, and neither surface can quietly drift from the other.
20
+ */
21
+ /** Minimal view of the oclif config this needs — keeps the module testable. */
22
+ export type CommandLookup = {
23
+ bin: string;
24
+ findCommand: (id: string) => {
25
+ flags?: Record<string, unknown>;
26
+ } | undefined;
27
+ commandIDs: string[];
28
+ topicNames: string[];
29
+ };
30
+ /** Cheap edit distance, bounded — this runs only on a miss. */
31
+ export declare function editDistance(a: string, b: string): number;
32
+ /**
33
+ * The closest real command ids, so a typo gets a route forward rather than
34
+ * only a verdict.
35
+ */
36
+ export declare function nearestCommands(id: string, all: string[], limit?: number): string[];
37
+ /**
38
+ * The message for a colon-joined id that did not resolve.
39
+ *
40
+ * Two shapes, in order:
41
+ *
42
+ * 1. A PREFIX of the id is a real command, so the extra tokens are arguments
43
+ * it did not want. Longest prefix first, because `a:b:c` should report
44
+ * `a b`, not `a`.
45
+ * 2. Nothing resolved, so the command genuinely is not there — say so, and
46
+ * suggest the nearest siblings under the same topic before falling back to
47
+ * the whole command list.
48
+ */
49
+ export declare function describeCommandMiss(config: CommandLookup, id: string): string;
50
+ /** Adapt an oclif `Config` to the lookup shape above. */
51
+ export declare function lookupFromConfig(config: {
52
+ bin: string;
53
+ findCommand: (id: string) => unknown;
54
+ commandIDs: Array<string | {
55
+ toString(): string;
56
+ }>;
57
+ topics?: Array<{
58
+ name?: string;
59
+ }>;
60
+ }): CommandLookup;