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