things-api 0.11.0 → 0.13.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 (208) hide show
  1. package/README.md +16 -5
  2. package/dist/audit/schema.d.ts +7 -0
  3. package/dist/audit/schema.js.map +1 -1
  4. package/dist/cli/canonical-ref.d.ts +21 -0
  5. package/dist/cli/canonical-ref.js +23 -0
  6. package/dist/cli/canonical-ref.js.map +1 -0
  7. package/dist/cli/commands/area.d.ts +18 -11
  8. package/dist/cli/commands/area.js +81 -77
  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 +19 -4
  14. package/dist/cli/commands/install-skill.js +130 -52
  15. package/dist/cli/commands/install-skill.js.map +1 -1
  16. package/dist/cli/commands/mcp.js +21 -1
  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 +92 -48
  20. package/dist/cli/commands/project.js.map +1 -1
  21. package/dist/cli/commands/reads.d.ts +5 -1
  22. package/dist/cli/commands/reads.js +110 -31
  23. package/dist/cli/commands/reads.js.map +1 -1
  24. package/dist/cli/commands/setup.js +11 -4
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/show.js +75 -36
  27. package/dist/cli/commands/show.js.map +1 -1
  28. package/dist/cli/commands/todo.js +2 -2
  29. package/dist/cli/commands/todo.js.map +1 -1
  30. package/dist/cli/commands/writes.js +795 -144
  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 +44 -7
  34. package/dist/cli/did-you-mean.js.map +1 -1
  35. package/dist/cli/dry-run.d.ts +55 -0
  36. package/dist/cli/dry-run.js +92 -0
  37. package/dist/cli/dry-run.js.map +1 -0
  38. package/dist/cli/glyphs.d.ts +16 -2
  39. package/dist/cli/glyphs.js +60 -12
  40. package/dist/cli/glyphs.js.map +1 -1
  41. package/dist/cli/help.js +69 -13
  42. package/dist/cli/help.js.map +1 -1
  43. package/dist/cli/main.js +8 -1
  44. package/dist/cli/main.js.map +1 -1
  45. package/dist/cli/move-hint.d.ts +3 -2
  46. package/dist/cli/move-hint.js +2 -2
  47. package/dist/cli/move-hint.js.map +1 -1
  48. package/dist/cli/read-driver.d.ts +53 -5
  49. package/dist/cli/read-driver.js +82 -15
  50. package/dist/cli/read-driver.js.map +1 -1
  51. package/dist/cli/ref-render.d.ts +14 -0
  52. package/dist/cli/ref-render.js +9 -0
  53. package/dist/cli/ref-render.js.map +1 -0
  54. package/dist/cli/render.d.ts +40 -14
  55. package/dist/cli/render.js +128 -55
  56. package/dist/cli/render.js.map +1 -1
  57. package/dist/cli/resolve-invocation.d.ts +7 -5
  58. package/dist/cli/resolve-invocation.js +16 -7
  59. package/dist/cli/resolve-invocation.js.map +1 -1
  60. package/dist/cli/skill-check.d.ts +12 -6
  61. package/dist/cli/skill-check.js +31 -14
  62. package/dist/cli/skill-check.js.map +1 -1
  63. package/dist/cli/skill.d.ts +8 -0
  64. package/dist/cli/skill.js +10 -0
  65. package/dist/cli/skill.js.map +1 -1
  66. package/dist/cli/verb-hint.js +1 -1
  67. package/dist/cli/verb-hint.js.map +1 -1
  68. package/dist/client.d.ts +158 -26
  69. package/dist/client.js +254 -38
  70. package/dist/client.js.map +1 -1
  71. package/dist/config.d.ts +66 -2
  72. package/dist/config.js +120 -12
  73. package/dist/config.js.map +1 -1
  74. package/dist/contracts.d.ts +187 -28
  75. package/dist/contracts.js +22 -1
  76. package/dist/contracts.js.map +1 -1
  77. package/dist/db/baselines/db-v26.js +4 -1
  78. package/dist/db/baselines/db-v26.js.map +1 -1
  79. package/dist/diagnose.d.ts +34 -2
  80. package/dist/diagnose.js +36 -1
  81. package/dist/diagnose.js.map +1 -1
  82. package/dist/index.d.ts +21 -8
  83. package/dist/index.js +26 -3
  84. package/dist/index.js.map +1 -1
  85. package/dist/mcp/server.d.ts +9 -0
  86. package/dist/mcp/server.js +790 -322
  87. package/dist/mcp/server.js.map +1 -1
  88. package/dist/model/entities.d.ts +57 -4
  89. package/dist/model/entities.js.map +1 -1
  90. package/dist/model/mappers.d.ts +2 -0
  91. package/dist/model/mappers.js +44 -2
  92. package/dist/model/mappers.js.map +1 -1
  93. package/dist/model/recurrence.d.ts +8 -1
  94. package/dist/model/recurrence.js.map +1 -1
  95. package/dist/model/serialize.js +15 -3
  96. package/dist/model/serialize.js.map +1 -1
  97. package/dist/read/area-filter.d.ts +76 -0
  98. package/dist/read/area-filter.js +58 -0
  99. package/dist/read/area-filter.js.map +1 -0
  100. package/dist/read/area-view.d.ts +22 -11
  101. package/dist/read/area-view.js +54 -28
  102. package/dist/read/area-view.js.map +1 -1
  103. package/dist/read/detail.js +15 -8
  104. package/dist/read/detail.js.map +1 -1
  105. package/dist/read/predicates.d.ts +18 -0
  106. package/dist/read/predicates.js +19 -0
  107. package/dist/read/predicates.js.map +1 -1
  108. package/dist/read/project-view.d.ts +91 -15
  109. package/dist/read/project-view.js +135 -37
  110. package/dist/read/project-view.js.map +1 -1
  111. package/dist/read/pseudo-area.d.ts +29 -0
  112. package/dist/read/pseudo-area.js +27 -0
  113. package/dist/read/pseudo-area.js.map +1 -0
  114. package/dist/read/queries.d.ts +175 -19
  115. package/dist/read/queries.js +376 -32
  116. package/dist/read/queries.js.map +1 -1
  117. package/dist/read/scope.d.ts +126 -0
  118. package/dist/read/scope.js +161 -0
  119. package/dist/read/scope.js.map +1 -0
  120. package/dist/read/search-rank.d.ts +2 -5
  121. package/dist/read/shape.d.ts +181 -0
  122. package/dist/read/shape.js +737 -0
  123. package/dist/read/shape.js.map +1 -0
  124. package/dist/read/show-target.d.ts +9 -1
  125. package/dist/read/show-target.js +77 -17
  126. package/dist/read/show-target.js.map +1 -1
  127. package/dist/read/stage.d.ts +199 -0
  128. package/dist/read/stage.js +125 -0
  129. package/dist/read/stage.js.map +1 -0
  130. package/dist/read/truncation.d.ts +7 -12
  131. package/dist/read/truncation.js +56 -21
  132. package/dist/read/truncation.js.map +1 -1
  133. package/dist/read/views.d.ts +68 -16
  134. package/dist/read/views.js +206 -52
  135. package/dist/read/views.js.map +1 -1
  136. package/dist/surface-copy.d.ts +16 -0
  137. package/dist/surface-copy.js +16 -0
  138. package/dist/surface-copy.js.map +1 -1
  139. package/dist/write/batch.d.ts +49 -10
  140. package/dist/write/batch.js +423 -71
  141. package/dist/write/batch.js.map +1 -1
  142. package/dist/write/commands.js +268 -55
  143. package/dist/write/commands.js.map +1 -1
  144. package/dist/write/guards.d.ts +1 -1
  145. package/dist/write/guards.js +71 -10
  146. package/dist/write/guards.js.map +1 -1
  147. package/dist/write/heading.d.ts +10 -1
  148. package/dist/write/heading.js +35 -5
  149. package/dist/write/heading.js.map +1 -1
  150. package/dist/write/make-repeating-project.d.ts +2 -2
  151. package/dist/write/make-repeating-project.js +9 -9
  152. package/dist/write/make-repeating-project.js.map +1 -1
  153. package/dist/write/move.d.ts +134 -0
  154. package/dist/write/move.js +1874 -0
  155. package/dist/write/move.js.map +1 -0
  156. package/dist/write/operations.d.ts +105 -19
  157. package/dist/write/operations.js +56 -7
  158. package/dist/write/operations.js.map +1 -1
  159. package/dist/write/opid.d.ts +31 -0
  160. package/dist/write/opid.js +30 -0
  161. package/dist/write/opid.js.map +1 -0
  162. package/dist/write/pipeline.d.ts +70 -3
  163. package/dist/write/pipeline.js +198 -33
  164. package/dist/write/pipeline.js.map +1 -1
  165. package/dist/write/pre-state.d.ts +172 -8
  166. package/dist/write/pre-state.js +467 -25
  167. package/dist/write/pre-state.js.map +1 -1
  168. package/dist/write/reorder.d.ts +23 -3
  169. package/dist/write/reorder.js +2106 -86
  170. package/dist/write/reorder.js.map +1 -1
  171. package/dist/write/reversibility.js +19 -7
  172. package/dist/write/reversibility.js.map +1 -1
  173. package/dist/write/scope-guard.d.ts +31 -0
  174. package/dist/write/scope-guard.js +162 -0
  175. package/dist/write/scope-guard.js.map +1 -0
  176. package/dist/write/undo.js +120 -13
  177. package/dist/write/undo.js.map +1 -1
  178. package/dist/write/vectors/applescript.js +16 -7
  179. package/dist/write/vectors/applescript.js.map +1 -1
  180. package/dist/write/vectors/shortcuts.js +1 -1
  181. package/dist/write/vectors/shortcuts.js.map +1 -1
  182. package/dist/write/vectors/simulator.js +83 -27
  183. package/dist/write/vectors/simulator.js.map +1 -1
  184. package/dist/write/vectors/types.d.ts +11 -0
  185. package/dist/write/vectors/ui-certification.d.ts +3 -3
  186. package/dist/write/vectors/ui-certification.js +41 -5
  187. package/dist/write/vectors/ui-certification.js.map +1 -1
  188. package/dist/write/vectors/ui-drag.d.ts +21 -1
  189. package/dist/write/vectors/ui-drag.js +138 -27
  190. package/dist/write/vectors/ui-drag.js.map +1 -1
  191. package/dist/write/vectors/ui-recipes.d.ts +21 -1
  192. package/dist/write/vectors/ui-recipes.js +166 -7
  193. package/dist/write/vectors/ui-recipes.js.map +1 -1
  194. package/dist/write/vectors/ui.d.ts +25 -1
  195. package/dist/write/vectors/ui.js +142 -33
  196. package/dist/write/vectors/ui.js.map +1 -1
  197. package/dist/write/verify/delta.d.ts +17 -1
  198. package/dist/write/verify/delta.js +106 -29
  199. package/dist/write/verify/delta.js.map +1 -1
  200. package/package.json +7 -1
  201. package/schema/envelope.schema.json +397 -0
  202. package/skills/things-cli/SKILL.md +36 -17
  203. package/skills/things-cli/references/banner.md +35 -0
  204. package/skills/things-cli/references/contracts.md +32 -9
  205. package/skills/things-cli/references/errors.md +57 -0
  206. package/skills/things-cli/references/gui.md +1 -1
  207. package/skills/things-cli/references/model.md +18 -7
  208. package/skills/things-cli/references/ordering.md +88 -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, ClockError, 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
  }
@@ -47,6 +103,38 @@ function addCreateTagsFlag(cmd) {
47
103
  function createTagsExtra(opts) {
48
104
  return opts["createTags"] === true ? { createTags: true } : {};
49
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
+ }
50
138
  /** Add the mandatory GUI-drive acknowledgement to a ui-vector command. */
51
139
  function addDriveGuiFlag(cmd) {
52
140
  return cmd.option("--dangerously-drive-gui", "required: visibly drives the Things app to make a change it offers nowhere else; " +
@@ -87,7 +175,9 @@ function splitCsv(value) {
87
175
  .map((s) => s.trim())
88
176
  .filter((s) => s !== "");
89
177
  }
90
- async function runWrite(opts, fn) {
178
+ async function runWrite(opts, fn, emitFn = emitResult) {
179
+ if (!opIdOk(opts))
180
+ return;
91
181
  const started = Date.now();
92
182
  let client = null;
93
183
  const meta = (client_) => {
@@ -105,7 +195,7 @@ async function runWrite(opts, fn) {
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 {
@@ -167,11 +257,14 @@ function emitResult(result, opts, meta) {
167
257
  process.stderr.write(`warning: ${warning}\n`);
168
258
  }
169
259
  if (opts.json) {
170
- process.stdout.write(`${JSON.stringify(okEnvelope("mutation-result", result, meta))}\n`);
260
+ process.stdout.write(`${JSON.stringify(okEnvelope("mutation-result", mutationWireData(result), meta))}\n`);
171
261
  }
172
262
  else {
173
263
  const uuid = result.uuid === null ? "" : ` uuid=${result.uuid}`;
174
- 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`);
175
268
  }
176
269
  process.exitCode = ExitCode.Ok;
177
270
  return;
@@ -233,7 +326,7 @@ function emitResult(result, opts, meta) {
233
326
  process.stdout.write(`${JSON.stringify(errorEnvelope({
234
327
  code: "unsupported",
235
328
  message: `no validated vector supports ${result.op}`,
236
- detail: result.considered,
329
+ detail: { considered: result.considered },
237
330
  }, meta))}\n`);
238
331
  }
239
332
  else {
@@ -251,6 +344,303 @@ function emitResult(result, opts, meta) {
251
344
  }
252
345
  }
253
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
+ // A hoisted placement block names its hazard — surface it with the canonical
437
+ // `blocked:<hazard>` code + `BLOCKED (<hazard>)` copy, exactly as a direct
438
+ // `things reorder` hazard block does (emitResult "blocked" case).
439
+ const jsonCode = result.hazard !== undefined
440
+ ? blockedCode({ hazard: result.hazard, reason: "hazard" })
441
+ : result.refusal;
442
+ if (opts.json) {
443
+ process.stdout.write(`${JSON.stringify(errorEnvelope({
444
+ code: jsonCode,
445
+ message: result.detail,
446
+ ...(result.remediation !== undefined && { remediation: result.remediation }),
447
+ ...(result.candidates !== undefined && {
448
+ detail: { candidates: result.candidates },
449
+ }),
450
+ }, meta))}\n`);
451
+ }
452
+ else {
453
+ const prefix = result.refusal === "usage"
454
+ ? "error"
455
+ : result.hazard !== undefined
456
+ ? `BLOCKED (${result.hazard})`
457
+ : result.refusal.toUpperCase();
458
+ process.stderr.write(`${prefix}: ${result.detail}\n` +
459
+ (result.remediation !== undefined ? ` remediation: ${result.remediation}\n` : ""));
460
+ }
461
+ process.exitCode = code;
462
+ return;
463
+ }
464
+ case "move-leg-failed": {
465
+ if (opts.json) {
466
+ process.stdout.write(`${JSON.stringify(errorEnvelope({ code: "verify-failed", message: result.detail, detail: { failed: result.failed, completed: result.completed } }, meta))}\n`);
467
+ }
468
+ else {
469
+ process.stderr.write(`MOVE FAILED: ${result.detail}\n`);
470
+ }
471
+ process.exitCode = ExitCode.VerifyFailed;
472
+ return;
473
+ }
474
+ }
475
+ }
476
+ /** Build a MovePosition from the shared --first/--last/--before/--after flags. */
477
+ function movePosition(opts) {
478
+ const chosen = [
479
+ opts["first"] === true,
480
+ opts["last"] === true,
481
+ opts["before"] !== undefined,
482
+ opts["after"] !== undefined,
483
+ ].filter(Boolean).length;
484
+ if (chosen > 1)
485
+ return "conflict";
486
+ if (opts["first"] === true)
487
+ return { at: "first" };
488
+ if (opts["last"] === true)
489
+ return { at: "last" };
490
+ if (opts["before"] !== undefined)
491
+ return { before: opts["before"] };
492
+ if (opts["after"] !== undefined)
493
+ return { after: opts["after"] };
494
+ return undefined;
495
+ }
496
+ /** Build a to-do move destination; "conflict" when more than one is named. */
497
+ function todoDestination(opts) {
498
+ const dests = [];
499
+ const toProject = opts["toProject"];
500
+ const toHeading = opts["toHeading"];
501
+ if (toHeading !== undefined) {
502
+ dests.push({
503
+ kind: "heading",
504
+ sel: toHeading,
505
+ ...(toProject !== undefined && { project: { uuid: toProject, title: toProject } }),
506
+ });
507
+ }
508
+ else if (toProject !== undefined) {
509
+ dests.push({ kind: "project", ref: { uuid: toProject, title: toProject } });
510
+ }
511
+ const toArea = opts["toArea"];
512
+ if (toArea !== undefined)
513
+ dests.push({ kind: "area", ref: { uuid: toArea, title: toArea } });
514
+ // Commander maps `--no-heading`/`--no-area` onto the base key as `false`.
515
+ if (opts["heading"] === false)
516
+ dests.push({ kind: "no-heading" });
517
+ if (opts["loose"] === true)
518
+ dests.push({ kind: "loose" });
519
+ if (opts["inbox"] === true)
520
+ dests.push({ kind: "inbox" });
521
+ if (opts["area"] === false)
522
+ dests.push({ kind: "no-area" });
523
+ if (opts["detach"] === true)
524
+ dests.push({ kind: "detach" });
525
+ if (dests.length > 1)
526
+ return "conflict";
527
+ return dests[0];
528
+ }
529
+ /** Build a project move destination; "conflict" when more than one is named. */
530
+ function projectDestination(opts) {
531
+ const dests = [];
532
+ const toArea = opts["toArea"];
533
+ if (toArea !== undefined)
534
+ dests.push({ kind: "area", ref: { uuid: toArea, title: toArea } });
535
+ // Commander maps `--no-area` onto the base key `area` as `false`.
536
+ if (opts["area"] === false)
537
+ dests.push({ kind: "no-area" });
538
+ if (opts["loose"] === true)
539
+ dests.push({ kind: "loose" });
540
+ if (opts["detach"] === true)
541
+ dests.push({ kind: "detach" });
542
+ if (dests.length > 1)
543
+ return "conflict";
544
+ return dests[0];
545
+ }
546
+ /** The shared position flags (--first/--last/--before/--after) for move/reorder. */
547
+ function addPositionFlags(cmd) {
548
+ return cmd
549
+ .option("--first", "place the block at the top of its bucket")
550
+ .option("--last", "place the block at the bottom of its bucket")
551
+ .option("--before <ref>", "place the block immediately before this item (same bucket)")
552
+ .option("--after <ref>", "place the block immediately after this item (same bucket)");
553
+ }
554
+ /** One TTY line for a bulk-add batch item (the human, non-JSON multi rendering). */
555
+ function addResultLine(r) {
556
+ const o = r.outcome;
557
+ switch (o.kind) {
558
+ case "ok":
559
+ return `ok todo.add uuid=${o.uuid ?? ""} (vector=${o.vector}, tier=${o.tier}, verified)\n`;
560
+ case "dry-run":
561
+ return `DRY RUN todo.add (vector=${o.plan.vector}, tier ${o.plan.tier}) ${o.plan.invocation}\n`;
562
+ case "already-applied":
563
+ return `already-applied todo.add uuid=${o.uuid}\n`;
564
+ case "skipped":
565
+ return `skipped todo.add: ${o.detail}\n`;
566
+ case "invalid":
567
+ return `FAILED todo.add: ${o.detail}\n`;
568
+ case "blocked":
569
+ return `BLOCKED todo.add (${o.hazard ?? o.reason}): ${o.detail}\n`;
570
+ case "verify-failed":
571
+ return `VERIFY FAILED todo.add (${o.reason}): ${o.detail}\n`;
572
+ case "unsupported":
573
+ return `UNSUPPORTED todo.add\n`;
574
+ default:
575
+ return `FAILED todo.add: ${JSON.stringify(o)}\n`;
576
+ }
577
+ }
578
+ /**
579
+ * Run a bulk `todo add` as ONE batch of `todo.add` legs (shared flags already
580
+ * compiled into every op's params). Streams per-line results and a trailing
581
+ * summary carrying the single `undoToken` that removes the whole skeleton —
582
+ * except under `--id-only`, where output is exactly one uuid per created item,
583
+ * in creation order, and nothing else. Exit code is the worst leg's failure.
584
+ */
585
+ async function runBulkAdd(opts, ops, idOnly) {
586
+ let client = null;
587
+ try {
588
+ client = openThings(opts.db ? { dbPath: opts.db } : {});
589
+ const batchResult = await client.write.batch(ops, {
590
+ ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
591
+ ...(opts.actor !== undefined && { actor: opts.actor }),
592
+ }, (r) => {
593
+ if (idOnly) {
594
+ if (r.outcome.kind === "ok" && r.outcome.uuid !== null)
595
+ process.stdout.write(`${r.outcome.uuid}\n`);
596
+ }
597
+ else if (opts.json) {
598
+ emit(r);
599
+ }
600
+ else {
601
+ process.stdout.write(addResultLine(r));
602
+ }
603
+ });
604
+ const failed = batchResult.results.filter((r) => outcomeFailed(r.outcome));
605
+ if (!idOnly) {
606
+ const total = batchResult.results.length;
607
+ const okCount = total - failed.length;
608
+ if (opts.json) {
609
+ const summary = {
610
+ summary: {
611
+ total,
612
+ ok: okCount,
613
+ failed: failed.filter((r) => r.outcome.kind !== "skipped").length,
614
+ skipped: batchResult.results.filter((r) => r.outcome.kind === "skipped").length,
615
+ ...(batchResult.undoToken !== undefined && { undoToken: batchResult.undoToken }),
616
+ },
617
+ };
618
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
619
+ }
620
+ else {
621
+ const undo = batchResult.undoToken !== undefined
622
+ ? ` (undo all: things undo --txn ${batchResult.undoToken})`
623
+ : "";
624
+ process.stdout.write(`added ${okCount}/${total} to-dos${undo}\n`);
625
+ }
626
+ }
627
+ process.exitCode = aggregateExitCode(failed.map((r) => r.outcome));
628
+ }
629
+ finally {
630
+ client?.close();
631
+ }
632
+ }
633
+ /** Read newline-delimited titles from stdin; blank lines (whitespace-only) skipped. */
634
+ async function readStdinTitles() {
635
+ const chunks = [];
636
+ for await (const chunk of process.stdin)
637
+ chunks.push(chunk);
638
+ return Buffer.concat(chunks)
639
+ .toString("utf8")
640
+ .split("\n")
641
+ .map((l) => l.replace(/\r$/, ""))
642
+ .filter((l) => l.trim() !== "");
643
+ }
254
644
  function group(program, name, description) {
255
645
  const existing = program.commands.find((c) => c.name() === name);
256
646
  if (existing !== undefined)
@@ -261,12 +651,15 @@ const containerRef = (value) => value === undefined ? undefined : { uuid: value,
261
651
  export function registerWriteCommands(program) {
262
652
  const todo = group(program, "todo", "To-do–scoped operations");
263
653
  addCreateTagsFlag(addWriteFlags(todo
264
- .command("add <title>")
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 " +
654
+ .command("add [titles...]")
655
+ .description("Create one or more to-dos; each new uuid is printed on success. Pass several " +
656
+ "titles to create a quick skeleton in one call, or stream them with --stdin " +
657
+ "(one title per line); every shared flag below applies to each title. Projects, " +
658
+ "areas, and headings must name existing items — unknown or ambiguous references " +
659
+ "are rejected. A tag may be a name or a parent/child path, and must exist unless " +
268
660
  "--create-tags. Adding into a completed/canceled project reopens that project — " +
269
- "requires --acknowledge-project-reopen.")
661
+ "requires --acknowledge-project-reopen. When several to-dos are created, one undo " +
662
+ "token removes the whole skeleton at once.")
270
663
  .option("--notes <text>", "notes body")
271
664
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
272
665
  .option("--reminder <HH:mm>", "time-of-day reminder (24h); requires --when today|evening|YYYY-MM-DD")
@@ -276,14 +669,33 @@ export function registerWriteCommands(program) {
276
669
  .option("--project <ref>", "destination project (uuid or unique name)")
277
670
  .option("--area <ref>", "destination area (uuid or unique name)")
278
671
  .option("--heading <name>", "existing heading in the destination project")
279
- .option("--acknowledge-project-reopen", "allow adding into a completed/canceled project"))).action(async (title, opts) => {
672
+ .option("--acknowledge-project-reopen", "allow adding into a completed/canceled project")
673
+ .option("--stdin", "read newline-delimited titles from stdin (blank lines skipped); exclusive with title arguments")
674
+ .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) => {
675
+ const idOnly = opts["idOnly"] === true;
676
+ const useStdin = opts["stdin"] === true;
677
+ if (idOnly && opts.json === true) {
678
+ usageError(opts, "--id-only and --json are mutually exclusive");
679
+ return;
680
+ }
681
+ if (useStdin && titles.length > 0) {
682
+ usageError(opts, "--stdin is mutually exclusive with title arguments");
683
+ return;
684
+ }
685
+ const finalTitles = useStdin ? await readStdinTitles() : titles;
686
+ if (finalTitles.length === 0) {
687
+ usageError(opts, useStdin
688
+ ? "no titles: --stdin received no non-empty lines"
689
+ : "provide at least one title, or pass --stdin to read titles from stdin");
690
+ return;
691
+ }
280
692
  const checklist = opts["checklistItem"];
281
693
  const tags = splitCsv(opts["tags"]);
282
694
  const project = containerRef(opts["project"]);
283
695
  const area = containerRef(opts["area"]);
284
696
  if (!whenSugarOk(opts))
285
697
  return;
286
- await runWrite(opts, (c) => c.write.addTodo({
698
+ const buildParams = (title) => ({
287
699
  title,
288
700
  ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
289
701
  ...(opts["when"] !== undefined && { when: opts["when"] }),
@@ -294,12 +706,65 @@ export function registerWriteCommands(program) {
294
706
  ...(project !== undefined && { project }),
295
707
  ...(area !== undefined && { area }),
296
708
  ...(opts["heading"] !== undefined && { heading: opts["heading"] }),
297
- }, writeOptionsFrom(opts, {
298
- ...(opts["acknowledgeProjectReopen"] !== undefined && {
299
- acknowledgeProjectReopen: opts["acknowledgeProjectReopen"],
300
- }),
301
- ...createTagsExtra(opts),
302
- })));
709
+ });
710
+ const ackReopen = opts["acknowledgeProjectReopen"] !== undefined
711
+ ? { acknowledgeProjectReopen: opts["acknowledgeProjectReopen"] }
712
+ : {};
713
+ // A multi-title add compiles onto the batch machinery (below), where
714
+ // idempotency is a per-LINE op_id, not one key for the whole skeleton —
715
+ // refuse --op-id there rather than drop it. A single-title add is a single
716
+ // mutation and takes --op-id normally (validated inside runWrite).
717
+ if (finalTitles.length > 1 && opts.opId !== undefined) {
718
+ usageError(opts, "--op-id applies to a single-title add — for a multi-title add use `things batch` with a " +
719
+ "per-line op_id");
720
+ return;
721
+ }
722
+ // Single title (positional or a one-line stdin) keeps today's single
723
+ // mutation-result envelope exactly — unless --id-only, which prints just
724
+ // the new uuid. Multiple titles compile onto the batch machinery.
725
+ if (finalTitles.length === 1) {
726
+ const params = buildParams(finalTitles[0]);
727
+ const wopts = writeOptionsFrom(opts, { ...ackReopen, ...createTagsExtra(opts) });
728
+ if (!idOnly) {
729
+ await runWrite(opts, (c) => c.write.addTodo(params, wopts));
730
+ return;
731
+ }
732
+ await runWrite(opts, (c) => c.write.addTodo(params, wopts), (result, o, meta) => {
733
+ if (result.kind === "ok") {
734
+ if (result.uuid !== null)
735
+ process.stdout.write(`${result.uuid}\n`);
736
+ process.exitCode = ExitCode.Ok;
737
+ }
738
+ else if (result.kind === "dry-run") {
739
+ // --id-only mints no uuid to print on a dry-run; stay silent.
740
+ process.exitCode = ExitCode.Ok;
741
+ }
742
+ else {
743
+ emitResult(result, { ...o, json: false }, meta);
744
+ }
745
+ });
746
+ return;
747
+ }
748
+ // Multi-title: one batch of todo.add legs, shared flags applied to each.
749
+ const perOpOptions = {
750
+ ...ackReopen,
751
+ ...(opts["createTags"] === true && { createTags: true }),
752
+ ...(opts.vector !== undefined && { vector: opts.vector }),
753
+ ...(opts.allowVeryDisruptive === true
754
+ ? { maxDisruption: 3 }
755
+ : opts.allowDisruptive === true
756
+ ? { maxDisruption: 2 }
757
+ : {}),
758
+ ...(opts.verifyTimeout !== undefined && { verifyTimeoutMs: Number(opts.verifyTimeout) }),
759
+ };
760
+ const hasPerOpOptions = Object.keys(perOpOptions).length > 0;
761
+ const ops = finalTitles.map((title) => {
762
+ const op = { op: "todo.add", params: buildParams(title) };
763
+ if (hasPerOpOptions)
764
+ op.options = perOpOptions;
765
+ return op;
766
+ });
767
+ await runBulkAdd(opts, ops, idOnly);
303
768
  });
304
769
  addWriteFlags(todo
305
770
  .command("update <uuid>")
@@ -355,35 +820,47 @@ export function registerWriteCommands(program) {
355
820
  await runWrite(opts, (c) => c.write[method](uuid, writeOptionsFrom(opts)));
356
821
  });
357
822
  }
358
- addWriteFlags(todo
359
- .command("move <uuid>")
360
- .description("Move a to-do into a project or area (optionally under an existing heading), back " +
361
- "to the Inbox, or out of every container. Unknown or ambiguous destinations are " +
362
- "rejected. Moving into a completed/canceled project reopens that project " +
363
- "requires --acknowledge-project-reopen.")
364
- .option("--project <ref>", "destination project (uuid or unique name)")
365
- .option("--area <ref>", "destination area (uuid or unique name)")
366
- .option("--heading <name>", "existing heading in the destination project")
823
+ addPositionFlags(addWriteFlags(todo
824
+ .command("move <refs...>")
825
+ .description("Move one or more to-dos as an ordered block (the argument order is the order they " +
826
+ "land name them backwards to reverse). MOVE changes WHAT a to-do belongs to; to " +
827
+ "rearrange to-dos that already share a container use `things todo reorder`. Pass one " +
828
+ "destination: --to-project / --to-heading / --to-area, or the detach family " +
829
+ "--no-heading (leave the heading, stay in the project) / --loose (leave heading, " +
830
+ "project, AND area). --inbox files back to the Inbox. Position with " +
831
+ "--first/--last/--before/--after (an anchor positions but never migrates — an " +
832
+ "anchor-only move that would cross containers is refused). Membership always " +
833
+ "succeeds; placement is guaranteed for loose lists, a project's/area's members, a " +
834
+ "heading's children, and a container's same-day scheduled children — the result " +
835
+ "states the placement class, and a bounce that co-bounces unnamed siblings to honor " +
836
+ "an anchor lists them. Set bounce-max-items to cap how many items a bounce touches; " +
837
+ "bounce-enabled=false refuses bounce-dependent placements. Moving into a " +
838
+ "completed/canceled project reopens it — requires --acknowledge-project-reopen.")
839
+ .option("--to-project <ref>", "destination project (uuid or unique name)")
840
+ .option("--to-heading <sel>", "destination heading (exact title or uuid; within --to-project)")
841
+ .option("--to-area <ref>", "destination area (uuid or unique name)")
842
+ .option("--no-heading", "leave the heading but stay in the current project (unheaded block)")
843
+ .option("--loose", "detach from heading, project, AND area (keeping the schedule)")
367
844
  .option("--inbox", "move back to the Inbox — removes any schedule")
368
- .option("--detach", "remove ALL container links (project/area/heading) keeping the schedule")
369
- .option("--acknowledge-project-reopen", "allow moving into a completed/canceled project")).action(async (uuid, opts) => {
370
- const project = containerRef(opts["project"]);
371
- const area = containerRef(opts["area"]);
372
- const inbox = opts["inbox"] === true;
373
- const detach = opts["detach"] === true;
374
- const dest = project !== undefined || area !== undefined || opts["heading"] !== undefined;
375
- if ((inbox && (dest || detach)) || (detach && dest)) {
376
- usageError(opts, "--inbox/--detach are exclusive with each other and with --project/--area/--heading");
845
+ .option("--no-area", "(not a to-do flag teaches the correct spelling)")
846
+ .option("--detach", "(removed teaches the replacement family)")
847
+ .option("--acknowledge-project-reopen", "allow moving into a completed/canceled project"))).action(async (refs, opts) => {
848
+ const dest = todoDestination(opts);
849
+ if (dest === "conflict") {
850
+ usageError(opts, "pass at most one destination (--to-project/--to-heading/--to-area/--no-heading/--loose/--inbox)");
377
851
  return;
378
852
  }
379
- await runWrite(opts, (c) => c.write.moveTodo(uuid, {
380
- ...(project !== undefined && { project }),
381
- ...(area !== undefined && { area }),
382
- ...(opts["heading"] !== undefined && { heading: opts["heading"] }),
383
- ...(inbox && { inbox: true }),
384
- ...(detach && { detach: true }),
385
- }, writeOptionsFrom(opts, {
386
- ...(inbox && { vector: "applescript" }),
853
+ const position = movePosition(opts);
854
+ if (position === "conflict") {
855
+ usageError(opts, "pass at most one of --first/--last/--before/--after");
856
+ return;
857
+ }
858
+ const request = {
859
+ uuids: refs,
860
+ ...(dest !== undefined && { destination: dest }),
861
+ ...(position !== undefined && { position }),
862
+ };
863
+ await runMoveCmd(opts, (c) => c.write.moveTodos(request, writeOptionsFrom(opts, {
387
864
  ...(opts["acknowledgeProjectReopen"] !== undefined && {
388
865
  acknowledgeProjectReopen: opts["acknowledgeProjectReopen"],
389
866
  }),
@@ -475,6 +952,8 @@ export function registerWriteCommands(program) {
475
952
  : action === "rename"
476
953
  ? { action: "rename", ...target, title: opts["to"] }
477
954
  : { action: "move", ...target, to: Number(opts["toPosition"]) };
955
+ if (opIdCompoundRefused(opts, "a granular checklist edit"))
956
+ return;
478
957
  await runWrite(opts, (c) => c.write.editChecklist(uuid, edit, writeOptionsFrom(opts)));
479
958
  return;
480
959
  }
@@ -503,6 +982,8 @@ export function registerWriteCommands(program) {
503
982
  "shortcuts installed (`things setup shortcuts`) this is in place, and is the only " +
504
983
  "way for a repeating to-do; otherwise a date-scheduled to-do is cleared by a brief " +
505
984
  "re-schedule through Today. Reversible with `things undo`.")).action(async (uuid, opts) => {
985
+ if (opIdCompoundRefused(opts, "clear-reminder"))
986
+ return;
506
987
  await runWrite(opts, (c) => c.write.clearReminder(uuid, writeOptionsFrom(opts)));
507
988
  });
508
989
  addWriteFlags(todo
@@ -591,62 +1072,147 @@ export function registerWriteCommands(program) {
591
1072
  await runWrite(opts, (c) => c.write.run(op, { uuid }, writeOptionsFrom(opts)));
592
1073
  });
593
1074
  }
594
- const heading = group(program, "heading", "Heading-scoped operations");
595
- addWriteFlags(heading
596
- .command("add <project> <title>")
1075
+ const project = group(program, "project", "Project-scoped operations");
1076
+ // --- project headings (spec §2) ------------------------------------------
1077
+ // A heading exists only inside a project. Each verb takes <project-ref> then
1078
+ // a heading selector: an exact title OR a uuid (never an ordinal — an index
1079
+ // silently re-targets a different heading after any reorder). An empty-string
1080
+ // title selects a titleless heading; duplicates fail closed with uuid
1081
+ // candidates.
1082
+ addPlacementFlags(addWriteFlags(project
1083
+ .command("add-heading <project> <title>")
597
1084
  .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)));
1085
+ "project must name an existing project (uuid or unique name). Uses the Things proxy " +
1086
+ "shortcuts — run `things setup shortcuts` once first. By default the heading is " +
1087
+ "appended; a placement flag positions it among the project's headings (that leg " +
1088
+ "needs `things config set allow-experimental true`)."))).action(async (projectRef, title, opts) => {
1089
+ if (countPlacementFlags(opts) > 1) {
1090
+ usageError(opts, "pass at most one of --first / --last / --before-heading / --after-heading");
1091
+ return;
1092
+ }
1093
+ if (opIdCompoundRefused(opts, "add-heading"))
1094
+ return;
1095
+ await runWrite(opts, (c) => {
1096
+ const proj = c.resolve.project(projectRef);
1097
+ const placement = headingPlacement(c, proj.uuid, opts);
1098
+ return c.write.addHeading({ uuid: proj.uuid }, title, placement, writeOptionsFrom(opts));
1099
+ });
601
1100
  });
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)));
1101
+ addWriteFlags(project
1102
+ .command("rename-heading <project> <heading>")
1103
+ .description(`Rename a heading in place (works on archived headings too). <heading> is a ${HEADING_SEL_HELP}.`)
1104
+ .requiredOption("--to <title>", "the new heading title")).action(async (projectRef, sel, opts) => {
1105
+ await runWrite(opts, (c) => {
1106
+ const proj = c.resolve.project(projectRef);
1107
+ const h = c.resolve.heading(proj.uuid, sel);
1108
+ return c.write.renameHeading(h.uuid, opts["to"], writeOptionsFrom(opts));
1109
+ });
606
1110
  });
607
- addWriteFlags(heading
608
- .command("archive <uuid>")
1111
+ addWriteFlags(project
1112
+ .command("archive-heading <project> <heading>")
609
1113
  .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: " +
1114
+ "`things project unarchive-heading`). This is the preferred way to retire a heading: " +
611
1115
  "row DELETION exists only in the app's UI and Shortcuts with a per-run consent " +
612
1116
  "dialog, never headlessly. With open children, --children is required: " +
613
1117
  "complete/cancel resolve them with the heading (one atomic cascade); reparent " +
614
1118
  "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) => {
1119
+ `that \`things undo\` reverses as one unit. <heading> is a ${HEADING_SEL_HELP}.`)
1120
+ .option("--children <policy>", "complete | cancel | reparent (required when children are open)")).action(async (projectRef, sel, opts) => {
617
1121
  const children = opts["children"];
1122
+ if (opIdCompoundRefused(opts, "archive-heading"))
1123
+ return;
618
1124
  await runWrite(opts, async (c) => {
619
- const outcome = await c.write.archiveHeading(uuid, children ? { children } : {}, writeOptionsFrom(opts));
1125
+ const proj = c.resolve.project(projectRef);
1126
+ const h = c.resolve.heading(proj.uuid, sel);
1127
+ const outcome = await c.write.archiveHeading(h.uuid, children ? { children } : {}, writeOptionsFrom(opts));
620
1128
  for (const leg of outcome.reparented) {
621
1129
  process.stderr.write(`reparented: ${leg.title} (${leg.result.kind})\n`);
622
1130
  }
623
1131
  return outcome.heading;
624
1132
  });
625
1133
  });
626
- addWriteFlags(heading
627
- .command("unarchive <uuid>")
1134
+ addWriteFlags(project
1135
+ .command("unarchive-heading <project> <heading>")
628
1136
  .description("Un-archive a heading. --restore-children also reopens the children the archive " +
629
1137
  "cascade resolved with it (identified by matching resolution timestamps; a " +
630
1138
  "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) => {
1139
+ `never touched. <heading> is a ${HEADING_SEL_HELP}.`)
1140
+ .option("--restore-children", "reopen cascade-resolved children too")).action(async (projectRef, sel, opts) => {
1141
+ if (opIdCompoundRefused(opts, "unarchive-heading"))
1142
+ return;
633
1143
  await runWrite(opts, async (c) => {
634
- const outcome = await c.write.unarchiveHeading(uuid, opts["restoreChildren"] === true ? { restoreChildren: true } : {}, writeOptionsFrom(opts));
1144
+ const proj = c.resolve.project(projectRef);
1145
+ const h = c.resolve.heading(proj.uuid, sel);
1146
+ const outcome = await c.write.unarchiveHeading(h.uuid, opts["restoreChildren"] === true ? { restoreChildren: true } : {}, writeOptionsFrom(opts));
635
1147
  for (const child of outcome.children) {
636
1148
  process.stderr.write(`restored: ${child.title} (${child.result.kind})\n`);
637
1149
  }
638
1150
  return outcome.heading;
639
1151
  });
640
1152
  });
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 " +
1153
+ addDriveGuiFlag(addWriteFlags(project
1154
+ .command("promote-heading <project> <heading>")
1155
+ .description("Promote a heading into a project. This REPLACES the heading with a new project — it " +
644
1156
  "is promoted alongside its parent project (into the same area) and the heading's " +
645
1157
  "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)));
1158
+ `undone. The new project's uuid is printed on success. <heading> is a ${HEADING_SEL_HELP}.`))).action(async (projectRef, sel, opts) => {
1159
+ await runWrite(opts, (c) => {
1160
+ const proj = c.resolve.project(projectRef);
1161
+ const h = c.resolve.heading(proj.uuid, sel);
1162
+ return c.write.run("project.promote-heading", { uuid: h.uuid }, writeOptionsFrom(opts));
1163
+ });
1164
+ });
1165
+ addPlacementFlags(addWriteFlags(project
1166
+ .command("move-heading <project> <headings...>")
1167
+ .description("Reposition one or more of a project's headings as an ordered block — the selection " +
1168
+ "order is the resulting order, and each heading's to-dos follow it. Pass exactly one " +
1169
+ "placement: --first, --last, --before-heading <sel>, or --after-heading <sel>. Each " +
1170
+ `<heading> is a ${HEADING_SEL_HELP}. Reordering headings rides the same experimental ` +
1171
+ "surface as `things reorder` — enable it once with `things config set " +
1172
+ "allow-experimental true`."))).action(async (projectRef, sels, opts) => {
1173
+ if (countPlacementFlags(opts) !== 1) {
1174
+ usageError(opts, "pass exactly one of --first / --last / --before-heading / --after-heading");
1175
+ return;
1176
+ }
1177
+ await runWrite(opts, (c) => {
1178
+ const proj = c.resolve.project(projectRef);
1179
+ const headings = sels.map((s) => c.resolve.heading(proj.uuid, s).uuid);
1180
+ const placement = headingPlacement(c, proj.uuid, opts);
1181
+ return c.write.moveHeading({ uuid: proj.uuid }, headings, placement, writeOptionsFrom(opts));
1182
+ });
1183
+ });
1184
+ addWriteFlags(project
1185
+ .command("move-heading-to-project <project> <heading>")
1186
+ .requiredOption("--to <project>", "destination project (uuid or unique title)")
1187
+ .description("Move a heading — WITH its to-dos — to a DIFFERENT project. This is the cross-project " +
1188
+ "relocation, distinct from `move-heading` (which only reorders a heading within its own " +
1189
+ `project). <heading> is a ${HEADING_SEL_HELP} in the SOURCE <project>; --to names the ` +
1190
+ "destination. GUI-ONLY: it drives the heading row's ellipsis Move… menu, so enable it " +
1191
+ "once with `things config set ui.enabled true` and pass --dangerously-drive-gui. Fails " +
1192
+ "closed when the heading title is shared by another heading in the project, or the " +
1193
+ "destination title by another project (the drive addresses both by title). No undo — " +
1194
+ "move it back to reverse.")).action(async (projectRef, sel, opts) => {
1195
+ await runWrite(opts, (c) => {
1196
+ const proj = c.resolve.project(projectRef);
1197
+ const dest = c.resolve.project(String(opts["to"]));
1198
+ return c.write.moveHeadingToProject({ uuid: proj.uuid }, sel, { uuid: dest.uuid }, writeOptionsFrom(opts));
1199
+ });
1200
+ });
1201
+ addWriteFlags(project
1202
+ .command("dissolve-heading <project> <heading>")
1203
+ .description("Dissolve a heading — remove it but KEEP its to-dos, which become direct children of the " +
1204
+ "project (in the same order), NOT trashed. This is the opposite of deleting: nothing is " +
1205
+ `lost. <heading> is a ${HEADING_SEL_HELP}. Contrast \`things todo delete\` and the ` +
1206
+ "Shortcuts heading-delete cascade, which TRASH the children. GUI-ONLY (drives the " +
1207
+ "heading row's ellipsis Delete): enable it once with `things config set ui.enabled true` " +
1208
+ "and pass --dangerously-drive-gui. Fails closed when the heading title is shared by " +
1209
+ "another heading in the project. No undo — re-create the heading and move the to-dos back.")).action(async (projectRef, sel, opts) => {
1210
+ await runWrite(opts, (c) => {
1211
+ const proj = c.resolve.project(projectRef);
1212
+ const h = c.resolve.heading(proj.uuid, sel);
1213
+ return c.write.dissolveHeading(h.uuid, writeOptionsFrom(opts));
1214
+ });
648
1215
  });
649
- const project = group(program, "project", "Project-scoped operations");
650
1216
  // --- ui vector: repeating-project transforms (two-key gated) -------------
651
1217
  addDriveGuiFlag(addRepeatRuleFlags(addWriteFlags(project
652
1218
  .command("reschedule-repeat <ref>")
@@ -690,6 +1256,8 @@ export function registerWriteCommands(program) {
690
1256
  .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
691
1257
  .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (uuid, opts) => {
692
1258
  const frequency = opts["frequency"];
1259
+ if (opIdCompoundRefused(opts, "project make-repeating"))
1260
+ return;
693
1261
  await runWrite(opts, (c) => c.write.makeRepeatingProject(uuid, {
694
1262
  frequency,
695
1263
  interval: Number(opts["interval"]),
@@ -697,7 +1265,7 @@ export function registerWriteCommands(program) {
697
1265
  }, writeOptionsFrom(opts)));
698
1266
  });
699
1267
  addDriveGuiFlag(addWriteFlags(project
700
- .command("create-repeating <title>")
1268
+ .command("add-repeating <title>")
701
1269
  .description("Create a project and turn it into a repeating series in ONE call. Two operations: the " +
702
1270
  "project is created first and PERSISTS even if the make-repeating step refuses; then it " +
703
1271
  "is promoted (which drives the GUI). Give --area to place it, or omit it to create in " +
@@ -710,7 +1278,9 @@ export function registerWriteCommands(program) {
710
1278
  .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP))).action(async (title, opts) => {
711
1279
  const todos = opts["todo"];
712
1280
  const area = containerRef(opts["area"]);
713
- await runWrite(opts, (c) => c.write.createRepeatingProject({
1281
+ if (opIdCompoundRefused(opts, "project add-repeating"))
1282
+ return;
1283
+ await runWrite(opts, (c) => c.write.addRepeatingProject({
714
1284
  title,
715
1285
  ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
716
1286
  ...(area !== undefined && { area }),
@@ -722,12 +1292,14 @@ export function registerWriteCommands(program) {
722
1292
  });
723
1293
  addWriteFlags(project
724
1294
  .command("add <title>")
725
- .description("Create a project; its uuid is printed on success.")
1295
+ .description("Create a project; its uuid is printed on success. Give --todo (repeatable) to " +
1296
+ "seed it with child to-dos in the same call — the quick way to stand up a new " +
1297
+ "project skeleton.")
726
1298
  .option("--notes <text>", "notes body")
727
1299
  .option("--area <ref>", "destination area (uuid or unique name)")
728
1300
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
729
1301
  .option("--deadline <date>", "YYYY-MM-DD")
730
- .option("--todo <title>", "initial child to-do (repeatable)", collect, [])).action(async (title, opts) => {
1302
+ .option("--todo <title>", "initial child to-do, repeatable (seeds the new project)", collect, [])).action(async (title, opts) => {
731
1303
  const todos = opts["todo"];
732
1304
  const area = containerRef(opts["area"]);
733
1305
  await runWrite(opts, (c) => c.write.addProject({
@@ -801,20 +1373,35 @@ export function registerWriteCommands(program) {
801
1373
  ? c.write.setProjectTags(uuid, set, writeOptionsFrom(opts, createTagsExtra(opts)))
802
1374
  : c.write.addProjectTags(uuid, add ?? [], writeOptionsFrom(opts, createTagsExtra(opts))));
803
1375
  });
804
- addWriteFlags(project
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.")
809
- .option("--area <ref>", "destination area (uuid or unique name)")
810
- .option("--detach", "remove the current area assignment (exclusive with --area)")).action(async (uuid, opts) => {
811
- if ((opts.detach === true) === (opts.area !== undefined)) {
812
- usageError(opts, "pass exactly one of --area / --detach");
1376
+ addPositionFlags(addWriteFlags(project
1377
+ .command("move <refs...>")
1378
+ .description("Move one or more projects as an ordered block (argument order = resulting order). " +
1379
+ "Pass one destination: --to-area (uuid or unique name) or --no-area (leave the " +
1380
+ "area — a project's complete detach). Position among siblings with " +
1381
+ "--first/--last/--before/--after (an anchor positions but never migrates). " +
1382
+ "Membership always succeeds; top-of-bucket placement is guaranteed only where a " +
1383
+ "reorder protocol exists (the result states the class). Status and schedule are " +
1384
+ "untouched.")
1385
+ .option("--to-area <ref>", "destination area (uuid or unique name)")
1386
+ .option("--no-area", "leave the current area (a project's single-level detach)")
1387
+ .option("--loose", "(not a project flag — teaches the correct spelling)")
1388
+ .option("--detach", "(removed — teaches the replacement)"))).action(async (refs, opts) => {
1389
+ const dest = projectDestination(opts);
1390
+ if (dest === "conflict") {
1391
+ usageError(opts, "pass at most one of --to-area / --no-area");
813
1392
  return;
814
1393
  }
815
- await runWrite(opts, (c) => opts.detach === true
816
- ? c.write.detachProject(uuid, writeOptionsFrom(opts))
817
- : c.write.moveProject(uuid, { uuid: opts.area, title: opts.area }, writeOptionsFrom(opts)));
1394
+ const position = movePosition(opts);
1395
+ if (position === "conflict") {
1396
+ usageError(opts, "pass at most one of --first/--last/--before/--after");
1397
+ return;
1398
+ }
1399
+ const request = {
1400
+ uuids: refs,
1401
+ ...(dest !== undefined && { destination: dest }),
1402
+ ...(position !== undefined && { position }),
1403
+ };
1404
+ await runMoveCmd(opts, (c) => c.write.moveProjects(request, writeOptionsFrom(opts)));
818
1405
  });
819
1406
  addWriteFlags(project
820
1407
  .command("cancel <ref>")
@@ -832,6 +1419,8 @@ export function registerWriteCommands(program) {
832
1419
  "project — children resolved earlier are never touched. Exit 3 if any child " +
833
1420
  "restore fails.")
834
1421
  .option("--restore-children", "also reopen the children resolved with the project")).action(async (uuid, opts) => {
1422
+ if (opIdCompoundRefused(opts, "project reopen"))
1423
+ return;
835
1424
  const started = Date.now();
836
1425
  let client = null;
837
1426
  try {
@@ -953,13 +1542,18 @@ export function registerWriteCommands(program) {
953
1542
  addWriteFlags(area
954
1543
  .command("delete <ref>")
955
1544
  .description("Delete an area PERMANENTLY — areas do not go to the Trash, so this cannot be " +
956
- "undone; requires --dangerously-permanent. The area's to-dos move to the Trash; " +
957
- "its projects remain, no longer assigned to any area.")
958
- .option("--dangerously-permanent", "accept permanent, unrecoverable deletion")).action(async (target, opts) => {
1545
+ "undone; requires --dangerously-permanent. Deleting an area moves its to-dos and " +
1546
+ "projects to the Trash, so a non-empty area is refused unless you pass " +
1547
+ "--allow-non-empty; empty the area first to keep its contents.")
1548
+ .option("--dangerously-permanent", "accept permanent, unrecoverable deletion")
1549
+ .option("--allow-non-empty", "delete the area together with its contents (its to-dos and projects move to the Trash)")).action(async (target, opts) => {
959
1550
  await runWrite(opts, (c) => c.write.deleteArea(target, writeOptionsFrom(opts, {
960
1551
  ...(opts["dangerouslyPermanent"] !== undefined && {
961
1552
  dangerouslyPermanent: opts["dangerouslyPermanent"],
962
1553
  }),
1554
+ ...(opts["allowNonEmpty"] !== undefined && {
1555
+ allowNonEmptyArea: opts["allowNonEmpty"],
1556
+ }),
963
1557
  })));
964
1558
  });
965
1559
  const tag = group(program, "tag", "Tag-scoped operations");
@@ -1041,6 +1635,15 @@ export function registerWriteCommands(program) {
1041
1635
  "failure does not roll back earlier ops. Per-op results stream as JSONL. Per-op " +
1042
1636
  "options carry the confirmation flags (acknowledgeChecklistReset, " +
1043
1637
  "acknowledgeProjectReopen, dangerouslyPermanent, acknowledgeTagSubtree). " +
1638
+ "CHAINING: an op that creates something may carry a `tempId` (a handle like " +
1639
+ '"proj1"); a LATER op references that new uuid as "$proj1" in any id/container ' +
1640
+ 'param (dotted "$proj1.instance"/"$proj1.replaced" reach a repeating op\'s spawned ' +
1641
+ "instance / replaced source). A tempId is valid only on a creating op (not tag.add — " +
1642
+ "reference a tag by title) and unique per batch; an unresolved/forward $ref fails just " +
1643
+ "that line. IDEMPOTENCY: a line's `opId` makes resubmission safe — a matching earlier " +
1644
+ "success is reported already-applied, not re-created. The trailing summary line adds " +
1645
+ "`tempIdMapping` (handle → uuid) and `undoToken` — undo the WHOLE batch with " +
1646
+ "`things undo --txn <undoToken>`. " +
1044
1647
  "--dry-run plans everything without executing; --fail-fast skips the rest after " +
1045
1648
  "the first failure. Exit (worst failure wins): 0 all ok · 3 any verify-failed/invalid " +
1046
1649
  "· 4 any blocked · 5 any drift-blocked · 6 any unsupported.")
@@ -1084,7 +1687,7 @@ export function registerWriteCommands(program) {
1084
1687
  let client = null;
1085
1688
  try {
1086
1689
  client = openThings(opts.db ? { dbPath: opts.db } : {});
1087
- const results = await client.write.batch(ops, {
1690
+ const batchResult = await client.write.batch(ops, {
1088
1691
  ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
1089
1692
  ...(opts["failFast"] === true && { failFast: true }),
1090
1693
  ...(opts.actor !== undefined && { actor: opts.actor }),
@@ -1092,14 +1695,19 @@ export function registerWriteCommands(program) {
1092
1695
  const pre = preInvalid.find((p) => p.index === r.index);
1093
1696
  emit(pre ?? r);
1094
1697
  });
1095
- const merged = results.map((r) => preInvalid.find((p) => p.index === r.index) ?? r);
1698
+ const merged = batchResult.results.map((r) => preInvalid.find((p) => p.index === r.index) ?? r);
1096
1699
  const failed = merged.filter((r) => outcomeFailed(r.outcome));
1700
+ const hasMapping = Object.keys(batchResult.tempIdMapping).length > 0;
1097
1701
  const summary = {
1098
1702
  summary: {
1099
1703
  total: merged.length,
1100
1704
  ok: merged.length - failed.length,
1101
1705
  failed: failed.filter((r) => r.outcome.kind !== "skipped").length,
1102
1706
  skipped: merged.filter((r) => r.outcome.kind === "skipped").length,
1707
+ // ADDITIVE: the temp-id → uuid mapping and the batch undo token
1708
+ // (undo the whole submission with `things undo --txn <token>`).
1709
+ ...(hasMapping && { tempIdMapping: batchResult.tempIdMapping }),
1710
+ ...(batchResult.undoToken !== undefined && { undoToken: batchResult.undoToken }),
1103
1711
  },
1104
1712
  };
1105
1713
  process.stdout.write(`${JSON.stringify(summary)}\n`);
@@ -1209,35 +1817,41 @@ export function registerWriteCommands(program) {
1209
1817
  client?.close();
1210
1818
  }
1211
1819
  });
1212
- addWriteFlags(program
1213
- .command("reorder <uuids...>")
1214
- .description("Reorder items within Today, This Evening, the Inbox, Someday (loose to-dos or " +
1215
- "area-less someday projects one kind per call), a " +
1216
- "project's to-dos, a project's HEADINGS, an area, or the top-level sidebar " +
1217
- "projects uuids are placed at the TOP in the given order; unlisted members " +
1218
- "keep their relative order below. Strategies: native (EXPERIMENTAL requires " +
1219
- "`things config set allow-experimental true` and may stop working after a " +
1220
- "Things update; today/inbox/someday/project/headings/area) and bounce " +
1221
- `(today/evening/projects, max ${BOUNCE_MAX_ITEMS} items; an interrupted run ` +
1222
- "reports which items were placed). Evening and projects (top-level sidebar " +
1223
- "order each project takes a brief someday/anytime round-trip) are " +
1224
- "bounce-only. Project children under headings cannot be reordered; reordering " +
1225
- "a heading carries its children with it. Area scope reorders to-dos OR " +
1226
- "projects never mixed in one request.")
1227
- .requiredOption("--scope <scope>", "today | evening | inbox | someday | project | headings | area | projects")
1228
- .option("--project <ref>", "project (uuid or unique name) scope=project|headings")
1229
- .option("--area <ref>", "area (uuid or unique name) — scope=area")
1230
- .option("--strategy <name>", "force native | bounce (default: per-scope)")).action(async (uuids, opts) => {
1231
- const scope = opts["scope"];
1232
- const container = containerRef(opts["project"] ?? opts["area"]);
1233
- await runWrite(opts, (c) => c.write.reorder({
1234
- scope,
1235
- uuids,
1236
- ...(container !== undefined && { container }),
1237
- ...(opts["strategy"] !== undefined && {
1238
- strategy: opts["strategy"],
1239
- }),
1240
- }, writeOptionsFrom(opts)));
1820
+ addPositionFlags(addWriteFlags(program
1821
+ .command("reorder <refs...>")
1822
+ .description("Rearrange to-dos (and the project rows the Today/Evening/day lists intermix with " +
1823
+ "them) IN PLACE within the container and bucket they already share — REARRANGES, " +
1824
+ "never changes membership (to change what an item belongs to, use `things todo " +
1825
+ "move` / `things project move`). Argument order is the resulting order; unmentioned " +
1826
+ "siblings keep theirs. Bare (no position) assembles the named items as a block at " +
1827
+ "the EARLIEST one's current slot (partial-selection friendly); --first/--last/" +
1828
+ "--before/--after position the block. Operands that span containers or buckets fail " +
1829
+ "closed. A Today/Evening member also has an index slot in its container, so a set " +
1830
+ "sharing BOTH axes is ambiguous pass --in to say which (the refusal names both " +
1831
+ "spellings). A deadline-forecast set sharing one Upcoming day is dual-axis the same " +
1832
+ "way (the day-block vs its container order); --in upcoming or --in <YYYY-MM-DD> names " +
1833
+ "the day-block. Ordering uses the native re-rank where available (private surface, on " +
1834
+ "by default) and a verified when= bounce otherwise; bounce-max-items caps a bounce, " +
1835
+ "bounce-enabled=false refuses bounce-dependent placements rather than degrading. " +
1836
+ "For a project's HEADINGS use `things project move-heading`; for sidebar AREAS use " +
1837
+ "`things area reorder`.")
1838
+ .option("--in <target>", "name the axis to reorder on: today | evening | anytime | someday | inbox, a project/" +
1839
+ "area/heading ref (uuid or unique title), upcoming (the one future day the set shares), " +
1840
+ "or a YYYY-MM-DD day-block. A stage-list or container axis sorts one KIND at a time — " +
1841
+ "a mixed to-do+project set is refused (even sharing a container); only today | evening " +
1842
+ "| upcoming | a day-block intermix both kinds. Reorder a project's headings with " +
1843
+ "`things project move-heading`."))).action(async (refs, opts) => {
1844
+ const position = movePosition(opts);
1845
+ if (position === "conflict") {
1846
+ usageError(opts, "pass at most one of --first/--last/--before/--after");
1847
+ return;
1848
+ }
1849
+ const request = {
1850
+ uuids: refs,
1851
+ ...(position !== undefined && { position }),
1852
+ ...(opts["in"] !== undefined && { in: opts["in"] }),
1853
+ };
1854
+ await runMoveCmd(opts, (c) => c.write.reorderTodos(request, writeOptionsFrom(opts)));
1241
1855
  });
1242
1856
  program
1243
1857
  .command("capabilities")
@@ -1250,7 +1864,7 @@ export function registerWriteCommands(program) {
1250
1864
  const data = capabilitiesTable(opts.op);
1251
1865
  if (opts.json) {
1252
1866
  const meta = { dbVersion: null, fingerprint: "unknown", elapsedMs: 0 };
1253
- process.stdout.write(`${JSON.stringify(okEnvelope("capabilities", data, meta))}\n`);
1867
+ process.stdout.write(`${JSON.stringify(okEnvelope("capabilities", { items: data }, meta))}\n`);
1254
1868
  return;
1255
1869
  }
1256
1870
  for (const entry of data) {
@@ -1268,40 +1882,57 @@ export function registerWriteCommands(program) {
1268
1882
  });
1269
1883
  const config = group(program, "config", "things-api configuration");
1270
1884
  config
1271
- .command("show")
1272
- .description("Show the effective configuration (profile, disruption policy, actor)")
1885
+ .command("get [key]")
1886
+ .description("Show one config key's effective value, or every effective value when no key is given " +
1887
+ "(including read-only derived values like host). Precedence is env > stored > default; " +
1888
+ "each value is marked with the layer that supplied it. Unknown key is a usage error. " +
1889
+ "--json emits a versioned envelope.")
1273
1890
  .option("--json", "emit versioned JSON envelope on stdout")
1274
- .option("--db <path>", "explicit database path")
1275
- .action((opts) => {
1276
- const client = openThings(opts.db ? { dbPath: opts.db } : {});
1277
- try {
1891
+ .action((key, opts) => {
1892
+ const meta = { dbVersion: null, fingerprint: "unknown", elapsedMs: 0 };
1893
+ if (key !== undefined) {
1894
+ const entry = getConfigKey(key);
1895
+ if (entry === undefined) {
1896
+ process.stderr.write(`error: unknown config key "${key}"\n`);
1897
+ process.exitCode = ExitCode.Usage;
1898
+ return;
1899
+ }
1278
1900
  if (opts.json) {
1279
- const meta = { dbVersion: null, fingerprint: "unknown", elapsedMs: 0 };
1280
- process.stdout.write(`${JSON.stringify(okEnvelope("config", client.config, meta))}\n`);
1901
+ process.stdout.write(`${JSON.stringify(okEnvelope("config", entry, meta))}\n`);
1281
1902
  }
1282
1903
  else {
1283
- for (const [k, v] of Object.entries(client.config)) {
1284
- process.stdout.write(`${k}: ${String(v)}\n`);
1285
- }
1904
+ process.stdout.write(`${configKeyLine(entry)}\n`);
1286
1905
  }
1906
+ return;
1287
1907
  }
1288
- finally {
1289
- client.close();
1908
+ const all = describeConfig();
1909
+ if (opts.json) {
1910
+ process.stdout.write(`${JSON.stringify(okEnvelope("config", { items: all }, meta))}\n`);
1911
+ }
1912
+ else {
1913
+ for (const entry of all) {
1914
+ process.stdout.write(`${configKeyLine(entry)}\n`);
1915
+ }
1290
1916
  }
1291
1917
  });
1292
1918
  config
1293
1919
  .command("set <key> <value>")
1294
1920
  .description("Persist a config key: profile | maxDisruption | actor | auditEnabled | " +
1295
- "accepted-fingerprint | allow-experimental | ui-enabled")
1296
- .action((key, value) => {
1921
+ "accepted-fingerprint | certified-app-version | allow-experimental | bounce-enabled | " +
1922
+ "bounce-max-items | ui-enabled | scope")
1923
+ .action((key, value, opts) => {
1297
1924
  const map = {
1298
1925
  profile: "profile",
1299
1926
  maxDisruption: "maxDisruption",
1300
1927
  actor: "actor",
1301
1928
  auditEnabled: "auditEnabled",
1302
1929
  "accepted-fingerprint": "acceptedFingerprint",
1930
+ "certified-app-version": "certifiedAppVersion",
1303
1931
  "allow-experimental": "allowExperimental",
1932
+ "bounce-enabled": "bounceEnabled",
1933
+ "bounce-max-items": "bounceMaxItems",
1304
1934
  "ui-enabled": "uiEnabled",
1935
+ scope: "scope",
1305
1936
  };
1306
1937
  const target = map[key];
1307
1938
  if (target === undefined) {
@@ -1309,13 +1940,33 @@ export function registerWriteCommands(program) {
1309
1940
  process.exitCode = ExitCode.Usage;
1310
1941
  return;
1311
1942
  }
1312
- const parsed = target === "maxDisruption"
1943
+ const parsed = target === "maxDisruption" || target === "bounceMaxItems"
1313
1944
  ? Number(value)
1314
- : target === "auditEnabled" || target === "allowExperimental" || target === "uiEnabled"
1945
+ : target === "auditEnabled" ||
1946
+ target === "allowExperimental" ||
1947
+ target === "bounceEnabled" ||
1948
+ target === "uiEnabled"
1315
1949
  ? value === "true"
1316
1950
  : value;
1951
+ // Universal `--dry-run` (../dry-run.ts): `config set` writes local config
1952
+ // state (not the Things DB), so it honors the flag with an honest preview —
1953
+ // the key is still validated above, but nothing is persisted. Exit 0.
1954
+ if (opts.dryRun === true) {
1955
+ process.stdout.write(`DRY RUN would set ${key} = ${String(parsed)} (nothing written)\n`);
1956
+ process.exitCode = ExitCode.Ok;
1957
+ return;
1958
+ }
1317
1959
  saveConfigKey(target, parsed);
1318
1960
  process.stdout.write(`set ${key} = ${String(parsed)}\n`);
1961
+ // A stored scope jails EVERY process on this host — including this
1962
+ // terminal — until removed. Per-process scoping belongs on the
1963
+ // THINGS_API_SCOPE env var or the `things mcp --scope` flag instead.
1964
+ if (target === "scope") {
1965
+ process.stderr.write("warning: a stored scope limits EVERY things-api process on this machine to " +
1966
+ `"${value}" — including your own terminal — until you clear it with ` +
1967
+ '`things config set scope ""`. For per-process limits (e.g. one MCP server), ' +
1968
+ "prefer `things mcp --scope <ref>` or the THINGS_API_SCOPE environment variable.\n");
1969
+ }
1319
1970
  });
1320
1971
  }
1321
1972
  //# sourceMappingURL=writes.js.map