things-api 0.5.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 (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/bin/things.js +9 -0
  4. package/dist/audit/log.d.ts +10 -0
  5. package/dist/audit/log.js +31 -0
  6. package/dist/audit/log.js.map +1 -0
  7. package/dist/audit/schema.d.ts +37 -0
  8. package/dist/audit/schema.js +9 -0
  9. package/dist/audit/schema.js.map +1 -0
  10. package/dist/cli/commands/doctor.d.ts +9 -0
  11. package/dist/cli/commands/doctor.js +60 -0
  12. package/dist/cli/commands/doctor.js.map +1 -0
  13. package/dist/cli/commands/mcp.d.ts +7 -0
  14. package/dist/cli/commands/mcp.js +26 -0
  15. package/dist/cli/commands/mcp.js.map +1 -0
  16. package/dist/cli/commands/project.d.ts +5 -0
  17. package/dist/cli/commands/project.js +50 -0
  18. package/dist/cli/commands/project.js.map +1 -0
  19. package/dist/cli/commands/reads.d.ts +21 -0
  20. package/dist/cli/commands/reads.js +295 -0
  21. package/dist/cli/commands/reads.js.map +1 -0
  22. package/dist/cli/commands/snapshot.d.ts +7 -0
  23. package/dist/cli/commands/snapshot.js +22 -0
  24. package/dist/cli/commands/snapshot.js.map +1 -0
  25. package/dist/cli/commands/todo.d.ts +6 -0
  26. package/dist/cli/commands/todo.js +43 -0
  27. package/dist/cli/commands/todo.js.map +1 -0
  28. package/dist/cli/commands/writes.d.ts +9 -0
  29. package/dist/cli/commands/writes.js +994 -0
  30. package/dist/cli/commands/writes.js.map +1 -0
  31. package/dist/cli/exit-codes.d.ts +26 -0
  32. package/dist/cli/exit-codes.js +26 -0
  33. package/dist/cli/exit-codes.js.map +1 -0
  34. package/dist/cli/main.d.ts +4 -0
  35. package/dist/cli/main.js +73 -0
  36. package/dist/cli/main.js.map +1 -0
  37. package/dist/cli/output.d.ts +42 -0
  38. package/dist/cli/output.js +16 -0
  39. package/dist/cli/output.js.map +1 -0
  40. package/dist/cli/style.d.ts +6 -0
  41. package/dist/cli/style.js +25 -0
  42. package/dist/cli/style.js.map +1 -0
  43. package/dist/client.d.ts +184 -0
  44. package/dist/client.js +187 -0
  45. package/dist/client.js.map +1 -0
  46. package/dist/config.d.ts +31 -0
  47. package/dist/config.js +76 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/contracts.d.ts +79 -0
  50. package/dist/contracts.js +46 -0
  51. package/dist/contracts.js.map +1 -0
  52. package/dist/db/baselines/db-v26.d.ts +8 -0
  53. package/dist/db/baselines/db-v26.js +16 -0
  54. package/dist/db/baselines/db-v26.js.map +1 -0
  55. package/dist/db/baselines/index.d.ts +3 -0
  56. package/dist/db/baselines/index.js +4 -0
  57. package/dist/db/baselines/index.js.map +1 -0
  58. package/dist/db/connection.d.ts +22 -0
  59. package/dist/db/connection.js +40 -0
  60. package/dist/db/connection.js.map +1 -0
  61. package/dist/db/fingerprint.d.ts +42 -0
  62. package/dist/db/fingerprint.js +82 -0
  63. package/dist/db/fingerprint.js.map +1 -0
  64. package/dist/db/locate.d.ts +13 -0
  65. package/dist/db/locate.js +44 -0
  66. package/dist/db/locate.js.map +1 -0
  67. package/dist/db/schema.d.ts +37 -0
  68. package/dist/db/schema.js +81 -0
  69. package/dist/db/schema.js.map +1 -0
  70. package/dist/diagnose.d.ts +67 -0
  71. package/dist/diagnose.js +143 -0
  72. package/dist/diagnose.js.map +1 -0
  73. package/dist/index.d.ts +46 -0
  74. package/dist/index.js +23 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/mcp/server.d.ts +8 -0
  77. package/dist/mcp/server.js +1002 -0
  78. package/dist/mcp/server.js.map +1 -0
  79. package/dist/model/dates.d.ts +38 -0
  80. package/dist/model/dates.js +104 -0
  81. package/dist/model/dates.js.map +1 -0
  82. package/dist/model/entities.d.ts +117 -0
  83. package/dist/model/entities.js +21 -0
  84. package/dist/model/entities.js.map +1 -0
  85. package/dist/model/mappers.d.ts +55 -0
  86. package/dist/model/mappers.js +105 -0
  87. package/dist/model/mappers.js.map +1 -0
  88. package/dist/model/occurrences.d.ts +46 -0
  89. package/dist/model/occurrences.js +164 -0
  90. package/dist/model/occurrences.js.map +1 -0
  91. package/dist/model/recurrence.d.ts +52 -0
  92. package/dist/model/recurrence.js +164 -0
  93. package/dist/model/recurrence.js.map +1 -0
  94. package/dist/paths.d.ts +6 -0
  95. package/dist/paths.js +30 -0
  96. package/dist/paths.js.map +1 -0
  97. package/dist/read/detail.d.ts +8 -0
  98. package/dist/read/detail.js +34 -0
  99. package/dist/read/detail.js.map +1 -0
  100. package/dist/read/project-view.d.ts +34 -0
  101. package/dist/read/project-view.js +87 -0
  102. package/dist/read/project-view.js.map +1 -0
  103. package/dist/read/queries.d.ts +39 -0
  104. package/dist/read/queries.js +134 -0
  105. package/dist/read/queries.js.map +1 -0
  106. package/dist/read/snapshot.d.ts +25 -0
  107. package/dist/read/snapshot.js +39 -0
  108. package/dist/read/snapshot.js.map +1 -0
  109. package/dist/read/tags.d.ts +17 -0
  110. package/dist/read/tags.js +64 -0
  111. package/dist/read/tags.js.map +1 -0
  112. package/dist/read/views.d.ts +152 -0
  113. package/dist/read/views.js +322 -0
  114. package/dist/read/views.js.map +1 -0
  115. package/dist/surface-copy.d.ts +15 -0
  116. package/dist/surface-copy.js +16 -0
  117. package/dist/surface-copy.js.map +1 -0
  118. package/dist/write/automation-probe.d.ts +12 -0
  119. package/dist/write/automation-probe.js +59 -0
  120. package/dist/write/automation-probe.js.map +1 -0
  121. package/dist/write/batch.d.ts +51 -0
  122. package/dist/write/batch.js +97 -0
  123. package/dist/write/batch.js.map +1 -0
  124. package/dist/write/capabilities.d.ts +16 -0
  125. package/dist/write/capabilities.js +16 -0
  126. package/dist/write/capabilities.js.map +1 -0
  127. package/dist/write/commands.d.ts +32 -0
  128. package/dist/write/commands.js +1274 -0
  129. package/dist/write/commands.js.map +1 -0
  130. package/dist/write/environment.d.ts +29 -0
  131. package/dist/write/environment.js +105 -0
  132. package/dist/write/environment.js.map +1 -0
  133. package/dist/write/experimental.d.ts +7 -0
  134. package/dist/write/experimental.js +39 -0
  135. package/dist/write/experimental.js.map +1 -0
  136. package/dist/write/failure-hints.d.ts +32 -0
  137. package/dist/write/failure-hints.js +71 -0
  138. package/dist/write/failure-hints.js.map +1 -0
  139. package/dist/write/guards.d.ts +21 -0
  140. package/dist/write/guards.js +312 -0
  141. package/dist/write/guards.js.map +1 -0
  142. package/dist/write/lock.d.ts +10 -0
  143. package/dist/write/lock.js +70 -0
  144. package/dist/write/lock.js.map +1 -0
  145. package/dist/write/operations.d.ts +262 -0
  146. package/dist/write/operations.js +34 -0
  147. package/dist/write/operations.js.map +1 -0
  148. package/dist/write/pipeline.d.ts +90 -0
  149. package/dist/write/pipeline.js +375 -0
  150. package/dist/write/pipeline.js.map +1 -0
  151. package/dist/write/planner.d.ts +40 -0
  152. package/dist/write/planner.js +57 -0
  153. package/dist/write/planner.js.map +1 -0
  154. package/dist/write/pre-state.d.ts +125 -0
  155. package/dist/write/pre-state.js +299 -0
  156. package/dist/write/pre-state.js.map +1 -0
  157. package/dist/write/reopen.d.ts +29 -0
  158. package/dist/write/reopen.js +63 -0
  159. package/dist/write/reopen.js.map +1 -0
  160. package/dist/write/reorder.d.ts +19 -0
  161. package/dist/write/reorder.js +285 -0
  162. package/dist/write/reorder.js.map +1 -0
  163. package/dist/write/undo.d.ts +53 -0
  164. package/dist/write/undo.js +703 -0
  165. package/dist/write/undo.js.map +1 -0
  166. package/dist/write/vectors/applescript.d.ts +5 -0
  167. package/dist/write/vectors/applescript.js +216 -0
  168. package/dist/write/vectors/applescript.js.map +1 -0
  169. package/dist/write/vectors/registry.d.ts +2 -0
  170. package/dist/write/vectors/registry.js +11 -0
  171. package/dist/write/vectors/registry.js.map +1 -0
  172. package/dist/write/vectors/types.d.ts +48 -0
  173. package/dist/write/vectors/types.js +2 -0
  174. package/dist/write/vectors/types.js.map +1 -0
  175. package/dist/write/vectors/url-scheme.d.ts +3 -0
  176. package/dist/write/vectors/url-scheme.js +176 -0
  177. package/dist/write/vectors/url-scheme.js.map +1 -0
  178. package/dist/write/verify/delta.d.ts +126 -0
  179. package/dist/write/verify/delta.js +281 -0
  180. package/dist/write/verify/delta.js.map +1 -0
  181. package/dist/write/verify/poller.d.ts +24 -0
  182. package/dist/write/verify/poller.js +28 -0
  183. package/dist/write/verify/poller.js.map +1 -0
  184. package/package.json +52 -0
@@ -0,0 +1,994 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { openThings } from "../../client.js";
3
+ import { saveConfigKey } from "../../config.js";
4
+ import { ThingsDbNotFoundError } from "../../db/locate.js";
5
+ import { ThingsDbOpenError } from "../../db/connection.js";
6
+ import { capabilitiesTable } from "../../write/capabilities.js";
7
+ import { outcomeFailed } from "../../write/batch.js";
8
+ import { BOUNCE_MAX_ITEMS } from "../../write/reorder.js";
9
+ import { errorEnvelope, ExitCode, okEnvelope } from "../../contracts.js";
10
+ function addWriteFlags(cmd) {
11
+ return cmd
12
+ .option("--json", "emit versioned JSON envelope on stdout")
13
+ .option("--db <path>", "explicit database path")
14
+ .option("--dry-run", "preview the planned change and its expected effect; nothing executes")
15
+ .option("--vector <id>", "force how the change is delivered: url-scheme | applescript")
16
+ .option("--allow-disruptive", "permit changes that briefly steal window focus")
17
+ .option("--allow-very-disruptive", "permit changes that visibly drive the Things UI")
18
+ .option("--verify-timeout <ms>", "how long to wait for the change to take effect")
19
+ .option("--actor <name>", "author name recorded for this change (default: from config)");
20
+ }
21
+ function writeOptionsFrom(opts, extra = {}) {
22
+ const maxDisruption = opts.allowVeryDisruptive
23
+ ? 3
24
+ : opts.allowDisruptive
25
+ ? 2
26
+ : undefined;
27
+ return {
28
+ ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
29
+ ...(opts.vector !== undefined && { vector: opts.vector }),
30
+ ...(maxDisruption !== undefined && { maxDisruption }),
31
+ ...(opts.verifyTimeout !== undefined && { verifyTimeoutMs: Number(opts.verifyTimeout) }),
32
+ ...(opts.actor !== undefined && { actor: opts.actor }),
33
+ ...extra,
34
+ };
35
+ }
36
+ function collect(value, previous) {
37
+ return [...previous, value];
38
+ }
39
+ function splitCsv(value) {
40
+ if (value === undefined)
41
+ return undefined;
42
+ return value
43
+ .split(",")
44
+ .map((s) => s.trim())
45
+ .filter((s) => s !== "");
46
+ }
47
+ async function runWrite(opts, fn) {
48
+ const started = Date.now();
49
+ let client = null;
50
+ const meta = (client_) => {
51
+ let dbVersion = null;
52
+ let fingerprint = "unknown";
53
+ if (client_ !== null) {
54
+ const fp = client_.fingerprint();
55
+ dbVersion = fp.observation.databaseVersion;
56
+ fingerprint = fp.kind === "ok" ? "ok" : fp.kind === "drift" ? "drift" : "unknown";
57
+ }
58
+ return { dbVersion, fingerprint, elapsedMs: Date.now() - started };
59
+ };
60
+ try {
61
+ client = openThings(opts.db ? { dbPath: opts.db } : {});
62
+ const result = await fn(client);
63
+ emitResult(result, opts, meta(client));
64
+ }
65
+ catch (err) {
66
+ const isEnv = err instanceof ThingsDbNotFoundError || err instanceof ThingsDbOpenError;
67
+ const message = err instanceof Error ? err.message : String(err);
68
+ if (opts.json) {
69
+ process.stdout.write(`${JSON.stringify(errorEnvelope({ code: isEnv ? "environment" : "unexpected", message }, meta(client)))}\n`);
70
+ }
71
+ else {
72
+ process.stderr.write(`error: ${message}\n`);
73
+ }
74
+ process.exitCode = isEnv ? ExitCode.Environment : ExitCode.Unexpected;
75
+ }
76
+ finally {
77
+ client?.close();
78
+ }
79
+ }
80
+ function emitResult(result, opts, meta) {
81
+ switch (result.kind) {
82
+ case "bounce-aborted": {
83
+ if (opts.json) {
84
+ process.stdout.write(`${JSON.stringify(errorEnvelope({
85
+ code: "bounce-aborted",
86
+ message: result.detail,
87
+ detail: { placed: result.placed, remaining: result.remaining, cause: result.cause },
88
+ }, meta))}\n`);
89
+ }
90
+ else {
91
+ process.stderr.write(`BOUNCE ABORTED: ${result.detail}\n` +
92
+ ` placed (now at the top, in order): ${result.placed.join(", ") || "none"}\n` +
93
+ ` not placed: ${result.remaining.join(", ")}\n`);
94
+ }
95
+ process.exitCode = ExitCode.VerifyFailed;
96
+ return;
97
+ }
98
+ case "ok": {
99
+ for (const warning of result.warnings ?? []) {
100
+ process.stderr.write(`warning: ${warning}\n`);
101
+ }
102
+ if (opts.json) {
103
+ process.stdout.write(`${JSON.stringify(okEnvelope("mutation-result", result, meta))}\n`);
104
+ }
105
+ else {
106
+ const uuid = result.uuid === null ? "" : ` uuid=${result.uuid}`;
107
+ process.stdout.write(`ok ${result.op}${uuid} (vector=${result.vector}, tier=${result.tier}, verified)\n`);
108
+ }
109
+ process.exitCode = ExitCode.Ok;
110
+ return;
111
+ }
112
+ case "dry-run": {
113
+ if (opts.json) {
114
+ process.stdout.write(`${JSON.stringify(okEnvelope("mutation-plan", result.plan, meta))}\n`);
115
+ }
116
+ else {
117
+ process.stdout.write([
118
+ `DRY RUN ${result.op}`,
119
+ ` vector: ${result.plan.vector} (tier ${result.plan.tier})`,
120
+ ` invocation: ${result.plan.invocation}`,
121
+ ` hazards checked: ${result.plan.hazardsChecked.join(", ") || "none"}`,
122
+ ` expected delta: ${JSON.stringify(result.plan.expectedDelta)}`,
123
+ "",
124
+ ].join("\n"));
125
+ }
126
+ process.exitCode = ExitCode.Ok;
127
+ return;
128
+ }
129
+ case "verify-failed": {
130
+ if (opts.json) {
131
+ process.stdout.write(`${JSON.stringify(errorEnvelope({
132
+ code: `verify-failed:${result.reason}`,
133
+ message: result.detail,
134
+ ...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
135
+ ...(result.hint !== undefined && { remediation: result.hint }),
136
+ detail: { expected: result.expected, observed: result.observed },
137
+ }, meta))}\n`);
138
+ }
139
+ else {
140
+ process.stderr.write(`VERIFY FAILED (${result.reason}): ${result.detail}\n`);
141
+ if (result.likelyCause !== undefined) {
142
+ process.stderr.write(` likely cause: ${result.likelyCause}${result.hint !== undefined ? ` — ${result.hint}` : ""}\n`);
143
+ }
144
+ }
145
+ process.exitCode = ExitCode.VerifyFailed;
146
+ return;
147
+ }
148
+ case "blocked": {
149
+ const code = result.reason === "drift" ? ExitCode.DriftBlocked : ExitCode.Blocked;
150
+ if (opts.json) {
151
+ process.stdout.write(`${JSON.stringify(errorEnvelope({
152
+ code: `blocked:${result.hazard ?? result.reason}`,
153
+ message: result.detail,
154
+ ...(result.likelyCause !== undefined && { likelyCause: result.likelyCause }),
155
+ remediation: result.remediation,
156
+ }, meta))}\n`);
157
+ }
158
+ else {
159
+ process.stderr.write(`BLOCKED (${result.hazard ?? result.reason}): ${result.detail}\n remediation: ${result.remediation}\n`);
160
+ }
161
+ process.exitCode = code;
162
+ return;
163
+ }
164
+ case "unsupported": {
165
+ if (opts.json) {
166
+ process.stdout.write(`${JSON.stringify(errorEnvelope({
167
+ code: "unsupported",
168
+ message: `no validated vector supports ${result.op}`,
169
+ detail: result.considered,
170
+ }, meta))}\n`);
171
+ }
172
+ else {
173
+ process.stderr.write(`UNSUPPORTED: no validated vector supports ${result.op}\n`);
174
+ for (const c of result.considered) {
175
+ process.stderr.write(` ${c.vector}: ${c.why}\n`);
176
+ }
177
+ }
178
+ process.exitCode = ExitCode.Unsupported;
179
+ return;
180
+ }
181
+ default: {
182
+ const exhaustive = result;
183
+ throw new Error(`unknown result: ${JSON.stringify(exhaustive)}`);
184
+ }
185
+ }
186
+ }
187
+ function group(program, name, description) {
188
+ const existing = program.commands.find((c) => c.name() === name);
189
+ if (existing !== undefined)
190
+ return existing;
191
+ return program.command(name).description(description);
192
+ }
193
+ const containerRef = (value) => value === undefined ? undefined : { uuid: value, title: value };
194
+ export function registerWriteCommands(program) {
195
+ const todo = group(program, "todo", "To-do–scoped operations");
196
+ addWriteFlags(todo
197
+ .command("add <title>")
198
+ .description("Create a to-do; its uuid is printed on success. Tags, projects, areas, and " +
199
+ "headings must name existing items — unknown or ambiguous references are " +
200
+ "rejected. Adding into a completed/canceled project reopens that project — " +
201
+ "requires --acknowledge-project-reopen.")
202
+ .option("--notes <text>", "notes body")
203
+ .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
204
+ .option("--reminder <HH:mm>", "time-of-day reminder (24h); requires --when today|evening|YYYY-MM-DD")
205
+ .option("--deadline <date>", "YYYY-MM-DD")
206
+ .option("--tags <list>", "comma-separated EXISTING tag names")
207
+ .option("--checklist-item <text>", "checklist item (repeatable)", collect, [])
208
+ .option("--project <ref>", "destination project (uuid or unique name)")
209
+ .option("--area <ref>", "destination area (uuid or unique name)")
210
+ .option("--heading <name>", "existing heading in the destination project")
211
+ .option("--acknowledge-project-reopen", "allow adding into a completed/canceled project")).action(async (title, opts) => {
212
+ const checklist = opts["checklistItem"];
213
+ const tags = splitCsv(opts["tags"]);
214
+ const project = containerRef(opts["project"]);
215
+ const area = containerRef(opts["area"]);
216
+ await runWrite(opts, (c) => c.write.addTodo({
217
+ title,
218
+ ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
219
+ ...(opts["when"] !== undefined && { when: opts["when"] }),
220
+ ...(opts["reminder"] !== undefined && { reminder: opts["reminder"] }),
221
+ ...(opts["deadline"] !== undefined && { deadline: opts["deadline"] }),
222
+ ...(tags !== undefined && { tags }),
223
+ ...(checklist.length > 0 && { checklistItems: checklist }),
224
+ ...(project !== undefined && { project }),
225
+ ...(area !== undefined && { area }),
226
+ ...(opts["heading"] !== undefined && { heading: opts["heading"] }),
227
+ }, writeOptionsFrom(opts, {
228
+ ...(opts["acknowledgeProjectReopen"] !== undefined && {
229
+ acknowledgeProjectReopen: opts["acknowledgeProjectReopen"],
230
+ }),
231
+ })));
232
+ });
233
+ addWriteFlags(todo
234
+ .command("update <uuid>")
235
+ .description("Update title/notes/when/reminder/deadline. Schedule and deadline changes are not " +
236
+ "available for repeating to-dos (title/notes are). --reminder needs --when " +
237
+ "today|evening|YYYY-MM-DD; when re-scheduling WITHOUT --reminder an existing " +
238
+ "reminder is auto-preserved. --clear-reminder works while the to-do is scheduled " +
239
+ "for today|evening — a DATED reminder can only be changed, not cleared " +
240
+ "(re-schedule to today first). --append-notes/--prepend-notes join with a " +
241
+ "newline (exclusive with --notes).")
242
+ .option("--title <text>", "new title")
243
+ .option("--notes <text>", "replace notes")
244
+ .option("--append-notes <text>", "append to existing notes (newline-joined)")
245
+ .option("--prepend-notes <text>", "prepend to existing notes (newline-joined)")
246
+ .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
247
+ .option("--reminder <HH:mm>", "set a reminder (24h); requires --when today|evening|date")
248
+ .option("--clear-reminder", "clear the reminder (works while scheduled today|evening)")
249
+ .option("--deadline <date>", "YYYY-MM-DD")
250
+ .option("--clear-deadline", "remove the deadline")).action(async (uuid, opts) => {
251
+ const notesModes = ["notes", "appendNotes", "prependNotes"].filter((k) => opts[k] !== undefined);
252
+ if (notesModes.length > 1) {
253
+ process.stderr.write("error: --notes, --append-notes, --prepend-notes are exclusive\n");
254
+ process.exitCode = ExitCode.Usage;
255
+ return;
256
+ }
257
+ if (opts["reminder"] !== undefined && opts["clearReminder"] === true) {
258
+ process.stderr.write("error: pass at most one of --reminder / --clear-reminder\n");
259
+ process.exitCode = ExitCode.Usage;
260
+ return;
261
+ }
262
+ await runWrite(opts, (c) => c.write.updateTodo(uuid, {
263
+ ...(opts["title"] !== undefined && { title: opts["title"] }),
264
+ ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
265
+ ...(opts["appendNotes"] !== undefined && { appendNotes: opts["appendNotes"] }),
266
+ ...(opts["prependNotes"] !== undefined && {
267
+ prependNotes: opts["prependNotes"],
268
+ }),
269
+ ...(opts["when"] !== undefined && { when: opts["when"] }),
270
+ ...(opts["reminder"] !== undefined && { reminder: opts["reminder"] }),
271
+ ...(opts["clearReminder"] === true && { reminder: null }),
272
+ ...(opts["deadline"] !== undefined && { deadline: opts["deadline"] }),
273
+ ...(opts["clearDeadline"] === true && { deadline: null }),
274
+ }, writeOptionsFrom(opts)));
275
+ });
276
+ for (const [verb, method] of [
277
+ ["complete", "completeTodo"],
278
+ ["cancel", "cancelTodo"],
279
+ ["reopen", "reopenTodo"],
280
+ ]) {
281
+ addWriteFlags(todo
282
+ .command(`${verb} <uuid>`)
283
+ .description(`${verb[0]?.toUpperCase()}${verb.slice(1)} a to-do. Not available for repeating to-dos.`)).action(async (uuid, opts) => {
284
+ await runWrite(opts, (c) => c.write[method](uuid, writeOptionsFrom(opts)));
285
+ });
286
+ }
287
+ addWriteFlags(todo
288
+ .command("move <uuid>")
289
+ .description("Move a to-do into a project or area (optionally under an existing heading), back " +
290
+ "to the Inbox, or out of every container. Unknown or ambiguous destinations are " +
291
+ "rejected. Moving into a completed/canceled project reopens that project — " +
292
+ "requires --acknowledge-project-reopen.")
293
+ .option("--project <ref>", "destination project (uuid or unique name)")
294
+ .option("--area <ref>", "destination area (uuid or unique name)")
295
+ .option("--heading <name>", "existing heading in the destination project")
296
+ .option("--inbox", "move back to the Inbox — removes any schedule")
297
+ .option("--detach", "remove ALL container links (project/area/heading) keeping the schedule")
298
+ .option("--acknowledge-project-reopen", "allow moving into a completed/canceled project")).action(async (uuid, opts) => {
299
+ const project = containerRef(opts["project"]);
300
+ const area = containerRef(opts["area"]);
301
+ const inbox = opts["inbox"] === true;
302
+ const detach = opts["detach"] === true;
303
+ const dest = project !== undefined || area !== undefined || opts["heading"] !== undefined;
304
+ if ((inbox && (dest || detach)) || (detach && dest)) {
305
+ process.stderr.write("error: --inbox/--detach are exclusive with each other and with --project/--area/--heading\n");
306
+ process.exitCode = ExitCode.Usage;
307
+ return;
308
+ }
309
+ await runWrite(opts, (c) => c.write.moveTodo(uuid, {
310
+ ...(project !== undefined && { project }),
311
+ ...(area !== undefined && { area }),
312
+ ...(opts["heading"] !== undefined && { heading: opts["heading"] }),
313
+ ...(inbox && { inbox: true }),
314
+ ...(detach && { detach: true }),
315
+ }, writeOptionsFrom(opts, {
316
+ ...(inbox && { vector: "applescript" }),
317
+ ...(opts["acknowledgeProjectReopen"] !== undefined && {
318
+ acknowledgeProjectReopen: opts["acknowledgeProjectReopen"],
319
+ }),
320
+ })));
321
+ });
322
+ addWriteFlags(todo
323
+ .command("duplicate <uuid>")
324
+ .description("Duplicate a to-do — an exact copy; the copy's uuid is printed on success. Not " +
325
+ "available for repeating to-dos.")).action(async (uuid, opts) => {
326
+ await runWrite(opts, (c) => c.write.duplicateTodo(uuid, writeOptionsFrom(opts)));
327
+ });
328
+ addWriteFlags(todo
329
+ .command("tags <uuid>")
330
+ .description("Set or extend a to-do's tags. --set REPLACES the full tag set (an empty value " +
331
+ "clears all tags); --add merges with the current tags. Tags must name existing " +
332
+ "tags — unknown tags are rejected.")
333
+ .option("--set <list>", "comma-separated tag names: full replacement")
334
+ .option("--add <list>", "comma-separated tag names: merge with existing")).action(async (uuid, opts) => {
335
+ const set = splitCsv(opts["set"]);
336
+ const add = splitCsv(opts["add"]);
337
+ if ((set === undefined) === (add === undefined)) {
338
+ process.stderr.write("error: pass exactly one of --set or --add\n");
339
+ process.exitCode = ExitCode.Usage;
340
+ return;
341
+ }
342
+ await runWrite(opts, (c) => set !== undefined
343
+ ? c.write.setTags(uuid, set, writeOptionsFrom(opts))
344
+ : c.write.addTags(uuid, add ?? [], writeOptionsFrom(opts)));
345
+ });
346
+ addWriteFlags(todo
347
+ .command("checklist <uuid>")
348
+ .description("Edit a to-do's checklist. WHOLESALE: --item (repeatable) replaces the whole list, " +
349
+ "discarding the existing items and their checked states — requires " +
350
+ "--acknowledge-checklist-reset when items exist. GRANULAR (one per call): " +
351
+ "--add/--remove/--check/--uncheck/--rename+--to/--move-item+--to-position change " +
352
+ "a single item with every other item's checked state PRESERVED (no reset flag " +
353
+ "needed). Items are matched by exact title — ambiguous titles are rejected; item " +
354
+ "uuids are not stable across any edit.")
355
+ .option("--item <text>", "wholesale: checklist item in order (repeatable)", collect, [])
356
+ .option("--acknowledge-checklist-reset", "accept wholesale replacement of existing items")
357
+ .option("--add <title>", "granular: append an item")
358
+ .option("--at <n>", "with --add: 1-based insert position")
359
+ .option("--remove <title>", "granular: delete an item")
360
+ .option("--check <title>", "granular: mark an item completed")
361
+ .option("--uncheck <title>", "granular: mark an item open")
362
+ .option("--rename <title>", "granular: rename an item (requires --to)")
363
+ .option("--move-item <title>", "granular: reposition an item (requires --to-position)")
364
+ .option("--to <title>", "new title for --rename")
365
+ .option("--to-position <n>", "1-based position for --move-item")).action(async (uuid, opts) => {
366
+ const granular = ["add", "remove", "check", "uncheck", "rename", "moveItem"].filter((k) => opts[k] !== undefined);
367
+ if (granular.length > 1) {
368
+ process.stderr.write("error: pass at most ONE granular checklist action per call\n");
369
+ process.exitCode = ExitCode.Usage;
370
+ return;
371
+ }
372
+ const action = granular[0];
373
+ if (action !== undefined) {
374
+ if (opts["item"].length > 0) {
375
+ process.stderr.write("error: --item (wholesale) is exclusive with granular actions\n");
376
+ process.exitCode = ExitCode.Usage;
377
+ return;
378
+ }
379
+ if (action === "rename" && opts["to"] === undefined) {
380
+ process.stderr.write("error: --rename requires --to <title>\n");
381
+ process.exitCode = ExitCode.Usage;
382
+ return;
383
+ }
384
+ if (action === "moveItem" && opts["toPosition"] === undefined) {
385
+ process.stderr.write("error: --move-item requires --to-position <n>\n");
386
+ process.exitCode = ExitCode.Usage;
387
+ return;
388
+ }
389
+ const edit = action === "add"
390
+ ? {
391
+ action: "add",
392
+ title: opts["add"],
393
+ ...(opts["at"] !== undefined && { at: Number(opts["at"]) }),
394
+ }
395
+ : action === "remove"
396
+ ? { action: "remove", item: opts["remove"] }
397
+ : action === "check"
398
+ ? { action: "check", item: opts["check"] }
399
+ : action === "uncheck"
400
+ ? { action: "uncheck", item: opts["uncheck"] }
401
+ : action === "rename"
402
+ ? {
403
+ action: "rename",
404
+ item: opts["rename"],
405
+ title: opts["to"],
406
+ }
407
+ : {
408
+ action: "move",
409
+ item: opts["moveItem"],
410
+ to: Number(opts["toPosition"]),
411
+ };
412
+ await runWrite(opts, (c) => c.write.editChecklist(uuid, edit, writeOptionsFrom(opts)));
413
+ return;
414
+ }
415
+ await runWrite(opts, (c) => c.write.replaceChecklist(uuid, opts["item"], writeOptionsFrom(opts, {
416
+ ...(opts["acknowledgeChecklistReset"] !== undefined && {
417
+ acknowledgeChecklistReset: opts["acknowledgeChecklistReset"],
418
+ }),
419
+ })));
420
+ });
421
+ addWriteFlags(todo
422
+ .command("delete <uuid>")
423
+ .description("Move a to-do to the Trash (recover with `things todo restore`). Not available " +
424
+ "for repeating to-dos.")).action(async (uuid, opts) => {
425
+ await runWrite(opts, (c) => c.write.deleteTodo(uuid, writeOptionsFrom(opts)));
426
+ });
427
+ addWriteFlags(todo
428
+ .command("restore <uuid>")
429
+ .description("Restore a TRASHED to-do: it returns to the Inbox, DE-SCHEDULED — its previous " +
430
+ "list and schedule are not restored. Only trashed to-dos qualify; for projects " +
431
+ "use `things project restore`.")).action(async (uuid, opts) => {
432
+ await runWrite(opts, (c) => c.write.restoreTodo(uuid, writeOptionsFrom(opts)));
433
+ });
434
+ addWriteFlags(todo
435
+ .command("backdate <uuid>")
436
+ .description("Rewrite a to-do's completion and/or creation timestamp to noon (local) on the " +
437
+ "given date. --completed-on requires the to-do to already be completed or " +
438
+ "canceled. The Logbook re-sorts to the new date.")
439
+ .option("--completed-on <date>", "YYYY-MM-DD — new completion date")
440
+ .option("--created-on <date>", "YYYY-MM-DD — new creation date")).action(async (uuid, opts) => {
441
+ await runWrite(opts, (c) => c.write.backdateTodo(uuid, {
442
+ ...(opts["completedOn"] !== undefined && {
443
+ completionDate: opts["completedOn"],
444
+ }),
445
+ ...(opts["createdOn"] !== undefined && { creationDate: opts["createdOn"] }),
446
+ }, writeOptionsFrom(opts)));
447
+ });
448
+ addWriteFlags(todo
449
+ .command("add-logged <title>")
450
+ .description("Create a to-do directly in the Logbook: completed, with the given past " +
451
+ "completion date (and optionally a past creation date). For importing history " +
452
+ "from another system.")
453
+ .requiredOption("--completed-on <date>", "YYYY-MM-DD — completion date (required)")
454
+ .option("--created-on <date>", "YYYY-MM-DD — creation date (must be <= completed-on)")
455
+ .option("--notes <text>", "notes body")).action(async (title, opts) => {
456
+ await runWrite(opts, (c) => c.write.addLoggedTodo({
457
+ title,
458
+ completionDate: opts["completedOn"],
459
+ ...(opts["createdOn"] !== undefined && { creationDate: opts["createdOn"] }),
460
+ ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
461
+ }, writeOptionsFrom(opts)));
462
+ });
463
+ const project = group(program, "project", "Project-scoped operations");
464
+ addWriteFlags(project
465
+ .command("add <title>")
466
+ .description("Create a project; its uuid is printed on success.")
467
+ .option("--notes <text>", "notes body")
468
+ .option("--area <ref>", "destination area (uuid or unique name)")
469
+ .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
470
+ .option("--deadline <date>", "YYYY-MM-DD")
471
+ .option("--todo <title>", "initial child to-do (repeatable)", collect, [])).action(async (title, opts) => {
472
+ const todos = opts["todo"];
473
+ const area = containerRef(opts["area"]);
474
+ await runWrite(opts, (c) => c.write.addProject({
475
+ title,
476
+ ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
477
+ ...(area !== undefined && { area }),
478
+ ...(opts["when"] !== undefined && { when: opts["when"] }),
479
+ ...(opts["deadline"] !== undefined && { deadline: opts["deadline"] }),
480
+ ...(todos.length > 0 && { todos }),
481
+ }, writeOptionsFrom(opts)));
482
+ });
483
+ addWriteFlags(project
484
+ .command("update <uuid>")
485
+ .description("Update a project's title/notes/when/deadline/reminder. --append-notes/" +
486
+ "--prepend-notes join with a newline (exclusive with --notes). --reminder needs " +
487
+ "--when today|evening|YYYY-MM-DD; when re-scheduling WITHOUT --reminder an existing " +
488
+ "reminder is auto-preserved."))
489
+ .option("--title <text>", "new title")
490
+ .option("--notes <text>", "replace notes")
491
+ .option("--append-notes <text>", "append to existing notes (newline-joined)")
492
+ .option("--prepend-notes <text>", "prepend to existing notes (newline-joined)")
493
+ .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
494
+ .option("--reminder <HH:mm>", "set a reminder (24h); requires --when today|evening|date")
495
+ .option("--clear-reminder", "clear the reminder (works while scheduled today|evening)")
496
+ .option("--deadline <date>", "YYYY-MM-DD")
497
+ .option("--clear-deadline", "remove the deadline")
498
+ .action(async (uuid, opts) => {
499
+ const notesModes = ["notes", "appendNotes", "prependNotes"].filter((k) => opts[k] !== undefined);
500
+ if (notesModes.length > 1) {
501
+ process.stderr.write("error: --notes, --append-notes, --prepend-notes are exclusive\n");
502
+ process.exitCode = ExitCode.Usage;
503
+ return;
504
+ }
505
+ if (opts["reminder"] !== undefined && opts["clearReminder"] === true) {
506
+ process.stderr.write("error: pass at most one of --reminder / --clear-reminder\n");
507
+ process.exitCode = ExitCode.Usage;
508
+ return;
509
+ }
510
+ await runWrite(opts, (c) => c.write.updateProject(uuid, {
511
+ ...(opts["title"] !== undefined && { title: opts["title"] }),
512
+ ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
513
+ ...(opts["appendNotes"] !== undefined && {
514
+ appendNotes: opts["appendNotes"],
515
+ }),
516
+ ...(opts["prependNotes"] !== undefined && {
517
+ prependNotes: opts["prependNotes"],
518
+ }),
519
+ ...(opts["when"] !== undefined && { when: opts["when"] }),
520
+ ...(opts["reminder"] !== undefined && { reminder: opts["reminder"] }),
521
+ ...(opts["clearReminder"] === true && { reminder: null }),
522
+ ...(opts["deadline"] !== undefined && { deadline: opts["deadline"] }),
523
+ ...(opts["clearDeadline"] === true && { deadline: null }),
524
+ }, writeOptionsFrom(opts)));
525
+ });
526
+ addWriteFlags(project
527
+ .command("tags <uuid>")
528
+ .description("Set or extend a project's tags. --set REPLACES the full tag set (an empty value " +
529
+ "clears all tags); --add merges with the current tags. Tags must name existing " +
530
+ "tags — unknown tags are rejected.")
531
+ .option("--set <list>", "comma-separated tag names: full replacement")
532
+ .option("--add <list>", "comma-separated tag names: merge with existing")).action(async (uuid, opts) => {
533
+ const set = splitCsv(opts["set"]);
534
+ const add = splitCsv(opts["add"]);
535
+ if ((set === undefined) === (add === undefined)) {
536
+ process.stderr.write("error: pass exactly one of --set or --add\n");
537
+ process.exitCode = ExitCode.Usage;
538
+ return;
539
+ }
540
+ await runWrite(opts, (c) => set !== undefined
541
+ ? c.write.setProjectTags(uuid, set, writeOptionsFrom(opts))
542
+ : c.write.addProjectTags(uuid, add ?? [], writeOptionsFrom(opts)));
543
+ });
544
+ addWriteFlags(project
545
+ .command("move <uuid>")
546
+ .description("Move a project to another area, or DETACH it from its current area (--detach). " +
547
+ "Status and schedule are untouched. Unknown areas are rejected.")
548
+ .option("--area <ref>", "destination area (uuid or unique name)")
549
+ .option("--detach", "remove the current area assignment (exclusive with --area)")).action(async (uuid, opts) => {
550
+ if ((opts.detach === true) === (opts.area !== undefined)) {
551
+ process.stderr.write("error: pass exactly one of --area / --detach\n");
552
+ process.exitCode = ExitCode.Usage;
553
+ return;
554
+ }
555
+ await runWrite(opts, (c) => opts.detach === true
556
+ ? c.write.detachProject(uuid, writeOptionsFrom(opts))
557
+ : c.write.moveProject(uuid, { uuid: opts.area, title: opts.area }, writeOptionsFrom(opts)));
558
+ });
559
+ addWriteFlags(project
560
+ .command("cancel <uuid>")
561
+ .description("Cancel a project. Canceling also cancels its open to-dos, so an explicit " +
562
+ "--children policy is required; already-completed children are never altered.")
563
+ .requiredOption("--children <policy>", "require-resolved (error if open to-dos remain) | auto-cancel (cancel them too)")).action(async (uuid, opts) => {
564
+ await runWrite(opts, (c) => c.write.cancelProject(uuid, { children: opts.children }, writeOptionsFrom(opts)));
565
+ });
566
+ addWriteFlags(project
567
+ .command("reopen <uuid>")
568
+ .description("Reopen a completed/canceled project. Its children stay completed/canceled unless " +
569
+ "--restore-children also reopens the ones that were resolved together with the " +
570
+ "project — children resolved earlier are never touched. Exit 3 if any child " +
571
+ "restore fails.")
572
+ .option("--restore-children", "also reopen the children resolved with the project")).action(async (uuid, opts) => {
573
+ const started = Date.now();
574
+ let client = null;
575
+ try {
576
+ client = openThings(opts.db ? { dbPath: opts.db } : {});
577
+ const outcome = await client.write.reopenProject(uuid, {
578
+ ...writeOptionsFrom(opts),
579
+ ...(opts.restoreChildren === true && { restoreChildren: true }),
580
+ });
581
+ const failedChildren = outcome.children.filter((c) => c.result.kind !== "ok" && c.result.kind !== "dry-run");
582
+ if (opts.json) {
583
+ const fp = client.fingerprint();
584
+ const meta = {
585
+ dbVersion: fp.observation.databaseVersion,
586
+ fingerprint: fp.kind === "ok" ? "ok" : fp.kind === "drift" ? "drift" : "unknown",
587
+ elapsedMs: Date.now() - started,
588
+ };
589
+ process.stdout.write(`${JSON.stringify(okEnvelope("project-reopen", outcome, meta))}\n`);
590
+ }
591
+ else {
592
+ emitResult(outcome.project, { ...opts, json: false }, {
593
+ dbVersion: null,
594
+ fingerprint: "unknown",
595
+ elapsedMs: Date.now() - started,
596
+ });
597
+ for (const child of outcome.children) {
598
+ process.stdout.write(` child ${child.result.kind === "ok" || child.result.kind === "dry-run" ? "reopened" : "FAILED"}: ${child.title} (${child.uuid})\n`);
599
+ }
600
+ }
601
+ process.exitCode =
602
+ outcome.project.kind === "ok" || outcome.project.kind === "dry-run"
603
+ ? failedChildren.length > 0
604
+ ? ExitCode.VerifyFailed
605
+ : ExitCode.Ok
606
+ : process.exitCode;
607
+ }
608
+ finally {
609
+ client?.close();
610
+ }
611
+ });
612
+ addWriteFlags(project
613
+ .command("restore <uuid>")
614
+ .description("Restore a TRASHED project IN PLACE: schedule, area, and children all keep their " +
615
+ "state. Only trashed projects qualify.")).action(async (uuid, opts) => {
616
+ await runWrite(opts, (c) => c.write.restoreProject(uuid, writeOptionsFrom(opts)));
617
+ });
618
+ addWriteFlags(project
619
+ .command("duplicate <uuid>")
620
+ .description("Duplicate a project INCLUDING its children; the copy's uuid is printed on " +
621
+ "success. Not available for repeating projects.")).action(async (uuid, opts) => {
622
+ await runWrite(opts, (c) => c.write.duplicateProject(uuid, writeOptionsFrom(opts)));
623
+ });
624
+ addWriteFlags(project
625
+ .command("complete <uuid>")
626
+ .description("Complete a project. Completing also completes its open to-dos, so an explicit " +
627
+ "--children policy is required.")
628
+ .requiredOption("--children <policy>", "require-resolved (error if open to-dos remain) | auto-complete (complete them too)")).action(async (uuid, opts) => {
629
+ await runWrite(opts, (c) => c.write.completeProject(uuid, { children: opts.children }, writeOptionsFrom(opts)));
630
+ });
631
+ addWriteFlags(project
632
+ .command("delete <uuid>")
633
+ .description("Move a project to the Trash; its children go with it (recover with `things " +
634
+ "project restore`).")).action(async (uuid, opts) => {
635
+ await runWrite(opts, (c) => c.write.deleteProject(uuid, writeOptionsFrom(opts)));
636
+ });
637
+ const area = group(program, "area", "Area-scoped operations");
638
+ addWriteFlags(area
639
+ .command("add <title>")
640
+ .description("Create an area, optionally tagged with EXISTING tags.")
641
+ .option("--tags <list>", "comma-separated existing tag names")).action(async (title, opts) => {
642
+ const tags = splitCsv(opts["tags"]);
643
+ await runWrite(opts, (c) => c.write.addArea({ title, ...(tags !== undefined && { tags }) }, writeOptionsFrom(opts)));
644
+ });
645
+ addWriteFlags(area
646
+ .command("update <target>")
647
+ .description("Rename an area and/or replace its tags (the full set; tags must name existing " +
648
+ "tags). Target by uuid or unique name.")
649
+ .option("--title <text>", "new name")
650
+ .option("--tags <list>", "comma-separated EXISTING tag names (full replacement)")).action(async (target, opts) => {
651
+ const tags = splitCsv(opts["tags"]);
652
+ if (opts["title"] === undefined && tags === undefined) {
653
+ process.stderr.write("error: pass --title and/or --tags\n");
654
+ process.exitCode = ExitCode.Usage;
655
+ return;
656
+ }
657
+ await runWrite(opts, (c) => c.write.updateArea(target, {
658
+ ...(opts["title"] !== undefined && { title: opts["title"] }),
659
+ ...(tags !== undefined && { tags }),
660
+ }, writeOptionsFrom(opts)));
661
+ });
662
+ addWriteFlags(area
663
+ .command("delete <target>")
664
+ .description("Delete an area PERMANENTLY — areas do not go to the Trash, so this cannot be " +
665
+ "undone; requires --dangerously-permanent. The area's to-dos move to the Trash; " +
666
+ "its projects remain, no longer assigned to any area.")
667
+ .option("--dangerously-permanent", "accept permanent, unrecoverable deletion")).action(async (target, opts) => {
668
+ await runWrite(opts, (c) => c.write.deleteArea(target, writeOptionsFrom(opts, {
669
+ ...(opts["dangerouslyPermanent"] !== undefined && {
670
+ dangerouslyPermanent: opts["dangerouslyPermanent"],
671
+ }),
672
+ })));
673
+ });
674
+ const tag = group(program, "tag", "Tag-scoped operations");
675
+ addWriteFlags(tag
676
+ .command("add <name>")
677
+ .description("Create a tag; --parent nests it under an existing tag.")
678
+ .option("--parent <name>", "existing parent tag")).action(async (name, opts) => {
679
+ await runWrite(opts, (c) => c.write.addTag({ title: name, ...(opts["parent"] !== undefined && { parent: opts["parent"] }) }, writeOptionsFrom(opts)));
680
+ });
681
+ addWriteFlags(tag
682
+ .command("update <target>")
683
+ .description("Rename a tag (existing assignments follow the rename), nest it under an existing " +
684
+ "tag, UN-NEST it to the root (--unnest; exclusive with --parent), and set or clear " +
685
+ "its keyboard shortcut (--shortcut / --clear-shortcut, exclusive).")
686
+ .option("--title <text>", "new name")
687
+ .option("--parent <name>", "existing tag to nest under")
688
+ .option("--unnest", "move the tag to the root of the hierarchy")
689
+ .option("--shortcut <char>", "keyboard shortcut character")
690
+ .option("--clear-shortcut", "remove the keyboard shortcut")).action(async (target, opts) => {
691
+ if (opts["title"] === undefined &&
692
+ opts["parent"] === undefined &&
693
+ opts["unnest"] === undefined &&
694
+ opts["shortcut"] === undefined &&
695
+ opts["clearShortcut"] === undefined) {
696
+ process.stderr.write("error: pass --title, --parent, --unnest, --shortcut, and/or --clear-shortcut\n");
697
+ process.exitCode = ExitCode.Usage;
698
+ return;
699
+ }
700
+ if (opts["parent"] !== undefined && opts["unnest"] === true) {
701
+ process.stderr.write("error: --parent and --unnest are exclusive\n");
702
+ process.exitCode = ExitCode.Usage;
703
+ return;
704
+ }
705
+ if (opts["shortcut"] !== undefined && opts["clearShortcut"] === true) {
706
+ process.stderr.write("error: --shortcut and --clear-shortcut are exclusive\n");
707
+ process.exitCode = ExitCode.Usage;
708
+ return;
709
+ }
710
+ await runWrite(opts, (c) => c.write.updateTag(target, {
711
+ ...(opts["title"] !== undefined && { title: opts["title"] }),
712
+ ...(opts["parent"] !== undefined && { parent: opts["parent"] }),
713
+ ...(opts["unnest"] === true && { unnest: true }),
714
+ ...(opts["shortcut"] !== undefined && { shortcut: opts["shortcut"] }),
715
+ ...(opts["clearShortcut"] === true && { clearShortcut: true }),
716
+ }, writeOptionsFrom(opts)));
717
+ });
718
+ addWriteFlags(tag
719
+ .command("delete <target>")
720
+ .description("Delete a tag PERMANENTLY — tags do not go to the Trash, so this cannot be " +
721
+ "undone; requires --dangerously-permanent. The tag is removed from every item, " +
722
+ "and ALL of its nested child tags are deleted with it — requires " +
723
+ "--acknowledge-subtree when children exist.")
724
+ .option("--dangerously-permanent", "accept permanent, unrecoverable deletion")
725
+ .option("--acknowledge-subtree", "accept cascade-deletion of ALL descendant tags")).action(async (target, opts) => {
726
+ await runWrite(opts, (c) => c.write.deleteTag(target, writeOptionsFrom(opts, {
727
+ ...(opts["dangerouslyPermanent"] !== undefined && {
728
+ dangerouslyPermanent: opts["dangerouslyPermanent"],
729
+ }),
730
+ ...(opts["acknowledgeSubtree"] === true && { acknowledgeTagSubtree: true }),
731
+ })));
732
+ });
733
+ const trash = group(program, "trash", "Trash-scoped operations");
734
+ addWriteFlags(trash
735
+ .command("empty")
736
+ .description("Empty the Trash: PERMANENTLY deletes every trashed item — this cannot be undone. " +
737
+ "Requires --dangerously-permanent.")
738
+ .option("--dangerously-permanent", "accept permanent, unrecoverable deletion")).action(async (rawOpts, cmd) => {
739
+ // The parent `trash` READ command declares --json/--db too and consumes
740
+ // flags along the dispatch chain — merge parent-captured globals back in.
741
+ const opts = { ...cmd.optsWithGlobals(), ...rawOpts };
742
+ await runWrite(opts, (c) => c.write.emptyTrash(writeOptionsFrom(opts, {
743
+ ...(opts["dangerouslyPermanent"] !== undefined && {
744
+ dangerouslyPermanent: opts["dangerouslyPermanent"],
745
+ }),
746
+ })));
747
+ });
748
+ program
749
+ .command("batch [file]")
750
+ .description("Run MANY mutations from JSONL (file, or stdin when omitted/'-'): one op per line, " +
751
+ '{"op": "<kind>", "params": {...}, "options": {...}} — see `things capabilities` for ' +
752
+ "op kinds and params. Ops run sequentially and independently — NO transactions; a " +
753
+ "failure does not roll back earlier ops. Per-op results stream as JSONL. Per-op " +
754
+ "options carry the confirmation flags (acknowledgeChecklistReset, " +
755
+ "acknowledgeProjectReopen, dangerouslyPermanent, acknowledgeTagSubtree). " +
756
+ "--dry-run plans everything without executing; --fail-fast skips the rest after " +
757
+ "the first failure. Exit: 0 all ok · 3 any verify-failed/invalid · 4 any blocked " +
758
+ "· 5 any drift-blocked.")
759
+ .option("--dry-run", "plan every op; execute nothing")
760
+ .option("--fail-fast", "skip remaining ops after the first failure")
761
+ .option("--json", "JSONL results + summary on stdout (also the default)")
762
+ .option("--db <path>", "explicit database path")
763
+ .option("--actor <name>", "author name recorded for the whole batch")
764
+ .action(async (file, opts) => {
765
+ let raw;
766
+ if (file !== undefined && file !== "-") {
767
+ raw = readFileSync(file, "utf8");
768
+ }
769
+ else {
770
+ const chunks = [];
771
+ for await (const chunk of process.stdin)
772
+ chunks.push(chunk);
773
+ raw = Buffer.concat(chunks).toString("utf8");
774
+ }
775
+ const lines = raw.split("\n").filter((l) => l.trim() !== "");
776
+ const ops = [];
777
+ const preInvalid = [];
778
+ for (let i = 0; i < lines.length; i++) {
779
+ try {
780
+ ops.push(JSON.parse(lines[i]));
781
+ }
782
+ catch (err) {
783
+ // keep index alignment: a placeholder op that runBatch flags invalid
784
+ ops.push({ op: "?", params: {} });
785
+ preInvalid.push({
786
+ index: i,
787
+ op: "?",
788
+ outcome: {
789
+ kind: "invalid",
790
+ op: "?",
791
+ detail: `line ${i + 1} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
792
+ },
793
+ });
794
+ }
795
+ }
796
+ let client = null;
797
+ try {
798
+ client = openThings(opts.db ? { dbPath: opts.db } : {});
799
+ const emit = (r) => {
800
+ process.stdout.write(`${JSON.stringify(r)}\n`);
801
+ };
802
+ const results = await client.write.batch(ops, {
803
+ ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
804
+ ...(opts["failFast"] === true && { failFast: true }),
805
+ ...(opts.actor !== undefined && { actor: opts.actor }),
806
+ }, (r) => {
807
+ const pre = preInvalid.find((p) => p.index === r.index);
808
+ emit(pre ?? r);
809
+ });
810
+ const merged = results.map((r) => preInvalid.find((p) => p.index === r.index) ?? r);
811
+ const failed = merged.filter((r) => outcomeFailed(r.outcome));
812
+ const summary = {
813
+ summary: {
814
+ total: merged.length,
815
+ ok: merged.length - failed.length,
816
+ failed: failed.filter((r) => r.outcome.kind !== "skipped").length,
817
+ skipped: merged.filter((r) => r.outcome.kind === "skipped").length,
818
+ },
819
+ };
820
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
821
+ const kinds = new Set(failed.map((r) => r.outcome.kind));
822
+ const reasons = new Set(failed.map((r) => (r.outcome.kind === "blocked" ? r.outcome.reason : "")));
823
+ process.exitCode = reasons.has("drift")
824
+ ? ExitCode.DriftBlocked
825
+ : kinds.has("blocked")
826
+ ? ExitCode.Blocked
827
+ : failed.length > 0
828
+ ? ExitCode.VerifyFailed
829
+ : ExitCode.Ok;
830
+ }
831
+ finally {
832
+ client?.close();
833
+ }
834
+ });
835
+ program
836
+ .command("undo")
837
+ .description("Undo the last N changes made through things-api, newest first — each undo applies " +
838
+ "the INVERSE change (recorded as actor `undo:<actor>`, never itself an undo " +
839
+ "target). Changes made directly in the Things app cannot be undone here. " +
840
+ "IRREVERSIBLE changes are reported, not guessed: permanent deletes and changes " +
841
+ "whose prior state is unknown. Partial restores carry notes (e.g. a delete-undo " +
842
+ "lands in the Inbox de-scheduled). --dry-run shows every inverse plan without " +
843
+ "executing. Undoing a CREATED area/tag deletes it permanently — requires " +
844
+ "--dangerously-permanent. Unwinding stops at the first failed inverse. " +
845
+ "Exit: 0 all ok · 3 any failed/partial · 0 with per-item detail otherwise.")
846
+ .option("--last <n>", "how many trailing mutations to undo", "1")
847
+ .option("--dry-run", "show the inverse plans; execute nothing")
848
+ .option("--dangerously-permanent", "allow inverses that delete areas/tags permanently")
849
+ .option("--json", "JSONL per-item results + summary on stdout (also the default)")
850
+ .option("--db <path>", "explicit database path")
851
+ .option("--verify-timeout <ms>", "how long to wait for each inverse change to take effect")
852
+ .option("--actor <name>", "author name recorded for the undo (as undo:<name>)")
853
+ .action(async (opts) => {
854
+ let client = null;
855
+ try {
856
+ client = openThings(opts.db ? { dbPath: opts.db } : {});
857
+ const items = await client.write.undo({
858
+ last: Number(opts["last"] ?? 1),
859
+ ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
860
+ ...(opts["dangerouslyPermanent"] === true && { dangerouslyPermanent: true }),
861
+ ...(opts.verifyTimeout !== undefined && {
862
+ verifyTimeoutMs: Number(opts.verifyTimeout),
863
+ }),
864
+ ...(opts.actor !== undefined && { actor: opts.actor }),
865
+ }, (item) => {
866
+ process.stdout.write(`${JSON.stringify(item)}\n`);
867
+ });
868
+ const summary = {
869
+ summary: {
870
+ targets: items.length,
871
+ ok: items.filter((i) => i.outcome === "ok").length,
872
+ irreversible: items.filter((i) => i.outcome === "irreversible").length,
873
+ failed: items.filter((i) => i.outcome === "failed" || i.outcome === "partial").length,
874
+ dryRun: items.filter((i) => i.outcome === "dry-run").length,
875
+ },
876
+ };
877
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
878
+ process.exitCode =
879
+ summary.summary.failed > 0
880
+ ? ExitCode.VerifyFailed
881
+ : items.length === 0
882
+ ? ExitCode.Usage
883
+ : ExitCode.Ok;
884
+ if (items.length === 0) {
885
+ process.stderr.write("error: no undoable mutations found in the audit trail\n");
886
+ }
887
+ }
888
+ finally {
889
+ client?.close();
890
+ }
891
+ });
892
+ addWriteFlags(program
893
+ .command("reorder <uuids...>")
894
+ .description("Reorder items within Today, This Evening, the Inbox, Someday (loose to-dos), a " +
895
+ "project's to-dos, a project's HEADINGS, an area, or the top-level sidebar " +
896
+ "projects — uuids are placed at the TOP in the given order; unlisted members " +
897
+ "keep their relative order below. Strategies: native (EXPERIMENTAL — requires " +
898
+ "`things config set allow-experimental true` and may stop working after a " +
899
+ "Things update; today/inbox/someday/project/headings/area) and bounce " +
900
+ `(today/evening/projects, max ${BOUNCE_MAX_ITEMS} items; an interrupted run ` +
901
+ "reports which items were placed). Evening and projects (top-level sidebar " +
902
+ "order — each project takes a brief someday/anytime round-trip) are " +
903
+ "bounce-only. Project children under headings cannot be reordered; reordering " +
904
+ "a heading carries its children with it. Area scope reorders to-dos OR " +
905
+ "projects — never mixed in one request.")
906
+ .requiredOption("--scope <scope>", "today | evening | inbox | someday | project | headings | area | projects")
907
+ .option("--project <ref>", "project (uuid or unique name) — scope=project|headings")
908
+ .option("--area <ref>", "area (uuid or unique name) — scope=area")
909
+ .option("--strategy <name>", "force native | bounce (default: per-scope)")).action(async (uuids, opts) => {
910
+ const scope = opts["scope"];
911
+ const container = containerRef(opts["project"] ?? opts["area"]);
912
+ await runWrite(opts, (c) => c.write.reorder({
913
+ scope,
914
+ uuids,
915
+ ...(container !== undefined && { container }),
916
+ ...(opts["strategy"] !== undefined && {
917
+ strategy: opts["strategy"],
918
+ }),
919
+ }, writeOptionsFrom(opts)));
920
+ });
921
+ program
922
+ .command("capabilities")
923
+ .description("Reference for every operation kind (used by `things batch` and the MCP " +
924
+ "run_operation tool): whether it is supported, its caveats, and the confirmation " +
925
+ "flags it needs — with the underlying support evidence")
926
+ .option("--op <operation>", "limit to one operation kind")
927
+ .option("--json", "emit versioned JSON envelope on stdout")
928
+ .action((opts) => {
929
+ const data = capabilitiesTable(opts.op);
930
+ if (opts.json) {
931
+ const meta = { dbVersion: null, fingerprint: "unknown", elapsedMs: 0 };
932
+ process.stdout.write(`${JSON.stringify(okEnvelope("capabilities", data, meta))}\n`);
933
+ return;
934
+ }
935
+ for (const entry of data) {
936
+ process.stdout.write(`${entry.op}\n`);
937
+ for (const v of entry.vectors) {
938
+ const s = v;
939
+ process.stdout.write(` ${v.vector}: ${s.support}${s.disruption !== undefined ? ` (tier ${s.disruption}, ${s.validation})` : ""}${s.notes !== undefined ? ` — ${s.notes}` : ""}\n`);
940
+ }
941
+ }
942
+ });
943
+ const config = group(program, "config", "things-api configuration");
944
+ config
945
+ .command("show")
946
+ .description("Show the effective configuration (profile, disruption policy, actor)")
947
+ .option("--json", "emit versioned JSON envelope on stdout")
948
+ .option("--db <path>", "explicit database path")
949
+ .action((opts) => {
950
+ const client = openThings(opts.db ? { dbPath: opts.db } : {});
951
+ try {
952
+ if (opts.json) {
953
+ const meta = { dbVersion: null, fingerprint: "unknown", elapsedMs: 0 };
954
+ process.stdout.write(`${JSON.stringify(okEnvelope("config", client.config, meta))}\n`);
955
+ }
956
+ else {
957
+ for (const [k, v] of Object.entries(client.config)) {
958
+ process.stdout.write(`${k}: ${String(v)}\n`);
959
+ }
960
+ }
961
+ }
962
+ finally {
963
+ client.close();
964
+ }
965
+ });
966
+ config
967
+ .command("set <key> <value>")
968
+ .description("Persist a config key: profile | maxDisruption | actor | auditEnabled | " +
969
+ "accepted-fingerprint | allow-experimental")
970
+ .action((key, value) => {
971
+ const map = {
972
+ profile: "profile",
973
+ maxDisruption: "maxDisruption",
974
+ actor: "actor",
975
+ auditEnabled: "auditEnabled",
976
+ "accepted-fingerprint": "acceptedFingerprint",
977
+ "allow-experimental": "allowExperimental",
978
+ };
979
+ const target = map[key];
980
+ if (target === undefined) {
981
+ process.stderr.write(`error: unknown config key "${key}"\n`);
982
+ process.exitCode = ExitCode.Usage;
983
+ return;
984
+ }
985
+ const parsed = target === "maxDisruption"
986
+ ? Number(value)
987
+ : target === "auditEnabled" || target === "allowExperimental"
988
+ ? value === "true"
989
+ : value;
990
+ saveConfigKey(target, parsed);
991
+ process.stdout.write(`set ${key} = ${String(parsed)}\n`);
992
+ });
993
+ }
994
+ //# sourceMappingURL=writes.js.map