things-api 0.9.0 → 0.10.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.
Files changed (214) hide show
  1. package/README.md +30 -11
  2. package/dist/audit/log.js +56 -2
  3. package/dist/audit/log.js.map +1 -1
  4. package/dist/audit/schema.d.ts +26 -8
  5. package/dist/audit/schema.js +42 -1
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/cli/commands/area.d.ts +26 -3
  8. package/dist/cli/commands/area.js +125 -70
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -1
  11. package/dist/cli/commands/doctor.js +44 -5
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/mcp.js +23 -4
  14. package/dist/cli/commands/mcp.js.map +1 -1
  15. package/dist/cli/commands/project.d.ts +18 -1
  16. package/dist/cli/commands/project.js +69 -31
  17. package/dist/cli/commands/project.js.map +1 -1
  18. package/dist/cli/commands/reads.js +423 -157
  19. package/dist/cli/commands/reads.js.map +1 -1
  20. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  21. package/dist/cli/commands/repeat-flags.js +76 -0
  22. package/dist/cli/commands/repeat-flags.js.map +1 -0
  23. package/dist/cli/commands/setup.js +2 -3
  24. package/dist/cli/commands/setup.js.map +1 -1
  25. package/dist/cli/commands/show.js +47 -24
  26. package/dist/cli/commands/show.js.map +1 -1
  27. package/dist/cli/commands/todo.d.ts +1 -1
  28. package/dist/cli/commands/todo.js +7 -6
  29. package/dist/cli/commands/todo.js.map +1 -1
  30. package/dist/cli/commands/writes.js +378 -148
  31. package/dist/cli/commands/writes.js.map +1 -1
  32. package/dist/cli/did-you-mean.d.ts +1 -1
  33. package/dist/cli/excess-args.d.ts +15 -0
  34. package/dist/cli/excess-args.js +51 -0
  35. package/dist/cli/excess-args.js.map +1 -0
  36. package/dist/cli/glyphs.d.ts +39 -2
  37. package/dist/cli/glyphs.js +91 -21
  38. package/dist/cli/glyphs.js.map +1 -1
  39. package/dist/cli/help.d.ts +54 -0
  40. package/dist/cli/help.js +355 -0
  41. package/dist/cli/help.js.map +1 -0
  42. package/dist/cli/main.js +32 -25
  43. package/dist/cli/main.js.map +1 -1
  44. package/dist/cli/period.d.ts +7 -0
  45. package/dist/cli/period.js +12 -0
  46. package/dist/cli/period.js.map +1 -1
  47. package/dist/cli/read-driver.d.ts +26 -19
  48. package/dist/cli/read-driver.js +67 -33
  49. package/dist/cli/read-driver.js.map +1 -1
  50. package/dist/cli/render.d.ts +92 -28
  51. package/dist/cli/render.js +318 -97
  52. package/dist/cli/render.js.map +1 -1
  53. package/dist/cli/resolve-invocation.d.ts +42 -4
  54. package/dist/cli/resolve-invocation.js +97 -14
  55. package/dist/cli/resolve-invocation.js.map +1 -1
  56. package/dist/cli/tag-filters.d.ts +59 -0
  57. package/dist/cli/tag-filters.js +57 -0
  58. package/dist/cli/tag-filters.js.map +1 -0
  59. package/dist/cli/verb-hint.d.ts +25 -0
  60. package/dist/cli/verb-hint.js +138 -0
  61. package/dist/cli/verb-hint.js.map +1 -0
  62. package/dist/cli/width.d.ts +135 -0
  63. package/dist/cli/width.js +313 -0
  64. package/dist/cli/width.js.map +1 -0
  65. package/dist/client.d.ts +137 -24
  66. package/dist/client.js +105 -16
  67. package/dist/client.js.map +1 -1
  68. package/dist/config.d.ts +11 -0
  69. package/dist/config.js +3 -0
  70. package/dist/config.js.map +1 -1
  71. package/dist/contracts.d.ts +87 -17
  72. package/dist/contracts.js +44 -1
  73. package/dist/contracts.js.map +1 -1
  74. package/dist/db/fingerprint.d.ts +12 -0
  75. package/dist/db/fingerprint.js +15 -1
  76. package/dist/db/fingerprint.js.map +1 -1
  77. package/dist/db/locate.js +1 -1
  78. package/dist/db/locate.js.map +1 -1
  79. package/dist/diagnose.d.ts +63 -0
  80. package/dist/diagnose.js +38 -1
  81. package/dist/diagnose.js.map +1 -1
  82. package/dist/index.d.ts +30 -4
  83. package/dist/index.js +35 -2
  84. package/dist/index.js.map +1 -1
  85. package/dist/mcp/server.d.ts +9 -1
  86. package/dist/mcp/server.js +691 -133
  87. package/dist/mcp/server.js.map +1 -1
  88. package/dist/model/entities.d.ts +31 -7
  89. package/dist/model/entities.js.map +1 -1
  90. package/dist/model/mappers.d.ts +7 -0
  91. package/dist/model/mappers.js +7 -2
  92. package/dist/model/mappers.js.map +1 -1
  93. package/dist/model/serialize.d.ts +28 -0
  94. package/dist/model/serialize.js +80 -0
  95. package/dist/model/serialize.js.map +1 -0
  96. package/dist/model/when-sugar.d.ts +47 -0
  97. package/dist/model/when-sugar.js +45 -0
  98. package/dist/model/when-sugar.js.map +1 -0
  99. package/dist/read/area-view.d.ts +8 -1
  100. package/dist/read/area-view.js +49 -13
  101. package/dist/read/area-view.js.map +1 -1
  102. package/dist/read/filter-contract.d.ts +117 -0
  103. package/dist/read/filter-contract.js +78 -0
  104. package/dist/read/filter-contract.js.map +1 -0
  105. package/dist/read/predicates.d.ts +30 -0
  106. package/dist/read/predicates.js +30 -0
  107. package/dist/read/predicates.js.map +1 -1
  108. package/dist/read/project-view.d.ts +12 -1
  109. package/dist/read/project-view.js +39 -8
  110. package/dist/read/project-view.js.map +1 -1
  111. package/dist/read/queries.d.ts +149 -6
  112. package/dist/read/queries.js +250 -31
  113. package/dist/read/queries.js.map +1 -1
  114. package/dist/read/sections.d.ts +51 -0
  115. package/dist/read/sections.js +37 -0
  116. package/dist/read/sections.js.map +1 -0
  117. package/dist/read/sidebar-order.js +2 -1
  118. package/dist/read/sidebar-order.js.map +1 -1
  119. package/dist/read/tags.d.ts +27 -3
  120. package/dist/read/tags.js +83 -12
  121. package/dist/read/tags.js.map +1 -1
  122. package/dist/read/truncation.d.ts +71 -0
  123. package/dist/read/{pagination.js → truncation.js} +103 -94
  124. package/dist/read/truncation.js.map +1 -0
  125. package/dist/read/views.d.ts +140 -16
  126. package/dist/read/views.js +264 -67
  127. package/dist/read/views.js.map +1 -1
  128. package/dist/surface-copy.d.ts +19 -0
  129. package/dist/surface-copy.js +32 -0
  130. package/dist/surface-copy.js.map +1 -1
  131. package/dist/sync-health.d.ts +78 -0
  132. package/dist/sync-health.js +312 -0
  133. package/dist/sync-health.js.map +1 -0
  134. package/dist/write/accessibility-probe.d.ts +12 -0
  135. package/dist/write/accessibility-probe.js +63 -0
  136. package/dist/write/accessibility-probe.js.map +1 -0
  137. package/dist/write/automation-probe.d.ts +7 -0
  138. package/dist/write/automation-probe.js +8 -1
  139. package/dist/write/automation-probe.js.map +1 -1
  140. package/dist/write/batch.js +4 -2
  141. package/dist/write/batch.js.map +1 -1
  142. package/dist/write/capabilities.d.ts +8 -0
  143. package/dist/write/capabilities.js +13 -6
  144. package/dist/write/capabilities.js.map +1 -1
  145. package/dist/write/commands.d.ts +2 -0
  146. package/dist/write/commands.js +562 -48
  147. package/dist/write/commands.js.map +1 -1
  148. package/dist/write/edit-checklist.js +3 -2
  149. package/dist/write/edit-checklist.js.map +1 -1
  150. package/dist/write/guards.d.ts +2 -7
  151. package/dist/write/guards.js +64 -6
  152. package/dist/write/guards.js.map +1 -1
  153. package/dist/write/heading.js +2 -0
  154. package/dist/write/heading.js.map +1 -1
  155. package/dist/write/lock.d.ts +40 -2
  156. package/dist/write/lock.js +91 -14
  157. package/dist/write/lock.js.map +1 -1
  158. package/dist/write/make-repeating-project.d.ts +4 -0
  159. package/dist/write/make-repeating-project.js +253 -0
  160. package/dist/write/make-repeating-project.js.map +1 -0
  161. package/dist/write/operations.d.ts +145 -1
  162. package/dist/write/operations.js +48 -0
  163. package/dist/write/operations.js.map +1 -1
  164. package/dist/write/pipeline.d.ts +19 -0
  165. package/dist/write/pipeline.js +109 -26
  166. package/dist/write/pipeline.js.map +1 -1
  167. package/dist/write/pre-state.d.ts +68 -3
  168. package/dist/write/pre-state.js +102 -3
  169. package/dist/write/pre-state.js.map +1 -1
  170. package/dist/write/reopen.js +3 -2
  171. package/dist/write/reopen.js.map +1 -1
  172. package/dist/write/reorder.js +27 -17
  173. package/dist/write/reorder.js.map +1 -1
  174. package/dist/write/repeat-rule.d.ts +22 -0
  175. package/dist/write/repeat-rule.js +258 -0
  176. package/dist/write/repeat-rule.js.map +1 -0
  177. package/dist/write/reversibility.js +49 -0
  178. package/dist/write/reversibility.js.map +1 -1
  179. package/dist/write/tag-refs.d.ts +47 -0
  180. package/dist/write/tag-refs.js +126 -0
  181. package/dist/write/tag-refs.js.map +1 -0
  182. package/dist/write/undo.d.ts +74 -5
  183. package/dist/write/undo.js +493 -80
  184. package/dist/write/undo.js.map +1 -1
  185. package/dist/write/vectors/registry.d.ts +17 -1
  186. package/dist/write/vectors/registry.js +18 -2
  187. package/dist/write/vectors/registry.js.map +1 -1
  188. package/dist/write/vectors/types.d.ts +119 -3
  189. package/dist/write/vectors/ui-certification.d.ts +48 -0
  190. package/dist/write/vectors/ui-certification.js +46 -0
  191. package/dist/write/vectors/ui-certification.js.map +1 -0
  192. package/dist/write/vectors/ui-drag.d.ts +188 -0
  193. package/dist/write/vectors/ui-drag.js +1095 -0
  194. package/dist/write/vectors/ui-drag.js.map +1 -0
  195. package/dist/write/vectors/ui-recipes.d.ts +70 -0
  196. package/dist/write/vectors/ui-recipes.js +558 -0
  197. package/dist/write/vectors/ui-recipes.js.map +1 -0
  198. package/dist/write/vectors/ui.d.ts +137 -0
  199. package/dist/write/vectors/ui.js +656 -0
  200. package/dist/write/vectors/ui.js.map +1 -0
  201. package/dist/write/verify/delta.d.ts +31 -3
  202. package/dist/write/verify/delta.js +22 -6
  203. package/dist/write/verify/delta.js.map +1 -1
  204. package/dist/write/verify/poller.js +1 -0
  205. package/dist/write/verify/poller.js.map +1 -1
  206. package/package.json +1 -1
  207. package/dist/cli/exit-codes.d.ts +0 -26
  208. package/dist/cli/exit-codes.js +0 -26
  209. package/dist/cli/exit-codes.js.map +0 -1
  210. package/dist/cli/output.d.ts +0 -42
  211. package/dist/cli/output.js +0 -16
  212. package/dist/cli/output.js.map +0 -1
  213. package/dist/read/pagination.d.ts +0 -104
  214. package/dist/read/pagination.js.map +0 -1
@@ -1,23 +1,24 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { openThings } from "../../client.js";
3
- import { saveConfigKey } from "../../config.js";
4
- import { ThingsDbNotFoundError } from "../../db/locate.js";
5
- import { ThingsDbOpenError } from "../../db/connection.js";
6
- import { capabilitiesTable } from "../../write/capabilities.js";
7
- import { outcomeFailed } from "../../write/batch.js";
8
- import { BOUNCE_MAX_ITEMS } from "../../write/reorder.js";
9
- import { errorEnvelope, ExitCode, okEnvelope } from "../../contracts.js";
2
+ import { addRepeatRuleFlags, repeatRuleFlagsFromOpts } from "./repeat-flags.js";
3
+ import { aggregateExitCode, blockedCode, BOUNCE_MAX_ITEMS, capabilitiesTable, errorEnvelope, ExitCode, okEnvelope, openThings, outcomeFailed, ReferenceResolutionError, saveConfigKey, splitWhenSugar, ThingsDbNotFoundError, ThingsDbOpenError, verifyFailedCode, } from "../../index.js";
4
+ import { usageError } from "../read-driver.js";
10
5
  function addWriteFlags(cmd) {
11
6
  return cmd
12
7
  .option("--json", "emit versioned JSON envelope on stdout")
13
8
  .option("--db <path>", "explicit database path")
14
9
  .option("--dry-run", "preview the planned change and its expected effect; nothing executes")
15
- .option("--vector <id>", "force how the change is delivered: url-scheme | applescript | shortcuts")
10
+ .option("--vector <id>", "force how the change is delivered: url-scheme | applescript | shortcuts | ui")
16
11
  .option("--allow-disruptive", "permit changes that briefly steal window focus")
17
12
  .option("--allow-very-disruptive", "permit changes that visibly drive the Things UI")
18
13
  .option("--verify-timeout <ms>", "how long to wait for the change to take effect")
19
14
  .option("--actor <name>", "author name recorded for this change (default: from config)");
20
15
  }
16
+ /** A commander flag value when present-with-value (bare presence yields `true`). */
17
+ const flagVal = (v) => (typeof v === "string" ? v : undefined);
18
+ /** Writes one batch result as an NDJSON line to stdout. */
19
+ const emit = (r) => {
20
+ process.stdout.write(`${JSON.stringify(r)}\n`);
21
+ };
21
22
  function writeOptionsFrom(opts, extra = {}) {
22
23
  const maxDisruption = opts.allowVeryDisruptive
23
24
  ? 3
@@ -30,31 +31,46 @@ function writeOptionsFrom(opts, extra = {}) {
30
31
  ...(maxDisruption !== undefined && { maxDisruption }),
31
32
  ...(opts.verifyTimeout !== undefined && { verifyTimeoutMs: Number(opts.verifyTimeout) }),
32
33
  ...(opts.actor !== undefined && { actor: opts.actor }),
34
+ ...(opts.dangerouslyDriveGui === true && { dangerouslyDriveGui: true }),
33
35
  ...extra,
34
36
  };
35
37
  }
38
+ /**
39
+ * `--create-tags` on a tag-accepting command: create any named tag that does
40
+ * not exist yet (mkdir-p for `parent/child`) before applying, instead of
41
+ * refusing. Nesting is via the clean `make new tag` path.
42
+ */
43
+ function addCreateTagsFlag(cmd) {
44
+ return cmd.option("--create-tags", "create any named tag that does not exist yet (nesting parent/child) before applying, " +
45
+ "instead of stopping on an unknown tag");
46
+ }
47
+ /** WriteOptions extra carrying createTags when the flag is set. */
48
+ function createTagsExtra(opts) {
49
+ return opts["createTags"] === true ? { createTags: true } : {};
50
+ }
51
+ /** Add the mandatory GUI-drive acknowledgement to a ui-vector command. */
52
+ function addDriveGuiFlag(cmd) {
53
+ return cmd.option("--dangerously-drive-gui", "required: this drives the local Things app through its accessibility interface to make a " +
54
+ "change the app offers nowhere else; also needs `things config set ui-enabled true`. " +
55
+ "Intended for a dedicated always-on Mac.");
56
+ }
36
57
  function collect(value, previous) {
37
58
  return [...previous, value];
38
59
  }
39
60
  /**
40
61
  * URL-style `--when DATE@TIME` sugar: splits into when + reminder for the
41
- * ops that take both (an explicit --reminder alongside the suffix errors).
62
+ * ops that take both (an explicit --reminder alongside the suffix errors). The
63
+ * `@` split and its usage copy are the shared {@link splitWhenSugar} core; this
64
+ * only mutates the parsed opts on a successful split.
42
65
  */
43
66
  function applyWhenSugar(opts) {
44
- const when = opts["when"];
45
- if (typeof when !== "string" || !when.includes("@"))
46
- return null;
47
- const at = when.indexOf("@");
48
- const date = when.slice(0, at);
49
- const time = when.slice(at + 1);
50
- if (date === "" || time === "" || time.includes("@")) {
51
- return `invalid --when "${when}" — expected today | evening | anytime | someday | YYYY-MM-DD (set a reminder with --reminder HH:mm)`;
67
+ const r = splitWhenSugar(opts["when"], opts["reminder"] !== undefined);
68
+ if (r.kind === "error")
69
+ return r.message;
70
+ if (r.kind === "split") {
71
+ opts["when"] = r.when;
72
+ opts["reminder"] = r.reminder;
52
73
  }
53
- if (opts["reminder"] !== undefined) {
54
- return `--when "${when}" carries an @time suffix and --reminder was also given — use one`;
55
- }
56
- opts["when"] = date;
57
- opts["reminder"] = time;
58
74
  return null;
59
75
  }
60
76
  /** Apply the sugar or print the usage error; false = caller returns. */
@@ -62,8 +78,7 @@ function whenSugarOk(opts) {
62
78
  const err = applyWhenSugar(opts);
63
79
  if (err === null)
64
80
  return true;
65
- process.stderr.write(`error: ${err}\n`);
66
- process.exitCode = ExitCode.Usage;
81
+ usageError(opts, err);
67
82
  return false;
68
83
  }
69
84
  function splitCsv(value) {
@@ -93,6 +108,23 @@ async function runWrite(opts, fn) {
93
108
  emitResult(result, opts, meta(client));
94
109
  }
95
110
  catch (err) {
111
+ // An unresolved write target (uuid/partial-uuid/name that is ambiguous or
112
+ // not-found) is a usage-class failure carrying machine-readable candidates
113
+ // — never the generic `unexpected`.
114
+ if (err instanceof ReferenceResolutionError) {
115
+ if (opts.json) {
116
+ process.stdout.write(`${JSON.stringify(errorEnvelope({
117
+ code: err.code,
118
+ message: err.message,
119
+ details: { candidates: err.candidates },
120
+ }, meta(client)))}\n`);
121
+ }
122
+ else {
123
+ process.stderr.write(`error: ${err.message}\n`);
124
+ }
125
+ process.exitCode = ExitCode.Usage;
126
+ return;
127
+ }
96
128
  const isEnv = err instanceof ThingsDbNotFoundError || err instanceof ThingsDbOpenError;
97
129
  const message = err instanceof Error ? err.message : String(err);
98
130
  if (opts.json) {
@@ -159,7 +191,7 @@ function emitResult(result, opts, meta) {
159
191
  case "verify-failed": {
160
192
  if (opts.json) {
161
193
  process.stdout.write(`${JSON.stringify(errorEnvelope({
162
- code: `verify-failed:${result.reason}`,
194
+ code: verifyFailedCode(result),
163
195
  message: result.detail,
164
196
  ...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
165
197
  ...(result.hint !== undefined && { remediation: result.hint }),
@@ -179,7 +211,7 @@ function emitResult(result, opts, meta) {
179
211
  const code = result.reason === "drift" ? ExitCode.DriftBlocked : ExitCode.Blocked;
180
212
  if (opts.json) {
181
213
  process.stdout.write(`${JSON.stringify(errorEnvelope({
182
- code: `blocked:${result.hazard ?? result.reason}`,
214
+ code: blockedCode(result),
183
215
  message: result.detail,
184
216
  ...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
185
217
  remediation: result.remediation,
@@ -223,22 +255,23 @@ function group(program, name, description) {
223
255
  const containerRef = (value) => value === undefined ? undefined : { uuid: value, title: value };
224
256
  export function registerWriteCommands(program) {
225
257
  const todo = group(program, "todo", "To-do–scoped operations");
226
- addWriteFlags(todo
258
+ addCreateTagsFlag(addWriteFlags(todo
227
259
  .command("add <title>")
228
- .description("Create a to-do; its uuid is printed on success. Tags, projects, areas, and " +
229
- "headings must name existing items — unknown or ambiguous references are " +
230
- "rejected. Adding into a completed/canceled project reopens that project " +
260
+ .description("Create a to-do; its uuid is printed on success. Projects, areas, and headings " +
261
+ "must name existing items — unknown or ambiguous references are rejected. A tag " +
262
+ "may be a name or a parent/child path, and must exist unless " +
263
+ "--create-tags. Adding into a completed/canceled project reopens that project — " +
231
264
  "requires --acknowledge-project-reopen.")
232
265
  .option("--notes <text>", "notes body")
233
266
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
234
267
  .option("--reminder <HH:mm>", "time-of-day reminder (24h); requires --when today|evening|YYYY-MM-DD")
235
268
  .option("--deadline <date>", "YYYY-MM-DD")
236
- .option("--tags <list>", "comma-separated EXISTING tag names")
269
+ .option("--tags <list>", "comma-separated tags; each a name or a parent/child path (must exist unless --create-tags)")
237
270
  .option("--checklist-item <text>", "checklist item (repeatable)", collect, [])
238
271
  .option("--project <ref>", "destination project (uuid or unique name)")
239
272
  .option("--area <ref>", "destination area (uuid or unique name)")
240
273
  .option("--heading <name>", "existing heading in the destination project")
241
- .option("--acknowledge-project-reopen", "allow adding into a completed/canceled project")).action(async (title, opts) => {
274
+ .option("--acknowledge-project-reopen", "allow adding into a completed/canceled project"))).action(async (title, opts) => {
242
275
  const checklist = opts["checklistItem"];
243
276
  const tags = splitCsv(opts["tags"]);
244
277
  const project = containerRef(opts["project"]);
@@ -260,6 +293,7 @@ export function registerWriteCommands(program) {
260
293
  ...(opts["acknowledgeProjectReopen"] !== undefined && {
261
294
  acknowledgeProjectReopen: opts["acknowledgeProjectReopen"],
262
295
  }),
296
+ ...createTagsExtra(opts),
263
297
  })));
264
298
  });
265
299
  addWriteFlags(todo
@@ -282,13 +316,11 @@ export function registerWriteCommands(program) {
282
316
  .option("--clear-deadline", "remove the deadline")).action(async (uuid, opts) => {
283
317
  const notesModes = ["notes", "appendNotes", "prependNotes"].filter((k) => opts[k] !== undefined);
284
318
  if (notesModes.length > 1) {
285
- process.stderr.write("error: --notes, --append-notes, --prepend-notes are exclusive\n");
286
- process.exitCode = ExitCode.Usage;
319
+ usageError(opts, "--notes, --append-notes, --prepend-notes are exclusive");
287
320
  return;
288
321
  }
289
322
  if (opts["reminder"] !== undefined && opts["clearReminder"] === true) {
290
- process.stderr.write("error: pass at most one of --reminder / --clear-reminder\n");
291
- process.exitCode = ExitCode.Usage;
323
+ usageError(opts, "pass at most one of --reminder / --clear-reminder");
292
324
  return;
293
325
  }
294
326
  if (!whenSugarOk(opts))
@@ -336,8 +368,7 @@ export function registerWriteCommands(program) {
336
368
  const detach = opts["detach"] === true;
337
369
  const dest = project !== undefined || area !== undefined || opts["heading"] !== undefined;
338
370
  if ((inbox && (dest || detach)) || (detach && dest)) {
339
- process.stderr.write("error: --inbox/--detach are exclusive with each other and with --project/--area/--heading\n");
340
- process.exitCode = ExitCode.Usage;
371
+ usageError(opts, "--inbox/--detach are exclusive with each other and with --project/--area/--heading");
341
372
  return;
342
373
  }
343
374
  await runWrite(opts, (c) => c.write.moveTodo(uuid, {
@@ -359,23 +390,22 @@ export function registerWriteCommands(program) {
359
390
  "available for repeating to-dos.")).action(async (uuid, opts) => {
360
391
  await runWrite(opts, (c) => c.write.duplicateTodo(uuid, writeOptionsFrom(opts)));
361
392
  });
362
- addWriteFlags(todo
393
+ addCreateTagsFlag(addWriteFlags(todo
363
394
  .command("tags <uuid>")
364
395
  .description("Set or extend a to-do's tags. --set REPLACES the full tag set (an empty value " +
365
- "clears all tags); --add merges with the current tags. Tags must name existing " +
366
- "tags unknown tags are rejected.")
367
- .option("--set <list>", "comma-separated tag names: full replacement")
368
- .option("--add <list>", "comma-separated tag names: merge with existing")).action(async (uuid, opts) => {
396
+ "clears all tags); --add merges with the current tags. Each tag may be a name " +
397
+ "or a parent/child path, and must exist unless --create-tags.")
398
+ .option("--set <list>", "comma-separated tags: full replacement")
399
+ .option("--add <list>", "comma-separated tags: merge with existing"))).action(async (uuid, opts) => {
369
400
  const set = splitCsv(opts["set"]);
370
401
  const add = splitCsv(opts["add"]);
371
402
  if ((set === undefined) === (add === undefined)) {
372
- process.stderr.write("error: pass exactly one of --set or --add\n");
373
- process.exitCode = ExitCode.Usage;
403
+ usageError(opts, "pass exactly one of --set or --add");
374
404
  return;
375
405
  }
376
406
  await runWrite(opts, (c) => set !== undefined
377
- ? c.write.setTags(uuid, set, writeOptionsFrom(opts))
378
- : c.write.addTags(uuid, add ?? [], writeOptionsFrom(opts)));
407
+ ? c.write.setTags(uuid, set, writeOptionsFrom(opts, createTagsExtra(opts)))
408
+ : c.write.addTags(uuid, add ?? [], writeOptionsFrom(opts, createTagsExtra(opts))));
379
409
  });
380
410
  addWriteFlags(todo
381
411
  .command("checklist <uuid>")
@@ -401,36 +431,30 @@ export function registerWriteCommands(program) {
401
431
  .option("--to-position <n>", "1-based position for --move-item")).action(async (uuid, opts) => {
402
432
  const granular = ["add", "remove", "check", "uncheck", "rename", "moveItem"].filter((k) => opts[k] !== undefined);
403
433
  if (granular.length > 1) {
404
- process.stderr.write("error: pass at most ONE granular checklist action per call\n");
405
- process.exitCode = ExitCode.Usage;
434
+ usageError(opts, "pass at most ONE granular checklist action per call");
406
435
  return;
407
436
  }
408
437
  const action = granular[0];
409
438
  if (action !== undefined) {
410
439
  if (opts["item"].length > 0) {
411
- process.stderr.write("error: --item (wholesale) is exclusive with granular actions\n");
412
- process.exitCode = ExitCode.Usage;
440
+ usageError(opts, "--item (wholesale) is exclusive with granular actions");
413
441
  return;
414
442
  }
415
443
  if (action === "rename" && opts["to"] === undefined) {
416
- process.stderr.write("error: --rename requires --to <title>\n");
417
- process.exitCode = ExitCode.Usage;
444
+ usageError(opts, "--rename requires --to <title>");
418
445
  return;
419
446
  }
420
447
  if (action === "moveItem" && opts["toPosition"] === undefined) {
421
- process.stderr.write("error: --move-item requires --to-position <n>\n");
422
- process.exitCode = ExitCode.Usage;
448
+ usageError(opts, "--move-item requires --to-position <n>");
423
449
  return;
424
450
  }
425
451
  // Target: --index (1-based) OR the action flag's title value. When a
426
452
  // targeting flag is present without a value commander yields `true`.
427
- const flagVal = (v) => (typeof v === "string" ? v : undefined);
428
453
  const target = opts["index"] !== undefined
429
454
  ? { index: Number(opts["index"]) }
430
455
  : { item: flagVal(opts[action]) ?? "" };
431
456
  if (action !== "add" && opts["index"] === undefined && target.item === "") {
432
- process.stderr.write(`error: --${action} needs a title, or use --index <n>\n`);
433
- process.exitCode = ExitCode.Usage;
457
+ usageError(opts, `--${action} needs a title, or use --index <n>`);
434
458
  return;
435
459
  }
436
460
  const edit = action === "add"
@@ -509,6 +533,64 @@ export function registerWriteCommands(program) {
509
533
  ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
510
534
  }, writeOptionsFrom(opts)));
511
535
  });
536
+ // --- ui vector: GUI-driven transforms (two-key gated) --------------------
537
+ const REPEAT_FREQ_HELP = "daily | weekly | monthly | yearly";
538
+ const REPEAT_INTERVAL_HELP = "every N units (1–99)";
539
+ for (const [verb, op, desc] of [
540
+ [
541
+ "make-repeating",
542
+ "todo.make-repeating",
543
+ "Turn a plain to-do into a repeating one. This REPLACES the to-do with a new repeating " +
544
+ "series — the original disappears and a fresh recurring item takes its place " +
545
+ "(cannot be undone). Set the frequency and interval, and optionally the weekday set, " +
546
+ "monthly/yearly day, end bound, reminders, or deadline.",
547
+ ],
548
+ [
549
+ "reschedule-repeat",
550
+ "todo.reschedule-repeat",
551
+ "Change an existing repeating to-do's rule in place (the item keeps its identity). Set the " +
552
+ "frequency and interval, and optionally the weekday set, monthly/yearly day, end bound, " +
553
+ "reminders, or deadline. `things undo` restores the previous rule.",
554
+ ],
555
+ ]) {
556
+ addDriveGuiFlag(addRepeatRuleFlags(addWriteFlags(todo
557
+ .command(`${verb} <uuid>`)
558
+ .description(desc)
559
+ .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
560
+ .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (uuid, opts) => {
561
+ const frequency = opts["frequency"];
562
+ await runWrite(opts, (c) => c.write.run(op, {
563
+ uuid,
564
+ frequency,
565
+ interval: Number(opts["interval"]),
566
+ ...repeatRuleFlagsFromOpts(opts, frequency),
567
+ }, writeOptionsFrom(opts)));
568
+ });
569
+ }
570
+ for (const [verb, op, desc] of [
571
+ [
572
+ "pause-repeat",
573
+ "todo.pause-repeat",
574
+ "Pause a repeating to-do: it stops spawning new occurrences but keeps its rule. Reversible " +
575
+ "with `things todo resume-repeat`.",
576
+ ],
577
+ [
578
+ "resume-repeat",
579
+ "todo.resume-repeat",
580
+ "Resume a paused repeating to-do: it starts spawning occurrences again.",
581
+ ],
582
+ [
583
+ "convert-to-project",
584
+ "todo.convert-to-project",
585
+ "Convert a to-do into a project. This REPLACES the to-do with a new project (its notes are " +
586
+ "kept); the to-do's identity is gone and it cannot be undone. The new project's uuid is " +
587
+ "printed on success.",
588
+ ],
589
+ ]) {
590
+ addDriveGuiFlag(addWriteFlags(todo.command(`${verb} <uuid>`).description(desc))).action(async (uuid, opts) => {
591
+ await runWrite(opts, (c) => c.write.run(op, { uuid }, writeOptionsFrom(opts)));
592
+ });
593
+ }
512
594
  const heading = group(program, "heading", "Heading-scoped operations");
513
595
  addWriteFlags(heading
514
596
  .command("add <project> <title>")
@@ -556,7 +638,90 @@ export function registerWriteCommands(program) {
556
638
  return outcome.heading;
557
639
  });
558
640
  });
641
+ addDriveGuiFlag(addWriteFlags(heading
642
+ .command("convert-to-project <uuid>")
643
+ .description("Convert a heading into a project. This REPLACES the heading with a new project — it " +
644
+ "is promoted alongside its parent project (into the same area) and the heading's " +
645
+ "to-dos move under the new project. The heading's identity is gone and it cannot be " +
646
+ "undone. The new project's uuid is printed on success."))).action(async (uuid, opts) => {
647
+ await runWrite(opts, (c) => c.write.run("heading.convert-to-project", { uuid }, writeOptionsFrom(opts)));
648
+ });
559
649
  const project = group(program, "project", "Project-scoped operations");
650
+ // --- ui vector: repeating-project transforms (two-key gated) -------------
651
+ addDriveGuiFlag(addRepeatRuleFlags(addWriteFlags(project
652
+ .command("reschedule-repeat <ref>")
653
+ .description("Change an existing repeating project's rule in place (target by uuid or unique name; " +
654
+ "the project keeps its identity). " +
655
+ "Set the frequency and interval, and optionally the weekday set, monthly/yearly day, " +
656
+ "end bound, reminders, or deadline. `things undo` restores the previous rule.")
657
+ .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
658
+ .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (uuid, opts) => {
659
+ const frequency = opts["frequency"];
660
+ await runWrite(opts, (c) => c.write.run("project.reschedule-repeat", {
661
+ uuid,
662
+ frequency,
663
+ interval: Number(opts["interval"]),
664
+ ...repeatRuleFlagsFromOpts(opts, frequency),
665
+ }, writeOptionsFrom(opts)));
666
+ });
667
+ for (const [verb, op, desc] of [
668
+ [
669
+ "pause-repeat",
670
+ "project.pause-repeat",
671
+ "Pause a repeating project: it stops spawning new occurrences but keeps its rule. Reversible " +
672
+ "with `things project resume-repeat`.",
673
+ ],
674
+ [
675
+ "resume-repeat",
676
+ "project.resume-repeat",
677
+ "Resume a paused repeating project: it starts spawning occurrences again.",
678
+ ],
679
+ ]) {
680
+ addDriveGuiFlag(addWriteFlags(project.command(`${verb} <ref>`).description(desc))).action(async (uuid, opts) => {
681
+ await runWrite(opts, (c) => c.write.run(op, { uuid }, writeOptionsFrom(opts)));
682
+ });
683
+ }
684
+ addDriveGuiFlag(addRepeatRuleFlags(addWriteFlags(project
685
+ .command("make-repeating <ref>")
686
+ .description("Turn a project into a repeating one. This REPLACES the project with a new repeating " +
687
+ "series — the original disappears and a fresh recurring project takes its place (its " +
688
+ "area is kept; cannot be undone). An Anytime project with no area is moved to Someday " +
689
+ "first (a cleanup-free intermediate step, shown in --dry-run). Set the frequency and " +
690
+ "interval, and optionally the weekday set, monthly/yearly day, end bound, reminders, " +
691
+ "or deadline.")
692
+ .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
693
+ .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (uuid, opts) => {
694
+ const frequency = opts["frequency"];
695
+ await runWrite(opts, (c) => c.write.makeRepeatingProject(uuid, {
696
+ frequency,
697
+ interval: Number(opts["interval"]),
698
+ ...repeatRuleFlagsFromOpts(opts, frequency),
699
+ }, writeOptionsFrom(opts)));
700
+ });
701
+ addDriveGuiFlag(addWriteFlags(project
702
+ .command("create-repeating <title>")
703
+ .description("Create a project and turn it into a repeating series in ONE call. Two operations: the " +
704
+ "project is created first and PERSISTS even if the make-repeating step refuses; then it " +
705
+ "is promoted (which drives the GUI). Give --area to place it, or omit it to create in " +
706
+ "Someday. The new repeating project's uuid is printed on success.")
707
+ .option("--notes <text>", "notes body")
708
+ .option("--area <ref>", "destination area (uuid or unique name)")
709
+ .option("--deadline <date>", "YYYY-MM-DD")
710
+ .option("--todo <title>", "initial child to-do (repeatable)", collect, [])
711
+ .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
712
+ .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP))).action(async (title, opts) => {
713
+ const todos = opts["todo"];
714
+ const area = containerRef(opts["area"]);
715
+ await runWrite(opts, (c) => c.write.createRepeatingProject({
716
+ title,
717
+ ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
718
+ ...(area !== undefined && { area }),
719
+ ...(opts["deadline"] !== undefined && { deadline: opts["deadline"] }),
720
+ ...(todos.length > 0 && { todos }),
721
+ frequency: opts["frequency"],
722
+ interval: Number(opts["interval"]),
723
+ }, writeOptionsFrom(opts)));
724
+ });
560
725
  addWriteFlags(project
561
726
  .command("add <title>")
562
727
  .description("Create a project; its uuid is printed on success.")
@@ -577,11 +742,12 @@ export function registerWriteCommands(program) {
577
742
  }, writeOptionsFrom(opts)));
578
743
  });
579
744
  addWriteFlags(project
580
- .command("update <uuid>")
581
- .description("Update a project's title/notes/when/deadline/reminder. --append-notes/" +
582
- "--prepend-notes join with a newline (exclusive with --notes). --reminder needs " +
583
- "--when today|evening|YYYY-MM-DD; when re-scheduling WITHOUT --reminder an existing " +
584
- "reminder is auto-preserved."))
745
+ .command("update <ref>")
746
+ .description("Update a project's title/notes/when/deadline/reminder. Target by uuid or unique " +
747
+ "name a duplicated project name is refused, listing the candidates to pick from " +
748
+ "by uuid. --append-notes/--prepend-notes join with a newline (exclusive with " +
749
+ "--notes). --reminder needs --when today|evening|YYYY-MM-DD; when re-scheduling " +
750
+ "WITHOUT --reminder an existing reminder is auto-preserved."))
585
751
  .option("--title <text>", "new title")
586
752
  .option("--notes <text>", "replace notes")
587
753
  .option("--append-notes <text>", "append to existing notes (newline-joined)")
@@ -594,13 +760,11 @@ export function registerWriteCommands(program) {
594
760
  .action(async (uuid, opts) => {
595
761
  const notesModes = ["notes", "appendNotes", "prependNotes"].filter((k) => opts[k] !== undefined);
596
762
  if (notesModes.length > 1) {
597
- process.stderr.write("error: --notes, --append-notes, --prepend-notes are exclusive\n");
598
- process.exitCode = ExitCode.Usage;
763
+ usageError(opts, "--notes, --append-notes, --prepend-notes are exclusive");
599
764
  return;
600
765
  }
601
766
  if (opts["reminder"] !== undefined && opts["clearReminder"] === true) {
602
- process.stderr.write("error: pass at most one of --reminder / --clear-reminder\n");
603
- process.exitCode = ExitCode.Usage;
767
+ usageError(opts, "pass at most one of --reminder / --clear-reminder");
604
768
  return;
605
769
  }
606
770
  if (!whenSugarOk(opts))
@@ -621,33 +785,33 @@ export function registerWriteCommands(program) {
621
785
  ...(opts["clearDeadline"] === true && { deadline: null }),
622
786
  }, writeOptionsFrom(opts)));
623
787
  });
624
- addWriteFlags(project
625
- .command("tags <uuid>")
626
- .description("Set or extend a project's tags. --set REPLACES the full tag set (an empty value " +
627
- "clears all tags); --add merges with the current tags. Tags must name existing " +
628
- "tags unknown tags are rejected.")
629
- .option("--set <list>", "comma-separated tag names: full replacement")
630
- .option("--add <list>", "comma-separated tag names: merge with existing")).action(async (uuid, opts) => {
788
+ addCreateTagsFlag(addWriteFlags(project
789
+ .command("tags <ref>")
790
+ .description("Set or extend a project's tags (target by uuid or unique name). --set REPLACES the " +
791
+ "full tag set (an empty value clears all tags); --add merges with the current tags. " +
792
+ "Each tag may be a name or a parent/child path, and must exist unless " +
793
+ "--create-tags.")
794
+ .option("--set <list>", "comma-separated tags: full replacement")
795
+ .option("--add <list>", "comma-separated tags: merge with existing"))).action(async (uuid, opts) => {
631
796
  const set = splitCsv(opts["set"]);
632
797
  const add = splitCsv(opts["add"]);
633
798
  if ((set === undefined) === (add === undefined)) {
634
- process.stderr.write("error: pass exactly one of --set or --add\n");
635
- process.exitCode = ExitCode.Usage;
799
+ usageError(opts, "pass exactly one of --set or --add");
636
800
  return;
637
801
  }
638
802
  await runWrite(opts, (c) => set !== undefined
639
- ? c.write.setProjectTags(uuid, set, writeOptionsFrom(opts))
640
- : c.write.addProjectTags(uuid, add ?? [], writeOptionsFrom(opts)));
803
+ ? c.write.setProjectTags(uuid, set, writeOptionsFrom(opts, createTagsExtra(opts)))
804
+ : c.write.addProjectTags(uuid, add ?? [], writeOptionsFrom(opts, createTagsExtra(opts))));
641
805
  });
642
806
  addWriteFlags(project
643
- .command("move <uuid>")
644
- .description("Move a project to another area, or DETACH it from its current area (--detach). " +
645
- "Status and schedule are untouched. Unknown areas are rejected.")
807
+ .command("move <ref>")
808
+ .description("Move a project (target by uuid or unique name) to another area, or DETACH it from " +
809
+ "its current area (--detach). Status and schedule are untouched. Unknown areas are " +
810
+ "rejected.")
646
811
  .option("--area <ref>", "destination area (uuid or unique name)")
647
812
  .option("--detach", "remove the current area assignment (exclusive with --area)")).action(async (uuid, opts) => {
648
813
  if ((opts.detach === true) === (opts.area !== undefined)) {
649
- process.stderr.write("error: pass exactly one of --area / --detach\n");
650
- process.exitCode = ExitCode.Usage;
814
+ usageError(opts, "pass exactly one of --area / --detach");
651
815
  return;
652
816
  }
653
817
  await runWrite(opts, (c) => opts.detach === true
@@ -655,15 +819,17 @@ export function registerWriteCommands(program) {
655
819
  : c.write.moveProject(uuid, { uuid: opts.area, title: opts.area }, writeOptionsFrom(opts)));
656
820
  });
657
821
  addWriteFlags(project
658
- .command("cancel <uuid>")
659
- .description("Cancel a project. Canceling also cancels its open to-dos, so an explicit " +
660
- "--children policy is required; already-completed children are never altered.")
822
+ .command("cancel <ref>")
823
+ .description("Cancel a project (target by uuid or unique name). Canceling also cancels its open " +
824
+ "to-dos, so an explicit --children policy is required; already-completed children " +
825
+ "are never altered.")
661
826
  .requiredOption("--children <policy>", "require-resolved (error if open to-dos remain) | auto-cancel (cancel them too)")).action(async (uuid, opts) => {
662
827
  await runWrite(opts, (c) => c.write.cancelProject(uuid, { children: opts.children }, writeOptionsFrom(opts)));
663
828
  });
664
829
  addWriteFlags(project
665
- .command("reopen <uuid>")
666
- .description("Reopen a completed/canceled project. Its children stay completed/canceled unless " +
830
+ .command("reopen <ref>")
831
+ .description("Reopen a completed/canceled project (target by uuid or unique name). Its children " +
832
+ "stay completed/canceled unless " +
667
833
  "--restore-children also reopens the ones that were resolved together with the " +
668
834
  "project — children resolved earlier are never touched. Exit 3 if any child " +
669
835
  "restore fails.")
@@ -708,57 +874,87 @@ export function registerWriteCommands(program) {
708
874
  }
709
875
  });
710
876
  addWriteFlags(project
711
- .command("restore <uuid>")
712
- .description("Restore a TRASHED project IN PLACE: schedule, area, and children all keep their " +
713
- "state. Only trashed projects qualify.")).action(async (uuid, opts) => {
877
+ .command("restore <ref>")
878
+ .description("Restore a TRASHED project IN PLACE (target by uuid or unique name): schedule, area, " +
879
+ "and children all keep their state. Only trashed projects qualify.")).action(async (uuid, opts) => {
714
880
  await runWrite(opts, (c) => c.write.restoreProject(uuid, writeOptionsFrom(opts)));
715
881
  });
716
882
  addWriteFlags(project
717
- .command("duplicate <uuid>")
718
- .description("Duplicate a project INCLUDING its children; the copy's uuid is printed on " +
719
- "success. Not available for repeating projects.")).action(async (uuid, opts) => {
883
+ .command("duplicate <ref>")
884
+ .description("Duplicate a project (target by uuid or unique name) INCLUDING its children; the " +
885
+ "copy's uuid is printed on success. Not available for repeating projects.")).action(async (uuid, opts) => {
720
886
  await runWrite(opts, (c) => c.write.duplicateProject(uuid, writeOptionsFrom(opts)));
721
887
  });
722
888
  addWriteFlags(project
723
- .command("complete <uuid>")
724
- .description("Complete a project. Completing also completes its open to-dos, so an explicit " +
725
- "--children policy is required.")
889
+ .command("complete <ref>")
890
+ .description("Complete a project (target by uuid or unique name). Completing also completes its " +
891
+ "open to-dos, so an explicit --children policy is required.")
726
892
  .requiredOption("--children <policy>", "require-resolved (error if open to-dos remain) | auto-complete (complete them too)")).action(async (uuid, opts) => {
727
893
  await runWrite(opts, (c) => c.write.completeProject(uuid, { children: opts.children }, writeOptionsFrom(opts)));
728
894
  });
729
895
  addWriteFlags(project
730
- .command("delete <uuid>")
731
- .description("Move a project to the Trash; its children go with it (recover with `things " +
732
- "project restore`).")).action(async (uuid, opts) => {
896
+ .command("delete <ref>")
897
+ .description("Move a project (target by uuid or unique name) to the Trash; its children go with " +
898
+ "it (recover with `things project restore`).")).action(async (uuid, opts) => {
733
899
  await runWrite(opts, (c) => c.write.deleteProject(uuid, writeOptionsFrom(opts)));
734
900
  });
735
901
  const area = group(program, "area", "Area-scoped operations");
736
- addWriteFlags(area
902
+ addCreateTagsFlag(addWriteFlags(area
737
903
  .command("add <title>")
738
- .description("Create an area, optionally tagged with EXISTING tags.")
739
- .option("--tags <list>", "comma-separated existing tag names")).action(async (title, opts) => {
904
+ .description("Create an area, optionally tagged. Each tag may be a name or a " +
905
+ "parent/child path, and must exist unless --create-tags.")
906
+ .option("--tags <list>", "comma-separated tags; each a name or a parent/child path (must exist unless --create-tags)"))).action(async (title, opts) => {
740
907
  const tags = splitCsv(opts["tags"]);
741
- await runWrite(opts, (c) => c.write.addArea({ title, ...(tags !== undefined && { tags }) }, writeOptionsFrom(opts)));
908
+ await runWrite(opts, (c) => c.write.addArea({ title, ...(tags !== undefined && { tags }) }, writeOptionsFrom(opts, createTagsExtra(opts))));
742
909
  });
743
- addWriteFlags(area
744
- .command("update <target>")
745
- .description("Rename an area and/or replace its tags (the full set; tags must name existing " +
746
- "tags). Target by uuid or unique name.")
910
+ addCreateTagsFlag(addWriteFlags(area
911
+ .command("update <ref>")
912
+ .description("Rename an area and/or replace its tags (the full set). Each tag may be a name " +
913
+ "or a parent/child path, and must exist unless --create-tags. Target by " +
914
+ "uuid or unique name.")
747
915
  .option("--title <text>", "new name")
748
- .option("--tags <list>", 'comma-separated EXISTING tag names (full replacement; "" clears all)')).action(async (target, opts) => {
916
+ .option("--tags <list>", 'comma-separated tags (full replacement; "" clears all); each a name or a parent/child path'))).action(async (target, opts) => {
749
917
  const tags = splitCsv(opts["tags"]);
750
918
  if (opts["title"] === undefined && tags === undefined) {
751
- process.stderr.write("error: pass --title and/or --tags\n");
752
- process.exitCode = ExitCode.Usage;
919
+ usageError(opts, "pass --title and/or --tags");
753
920
  return;
754
921
  }
755
922
  await runWrite(opts, (c) => c.write.updateArea(target, {
756
923
  ...(opts["title"] !== undefined && { title: opts["title"] }),
757
924
  ...(tags !== undefined && { tags }),
925
+ }, writeOptionsFrom(opts, createTagsExtra(opts))));
926
+ });
927
+ addDriveGuiFlag(addWriteFlags(area
928
+ .command("reorder <ref>")
929
+ .description("Move an area to a new position in the area order (target by uuid or unique name). " +
930
+ "Pass exactly one destination: --before/--after another area, or --first/--last. " +
931
+ "This drives the Things window with the pointer — the app comes to the front and " +
932
+ "the sidebar may scroll while the area is moved; the area's projects and to-dos " +
933
+ "are untouched.")
934
+ .option("--before <area>", "place it immediately above this area (uuid or unique name)")
935
+ .option("--after <area>", "place it immediately below this area (uuid or unique name)")
936
+ .option("--first", "move it to the top of the area list")
937
+ .option("--last", "move it to the bottom of the area list"))).action(async (target, opts) => {
938
+ const chosen = [
939
+ opts["before"] !== undefined,
940
+ opts["after"] !== undefined,
941
+ opts["first"] === true,
942
+ opts["last"] === true,
943
+ ].filter(Boolean).length;
944
+ if (chosen !== 1) {
945
+ usageError(opts, "pass exactly one of --before / --after / --first / --last");
946
+ return;
947
+ }
948
+ await runWrite(opts, (c) => c.write.run("area.reorder", {
949
+ target,
950
+ ...(opts["before"] !== undefined && { before: opts["before"] }),
951
+ ...(opts["after"] !== undefined && { after: opts["after"] }),
952
+ ...(opts["first"] === true && { position: "first" }),
953
+ ...(opts["last"] === true && { position: "last" }),
758
954
  }, writeOptionsFrom(opts)));
759
955
  });
760
956
  addWriteFlags(area
761
- .command("delete <target>")
957
+ .command("delete <ref>")
762
958
  .description("Delete an area PERMANENTLY — areas do not go to the Trash, so this cannot be " +
763
959
  "undone; requires --dangerously-permanent. The area's to-dos move to the Trash; " +
764
960
  "its projects remain, no longer assigned to any area.")
@@ -777,7 +973,7 @@ export function registerWriteCommands(program) {
777
973
  await runWrite(opts, (c) => c.write.addTag({ title: name, ...(opts["parent"] !== undefined && { parent: opts["parent"] }) }, writeOptionsFrom(opts)));
778
974
  });
779
975
  addWriteFlags(tag
780
- .command("update <target>")
976
+ .command("update <ref>")
781
977
  .description("Rename a tag (existing assignments follow the rename), nest it under an existing " +
782
978
  "tag, UN-NEST it to the root (--unnest; exclusive with --parent), and set or clear " +
783
979
  "its keyboard shortcut (--shortcut / --clear-shortcut, exclusive).")
@@ -791,18 +987,15 @@ export function registerWriteCommands(program) {
791
987
  opts["unnest"] === undefined &&
792
988
  opts["shortcut"] === undefined &&
793
989
  opts["clearShortcut"] === undefined) {
794
- process.stderr.write("error: pass --title, --parent, --unnest, --shortcut, and/or --clear-shortcut\n");
795
- process.exitCode = ExitCode.Usage;
990
+ usageError(opts, "pass --title, --parent, --unnest, --shortcut, and/or --clear-shortcut");
796
991
  return;
797
992
  }
798
993
  if (opts["parent"] !== undefined && opts["unnest"] === true) {
799
- process.stderr.write("error: --parent and --unnest are exclusive\n");
800
- process.exitCode = ExitCode.Usage;
994
+ usageError(opts, "--parent and --unnest are exclusive");
801
995
  return;
802
996
  }
803
997
  if (opts["shortcut"] !== undefined && opts["clearShortcut"] === true) {
804
- process.stderr.write("error: --shortcut and --clear-shortcut are exclusive\n");
805
- process.exitCode = ExitCode.Usage;
998
+ usageError(opts, "--shortcut and --clear-shortcut are exclusive");
806
999
  return;
807
1000
  }
808
1001
  await runWrite(opts, (c) => c.write.updateTag(target, {
@@ -814,7 +1007,7 @@ export function registerWriteCommands(program) {
814
1007
  }, writeOptionsFrom(opts)));
815
1008
  });
816
1009
  addWriteFlags(tag
817
- .command("delete <target>")
1010
+ .command("delete <ref>")
818
1011
  .description("Delete a tag PERMANENTLY — tags do not go to the Trash, so this cannot be " +
819
1012
  "undone; requires --dangerously-permanent. The tag is removed from every item, " +
820
1013
  "and ALL of its nested child tags are deleted with it — requires " +
@@ -852,8 +1045,8 @@ export function registerWriteCommands(program) {
852
1045
  "options carry the confirmation flags (acknowledgeChecklistReset, " +
853
1046
  "acknowledgeProjectReopen, dangerouslyPermanent, acknowledgeTagSubtree). " +
854
1047
  "--dry-run plans everything without executing; --fail-fast skips the rest after " +
855
- "the first failure. Exit: 0 all ok · 3 any verify-failed/invalid · 4 any blocked " +
856
- "· 5 any drift-blocked.")
1048
+ "the first failure. Exit (worst failure wins): 0 all ok · 3 any verify-failed/invalid " +
1049
+ 4 any blocked · 5 any drift-blocked · 6 any unsupported.")
857
1050
  .option("--dry-run", "plan every op; execute nothing")
858
1051
  .option("--fail-fast", "skip remaining ops after the first failure")
859
1052
  .option("--json", "JSONL results + summary on stdout (also the default)")
@@ -894,9 +1087,6 @@ export function registerWriteCommands(program) {
894
1087
  let client = null;
895
1088
  try {
896
1089
  client = openThings(opts.db ? { dbPath: opts.db } : {});
897
- const emit = (r) => {
898
- process.stdout.write(`${JSON.stringify(r)}\n`);
899
- };
900
1090
  const results = await client.write.batch(ops, {
901
1091
  ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
902
1092
  ...(opts["failFast"] === true && { failFast: true }),
@@ -916,15 +1106,9 @@ export function registerWriteCommands(program) {
916
1106
  },
917
1107
  };
918
1108
  process.stdout.write(`${JSON.stringify(summary)}\n`);
919
- const kinds = new Set(failed.map((r) => r.outcome.kind));
920
- const reasons = new Set(failed.map((r) => (r.outcome.kind === "blocked" ? r.outcome.reason : "")));
921
- process.exitCode = reasons.has("drift")
922
- ? ExitCode.DriftBlocked
923
- : kinds.has("blocked")
924
- ? ExitCode.Blocked
925
- : failed.length > 0
926
- ? ExitCode.VerifyFailed
927
- : ExitCode.Ok;
1109
+ // Worst failure decides the exit code, by the stable precedence
1110
+ // drift > blocked > unsupported > verify-failed (see aggregateExitCode).
1111
+ process.exitCode = aggregateExitCode(failed.map((r) => r.outcome));
928
1112
  }
929
1113
  finally {
930
1114
  client?.close();
@@ -934,28 +1118,54 @@ export function registerWriteCommands(program) {
934
1118
  .command("undo")
935
1119
  .description("Undo the last N changes made through things-api, newest first — each undo applies " +
936
1120
  "the INVERSE change (recorded as actor `undo:<actor>`, never itself an undo " +
937
- "target). Changes made directly in the Things app cannot be undone here. " +
938
- "IRREVERSIBLE changes are reported, not guessed: permanent deletes and changes " +
939
- "whose prior state is unknown. Partial restores carry notes (e.g. a delete-undo " +
940
- "lands in the Inbox de-scheduled). --dry-run shows every inverse plan without " +
941
- "executing. Undoing a CREATED area/tag deletes it permanently requires " +
942
- "--dangerously-permanent. Unwinding stops at the first failed inverse. " +
943
- "Exit: 0 all ok · 3 any failed/partial · 0 with per-item detail otherwise.")
944
- .option("--last <n>", "how many trailing mutations to undo", "1")
1121
+ "target). By default undo is GLOBAL — the owner's Cmd+Z reversing the latest " +
1122
+ "changes whoever made them; narrow it with --by <actor> to undo only a given " +
1123
+ "author's changes (e.g. `--by mcp` to clean up after an agent), or --txn <token> " +
1124
+ "to undo one exact change by the `undoToken` its result returned (immune to any " +
1125
+ "changes made in between). Changes made directly in the Things app cannot be " +
1126
+ "undone here. IRREVERSIBLE changes are reported, not guessed: permanent deletes " +
1127
+ "and changes whose prior state is unknown. Partial restores carry notes (e.g. a " +
1128
+ "delete-undo lands in the Inbox de-scheduled). --dry-run shows every inverse plan " +
1129
+ "without executing. Undoing a CREATED area/tag deletes it permanently — requires " +
1130
+ "--dangerously-permanent. An undo is refused when the item changed outside things-api " +
1131
+ "since (its list/project, status, schedule, trashed state, or a field like the title " +
1132
+ "moved) — pass --acknowledge-out-of-band-changes to overwrite it anyway. Unwinding " +
1133
+ "stops at the first failed inverse. " +
1134
+ "Exit: 0 all ok · 3 any failed/partial · 2 nothing matched or bad flags.")
1135
+ .option("--last <n>", "how many trailing mutations to undo (default 1)")
1136
+ .option("--by <actor>", "undo only changes recorded under this author — an exact actor name (`mike`, `mcp`) " +
1137
+ "or `*` for all; matches exactly, so `--by mcp` never touches an `undo:mcp` " +
1138
+ "record. This SELECTS which changes to undo; --actor names who the undo is " +
1139
+ "recorded as. Not combinable with --txn.")
1140
+ .option("--txn <token>", "undo exactly the one change with this undo token (the `undoToken` field from its " +
1141
+ "result); immune to interleaving. Not combinable with --last/--by.")
945
1142
  .option("--dry-run", "show the inverse plans; execute nothing")
946
1143
  .option("--dangerously-permanent", "allow inverses that delete areas/tags permanently")
1144
+ .option("--acknowledge-out-of-band-changes", "proceed even when the item changed outside things-api since (in the Things app or by " +
1145
+ "another tool) — overwrites whatever the out-of-band change left")
947
1146
  .option("--json", "JSONL per-item results + summary on stdout (also the default)")
948
1147
  .option("--db <path>", "explicit database path")
949
1148
  .option("--verify-timeout <ms>", "how long to wait for each inverse change to take effect")
950
- .option("--actor <name>", "author name recorded for the undo (as undo:<name>)")
1149
+ .option("--actor <name>", "author name RECORDED for the undo (as undo:<name>); see --by")
951
1150
  .action(async (opts) => {
1151
+ // --txn selects one exact record; --last/--by select a set. Mixing them
1152
+ // is a usage error (house style).
1153
+ if (opts["txn"] !== undefined && (opts["last"] !== undefined || opts["by"] !== undefined)) {
1154
+ usageError(opts, "--txn cannot be combined with --last or --by");
1155
+ return;
1156
+ }
952
1157
  let client = null;
953
1158
  try {
954
1159
  client = openThings(opts.db ? { dbPath: opts.db } : {});
955
1160
  const items = await client.write.undo({
956
- last: Number(opts["last"] ?? 1),
1161
+ ...(opts["last"] !== undefined && { last: Number(opts["last"]) }),
1162
+ ...(opts["by"] !== undefined && { by: String(opts["by"]) }),
1163
+ ...(opts["txn"] !== undefined && { txn: String(opts["txn"]) }),
957
1164
  ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
958
1165
  ...(opts["dangerouslyPermanent"] === true && { dangerouslyPermanent: true }),
1166
+ ...(opts["acknowledgeOutOfBandChanges"] === true && {
1167
+ acknowledgeOutOfBandChanges: true,
1168
+ }),
959
1169
  ...(opts.verifyTimeout !== undefined && {
960
1170
  verifyTimeoutMs: Number(opts.verifyTimeout),
961
1171
  }),
@@ -980,7 +1190,22 @@ export function registerWriteCommands(program) {
980
1190
  ? ExitCode.Usage
981
1191
  : ExitCode.Ok;
982
1192
  if (items.length === 0) {
983
- process.stderr.write("error: no undoable mutations found in the audit trail\n");
1193
+ // The JSONL summary line (targets: 0) already conveys this as data on
1194
+ // stdout; the prose note is a human-only affordance on stderr.
1195
+ if (opts.json !== true) {
1196
+ const scope = opts["by"] !== undefined ? ` for actor ${String(opts["by"])}` : "";
1197
+ process.stderr.write(`error: no undoable mutations found in the audit trail${scope}\n`);
1198
+ }
1199
+ }
1200
+ }
1201
+ catch (err) {
1202
+ // runUndo throws RangeError for a --txn token that names no undoable
1203
+ // mutation or one already undone — a usage error (exit 2).
1204
+ if (err instanceof RangeError) {
1205
+ usageError(opts, err.message);
1206
+ }
1207
+ else {
1208
+ throw err;
984
1209
  }
985
1210
  }
986
1211
  finally {
@@ -1034,6 +1259,10 @@ export function registerWriteCommands(program) {
1034
1259
  for (const entry of data) {
1035
1260
  process.stdout.write(`${entry.op}\n`);
1036
1261
  process.stdout.write(` undo: ${entry.undo.class}${entry.undo.ack !== undefined ? ` (ack: ${entry.undo.ack})` : ""} — ${entry.undo.note}\n`);
1262
+ if (entry.certification !== undefined) {
1263
+ process.stdout.write(` certification: ${entry.certification.status}` +
1264
+ `${entry.certification.evidence.length > 0 ? ` (${entry.certification.evidence.join(", ")})` : ""}\n`);
1265
+ }
1037
1266
  for (const v of entry.vectors) {
1038
1267
  const s = v;
1039
1268
  process.stdout.write(` ${v.vector}: ${s.support}${s.disruption !== undefined ? ` (tier ${s.disruption}, ${s.validation})` : ""}${s.notes !== undefined ? ` — ${s.notes}` : ""}\n`);
@@ -1066,7 +1295,7 @@ export function registerWriteCommands(program) {
1066
1295
  config
1067
1296
  .command("set <key> <value>")
1068
1297
  .description("Persist a config key: profile | maxDisruption | actor | auditEnabled | " +
1069
- "accepted-fingerprint | allow-experimental")
1298
+ "accepted-fingerprint | allow-experimental | ui-enabled")
1070
1299
  .action((key, value) => {
1071
1300
  const map = {
1072
1301
  profile: "profile",
@@ -1075,6 +1304,7 @@ export function registerWriteCommands(program) {
1075
1304
  auditEnabled: "auditEnabled",
1076
1305
  "accepted-fingerprint": "acceptedFingerprint",
1077
1306
  "allow-experimental": "allowExperimental",
1307
+ "ui-enabled": "uiEnabled",
1078
1308
  };
1079
1309
  const target = map[key];
1080
1310
  if (target === undefined) {
@@ -1084,7 +1314,7 @@ export function registerWriteCommands(program) {
1084
1314
  }
1085
1315
  const parsed = target === "maxDisruption"
1086
1316
  ? Number(value)
1087
- : target === "auditEnabled" || target === "allowExperimental"
1317
+ : target === "auditEnabled" || target === "allowExperimental" || target === "uiEnabled"
1088
1318
  ? value === "true"
1089
1319
  : value;
1090
1320
  saveConfigKey(target, parsed);