things-api 0.10.0 → 0.12.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 (239) hide show
  1. package/README.md +50 -13
  2. package/dist/audit/schema.d.ts +7 -0
  3. package/dist/audit/schema.js.map +1 -1
  4. package/dist/cli/clock.d.ts +8 -0
  5. package/dist/cli/clock.js +24 -0
  6. package/dist/cli/clock.js.map +1 -0
  7. package/dist/cli/commands/area.d.ts +18 -11
  8. package/dist/cli/commands/area.js +61 -74
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -2
  11. package/dist/cli/commands/doctor.js +11 -1
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/install-skill.d.ts +60 -0
  14. package/dist/cli/commands/install-skill.js +256 -0
  15. package/dist/cli/commands/install-skill.js.map +1 -0
  16. package/dist/cli/commands/mcp.js +9 -0
  17. package/dist/cli/commands/mcp.js.map +1 -1
  18. package/dist/cli/commands/project.d.ts +1 -0
  19. package/dist/cli/commands/project.js +27 -34
  20. package/dist/cli/commands/project.js.map +1 -1
  21. package/dist/cli/commands/reads.d.ts +15 -2
  22. package/dist/cli/commands/reads.js +144 -44
  23. package/dist/cli/commands/reads.js.map +1 -1
  24. package/dist/cli/commands/setup.js +7 -2
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/show.js +38 -14
  27. package/dist/cli/commands/show.js.map +1 -1
  28. package/dist/cli/commands/todo.js +15 -9
  29. package/dist/cli/commands/todo.js.map +1 -1
  30. package/dist/cli/commands/writes.js +797 -174
  31. package/dist/cli/commands/writes.js.map +1 -1
  32. package/dist/cli/did-you-mean.d.ts +14 -3
  33. package/dist/cli/did-you-mean.js +26 -3
  34. package/dist/cli/did-you-mean.js.map +1 -1
  35. package/dist/cli/glyphs.d.ts +32 -2
  36. package/dist/cli/glyphs.js +83 -12
  37. package/dist/cli/glyphs.js.map +1 -1
  38. package/dist/cli/help.js +121 -16
  39. package/dist/cli/help.js.map +1 -1
  40. package/dist/cli/main.js +33 -2
  41. package/dist/cli/main.js.map +1 -1
  42. package/dist/cli/move-hint.d.ts +46 -0
  43. package/dist/cli/move-hint.js +196 -0
  44. package/dist/cli/move-hint.js.map +1 -0
  45. package/dist/cli/period.d.ts +2 -8
  46. package/dist/cli/period.js +44 -30
  47. package/dist/cli/period.js.map +1 -1
  48. package/dist/cli/read-driver.d.ts +43 -5
  49. package/dist/cli/read-driver.js +91 -15
  50. package/dist/cli/read-driver.js.map +1 -1
  51. package/dist/cli/render.d.ts +19 -8
  52. package/dist/cli/render.js +70 -27
  53. package/dist/cli/render.js.map +1 -1
  54. package/dist/cli/resolve-invocation.d.ts +2 -2
  55. package/dist/cli/resolve-invocation.js +3 -3
  56. package/dist/cli/resolve-invocation.js.map +1 -1
  57. package/dist/cli/skill-check.d.ts +27 -0
  58. package/dist/cli/skill-check.js +99 -0
  59. package/dist/cli/skill-check.js.map +1 -0
  60. package/dist/cli/skill.d.ts +70 -0
  61. package/dist/cli/skill.js +152 -0
  62. package/dist/cli/skill.js.map +1 -0
  63. package/dist/cli/tag-filters.d.ts +6 -2
  64. package/dist/cli/tag-filters.js +8 -6
  65. package/dist/cli/tag-filters.js.map +1 -1
  66. package/dist/cli/verb-hint.js +28 -3
  67. package/dist/cli/verb-hint.js.map +1 -1
  68. package/dist/cli/version.d.ts +4 -0
  69. package/dist/cli/version.js +18 -0
  70. package/dist/cli/version.js.map +1 -0
  71. package/dist/client.d.ts +171 -27
  72. package/dist/client.js +279 -40
  73. package/dist/client.js.map +1 -1
  74. package/dist/config.d.ts +66 -2
  75. package/dist/config.js +120 -12
  76. package/dist/config.js.map +1 -1
  77. package/dist/contracts.d.ts +186 -28
  78. package/dist/contracts.js +22 -1
  79. package/dist/contracts.js.map +1 -1
  80. package/dist/diagnose.d.ts +34 -2
  81. package/dist/diagnose.js +36 -1
  82. package/dist/diagnose.js.map +1 -1
  83. package/dist/index.d.ts +25 -9
  84. package/dist/index.js +22 -3
  85. package/dist/index.js.map +1 -1
  86. package/dist/mcp/server.d.ts +9 -0
  87. package/dist/mcp/server.js +1157 -684
  88. package/dist/mcp/server.js.map +1 -1
  89. package/dist/model/clock.d.ts +61 -0
  90. package/dist/model/clock.js +71 -0
  91. package/dist/model/clock.js.map +1 -0
  92. package/dist/model/dates.d.ts +26 -3
  93. package/dist/model/dates.js +84 -3
  94. package/dist/model/dates.js.map +1 -1
  95. package/dist/model/entities.d.ts +60 -3
  96. package/dist/model/entities.js.map +1 -1
  97. package/dist/model/mappers.d.ts +4 -2
  98. package/dist/model/mappers.js +66 -9
  99. package/dist/model/mappers.js.map +1 -1
  100. package/dist/model/recurrence.d.ts +8 -1
  101. package/dist/model/recurrence.js.map +1 -1
  102. package/dist/read/area-filter.d.ts +76 -0
  103. package/dist/read/area-filter.js +59 -0
  104. package/dist/read/area-filter.js.map +1 -0
  105. package/dist/read/area-view.d.ts +12 -12
  106. package/dist/read/area-view.js +58 -34
  107. package/dist/read/area-view.js.map +1 -1
  108. package/dist/read/detail.d.ts +1 -1
  109. package/dist/read/detail.js +26 -12
  110. package/dist/read/detail.js.map +1 -1
  111. package/dist/read/log-boundary.d.ts +1 -1
  112. package/dist/read/log-boundary.js +13 -3
  113. package/dist/read/log-boundary.js.map +1 -1
  114. package/dist/read/predicates.d.ts +18 -0
  115. package/dist/read/predicates.js +19 -0
  116. package/dist/read/predicates.js.map +1 -1
  117. package/dist/read/project-view.d.ts +32 -16
  118. package/dist/read/project-view.js +80 -45
  119. package/dist/read/project-view.js.map +1 -1
  120. package/dist/read/pseudo-area.d.ts +29 -0
  121. package/dist/read/pseudo-area.js +27 -0
  122. package/dist/read/pseudo-area.js.map +1 -0
  123. package/dist/read/queries.d.ts +108 -19
  124. package/dist/read/queries.js +212 -35
  125. package/dist/read/queries.js.map +1 -1
  126. package/dist/read/scope.d.ts +126 -0
  127. package/dist/read/scope.js +162 -0
  128. package/dist/read/scope.js.map +1 -0
  129. package/dist/read/search-rank.d.ts +2 -5
  130. package/dist/read/shape.d.ts +162 -0
  131. package/dist/read/shape.js +686 -0
  132. package/dist/read/shape.js.map +1 -0
  133. package/dist/read/show-target.d.ts +9 -1
  134. package/dist/read/show-target.js +45 -4
  135. package/dist/read/show-target.js.map +1 -1
  136. package/dist/read/snapshot.d.ts +1 -1
  137. package/dist/read/snapshot.js +9 -2
  138. package/dist/read/snapshot.js.map +1 -1
  139. package/dist/read/stage.d.ts +199 -0
  140. package/dist/read/stage.js +125 -0
  141. package/dist/read/stage.js.map +1 -0
  142. package/dist/read/truncation.d.ts +8 -6
  143. package/dist/read/truncation.js +35 -10
  144. package/dist/read/truncation.js.map +1 -1
  145. package/dist/read/views.d.ts +61 -20
  146. package/dist/read/views.js +240 -82
  147. package/dist/read/views.js.map +1 -1
  148. package/dist/surface-copy.d.ts +9 -0
  149. package/dist/surface-copy.js +9 -0
  150. package/dist/surface-copy.js.map +1 -1
  151. package/dist/write/accessibility-probe.d.ts +1 -1
  152. package/dist/write/accessibility-probe.js +8 -0
  153. package/dist/write/accessibility-probe.js.map +1 -1
  154. package/dist/write/automation-probe.d.ts +1 -1
  155. package/dist/write/automation-probe.js +8 -0
  156. package/dist/write/automation-probe.js.map +1 -1
  157. package/dist/write/batch.d.ts +49 -10
  158. package/dist/write/batch.js +423 -70
  159. package/dist/write/batch.js.map +1 -1
  160. package/dist/write/commands.d.ts +1 -1
  161. package/dist/write/commands.js +316 -66
  162. package/dist/write/commands.js.map +1 -1
  163. package/dist/write/guards.d.ts +1 -1
  164. package/dist/write/guards.js +94 -10
  165. package/dist/write/guards.js.map +1 -1
  166. package/dist/write/heading.d.ts +10 -1
  167. package/dist/write/heading.js +35 -5
  168. package/dist/write/heading.js.map +1 -1
  169. package/dist/write/make-repeating-project.d.ts +2 -2
  170. package/dist/write/make-repeating-project.js +9 -9
  171. package/dist/write/make-repeating-project.js.map +1 -1
  172. package/dist/write/move.d.ts +130 -0
  173. package/dist/write/move.js +1533 -0
  174. package/dist/write/move.js.map +1 -0
  175. package/dist/write/operations.d.ts +105 -19
  176. package/dist/write/operations.js +56 -7
  177. package/dist/write/operations.js.map +1 -1
  178. package/dist/write/opid.d.ts +31 -0
  179. package/dist/write/opid.js +30 -0
  180. package/dist/write/opid.js.map +1 -0
  181. package/dist/write/pipeline.d.ts +122 -3
  182. package/dist/write/pipeline.js +309 -52
  183. package/dist/write/pipeline.js.map +1 -1
  184. package/dist/write/planner.js +8 -1
  185. package/dist/write/planner.js.map +1 -1
  186. package/dist/write/pre-state.d.ts +197 -6
  187. package/dist/write/pre-state.js +427 -20
  188. package/dist/write/pre-state.js.map +1 -1
  189. package/dist/write/recurrence-rule-blob.d.ts +65 -0
  190. package/dist/write/recurrence-rule-blob.js +150 -0
  191. package/dist/write/recurrence-rule-blob.js.map +1 -0
  192. package/dist/write/reorder.d.ts +23 -3
  193. package/dist/write/reorder.js +1871 -85
  194. package/dist/write/reorder.js.map +1 -1
  195. package/dist/write/reversibility.js +19 -7
  196. package/dist/write/reversibility.js.map +1 -1
  197. package/dist/write/scope-guard.d.ts +31 -0
  198. package/dist/write/scope-guard.js +162 -0
  199. package/dist/write/scope-guard.js.map +1 -0
  200. package/dist/write/undo.js +120 -13
  201. package/dist/write/undo.js.map +1 -1
  202. package/dist/write/vectors/applescript.js +16 -7
  203. package/dist/write/vectors/applescript.js.map +1 -1
  204. package/dist/write/vectors/registry.d.ts +5 -7
  205. package/dist/write/vectors/registry.js +49 -2
  206. package/dist/write/vectors/registry.js.map +1 -1
  207. package/dist/write/vectors/shortcuts.js +1 -1
  208. package/dist/write/vectors/shortcuts.js.map +1 -1
  209. package/dist/write/vectors/simulator.d.ts +44 -0
  210. package/dist/write/vectors/simulator.js +1192 -0
  211. package/dist/write/vectors/simulator.js.map +1 -0
  212. package/dist/write/vectors/types.d.ts +46 -0
  213. package/dist/write/vectors/ui-certification.d.ts +10 -4
  214. package/dist/write/vectors/ui-certification.js +43 -10
  215. package/dist/write/vectors/ui-certification.js.map +1 -1
  216. package/dist/write/vectors/ui-drag.d.ts +21 -1
  217. package/dist/write/vectors/ui-drag.js +138 -27
  218. package/dist/write/vectors/ui-drag.js.map +1 -1
  219. package/dist/write/vectors/ui-recipes.d.ts +34 -1
  220. package/dist/write/vectors/ui-recipes.js +217 -6
  221. package/dist/write/vectors/ui-recipes.js.map +1 -1
  222. package/dist/write/vectors/ui.d.ts +40 -1
  223. package/dist/write/vectors/ui.js +196 -39
  224. package/dist/write/vectors/ui.js.map +1 -1
  225. package/dist/write/verify/delta.d.ts +127 -2
  226. package/dist/write/verify/delta.js +255 -16
  227. package/dist/write/verify/delta.js.map +1 -1
  228. package/dist/write/verify/poller.d.ts +7 -1
  229. package/dist/write/verify/poller.js +20 -1
  230. package/dist/write/verify/poller.js.map +1 -1
  231. package/package.json +24 -3
  232. package/schema/envelope.schema.json +383 -0
  233. package/skills/things-cli/SKILL.md +70 -0
  234. package/skills/things-cli/references/banner.md +35 -0
  235. package/skills/things-cli/references/contracts.md +62 -0
  236. package/skills/things-cli/references/errors.md +49 -0
  237. package/skills/things-cli/references/gui.md +11 -0
  238. package/skills/things-cli/references/model.md +60 -0
  239. package/skills/things-cli/references/ordering.md +71 -0
@@ -1,7 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
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";
3
+ import { aggregateExitCode, blockedCode, capabilitiesTable, ClockError, describeConfig, errorEnvelope, ExitCode, getConfigKey, mutationWireData, okEnvelope, openThings, OP_ID_RE, outcomeFailed, ReferenceResolutionError, saveConfigKey, splitWhenSugar, ThingsDbNotFoundError, ThingsDbOpenError, verifyFailedCode, } from "../../index.js";
4
4
  import { usageError } from "../read-driver.js";
5
+ import { dim } from "../style.js";
5
6
  function addWriteFlags(cmd) {
6
7
  return cmd
7
8
  .option("--json", "emit versioned JSON envelope on stdout")
@@ -11,14 +12,68 @@ function addWriteFlags(cmd) {
11
12
  .option("--allow-disruptive", "permit changes that briefly steal window focus")
12
13
  .option("--allow-very-disruptive", "permit changes that visibly drive the Things UI")
13
14
  .option("--verify-timeout <ms>", "how long to wait for the change to take effect")
14
- .option("--actor <name>", "author name recorded for this change (default: from config)");
15
+ .option("--actor <name>", "author name recorded for this change (default: from config)")
16
+ .option("--op-id <key>", "idempotency key: a resubmission with the same key is recognized as already applied " +
17
+ "and not re-run (matches [A-Za-z0-9_-], 1-64 chars)");
18
+ }
19
+ /** Validate an `--op-id`; on a malformed value emit the usage error and return false. */
20
+ function opIdOk(opts) {
21
+ if (opts.opId === undefined)
22
+ return true;
23
+ if (OP_ID_RE.test(opts.opId))
24
+ return true;
25
+ usageError(opts, "--op-id must match [A-Za-z0-9_-] and be 1-64 characters");
26
+ return false;
27
+ }
28
+ /**
29
+ * Refuse `--op-id` on a multi-leg COMPOUND command. Single-op idempotency (phase
30
+ * 1) replays exactly ONE recorded ok result; a compound records several (or a
31
+ * summary) and its idempotency is the batch-shaped per-line `op_id`. Refusing is
32
+ * honest — dropping the flag silently would leave a resubmission un-deduped.
33
+ * Returns true when refused (the caller returns).
34
+ */
35
+ function opIdCompoundRefused(opts, what) {
36
+ if (opts.opId === undefined)
37
+ return false;
38
+ usageError(opts, `--op-id is not available on ${what} (a multi-leg compound in phase 1) — express it as ` +
39
+ "`things batch` with a per-line op_id for idempotent resubmission");
40
+ return true;
15
41
  }
16
42
  /** A commander flag value when present-with-value (bare presence yields `true`). */
17
43
  const flagVal = (v) => (typeof v === "string" ? v : undefined);
18
- /** Writes one batch result as an NDJSON line to stdout. */
44
+ /**
45
+ * Writes one batch result as a FLATTENED NDJSON line to stdout: the internal
46
+ * `outcome` union object is collapsed to one level. `outcome` becomes its tag
47
+ * as a plain string (`"ok"`, `"blocked"`, `"verify-failed"`, `"unsupported"`,
48
+ * `"dry-run"`, `"invalid"`, `"skipped"`, `"already-applied"`, `"bounce-aborted"`),
49
+ * and every variant field (uuid, detail, plan, expected, observed, considered,
50
+ * placed, remaining, cause, …) sits as a sibling of the line-level keys. The
51
+ * outcome's own `op` duplicates the line-level `op`, so it is dropped. No
52
+ * variant field name collides with a line-level key (index, op, outcome,
53
+ * tempId, boundUuid, opId).
54
+ */
19
55
  const emit = (r) => {
20
- process.stdout.write(`${JSON.stringify(r)}\n`);
56
+ const { index, op, outcome, tempId, boundUuid, opId } = r;
57
+ const { kind, op: _outcomeOp, ...variant } = outcome;
58
+ const line = {
59
+ index,
60
+ op,
61
+ outcome: kind,
62
+ ...variant,
63
+ ...(tempId !== undefined && { tempId }),
64
+ ...(boundUuid !== undefined && { boundUuid }),
65
+ ...(opId !== undefined && { opId }),
66
+ };
67
+ process.stdout.write(`${JSON.stringify(line)}\n`);
21
68
  };
69
+ /**
70
+ * One `config get` line: `key: value`, plus a dim provenance marker for a
71
+ * default, env-sourced, or derived value (stored keys render bare).
72
+ */
73
+ function configKeyLine(entry) {
74
+ const marker = entry.source === "stored" ? "" : ` ${dim(`(${entry.source})`)}`;
75
+ return `${entry.key}: ${String(entry.value)}${marker}`;
76
+ }
22
77
  function writeOptionsFrom(opts, extra = {}) {
23
78
  const maxDisruption = opts.allowVeryDisruptive
24
79
  ? 3
@@ -32,6 +87,7 @@ function writeOptionsFrom(opts, extra = {}) {
32
87
  ...(opts.verifyTimeout !== undefined && { verifyTimeoutMs: Number(opts.verifyTimeout) }),
33
88
  ...(opts.actor !== undefined && { actor: opts.actor }),
34
89
  ...(opts.dangerouslyDriveGui === true && { dangerouslyDriveGui: true }),
90
+ ...(opts.opId !== undefined && { opId: opts.opId }),
35
91
  ...extra,
36
92
  };
37
93
  }
@@ -41,18 +97,48 @@ function writeOptionsFrom(opts, extra = {}) {
41
97
  * refusing. Nesting is via the clean `make new tag` path.
42
98
  */
43
99
  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");
100
+ return cmd.option("--create-tags", "create any missing tag (nesting parent/child) instead of failing on an unknown tag");
46
101
  }
47
102
  /** WriteOptions extra carrying createTags when the flag is set. */
48
103
  function createTagsExtra(opts) {
49
104
  return opts["createTags"] === true ? { createTags: true } : {};
50
105
  }
106
+ /** Heading selector help string (exact title or uuid — never an ordinal). */
107
+ const HEADING_SEL_HELP = "heading selector: exact title or uuid";
108
+ /** The four heading-placement flags (spec §2), shared by add/move-heading. */
109
+ function addPlacementFlags(cmd) {
110
+ return cmd
111
+ .option("--first", "place it first among the project's headings")
112
+ .option("--last", "place it last among the project's headings")
113
+ .option("--before-heading <sel>", "place it immediately before this heading (title or uuid)")
114
+ .option("--after-heading <sel>", "place it immediately after this heading (title or uuid)");
115
+ }
116
+ function countPlacementFlags(opts) {
117
+ return [
118
+ opts["first"] === true,
119
+ opts["last"] === true,
120
+ opts["beforeHeading"] !== undefined,
121
+ opts["afterHeading"] !== undefined,
122
+ ].filter(Boolean).length;
123
+ }
124
+ /** Build the resolved placement from the flags, resolving anchor selectors. */
125
+ function headingPlacement(c, projectUuid, opts) {
126
+ if (opts["first"] === true)
127
+ return { position: "first" };
128
+ if (opts["last"] === true)
129
+ return { position: "last" };
130
+ if (opts["beforeHeading"] !== undefined) {
131
+ return { before: c.resolve.heading(projectUuid, opts["beforeHeading"]).uuid };
132
+ }
133
+ if (opts["afterHeading"] !== undefined) {
134
+ return { after: c.resolve.heading(projectUuid, opts["afterHeading"]).uuid };
135
+ }
136
+ return undefined;
137
+ }
51
138
  /** Add the mandatory GUI-drive acknowledgement to a ui-vector command. */
52
139
  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.");
140
+ return cmd.option("--dangerously-drive-gui", "required: visibly drives the Things app to make a change it offers nowhere else; " +
141
+ "also needs `things config set ui-enabled true`");
56
142
  }
57
143
  function collect(value, previous) {
58
144
  return [...previous, value];
@@ -89,23 +175,27 @@ function splitCsv(value) {
89
175
  .map((s) => s.trim())
90
176
  .filter((s) => s !== "");
91
177
  }
92
- async function runWrite(opts, fn) {
178
+ async function runWrite(opts, fn, emitFn = emitResult) {
179
+ if (!opIdOk(opts))
180
+ return;
93
181
  const started = Date.now();
94
182
  let client = null;
95
183
  const meta = (client_) => {
96
184
  let dbVersion = null;
97
185
  let fingerprint = "unknown";
186
+ let clock;
98
187
  if (client_ !== null) {
99
188
  const fp = client_.fingerprint();
100
189
  dbVersion = fp.observation.databaseVersion;
101
190
  fingerprint = fp.kind === "ok" ? "ok" : fp.kind === "drift" ? "drift" : "unknown";
191
+ clock = client_.clockMeta();
102
192
  }
103
- return { dbVersion, fingerprint, elapsedMs: Date.now() - started };
193
+ return { dbVersion, fingerprint, elapsedMs: Date.now() - started, ...(clock && { clock }) };
104
194
  };
105
195
  try {
106
196
  client = openThings(opts.db ? { dbPath: opts.db } : {});
107
197
  const result = await fn(client);
108
- emitResult(result, opts, meta(client));
198
+ emitFn(result, opts, meta(client));
109
199
  }
110
200
  catch (err) {
111
201
  // An unresolved write target (uuid/partial-uuid/name that is ambiguous or
@@ -116,7 +206,7 @@ async function runWrite(opts, fn) {
116
206
  process.stdout.write(`${JSON.stringify(errorEnvelope({
117
207
  code: err.code,
118
208
  message: err.message,
119
- details: { candidates: err.candidates },
209
+ detail: { candidates: err.candidates },
120
210
  }, meta(client)))}\n`);
121
211
  }
122
212
  else {
@@ -125,6 +215,11 @@ async function runWrite(opts, fn) {
125
215
  process.exitCode = ExitCode.Usage;
126
216
  return;
127
217
  }
218
+ // A malformed THINGS_TZ / THINGS_NOW fails closed as a usage error.
219
+ if (err instanceof ClockError) {
220
+ usageError(opts, err.message);
221
+ return;
222
+ }
128
223
  const isEnv = err instanceof ThingsDbNotFoundError || err instanceof ThingsDbOpenError;
129
224
  const message = err instanceof Error ? err.message : String(err);
130
225
  if (opts.json) {
@@ -162,11 +257,14 @@ function emitResult(result, opts, meta) {
162
257
  process.stderr.write(`warning: ${warning}\n`);
163
258
  }
164
259
  if (opts.json) {
165
- process.stdout.write(`${JSON.stringify(okEnvelope("mutation-result", result, meta))}\n`);
260
+ process.stdout.write(`${JSON.stringify(okEnvelope("mutation-result", mutationWireData(result), meta))}\n`);
166
261
  }
167
262
  else {
168
263
  const uuid = result.uuid === null ? "" : ` uuid=${result.uuid}`;
169
- process.stdout.write(`ok ${result.op}${uuid} (vector=${result.vector}, tier=${result.tier}, verified)\n`);
264
+ const status = "alreadyApplied" in result && result.alreadyApplied === true
265
+ ? "already applied — matched op-id in the change history, not re-run"
266
+ : `vector=${result.vector}, tier=${result.tier}, verified`;
267
+ process.stdout.write(`ok ${result.op}${uuid} (${status})\n`);
170
268
  }
171
269
  process.exitCode = ExitCode.Ok;
172
270
  return;
@@ -228,7 +326,7 @@ function emitResult(result, opts, meta) {
228
326
  process.stdout.write(`${JSON.stringify(errorEnvelope({
229
327
  code: "unsupported",
230
328
  message: `no validated vector supports ${result.op}`,
231
- detail: result.considered,
329
+ detail: { considered: result.considered },
232
330
  }, meta))}\n`);
233
331
  }
234
332
  else {
@@ -246,6 +344,292 @@ function emitResult(result, opts, meta) {
246
344
  }
247
345
  }
248
346
  }
347
+ // -------------------------------------------------- move / reorder (spec §4)
348
+ /** Open the client, run a MoveResult-returning fn, and render it. */
349
+ async function runMoveCmd(opts, fn) {
350
+ // A variadic move/reorder is a multi-leg COMPOUND (the batch-shaped case), not
351
+ // a single recorded mutation — single-op idempotency (--op-id) does not apply
352
+ // in phase 1. Refuse it loudly rather than silently drop it; the compound
353
+ // analogue is `things batch` with a per-line op_id.
354
+ if (opts.opId !== undefined) {
355
+ usageError(opts, "--op-id is not available on a variadic move/reorder (a multi-leg compound) — express it " +
356
+ "as `things batch` with a per-line op_id for idempotent resubmission");
357
+ return;
358
+ }
359
+ const started = Date.now();
360
+ let client = null;
361
+ const meta = () => {
362
+ let dbVersion = null;
363
+ let fingerprint = "unknown";
364
+ if (client !== null) {
365
+ const fp = client.fingerprint();
366
+ dbVersion = fp.observation.databaseVersion;
367
+ fingerprint = fp.kind === "ok" ? "ok" : fp.kind === "drift" ? "drift" : "unknown";
368
+ }
369
+ return { dbVersion, fingerprint, elapsedMs: Date.now() - started };
370
+ };
371
+ try {
372
+ client = openThings(opts.db ? { dbPath: opts.db } : {});
373
+ emitMoveResult(await fn(client), opts, meta());
374
+ }
375
+ catch (err) {
376
+ if (err instanceof ReferenceResolutionError) {
377
+ if (opts.json) {
378
+ process.stdout.write(`${JSON.stringify(errorEnvelope({ code: err.code, message: err.message, detail: { candidates: err.candidates } }, meta()))}\n`);
379
+ }
380
+ else {
381
+ process.stderr.write(`error: ${err.message}\n`);
382
+ }
383
+ process.exitCode = ExitCode.Usage;
384
+ return;
385
+ }
386
+ const message = err instanceof Error ? err.message : String(err);
387
+ if (opts.json) {
388
+ process.stdout.write(`${JSON.stringify(errorEnvelope({ code: "unexpected", message }, meta()))}\n`);
389
+ }
390
+ else {
391
+ process.stderr.write(`error: ${message}\n`);
392
+ }
393
+ process.exitCode = ExitCode.Unexpected;
394
+ }
395
+ finally {
396
+ client?.close();
397
+ }
398
+ }
399
+ function emitMoveResult(result, opts, meta) {
400
+ switch (result.kind) {
401
+ case "move-ok": {
402
+ if (opts.json) {
403
+ process.stdout.write(`${JSON.stringify(okEnvelope("move-result", mutationWireData(result), meta))}\n`);
404
+ }
405
+ else {
406
+ const who = result.movees.map((m) => m.title ?? m.uuid).join(", ");
407
+ process.stdout.write(`ok ${result.op}: moved ${result.movees.length} item(s) — ${who}\n` +
408
+ ` placement: ${result.placementClass} — ${result.note}\n`);
409
+ }
410
+ process.exitCode = ExitCode.Ok;
411
+ return;
412
+ }
413
+ case "move-dry-run": {
414
+ if (opts.json) {
415
+ process.stdout.write(`${JSON.stringify(okEnvelope("move-plan", result.plan, meta))}\n`);
416
+ }
417
+ else {
418
+ process.stdout.write([
419
+ `DRY RUN ${result.op}`,
420
+ ` movees: ${result.plan.movees.join(", ")}`,
421
+ ` membership: ${result.plan.membership}`,
422
+ ` placement: ${result.plan.placement} (${result.plan.placementClass})`,
423
+ ` ${result.plan.note}`,
424
+ "",
425
+ ].join("\n"));
426
+ }
427
+ process.exitCode = ExitCode.Ok;
428
+ return;
429
+ }
430
+ case "move-refused": {
431
+ const code = result.refusal === "usage"
432
+ ? ExitCode.Usage
433
+ : result.refusal === "unsupported"
434
+ ? ExitCode.Unsupported
435
+ : ExitCode.Blocked;
436
+ if (opts.json) {
437
+ process.stdout.write(`${JSON.stringify(errorEnvelope({
438
+ code: result.refusal,
439
+ message: result.detail,
440
+ ...(result.remediation !== undefined && { remediation: result.remediation }),
441
+ ...(result.candidates !== undefined && {
442
+ detail: { candidates: result.candidates },
443
+ }),
444
+ }, meta))}\n`);
445
+ }
446
+ else {
447
+ process.stderr.write(`${result.refusal === "usage" ? "error" : result.refusal.toUpperCase()}: ${result.detail}\n` +
448
+ (result.remediation !== undefined ? ` remediation: ${result.remediation}\n` : ""));
449
+ }
450
+ process.exitCode = code;
451
+ return;
452
+ }
453
+ case "move-leg-failed": {
454
+ if (opts.json) {
455
+ process.stdout.write(`${JSON.stringify(errorEnvelope({ code: "verify-failed", message: result.detail, detail: { failed: result.failed, completed: result.completed } }, meta))}\n`);
456
+ }
457
+ else {
458
+ process.stderr.write(`MOVE FAILED: ${result.detail}\n`);
459
+ }
460
+ process.exitCode = ExitCode.VerifyFailed;
461
+ return;
462
+ }
463
+ }
464
+ }
465
+ /** Build a MovePosition from the shared --first/--last/--before/--after flags. */
466
+ function movePosition(opts) {
467
+ const chosen = [
468
+ opts["first"] === true,
469
+ opts["last"] === true,
470
+ opts["before"] !== undefined,
471
+ opts["after"] !== undefined,
472
+ ].filter(Boolean).length;
473
+ if (chosen > 1)
474
+ return "conflict";
475
+ if (opts["first"] === true)
476
+ return { at: "first" };
477
+ if (opts["last"] === true)
478
+ return { at: "last" };
479
+ if (opts["before"] !== undefined)
480
+ return { before: opts["before"] };
481
+ if (opts["after"] !== undefined)
482
+ return { after: opts["after"] };
483
+ return undefined;
484
+ }
485
+ /** Build a to-do move destination; "conflict" when more than one is named. */
486
+ function todoDestination(opts) {
487
+ const dests = [];
488
+ const toProject = opts["toProject"];
489
+ const toHeading = opts["toHeading"];
490
+ if (toHeading !== undefined) {
491
+ dests.push({
492
+ kind: "heading",
493
+ sel: toHeading,
494
+ ...(toProject !== undefined && { project: { uuid: toProject, title: toProject } }),
495
+ });
496
+ }
497
+ else if (toProject !== undefined) {
498
+ dests.push({ kind: "project", ref: { uuid: toProject, title: toProject } });
499
+ }
500
+ const toArea = opts["toArea"];
501
+ if (toArea !== undefined)
502
+ dests.push({ kind: "area", ref: { uuid: toArea, title: toArea } });
503
+ // Commander maps `--no-heading`/`--no-area` onto the base key as `false`.
504
+ if (opts["heading"] === false)
505
+ dests.push({ kind: "no-heading" });
506
+ if (opts["loose"] === true)
507
+ dests.push({ kind: "loose" });
508
+ if (opts["inbox"] === true)
509
+ dests.push({ kind: "inbox" });
510
+ if (opts["area"] === false)
511
+ dests.push({ kind: "no-area" });
512
+ if (opts["detach"] === true)
513
+ dests.push({ kind: "detach" });
514
+ if (dests.length > 1)
515
+ return "conflict";
516
+ return dests[0];
517
+ }
518
+ /** Build a project move destination; "conflict" when more than one is named. */
519
+ function projectDestination(opts) {
520
+ const dests = [];
521
+ const toArea = opts["toArea"];
522
+ if (toArea !== undefined)
523
+ dests.push({ kind: "area", ref: { uuid: toArea, title: toArea } });
524
+ // Commander maps `--no-area` onto the base key `area` as `false`.
525
+ if (opts["area"] === false)
526
+ dests.push({ kind: "no-area" });
527
+ if (opts["loose"] === true)
528
+ dests.push({ kind: "loose" });
529
+ if (opts["detach"] === true)
530
+ dests.push({ kind: "detach" });
531
+ if (dests.length > 1)
532
+ return "conflict";
533
+ return dests[0];
534
+ }
535
+ /** The shared position flags (--first/--last/--before/--after) for move/reorder. */
536
+ function addPositionFlags(cmd) {
537
+ return cmd
538
+ .option("--first", "place the block at the top of its bucket")
539
+ .option("--last", "place the block at the bottom of its bucket")
540
+ .option("--before <ref>", "place the block immediately before this item (same bucket)")
541
+ .option("--after <ref>", "place the block immediately after this item (same bucket)");
542
+ }
543
+ /** One TTY line for a bulk-add batch item (the human, non-JSON multi rendering). */
544
+ function addResultLine(r) {
545
+ const o = r.outcome;
546
+ switch (o.kind) {
547
+ case "ok":
548
+ return `ok todo.add uuid=${o.uuid ?? ""} (vector=${o.vector}, tier=${o.tier}, verified)\n`;
549
+ case "dry-run":
550
+ return `DRY RUN todo.add (vector=${o.plan.vector}, tier ${o.plan.tier}) ${o.plan.invocation}\n`;
551
+ case "already-applied":
552
+ return `already-applied todo.add uuid=${o.uuid}\n`;
553
+ case "skipped":
554
+ return `skipped todo.add: ${o.detail}\n`;
555
+ case "invalid":
556
+ return `FAILED todo.add: ${o.detail}\n`;
557
+ case "blocked":
558
+ return `BLOCKED todo.add (${o.hazard ?? o.reason}): ${o.detail}\n`;
559
+ case "verify-failed":
560
+ return `VERIFY FAILED todo.add (${o.reason}): ${o.detail}\n`;
561
+ case "unsupported":
562
+ return `UNSUPPORTED todo.add\n`;
563
+ default:
564
+ return `FAILED todo.add: ${JSON.stringify(o)}\n`;
565
+ }
566
+ }
567
+ /**
568
+ * Run a bulk `todo add` as ONE batch of `todo.add` legs (shared flags already
569
+ * compiled into every op's params). Streams per-line results and a trailing
570
+ * summary carrying the single `undoToken` that removes the whole skeleton —
571
+ * except under `--id-only`, where output is exactly one uuid per created item,
572
+ * in creation order, and nothing else. Exit code is the worst leg's failure.
573
+ */
574
+ async function runBulkAdd(opts, ops, idOnly) {
575
+ let client = null;
576
+ try {
577
+ client = openThings(opts.db ? { dbPath: opts.db } : {});
578
+ const batchResult = await client.write.batch(ops, {
579
+ ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
580
+ ...(opts.actor !== undefined && { actor: opts.actor }),
581
+ }, (r) => {
582
+ if (idOnly) {
583
+ if (r.outcome.kind === "ok" && r.outcome.uuid !== null)
584
+ process.stdout.write(`${r.outcome.uuid}\n`);
585
+ }
586
+ else if (opts.json) {
587
+ emit(r);
588
+ }
589
+ else {
590
+ process.stdout.write(addResultLine(r));
591
+ }
592
+ });
593
+ const failed = batchResult.results.filter((r) => outcomeFailed(r.outcome));
594
+ if (!idOnly) {
595
+ const total = batchResult.results.length;
596
+ const okCount = total - failed.length;
597
+ if (opts.json) {
598
+ const summary = {
599
+ summary: {
600
+ total,
601
+ ok: okCount,
602
+ failed: failed.filter((r) => r.outcome.kind !== "skipped").length,
603
+ skipped: batchResult.results.filter((r) => r.outcome.kind === "skipped").length,
604
+ ...(batchResult.undoToken !== undefined && { undoToken: batchResult.undoToken }),
605
+ },
606
+ };
607
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
608
+ }
609
+ else {
610
+ const undo = batchResult.undoToken !== undefined
611
+ ? ` (undo all: things undo --txn ${batchResult.undoToken})`
612
+ : "";
613
+ process.stdout.write(`added ${okCount}/${total} to-dos${undo}\n`);
614
+ }
615
+ }
616
+ process.exitCode = aggregateExitCode(failed.map((r) => r.outcome));
617
+ }
618
+ finally {
619
+ client?.close();
620
+ }
621
+ }
622
+ /** Read newline-delimited titles from stdin; blank lines (whitespace-only) skipped. */
623
+ async function readStdinTitles() {
624
+ const chunks = [];
625
+ for await (const chunk of process.stdin)
626
+ chunks.push(chunk);
627
+ return Buffer.concat(chunks)
628
+ .toString("utf8")
629
+ .split("\n")
630
+ .map((l) => l.replace(/\r$/, ""))
631
+ .filter((l) => l.trim() !== "");
632
+ }
249
633
  function group(program, name, description) {
250
634
  const existing = program.commands.find((c) => c.name() === name);
251
635
  if (existing !== undefined)
@@ -256,12 +640,15 @@ const containerRef = (value) => value === undefined ? undefined : { uuid: value,
256
640
  export function registerWriteCommands(program) {
257
641
  const todo = group(program, "todo", "To-do–scoped operations");
258
642
  addCreateTagsFlag(addWriteFlags(todo
259
- .command("add <title>")
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 " +
643
+ .command("add [titles...]")
644
+ .description("Create one or more to-dos; each new uuid is printed on success. Pass several " +
645
+ "titles to create a quick skeleton in one call, or stream them with --stdin " +
646
+ "(one title per line); every shared flag below applies to each title. Projects, " +
647
+ "areas, and headings must name existing items — unknown or ambiguous references " +
648
+ "are rejected. A tag may be a name or a parent/child path, and must exist unless " +
263
649
  "--create-tags. Adding into a completed/canceled project reopens that project — " +
264
- "requires --acknowledge-project-reopen.")
650
+ "requires --acknowledge-project-reopen. When several to-dos are created, one undo " +
651
+ "token removes the whole skeleton at once.")
265
652
  .option("--notes <text>", "notes body")
266
653
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
267
654
  .option("--reminder <HH:mm>", "time-of-day reminder (24h); requires --when today|evening|YYYY-MM-DD")
@@ -271,14 +658,33 @@ export function registerWriteCommands(program) {
271
658
  .option("--project <ref>", "destination project (uuid or unique name)")
272
659
  .option("--area <ref>", "destination area (uuid or unique name)")
273
660
  .option("--heading <name>", "existing heading in the destination project")
274
- .option("--acknowledge-project-reopen", "allow adding into a completed/canceled project"))).action(async (title, opts) => {
661
+ .option("--acknowledge-project-reopen", "allow adding into a completed/canceled project")
662
+ .option("--stdin", "read newline-delimited titles from stdin (blank lines skipped); exclusive with title arguments")
663
+ .option("--id-only", "print only the new uuid(s), one per line in creation order, and nothing else (exclusive with --json)"))).action(async (titles, opts) => {
664
+ const idOnly = opts["idOnly"] === true;
665
+ const useStdin = opts["stdin"] === true;
666
+ if (idOnly && opts.json === true) {
667
+ usageError(opts, "--id-only and --json are mutually exclusive");
668
+ return;
669
+ }
670
+ if (useStdin && titles.length > 0) {
671
+ usageError(opts, "--stdin is mutually exclusive with title arguments");
672
+ return;
673
+ }
674
+ const finalTitles = useStdin ? await readStdinTitles() : titles;
675
+ if (finalTitles.length === 0) {
676
+ usageError(opts, useStdin
677
+ ? "no titles: --stdin received no non-empty lines"
678
+ : "provide at least one title, or pass --stdin to read titles from stdin");
679
+ return;
680
+ }
275
681
  const checklist = opts["checklistItem"];
276
682
  const tags = splitCsv(opts["tags"]);
277
683
  const project = containerRef(opts["project"]);
278
684
  const area = containerRef(opts["area"]);
279
685
  if (!whenSugarOk(opts))
280
686
  return;
281
- await runWrite(opts, (c) => c.write.addTodo({
687
+ const buildParams = (title) => ({
282
688
  title,
283
689
  ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
284
690
  ...(opts["when"] !== undefined && { when: opts["when"] }),
@@ -289,12 +695,65 @@ export function registerWriteCommands(program) {
289
695
  ...(project !== undefined && { project }),
290
696
  ...(area !== undefined && { area }),
291
697
  ...(opts["heading"] !== undefined && { heading: opts["heading"] }),
292
- }, writeOptionsFrom(opts, {
293
- ...(opts["acknowledgeProjectReopen"] !== undefined && {
294
- acknowledgeProjectReopen: opts["acknowledgeProjectReopen"],
295
- }),
296
- ...createTagsExtra(opts),
297
- })));
698
+ });
699
+ const ackReopen = opts["acknowledgeProjectReopen"] !== undefined
700
+ ? { acknowledgeProjectReopen: opts["acknowledgeProjectReopen"] }
701
+ : {};
702
+ // A multi-title add compiles onto the batch machinery (below), where
703
+ // idempotency is a per-LINE op_id, not one key for the whole skeleton —
704
+ // refuse --op-id there rather than drop it. A single-title add is a single
705
+ // mutation and takes --op-id normally (validated inside runWrite).
706
+ if (finalTitles.length > 1 && opts.opId !== undefined) {
707
+ usageError(opts, "--op-id applies to a single-title add — for a multi-title add use `things batch` with a " +
708
+ "per-line op_id");
709
+ return;
710
+ }
711
+ // Single title (positional or a one-line stdin) keeps today's single
712
+ // mutation-result envelope exactly — unless --id-only, which prints just
713
+ // the new uuid. Multiple titles compile onto the batch machinery.
714
+ if (finalTitles.length === 1) {
715
+ const params = buildParams(finalTitles[0]);
716
+ const wopts = writeOptionsFrom(opts, { ...ackReopen, ...createTagsExtra(opts) });
717
+ if (!idOnly) {
718
+ await runWrite(opts, (c) => c.write.addTodo(params, wopts));
719
+ return;
720
+ }
721
+ await runWrite(opts, (c) => c.write.addTodo(params, wopts), (result, o, meta) => {
722
+ if (result.kind === "ok") {
723
+ if (result.uuid !== null)
724
+ process.stdout.write(`${result.uuid}\n`);
725
+ process.exitCode = ExitCode.Ok;
726
+ }
727
+ else if (result.kind === "dry-run") {
728
+ // --id-only mints no uuid to print on a dry-run; stay silent.
729
+ process.exitCode = ExitCode.Ok;
730
+ }
731
+ else {
732
+ emitResult(result, { ...o, json: false }, meta);
733
+ }
734
+ });
735
+ return;
736
+ }
737
+ // Multi-title: one batch of todo.add legs, shared flags applied to each.
738
+ const perOpOptions = {
739
+ ...ackReopen,
740
+ ...(opts["createTags"] === true && { createTags: true }),
741
+ ...(opts.vector !== undefined && { vector: opts.vector }),
742
+ ...(opts.allowVeryDisruptive === true
743
+ ? { maxDisruption: 3 }
744
+ : opts.allowDisruptive === true
745
+ ? { maxDisruption: 2 }
746
+ : {}),
747
+ ...(opts.verifyTimeout !== undefined && { verifyTimeoutMs: Number(opts.verifyTimeout) }),
748
+ };
749
+ const hasPerOpOptions = Object.keys(perOpOptions).length > 0;
750
+ const ops = finalTitles.map((title) => {
751
+ const op = { op: "todo.add", params: buildParams(title) };
752
+ if (hasPerOpOptions)
753
+ op.options = perOpOptions;
754
+ return op;
755
+ });
756
+ await runBulkAdd(opts, ops, idOnly);
298
757
  });
299
758
  addWriteFlags(todo
300
759
  .command("update <uuid>")
@@ -350,35 +809,47 @@ export function registerWriteCommands(program) {
350
809
  await runWrite(opts, (c) => c.write[method](uuid, writeOptionsFrom(opts)));
351
810
  });
352
811
  }
353
- addWriteFlags(todo
354
- .command("move <uuid>")
355
- .description("Move a to-do into a project or area (optionally under an existing heading), back " +
356
- "to the Inbox, or out of every container. Unknown or ambiguous destinations are " +
357
- "rejected. Moving into a completed/canceled project reopens that project " +
358
- "requires --acknowledge-project-reopen.")
359
- .option("--project <ref>", "destination project (uuid or unique name)")
360
- .option("--area <ref>", "destination area (uuid or unique name)")
361
- .option("--heading <name>", "existing heading in the destination project")
812
+ addPositionFlags(addWriteFlags(todo
813
+ .command("move <refs...>")
814
+ .description("Move one or more to-dos as an ordered block (the argument order is the order they " +
815
+ "land name them backwards to reverse). MOVE changes WHAT a to-do belongs to; to " +
816
+ "rearrange to-dos that already share a container use `things todo reorder`. Pass one " +
817
+ "destination: --to-project / --to-heading / --to-area, or the detach family " +
818
+ "--no-heading (leave the heading, stay in the project) / --loose (leave heading, " +
819
+ "project, AND area). --inbox files back to the Inbox. Position with " +
820
+ "--first/--last/--before/--after (an anchor positions but never migrates — an " +
821
+ "anchor-only move that would cross containers is refused). Membership always " +
822
+ "succeeds; placement is guaranteed for loose lists, a project's/area's members, a " +
823
+ "heading's children, and a container's same-day scheduled children — the result " +
824
+ "states the placement class, and a bounce that co-bounces unnamed siblings to honor " +
825
+ "an anchor lists them. Set bounce-max-items to cap how many items a bounce touches; " +
826
+ "bounce-enabled=false refuses bounce-dependent placements. Moving into a " +
827
+ "completed/canceled project reopens it — requires --acknowledge-project-reopen.")
828
+ .option("--to-project <ref>", "destination project (uuid or unique name)")
829
+ .option("--to-heading <sel>", "destination heading (exact title or uuid; within --to-project)")
830
+ .option("--to-area <ref>", "destination area (uuid or unique name)")
831
+ .option("--no-heading", "leave the heading but stay in the current project (unheaded block)")
832
+ .option("--loose", "detach from heading, project, AND area (keeping the schedule)")
362
833
  .option("--inbox", "move back to the Inbox — removes any schedule")
363
- .option("--detach", "remove ALL container links (project/area/heading) keeping the schedule")
364
- .option("--acknowledge-project-reopen", "allow moving into a completed/canceled project")).action(async (uuid, opts) => {
365
- const project = containerRef(opts["project"]);
366
- const area = containerRef(opts["area"]);
367
- const inbox = opts["inbox"] === true;
368
- const detach = opts["detach"] === true;
369
- const dest = project !== undefined || area !== undefined || opts["heading"] !== undefined;
370
- if ((inbox && (dest || detach)) || (detach && dest)) {
371
- usageError(opts, "--inbox/--detach are exclusive with each other and with --project/--area/--heading");
834
+ .option("--no-area", "(not a to-do flag teaches the correct spelling)")
835
+ .option("--detach", "(removed teaches the replacement family)")
836
+ .option("--acknowledge-project-reopen", "allow moving into a completed/canceled project"))).action(async (refs, opts) => {
837
+ const dest = todoDestination(opts);
838
+ if (dest === "conflict") {
839
+ usageError(opts, "pass at most one destination (--to-project/--to-heading/--to-area/--no-heading/--loose/--inbox)");
372
840
  return;
373
841
  }
374
- await runWrite(opts, (c) => c.write.moveTodo(uuid, {
375
- ...(project !== undefined && { project }),
376
- ...(area !== undefined && { area }),
377
- ...(opts["heading"] !== undefined && { heading: opts["heading"] }),
378
- ...(inbox && { inbox: true }),
379
- ...(detach && { detach: true }),
380
- }, writeOptionsFrom(opts, {
381
- ...(inbox && { vector: "applescript" }),
842
+ const position = movePosition(opts);
843
+ if (position === "conflict") {
844
+ usageError(opts, "pass at most one of --first/--last/--before/--after");
845
+ return;
846
+ }
847
+ const request = {
848
+ uuids: refs,
849
+ ...(dest !== undefined && { destination: dest }),
850
+ ...(position !== undefined && { position }),
851
+ };
852
+ await runMoveCmd(opts, (c) => c.write.moveTodos(request, writeOptionsFrom(opts, {
382
853
  ...(opts["acknowledgeProjectReopen"] !== undefined && {
383
854
  acknowledgeProjectReopen: opts["acknowledgeProjectReopen"],
384
855
  }),
@@ -411,12 +882,10 @@ export function registerWriteCommands(program) {
411
882
  .command("checklist <uuid>")
412
883
  .description("Edit a to-do's checklist. WHOLESALE: --item (repeatable) replaces the whole list, " +
413
884
  "discarding the existing items and their checked states — requires " +
414
- "--acknowledge-checklist-reset when items exist. GRANULAR (one per call): " +
415
- "--add/--remove/--check/--uncheck/--rename+--to/--move-item+--to-position change " +
416
- "a single item with every other item's checked state PRESERVED (no reset flag " +
417
- "needed). Target an item by title or by --index (1-based); duplicate titles are " +
418
- "resolved best-effort (check → first unchecked, etc.). Checklist item uuids are " +
419
- "internal and never exposed.")
885
+ "--acknowledge-checklist-reset when items exist. GRANULAR (one action per call): " +
886
+ "the flags below add, remove, check, uncheck, rename, or move a single item, with " +
887
+ "every other item's checked state PRESERVED. Target an item by title or by --index " +
888
+ "(1-based); duplicate titles resolve best-effort. Checklist items have no exposed uuid.")
420
889
  .option("--item <text>", "wholesale: checklist item in order (repeatable)", collect, [])
421
890
  .option("--acknowledge-checklist-reset", "accept wholesale replacement of existing items")
422
891
  .option("--add <title>", "granular: append an item")
@@ -472,6 +941,8 @@ export function registerWriteCommands(program) {
472
941
  : action === "rename"
473
942
  ? { action: "rename", ...target, title: opts["to"] }
474
943
  : { action: "move", ...target, to: Number(opts["toPosition"]) };
944
+ if (opIdCompoundRefused(opts, "a granular checklist edit"))
945
+ return;
475
946
  await runWrite(opts, (c) => c.write.editChecklist(uuid, edit, writeOptionsFrom(opts)));
476
947
  return;
477
948
  }
@@ -496,12 +967,12 @@ export function registerWriteCommands(program) {
496
967
  });
497
968
  addWriteFlags(todo
498
969
  .command("clear-reminder <uuid>")
499
- .description("Clear a to-do's time-of-day reminder while keeping its scheduled date. When the " +
500
- "Things proxy shortcuts are installed (`things setup shortcuts`) this happens in " +
501
- "place, and it is the only way for a repeating to-do; otherwise a non-repeating " +
502
- "date-scheduled to-do falls back to a URL re-schedule that briefly moves it to Today " +
503
- "and back. Reversible with `things undo`. Force a delivery path with --vector " +
504
- "shortcuts|url-scheme.")).action(async (uuid, opts) => {
970
+ .description("Clear a to-do's time-of-day reminder, keeping its scheduled date. With the proxy " +
971
+ "shortcuts installed (`things setup shortcuts`) this is in place, and is the only " +
972
+ "way for a repeating to-do; otherwise a date-scheduled to-do is cleared by a brief " +
973
+ "re-schedule through Today. Reversible with `things undo`.")).action(async (uuid, opts) => {
974
+ if (opIdCompoundRefused(opts, "clear-reminder"))
975
+ return;
505
976
  await runWrite(opts, (c) => c.write.clearReminder(uuid, writeOptionsFrom(opts)));
506
977
  });
507
978
  addWriteFlags(todo
@@ -542,15 +1013,14 @@ export function registerWriteCommands(program) {
542
1013
  "todo.make-repeating",
543
1014
  "Turn a plain to-do into a repeating one. This REPLACES the to-do with a new repeating " +
544
1015
  "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.",
1016
+ "(cannot be undone). Set the rule with the flags below; see `things help repeating`.",
547
1017
  ],
548
1018
  [
549
1019
  "reschedule-repeat",
550
1020
  "todo.reschedule-repeat",
551
1021
  "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.",
1022
+ "new rule with the flags below; see `things help repeating`. `things undo` restores the " +
1023
+ "previous rule.",
554
1024
  ],
555
1025
  ]) {
556
1026
  addDriveGuiFlag(addRepeatRuleFlags(addWriteFlags(todo
@@ -591,69 +1061,153 @@ export function registerWriteCommands(program) {
591
1061
  await runWrite(opts, (c) => c.write.run(op, { uuid }, writeOptionsFrom(opts)));
592
1062
  });
593
1063
  }
594
- const heading = group(program, "heading", "Heading-scoped operations");
595
- addWriteFlags(heading
596
- .command("add <project> <title>")
1064
+ const project = group(program, "project", "Project-scoped operations");
1065
+ // --- project headings (spec §2) ------------------------------------------
1066
+ // A heading exists only inside a project. Each verb takes <project-ref> then
1067
+ // a heading selector: an exact title OR a uuid (never an ordinal — an index
1068
+ // silently re-targets a different heading after any reorder). An empty-string
1069
+ // title selects a titleless heading; duplicates fail closed with uuid
1070
+ // candidates.
1071
+ addPlacementFlags(addWriteFlags(project
1072
+ .command("add-heading <project> <title>")
597
1073
  .description("Create a heading inside an existing project; its uuid is printed on success. The " +
598
- "project must name an existing project (uuid or unique name). This uses the Things " +
599
- "proxy shortcuts — run `things setup shortcuts` once first.")).action(async (project, title, opts) => {
600
- await runWrite(opts, (c) => c.write.createHeading({ uuid: project, title: project }, title, writeOptionsFrom(opts)));
1074
+ "project must name an existing project (uuid or unique name). Uses the Things proxy " +
1075
+ "shortcuts — run `things setup shortcuts` once first. By default the heading is " +
1076
+ "appended; a placement flag positions it among the project's headings (that leg " +
1077
+ "needs `things config set allow-experimental true`)."))).action(async (projectRef, title, opts) => {
1078
+ if (countPlacementFlags(opts) > 1) {
1079
+ usageError(opts, "pass at most one of --first / --last / --before-heading / --after-heading");
1080
+ return;
1081
+ }
1082
+ if (opIdCompoundRefused(opts, "add-heading"))
1083
+ return;
1084
+ await runWrite(opts, (c) => {
1085
+ const proj = c.resolve.project(projectRef);
1086
+ const placement = headingPlacement(c, proj.uuid, opts);
1087
+ return c.write.addHeading({ uuid: proj.uuid }, title, placement, writeOptionsFrom(opts));
1088
+ });
601
1089
  });
602
- addWriteFlags(heading
603
- .command("rename <uuid> <title>")
604
- .description("Rename a heading in place (works on archived headings too).")).action(async (uuid, title, opts) => {
605
- await runWrite(opts, (c) => c.write.renameHeading(uuid, title, writeOptionsFrom(opts)));
1090
+ addWriteFlags(project
1091
+ .command("rename-heading <project> <heading>")
1092
+ .description(`Rename a heading in place (works on archived headings too). <heading> is a ${HEADING_SEL_HELP}.`)
1093
+ .requiredOption("--to <title>", "the new heading title")).action(async (projectRef, sel, opts) => {
1094
+ await runWrite(opts, (c) => {
1095
+ const proj = c.resolve.project(projectRef);
1096
+ const h = c.resolve.heading(proj.uuid, sel);
1097
+ return c.write.renameHeading(h.uuid, opts["to"], writeOptionsFrom(opts));
1098
+ });
606
1099
  });
607
- addWriteFlags(heading
608
- .command("archive <uuid>")
1100
+ addWriteFlags(project
1101
+ .command("archive-heading <project> <heading>")
609
1102
  .description("Archive a heading — it leaves the active project view (reversible with " +
610
- "`things heading unarchive`). This is the preferred way to retire a heading: " +
1103
+ "`things project unarchive-heading`). This is the preferred way to retire a heading: " +
611
1104
  "row DELETION exists only in the app's UI and Shortcuts with a per-run consent " +
612
1105
  "dialog, never headlessly. With open children, --children is required: " +
613
1106
  "complete/cancel resolve them with the heading (one atomic cascade); reparent " +
614
1107
  "moves them to the project root first, keeping them open — a compound sequence " +
615
- "that `things undo` reverses as one unit.")
616
- .option("--children <policy>", "complete | cancel | reparent (required when children are open)")).action(async (uuid, opts) => {
1108
+ `that \`things undo\` reverses as one unit. <heading> is a ${HEADING_SEL_HELP}.`)
1109
+ .option("--children <policy>", "complete | cancel | reparent (required when children are open)")).action(async (projectRef, sel, opts) => {
617
1110
  const children = opts["children"];
1111
+ if (opIdCompoundRefused(opts, "archive-heading"))
1112
+ return;
618
1113
  await runWrite(opts, async (c) => {
619
- const outcome = await c.write.archiveHeading(uuid, children ? { children } : {}, writeOptionsFrom(opts));
1114
+ const proj = c.resolve.project(projectRef);
1115
+ const h = c.resolve.heading(proj.uuid, sel);
1116
+ const outcome = await c.write.archiveHeading(h.uuid, children ? { children } : {}, writeOptionsFrom(opts));
620
1117
  for (const leg of outcome.reparented) {
621
1118
  process.stderr.write(`reparented: ${leg.title} (${leg.result.kind})\n`);
622
1119
  }
623
1120
  return outcome.heading;
624
1121
  });
625
1122
  });
626
- addWriteFlags(heading
627
- .command("unarchive <uuid>")
1123
+ addWriteFlags(project
1124
+ .command("unarchive-heading <project> <heading>")
628
1125
  .description("Un-archive a heading. --restore-children also reopens the children the archive " +
629
1126
  "cascade resolved with it (identified by matching resolution timestamps; a " +
630
1127
  "someday child comes back as someday). Children resolved at other times are " +
631
- "never touched.")
632
- .option("--restore-children", "reopen cascade-resolved children too")).action(async (uuid, opts) => {
1128
+ `never touched. <heading> is a ${HEADING_SEL_HELP}.`)
1129
+ .option("--restore-children", "reopen cascade-resolved children too")).action(async (projectRef, sel, opts) => {
1130
+ if (opIdCompoundRefused(opts, "unarchive-heading"))
1131
+ return;
633
1132
  await runWrite(opts, async (c) => {
634
- const outcome = await c.write.unarchiveHeading(uuid, opts["restoreChildren"] === true ? { restoreChildren: true } : {}, writeOptionsFrom(opts));
1133
+ const proj = c.resolve.project(projectRef);
1134
+ const h = c.resolve.heading(proj.uuid, sel);
1135
+ const outcome = await c.write.unarchiveHeading(h.uuid, opts["restoreChildren"] === true ? { restoreChildren: true } : {}, writeOptionsFrom(opts));
635
1136
  for (const child of outcome.children) {
636
1137
  process.stderr.write(`restored: ${child.title} (${child.result.kind})\n`);
637
1138
  }
638
1139
  return outcome.heading;
639
1140
  });
640
1141
  });
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 " +
1142
+ addDriveGuiFlag(addWriteFlags(project
1143
+ .command("promote-heading <project> <heading>")
1144
+ .description("Promote a heading into a project. This REPLACES the heading with a new project — it " +
644
1145
  "is promoted alongside its parent project (into the same area) and the heading's " +
645
1146
  "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)));
1147
+ `undone. The new project's uuid is printed on success. <heading> is a ${HEADING_SEL_HELP}.`))).action(async (projectRef, sel, opts) => {
1148
+ await runWrite(opts, (c) => {
1149
+ const proj = c.resolve.project(projectRef);
1150
+ const h = c.resolve.heading(proj.uuid, sel);
1151
+ return c.write.run("project.promote-heading", { uuid: h.uuid }, writeOptionsFrom(opts));
1152
+ });
1153
+ });
1154
+ addPlacementFlags(addWriteFlags(project
1155
+ .command("move-heading <project> <headings...>")
1156
+ .description("Reposition one or more of a project's headings as an ordered block — the selection " +
1157
+ "order is the resulting order, and each heading's to-dos follow it. Pass exactly one " +
1158
+ "placement: --first, --last, --before-heading <sel>, or --after-heading <sel>. Each " +
1159
+ `<heading> is a ${HEADING_SEL_HELP}. Reordering headings rides the same experimental ` +
1160
+ "surface as `things reorder` — enable it once with `things config set " +
1161
+ "allow-experimental true`."))).action(async (projectRef, sels, opts) => {
1162
+ if (countPlacementFlags(opts) !== 1) {
1163
+ usageError(opts, "pass exactly one of --first / --last / --before-heading / --after-heading");
1164
+ return;
1165
+ }
1166
+ await runWrite(opts, (c) => {
1167
+ const proj = c.resolve.project(projectRef);
1168
+ const headings = sels.map((s) => c.resolve.heading(proj.uuid, s).uuid);
1169
+ const placement = headingPlacement(c, proj.uuid, opts);
1170
+ return c.write.moveHeading({ uuid: proj.uuid }, headings, placement, writeOptionsFrom(opts));
1171
+ });
1172
+ });
1173
+ addWriteFlags(project
1174
+ .command("move-heading-to-project <project> <heading>")
1175
+ .requiredOption("--to <project>", "destination project (uuid or unique title)")
1176
+ .description("Move a heading — WITH its to-dos — to a DIFFERENT project. This is the cross-project " +
1177
+ "relocation, distinct from `move-heading` (which only reorders a heading within its own " +
1178
+ `project). <heading> is a ${HEADING_SEL_HELP} in the SOURCE <project>; --to names the ` +
1179
+ "destination. GUI-ONLY: it drives the heading row's ellipsis Move… menu, so enable it " +
1180
+ "once with `things config set ui.enabled true` and pass --dangerously-drive-gui. Fails " +
1181
+ "closed when the heading title is shared by another heading in the project, or the " +
1182
+ "destination title by another project (the drive addresses both by title). No undo — " +
1183
+ "move it back to reverse.")).action(async (projectRef, sel, opts) => {
1184
+ await runWrite(opts, (c) => {
1185
+ const proj = c.resolve.project(projectRef);
1186
+ const dest = c.resolve.project(String(opts["to"]));
1187
+ return c.write.moveHeadingToProject({ uuid: proj.uuid }, sel, { uuid: dest.uuid }, writeOptionsFrom(opts));
1188
+ });
1189
+ });
1190
+ addWriteFlags(project
1191
+ .command("dissolve-heading <project> <heading>")
1192
+ .description("Dissolve a heading — remove it but KEEP its to-dos, which become direct children of the " +
1193
+ "project (in the same order), NOT trashed. This is the opposite of deleting: nothing is " +
1194
+ `lost. <heading> is a ${HEADING_SEL_HELP}. Contrast \`things todo delete\` and the ` +
1195
+ "Shortcuts heading-delete cascade, which TRASH the children. GUI-ONLY (drives the " +
1196
+ "heading row's ellipsis Delete): enable it once with `things config set ui.enabled true` " +
1197
+ "and pass --dangerously-drive-gui. Fails closed when the heading title is shared by " +
1198
+ "another heading in the project. No undo — re-create the heading and move the to-dos back.")).action(async (projectRef, sel, opts) => {
1199
+ await runWrite(opts, (c) => {
1200
+ const proj = c.resolve.project(projectRef);
1201
+ const h = c.resolve.heading(proj.uuid, sel);
1202
+ return c.write.dissolveHeading(h.uuid, writeOptionsFrom(opts));
1203
+ });
648
1204
  });
649
- const project = group(program, "project", "Project-scoped operations");
650
1205
  // --- ui vector: repeating-project transforms (two-key gated) -------------
651
1206
  addDriveGuiFlag(addRepeatRuleFlags(addWriteFlags(project
652
1207
  .command("reschedule-repeat <ref>")
653
1208
  .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.")
1209
+ "the project keeps its identity). Set the new rule with the flags below; see " +
1210
+ "`things help repeating`. `things undo` restores the previous rule.")
657
1211
  .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
658
1212
  .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (uuid, opts) => {
659
1213
  const frequency = opts["frequency"];
@@ -686,12 +1240,13 @@ export function registerWriteCommands(program) {
686
1240
  .description("Turn a project into a repeating one. This REPLACES the project with a new repeating " +
687
1241
  "series — the original disappears and a fresh recurring project takes its place (its " +
688
1242
  "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.")
1243
+ "first (a cleanup-free intermediate step, shown in --dry-run). Set the rule with the " +
1244
+ "flags below; see `things help repeating`.")
692
1245
  .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
693
1246
  .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (uuid, opts) => {
694
1247
  const frequency = opts["frequency"];
1248
+ if (opIdCompoundRefused(opts, "project make-repeating"))
1249
+ return;
695
1250
  await runWrite(opts, (c) => c.write.makeRepeatingProject(uuid, {
696
1251
  frequency,
697
1252
  interval: Number(opts["interval"]),
@@ -699,7 +1254,7 @@ export function registerWriteCommands(program) {
699
1254
  }, writeOptionsFrom(opts)));
700
1255
  });
701
1256
  addDriveGuiFlag(addWriteFlags(project
702
- .command("create-repeating <title>")
1257
+ .command("add-repeating <title>")
703
1258
  .description("Create a project and turn it into a repeating series in ONE call. Two operations: the " +
704
1259
  "project is created first and PERSISTS even if the make-repeating step refuses; then it " +
705
1260
  "is promoted (which drives the GUI). Give --area to place it, or omit it to create in " +
@@ -712,7 +1267,9 @@ export function registerWriteCommands(program) {
712
1267
  .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP))).action(async (title, opts) => {
713
1268
  const todos = opts["todo"];
714
1269
  const area = containerRef(opts["area"]);
715
- await runWrite(opts, (c) => c.write.createRepeatingProject({
1270
+ if (opIdCompoundRefused(opts, "project add-repeating"))
1271
+ return;
1272
+ await runWrite(opts, (c) => c.write.addRepeatingProject({
716
1273
  title,
717
1274
  ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
718
1275
  ...(area !== undefined && { area }),
@@ -724,12 +1281,14 @@ export function registerWriteCommands(program) {
724
1281
  });
725
1282
  addWriteFlags(project
726
1283
  .command("add <title>")
727
- .description("Create a project; its uuid is printed on success.")
1284
+ .description("Create a project; its uuid is printed on success. Give --todo (repeatable) to " +
1285
+ "seed it with child to-dos in the same call — the quick way to stand up a new " +
1286
+ "project skeleton.")
728
1287
  .option("--notes <text>", "notes body")
729
1288
  .option("--area <ref>", "destination area (uuid or unique name)")
730
1289
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
731
1290
  .option("--deadline <date>", "YYYY-MM-DD")
732
- .option("--todo <title>", "initial child to-do (repeatable)", collect, [])).action(async (title, opts) => {
1291
+ .option("--todo <title>", "initial child to-do, repeatable (seeds the new project)", collect, [])).action(async (title, opts) => {
733
1292
  const todos = opts["todo"];
734
1293
  const area = containerRef(opts["area"]);
735
1294
  await runWrite(opts, (c) => c.write.addProject({
@@ -803,20 +1362,35 @@ export function registerWriteCommands(program) {
803
1362
  ? c.write.setProjectTags(uuid, set, writeOptionsFrom(opts, createTagsExtra(opts)))
804
1363
  : c.write.addProjectTags(uuid, add ?? [], writeOptionsFrom(opts, createTagsExtra(opts))));
805
1364
  });
806
- addWriteFlags(project
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.")
811
- .option("--area <ref>", "destination area (uuid or unique name)")
812
- .option("--detach", "remove the current area assignment (exclusive with --area)")).action(async (uuid, opts) => {
813
- if ((opts.detach === true) === (opts.area !== undefined)) {
814
- usageError(opts, "pass exactly one of --area / --detach");
1365
+ addPositionFlags(addWriteFlags(project
1366
+ .command("move <refs...>")
1367
+ .description("Move one or more projects as an ordered block (argument order = resulting order). " +
1368
+ "Pass one destination: --to-area (uuid or unique name) or --no-area (leave the " +
1369
+ "area — a project's complete detach). Position among siblings with " +
1370
+ "--first/--last/--before/--after (an anchor positions but never migrates). " +
1371
+ "Membership always succeeds; top-of-bucket placement is guaranteed only where a " +
1372
+ "reorder protocol exists (the result states the class). Status and schedule are " +
1373
+ "untouched.")
1374
+ .option("--to-area <ref>", "destination area (uuid or unique name)")
1375
+ .option("--no-area", "leave the current area (a project's single-level detach)")
1376
+ .option("--loose", "(not a project flag — teaches the correct spelling)")
1377
+ .option("--detach", "(removed — teaches the replacement)"))).action(async (refs, opts) => {
1378
+ const dest = projectDestination(opts);
1379
+ if (dest === "conflict") {
1380
+ usageError(opts, "pass at most one of --to-area / --no-area");
815
1381
  return;
816
1382
  }
817
- await runWrite(opts, (c) => opts.detach === true
818
- ? c.write.detachProject(uuid, writeOptionsFrom(opts))
819
- : c.write.moveProject(uuid, { uuid: opts.area, title: opts.area }, writeOptionsFrom(opts)));
1383
+ const position = movePosition(opts);
1384
+ if (position === "conflict") {
1385
+ usageError(opts, "pass at most one of --first/--last/--before/--after");
1386
+ return;
1387
+ }
1388
+ const request = {
1389
+ uuids: refs,
1390
+ ...(dest !== undefined && { destination: dest }),
1391
+ ...(position !== undefined && { position }),
1392
+ };
1393
+ await runMoveCmd(opts, (c) => c.write.moveProjects(request, writeOptionsFrom(opts)));
820
1394
  });
821
1395
  addWriteFlags(project
822
1396
  .command("cancel <ref>")
@@ -834,6 +1408,8 @@ export function registerWriteCommands(program) {
834
1408
  "project — children resolved earlier are never touched. Exit 3 if any child " +
835
1409
  "restore fails.")
836
1410
  .option("--restore-children", "also reopen the children resolved with the project")).action(async (uuid, opts) => {
1411
+ if (opIdCompoundRefused(opts, "project reopen"))
1412
+ return;
837
1413
  const started = Date.now();
838
1414
  let client = null;
839
1415
  try {
@@ -928,9 +1504,8 @@ export function registerWriteCommands(program) {
928
1504
  .command("reorder <ref>")
929
1505
  .description("Move an area to a new position in the area order (target by uuid or unique name). " +
930
1506
  "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.")
1507
+ "This visibly drives the Things app (the window comes forward and the sidebar may " +
1508
+ "scroll); the area's projects and to-dos are untouched.")
934
1509
  .option("--before <area>", "place it immediately above this area (uuid or unique name)")
935
1510
  .option("--after <area>", "place it immediately below this area (uuid or unique name)")
936
1511
  .option("--first", "move it to the top of the area list")
@@ -956,13 +1531,18 @@ export function registerWriteCommands(program) {
956
1531
  addWriteFlags(area
957
1532
  .command("delete <ref>")
958
1533
  .description("Delete an area PERMANENTLY — areas do not go to the Trash, so this cannot be " +
959
- "undone; requires --dangerously-permanent. The area's to-dos move to the Trash; " +
960
- "its projects remain, no longer assigned to any area.")
961
- .option("--dangerously-permanent", "accept permanent, unrecoverable deletion")).action(async (target, opts) => {
1534
+ "undone; requires --dangerously-permanent. Deleting an area moves its to-dos and " +
1535
+ "projects to the Trash, so a non-empty area is refused unless you pass " +
1536
+ "--allow-non-empty; empty the area first to keep its contents.")
1537
+ .option("--dangerously-permanent", "accept permanent, unrecoverable deletion")
1538
+ .option("--allow-non-empty", "delete the area together with its contents (its to-dos and projects move to the Trash)")).action(async (target, opts) => {
962
1539
  await runWrite(opts, (c) => c.write.deleteArea(target, writeOptionsFrom(opts, {
963
1540
  ...(opts["dangerouslyPermanent"] !== undefined && {
964
1541
  dangerouslyPermanent: opts["dangerouslyPermanent"],
965
1542
  }),
1543
+ ...(opts["allowNonEmpty"] !== undefined && {
1544
+ allowNonEmptyArea: opts["allowNonEmpty"],
1545
+ }),
966
1546
  })));
967
1547
  });
968
1548
  const tag = group(program, "tag", "Tag-scoped operations");
@@ -1044,6 +1624,15 @@ export function registerWriteCommands(program) {
1044
1624
  "failure does not roll back earlier ops. Per-op results stream as JSONL. Per-op " +
1045
1625
  "options carry the confirmation flags (acknowledgeChecklistReset, " +
1046
1626
  "acknowledgeProjectReopen, dangerouslyPermanent, acknowledgeTagSubtree). " +
1627
+ "CHAINING: an op that creates something may carry a `tempId` (a handle like " +
1628
+ '"proj1"); a LATER op references that new uuid as "$proj1" in any id/container ' +
1629
+ 'param (dotted "$proj1.instance"/"$proj1.replaced" reach a repeating op\'s spawned ' +
1630
+ "instance / replaced source). A tempId is valid only on a creating op (not tag.add — " +
1631
+ "reference a tag by title) and unique per batch; an unresolved/forward $ref fails just " +
1632
+ "that line. IDEMPOTENCY: a line's `opId` makes resubmission safe — a matching earlier " +
1633
+ "success is reported already-applied, not re-created. The trailing summary line adds " +
1634
+ "`tempIdMapping` (handle → uuid) and `undoToken` — undo the WHOLE batch with " +
1635
+ "`things undo --txn <undoToken>`. " +
1047
1636
  "--dry-run plans everything without executing; --fail-fast skips the rest after " +
1048
1637
  "the first failure. Exit (worst failure wins): 0 all ok · 3 any verify-failed/invalid " +
1049
1638
  "· 4 any blocked · 5 any drift-blocked · 6 any unsupported.")
@@ -1087,7 +1676,7 @@ export function registerWriteCommands(program) {
1087
1676
  let client = null;
1088
1677
  try {
1089
1678
  client = openThings(opts.db ? { dbPath: opts.db } : {});
1090
- const results = await client.write.batch(ops, {
1679
+ const batchResult = await client.write.batch(ops, {
1091
1680
  ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
1092
1681
  ...(opts["failFast"] === true && { failFast: true }),
1093
1682
  ...(opts.actor !== undefined && { actor: opts.actor }),
@@ -1095,14 +1684,19 @@ export function registerWriteCommands(program) {
1095
1684
  const pre = preInvalid.find((p) => p.index === r.index);
1096
1685
  emit(pre ?? r);
1097
1686
  });
1098
- const merged = results.map((r) => preInvalid.find((p) => p.index === r.index) ?? r);
1687
+ const merged = batchResult.results.map((r) => preInvalid.find((p) => p.index === r.index) ?? r);
1099
1688
  const failed = merged.filter((r) => outcomeFailed(r.outcome));
1689
+ const hasMapping = Object.keys(batchResult.tempIdMapping).length > 0;
1100
1690
  const summary = {
1101
1691
  summary: {
1102
1692
  total: merged.length,
1103
1693
  ok: merged.length - failed.length,
1104
1694
  failed: failed.filter((r) => r.outcome.kind !== "skipped").length,
1105
1695
  skipped: merged.filter((r) => r.outcome.kind === "skipped").length,
1696
+ // ADDITIVE: the temp-id → uuid mapping and the batch undo token
1697
+ // (undo the whole submission with `things undo --txn <token>`).
1698
+ ...(hasMapping && { tempIdMapping: batchResult.tempIdMapping }),
1699
+ ...(batchResult.undoToken !== undefined && { undoToken: batchResult.undoToken }),
1106
1700
  },
1107
1701
  };
1108
1702
  process.stdout.write(`${JSON.stringify(summary)}\n`);
@@ -1212,35 +1806,35 @@ export function registerWriteCommands(program) {
1212
1806
  client?.close();
1213
1807
  }
1214
1808
  });
1215
- addWriteFlags(program
1216
- .command("reorder <uuids...>")
1217
- .description("Reorder items within Today, This Evening, the Inbox, Someday (loose to-dos or " +
1218
- "area-less someday projects one kind per call), a " +
1219
- "project's to-dos, a project's HEADINGS, an area, or the top-level sidebar " +
1220
- "projects uuids are placed at the TOP in the given order; unlisted members " +
1221
- "keep their relative order below. Strategies: native (EXPERIMENTAL requires " +
1222
- "`things config set allow-experimental true` and may stop working after a " +
1223
- "Things update; today/inbox/someday/project/headings/area) and bounce " +
1224
- `(today/evening/projects, max ${BOUNCE_MAX_ITEMS} items; an interrupted run ` +
1225
- "reports which items were placed). Evening and projects (top-level sidebar " +
1226
- "order each project takes a brief someday/anytime round-trip) are " +
1227
- "bounce-only. Project children under headings cannot be reordered; reordering " +
1228
- "a heading carries its children with it. Area scope reorders to-dos OR " +
1229
- "projects never mixed in one request.")
1230
- .requiredOption("--scope <scope>", "today | evening | inbox | someday | project | headings | area | projects")
1231
- .option("--project <ref>", "project (uuid or unique name) scope=project|headings")
1232
- .option("--area <ref>", "area (uuid or unique name) scope=area")
1233
- .option("--strategy <name>", "force native | bounce (default: per-scope)")).action(async (uuids, opts) => {
1234
- const scope = opts["scope"];
1235
- const container = containerRef(opts["project"] ?? opts["area"]);
1236
- await runWrite(opts, (c) => c.write.reorder({
1237
- scope,
1238
- uuids,
1239
- ...(container !== undefined && { container }),
1240
- ...(opts["strategy"] !== undefined && {
1241
- strategy: opts["strategy"],
1242
- }),
1243
- }, writeOptionsFrom(opts)));
1809
+ addPositionFlags(addWriteFlags(program
1810
+ .command("reorder <refs...>")
1811
+ .description("Rearrange to-dos (and the project rows the Today/Evening/day lists intermix with " +
1812
+ "them) IN PLACE within the container and bucket they already share — REARRANGES, " +
1813
+ "never changes membership (to change what an item belongs to, use `things todo " +
1814
+ "move` / `things project move`). Argument order is the resulting order; unmentioned " +
1815
+ "siblings keep theirs. Bare (no position) assembles the named items as a block at " +
1816
+ "the EARLIEST one's current slot (partial-selection friendly); --first/--last/" +
1817
+ "--before/--after position the block. Operands that span containers or buckets fail " +
1818
+ "closed. A Today/Evening member also has an index slot in its container, so a set " +
1819
+ "sharing BOTH axes is ambiguous pass --in to say which (the refusal names both " +
1820
+ "spellings). Ordering uses the native re-rank where available (private surface, on " +
1821
+ "by default) and a verified when= bounce otherwise; bounce-max-items caps a bounce, " +
1822
+ "bounce-enabled=false refuses bounce-dependent placements rather than degrading. " +
1823
+ "For a project's HEADINGS use `things project move-heading`; for sidebar AREAS use " +
1824
+ "`things area reorder`.")
1825
+ .option("--in <target>", "disambiguate the axis of a Today/Evening set: today | evening | anytime | someday | " +
1826
+ "inbox, or a project/area/heading ref (uuid or unique title)"))).action(async (refs, opts) => {
1827
+ const position = movePosition(opts);
1828
+ if (position === "conflict") {
1829
+ usageError(opts, "pass at most one of --first/--last/--before/--after");
1830
+ return;
1831
+ }
1832
+ const request = {
1833
+ uuids: refs,
1834
+ ...(position !== undefined && { position }),
1835
+ ...(opts["in"] !== undefined && { in: opts["in"] }),
1836
+ };
1837
+ await runMoveCmd(opts, (c) => c.write.reorderTodos(request, writeOptionsFrom(opts)));
1244
1838
  });
1245
1839
  program
1246
1840
  .command("capabilities")
@@ -1253,7 +1847,7 @@ export function registerWriteCommands(program) {
1253
1847
  const data = capabilitiesTable(opts.op);
1254
1848
  if (opts.json) {
1255
1849
  const meta = { dbVersion: null, fingerprint: "unknown", elapsedMs: 0 };
1256
- process.stdout.write(`${JSON.stringify(okEnvelope("capabilities", data, meta))}\n`);
1850
+ process.stdout.write(`${JSON.stringify(okEnvelope("capabilities", { items: data }, meta))}\n`);
1257
1851
  return;
1258
1852
  }
1259
1853
  for (const entry of data) {
@@ -1271,31 +1865,44 @@ export function registerWriteCommands(program) {
1271
1865
  });
1272
1866
  const config = group(program, "config", "things-api configuration");
1273
1867
  config
1274
- .command("show")
1275
- .description("Show the effective configuration (profile, disruption policy, actor)")
1868
+ .command("get [key]")
1869
+ .description("Show one config key's effective value, or every effective value when no key is given " +
1870
+ "(including read-only derived values like host). Precedence is env > stored > default; " +
1871
+ "each value is marked with the layer that supplied it. Unknown key is a usage error. " +
1872
+ "--json emits a versioned envelope.")
1276
1873
  .option("--json", "emit versioned JSON envelope on stdout")
1277
- .option("--db <path>", "explicit database path")
1278
- .action((opts) => {
1279
- const client = openThings(opts.db ? { dbPath: opts.db } : {});
1280
- try {
1874
+ .action((key, opts) => {
1875
+ const meta = { dbVersion: null, fingerprint: "unknown", elapsedMs: 0 };
1876
+ if (key !== undefined) {
1877
+ const entry = getConfigKey(key);
1878
+ if (entry === undefined) {
1879
+ process.stderr.write(`error: unknown config key "${key}"\n`);
1880
+ process.exitCode = ExitCode.Usage;
1881
+ return;
1882
+ }
1281
1883
  if (opts.json) {
1282
- const meta = { dbVersion: null, fingerprint: "unknown", elapsedMs: 0 };
1283
- process.stdout.write(`${JSON.stringify(okEnvelope("config", client.config, meta))}\n`);
1884
+ process.stdout.write(`${JSON.stringify(okEnvelope("config", entry, meta))}\n`);
1284
1885
  }
1285
1886
  else {
1286
- for (const [k, v] of Object.entries(client.config)) {
1287
- process.stdout.write(`${k}: ${String(v)}\n`);
1288
- }
1887
+ process.stdout.write(`${configKeyLine(entry)}\n`);
1289
1888
  }
1889
+ return;
1290
1890
  }
1291
- finally {
1292
- client.close();
1891
+ const all = describeConfig();
1892
+ if (opts.json) {
1893
+ process.stdout.write(`${JSON.stringify(okEnvelope("config", { items: all }, meta))}\n`);
1894
+ }
1895
+ else {
1896
+ for (const entry of all) {
1897
+ process.stdout.write(`${configKeyLine(entry)}\n`);
1898
+ }
1293
1899
  }
1294
1900
  });
1295
1901
  config
1296
1902
  .command("set <key> <value>")
1297
1903
  .description("Persist a config key: profile | maxDisruption | actor | auditEnabled | " +
1298
- "accepted-fingerprint | allow-experimental | ui-enabled")
1904
+ "accepted-fingerprint | certified-app-version | allow-experimental | bounce-enabled | " +
1905
+ "bounce-max-items | ui-enabled | scope")
1299
1906
  .action((key, value) => {
1300
1907
  const map = {
1301
1908
  profile: "profile",
@@ -1303,8 +1910,12 @@ export function registerWriteCommands(program) {
1303
1910
  actor: "actor",
1304
1911
  auditEnabled: "auditEnabled",
1305
1912
  "accepted-fingerprint": "acceptedFingerprint",
1913
+ "certified-app-version": "certifiedAppVersion",
1306
1914
  "allow-experimental": "allowExperimental",
1915
+ "bounce-enabled": "bounceEnabled",
1916
+ "bounce-max-items": "bounceMaxItems",
1307
1917
  "ui-enabled": "uiEnabled",
1918
+ scope: "scope",
1308
1919
  };
1309
1920
  const target = map[key];
1310
1921
  if (target === undefined) {
@@ -1312,13 +1923,25 @@ export function registerWriteCommands(program) {
1312
1923
  process.exitCode = ExitCode.Usage;
1313
1924
  return;
1314
1925
  }
1315
- const parsed = target === "maxDisruption"
1926
+ const parsed = target === "maxDisruption" || target === "bounceMaxItems"
1316
1927
  ? Number(value)
1317
- : target === "auditEnabled" || target === "allowExperimental" || target === "uiEnabled"
1928
+ : target === "auditEnabled" ||
1929
+ target === "allowExperimental" ||
1930
+ target === "bounceEnabled" ||
1931
+ target === "uiEnabled"
1318
1932
  ? value === "true"
1319
1933
  : value;
1320
1934
  saveConfigKey(target, parsed);
1321
1935
  process.stdout.write(`set ${key} = ${String(parsed)}\n`);
1936
+ // A stored scope jails EVERY process on this host — including this
1937
+ // terminal — until removed. Per-process scoping belongs on the
1938
+ // THINGS_API_SCOPE env var or the `things mcp --scope` flag instead.
1939
+ if (target === "scope") {
1940
+ process.stderr.write("warning: a stored scope limits EVERY things-api process on this machine to " +
1941
+ `"${value}" — including your own terminal — until you clear it with ` +
1942
+ '`things config set scope ""`. For per-process limits (e.g. one MCP server), ' +
1943
+ "prefer `things mcp --scope <ref>` or the THINGS_API_SCOPE environment variable.\n");
1944
+ }
1322
1945
  });
1323
1946
  }
1324
1947
  //# sourceMappingURL=writes.js.map