@thenavidm/apple-photos-mcp-cli 1.1.0 → 2.0.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 (52) hide show
  1. package/README.md +76 -24
  2. package/SKILL.md +8 -10
  3. package/lib/app.d.ts +14 -0
  4. package/lib/app.js +138 -0
  5. package/lib/app.js.map +1 -0
  6. package/lib/bridge.d.ts +6 -6
  7. package/lib/bridge.js +40 -46
  8. package/lib/bridge.js.map +1 -1
  9. package/lib/config.d.ts +6 -4
  10. package/lib/config.js +19 -25
  11. package/lib/config.js.map +1 -1
  12. package/lib/engine.d.ts +44 -0
  13. package/lib/engine.js +127 -0
  14. package/lib/engine.js.map +1 -0
  15. package/lib/errors.d.ts +0 -3
  16. package/lib/errors.js +0 -3
  17. package/lib/errors.js.map +1 -1
  18. package/lib/index.d.ts +10 -6
  19. package/lib/index.js +17 -104
  20. package/lib/index.js.map +1 -1
  21. package/lib/npx.d.ts +2 -0
  22. package/lib/npx.js +14 -0
  23. package/lib/npx.js.map +1 -0
  24. package/lib/tools/index.d.ts +20 -11
  25. package/lib/tools/index.js +6 -2
  26. package/lib/tools/index.js.map +1 -1
  27. package/lib/tools/kit.d.ts +26 -24
  28. package/lib/tools/kit.js +68 -44
  29. package/lib/tools/kit.js.map +1 -1
  30. package/lib/version.d.ts +2 -0
  31. package/lib/version.js +4 -0
  32. package/lib/version.js.map +1 -0
  33. package/package.json +5 -5
  34. package/lib/cli.d.ts +0 -105
  35. package/lib/cli.js +0 -644
  36. package/lib/cli.js.map +0 -1
  37. package/lib/safety.d.ts +0 -27
  38. package/lib/safety.js +0 -70
  39. package/lib/safety.js.map +0 -1
  40. package/lib/server.d.ts +0 -10
  41. package/lib/server.js +0 -38
  42. package/lib/server.js.map +0 -1
  43. package/src/apple_photos_mcp/__pycache__/__init__.cpython-313.pyc +0 -0
  44. package/src/apple_photos_mcp/__pycache__/__main__.cpython-313.pyc +0 -0
  45. package/src/apple_photos_mcp/__pycache__/config.cpython-313.pyc +0 -0
  46. package/src/apple_photos_mcp/__pycache__/doctor.cpython-313.pyc +0 -0
  47. package/src/apple_photos_mcp/__pycache__/library.cpython-313.pyc +0 -0
  48. package/src/apple_photos_mcp/__pycache__/previews.cpython-313.pyc +0 -0
  49. package/src/apple_photos_mcp/__pycache__/safety.cpython-313.pyc +0 -0
  50. package/src/apple_photos_mcp/__pycache__/search.cpython-313.pyc +0 -0
  51. package/src/apple_photos_mcp/__pycache__/server.cpython-313.pyc +0 -0
  52. package/src/apple_photos_mcp/__pycache__/writes.cpython-313.pyc +0 -0
package/lib/cli.js DELETED
@@ -1,644 +0,0 @@
1
- /**
2
- * The CLI adapter.
3
- *
4
- * `register()` in tools/kit.ts turns a `ToolSpec` into an MCP tool. This turns
5
- * the same spec into a shell command, from the same `ALL_TOOLS` array, through
6
- * the same handler and the same `WriteGuard`. Nothing is described twice, so a
7
- * tool added tomorrow is a command tomorrow and the two surfaces cannot drift.
8
- *
9
- * The command IS the tool name. `submit_imagine` runs as `submit-imagine`, and
10
- * the underscore form works too. Inventing a prettier command tree would mean a
11
- * hand-written mapping, which is exactly the drift this avoids, and it would
12
- * force anyone reading the SKILL.md to learn two vocabularies for one action.
13
- *
14
- * Zod is the only schema: every flag, its placeholder, its help text and its
15
- * validation come from the shape the MCP tool already declares.
16
- */
17
- import { z } from "zod";
18
- import { PythonBridge } from "./bridge.js";
19
- import { PhotosError } from "./errors.js";
20
- import { loadConfig } from "./config.js";
21
- import { WriteGuard } from "./safety.js";
22
- import { ALL_TOOLS } from "./tools/index.js";
23
- import { runTool } from "./tools/kit.js";
24
- /**
25
- * Peel `.optional()`, `.default()` and `.nullable()` to reach the real type.
26
- *
27
- * A description can sit on either the wrapper or the inner type depending on
28
- * the order the tool author chained them, so both are collected on the way down
29
- * and the outermost one wins.
30
- */
31
- function unwrap(schema) {
32
- let inner = schema;
33
- let optional = false;
34
- let description = schema.description;
35
- for (;;) {
36
- const typeName = inner._def.typeName;
37
- if (typeName === "ZodOptional" || typeName === "ZodDefault" || typeName === "ZodNullable") {
38
- if (typeName !== "ZodNullable")
39
- optional = true;
40
- inner = inner._def.innerType;
41
- description ??= inner.description;
42
- continue;
43
- }
44
- return { inner, optional, description };
45
- }
46
- }
47
- function kindOf(schema) {
48
- const typeName = schema._def.typeName;
49
- switch (typeName) {
50
- case "ZodString":
51
- return { kind: "string", repeatable: false };
52
- case "ZodNumber":
53
- return { kind: "number", repeatable: false };
54
- case "ZodBoolean":
55
- return { kind: "boolean", repeatable: false };
56
- case "ZodEnum":
57
- return {
58
- kind: "enum",
59
- choices: schema._def.values,
60
- repeatable: false,
61
- };
62
- case "ZodArray": {
63
- // An array of scalars is repeatable (`--style-refs a --style-refs b`). An
64
- // array of objects is not worth flattening, so it takes JSON.
65
- //
66
- // The element kind has to be carried through, not flattened to a string:
67
- // an array of numbers coerced as strings passes the parser and then fails
68
- // Zod with "expected number, received string", which reads as the caller's
69
- // mistake when it was ours.
70
- const element = unwrap(schema._def.type).inner;
71
- const elementKind = element._def.typeName;
72
- if (elementKind === "ZodNumber")
73
- return { kind: "number", repeatable: true };
74
- if (elementKind === "ZodString")
75
- return { kind: "string", repeatable: true };
76
- if (elementKind === "ZodEnum") {
77
- return {
78
- kind: "enum",
79
- choices: element._def.values,
80
- repeatable: true,
81
- };
82
- }
83
- return { kind: "json", repeatable: true };
84
- }
85
- default:
86
- // Objects, unions, records and anything else take a JSON literal.
87
- return { kind: "json", repeatable: false };
88
- }
89
- }
90
- function toFlag(key, schema) {
91
- const { inner, optional, description } = unwrap(schema);
92
- const { kind, choices, repeatable } = kindOf(inner);
93
- return {
94
- key,
95
- flag: `--${key.replace(/_/g, "-")}`,
96
- kind,
97
- required: !optional,
98
- repeatable,
99
- choices,
100
- help: description ?? "",
101
- };
102
- }
103
- export function flagsFor(shape) {
104
- return Object.entries(shape).map(([key, schema]) => toFlag(key, schema));
105
- }
106
- /** Shorthands for this repo's arguments. Kept small on purpose. */
107
- export const FLAG_ALIASES = {
108
- uuid: "refs",
109
- uuids: "refs",
110
- ref: "refs",
111
- file: "refs",
112
- files: "refs",
113
- out: "directory",
114
- dir: "directory",
115
- n: "limit",
116
- };
117
- class UsageError extends Error {
118
- }
119
- /** Accept a flag as `--foo-bar`, `--foo_bar`, or `foo_bar`. */
120
- function normalize(token) {
121
- return token.replace(/^--/, "").replace(/-/g, "_");
122
- }
123
- function coerce(flag, raw) {
124
- switch (flag.kind) {
125
- case "number": {
126
- const value = Number(raw);
127
- if (!Number.isFinite(value))
128
- throw new UsageError(`${flag.flag} expects a number, got '${raw}'.`);
129
- return value;
130
- }
131
- case "enum":
132
- if (flag.choices && !flag.choices.includes(raw)) {
133
- throw new UsageError(`${flag.flag} expects one of: ${flag.choices.join(", ")}. Got '${raw}'.`);
134
- }
135
- return raw;
136
- case "json":
137
- try {
138
- return JSON.parse(raw);
139
- }
140
- catch {
141
- throw new UsageError(`${flag.flag} expects JSON, got '${raw}'.`);
142
- }
143
- default:
144
- return raw;
145
- }
146
- }
147
- /**
148
- * Parse argv against a tool's flags.
149
- *
150
- * Zod does the real validation afterwards, so this only has to get values into
151
- * the right JavaScript types and catch the mistakes Zod would otherwise report
152
- * in terms of a schema the person at the terminal never sees.
153
- */
154
- export function parseArgs(argv, flags) {
155
- const byKey = new Map(flags.map((flag) => [flag.key, flag]));
156
- const out = {};
157
- const positional = [];
158
- for (let index = 0; index < argv.length; index++) {
159
- const token = argv[index];
160
- if (!token.startsWith("--")) {
161
- positional.push(token);
162
- continue;
163
- }
164
- const equals = token.indexOf("=");
165
- const name = normalize(equals === -1 ? token : token.slice(0, equals));
166
- const flag = byKey.get(name) ?? byKey.get(FLAG_ALIASES[name] ?? "");
167
- if (!flag)
168
- throw new UsageError(`Unknown option ${token}.`);
169
- if (flag.kind === "boolean") {
170
- if (equals !== -1) {
171
- const value = token.slice(equals + 1);
172
- out[flag.key] = value !== "false" && value !== "0";
173
- }
174
- else {
175
- out[flag.key] = true;
176
- }
177
- continue;
178
- }
179
- const raw = equals === -1 ? argv[++index] : token.slice(equals + 1);
180
- if (raw === undefined)
181
- throw new UsageError(`${flag.flag} expects a value.`);
182
- const value = coerce(flag, raw);
183
- if (flag.repeatable) {
184
- out[flag.key] = [...(out[flag.key] ?? []), value];
185
- }
186
- else {
187
- out[flag.key] = value;
188
- }
189
- }
190
- // Bare arguments fill the first flag that is waiting for one. A repeatable
191
- // flag takes all of them, because `photo-info uuid1 uuid2` is the obvious
192
- // call and erroring on the second was pure friction.
193
- if (positional.length > 0) {
194
- const target = flags.find((flag) => flag.required && out[flag.key] === undefined) ??
195
- flags.find((flag) => out[flag.key] === undefined && flag.kind !== "boolean");
196
- if (!target)
197
- throw new UsageError(`Unexpected argument '${positional[0]}'.`);
198
- if (target.repeatable) {
199
- out[target.key] = [...(out[target.key] ?? []), ...positional.map((p) => coerce(target, p))];
200
- }
201
- else {
202
- if (positional.length > 1)
203
- throw new UsageError(`Unexpected argument '${positional[1]}'.`);
204
- out[target.key] = coerce(target, positional[0]);
205
- }
206
- }
207
- return out;
208
- }
209
- /**
210
- * Keep only the named fields.
211
- *
212
- * These endpoints are verbose: one explore page is tens of kilobytes of JSON,
213
- * most of it layout hints. An agent piping that into its own context pays for
214
- * every byte. `--select id,status,images` cuts it to what was asked for.
215
- * Dotted paths descend, and arrays are traversed element-wise.
216
- */
217
- export function selectFields(data, paths) {
218
- if (paths.length === 0)
219
- return data;
220
- const pick = (value, segments) => {
221
- if (segments.length === 0)
222
- return value;
223
- if (Array.isArray(value))
224
- return value.map((item) => pick(item, segments));
225
- if (value === null || typeof value !== "object")
226
- return undefined;
227
- const [head, ...tail] = segments;
228
- return pick(value[head], tail);
229
- };
230
- const apply = (value) => {
231
- if (Array.isArray(value))
232
- return value.map(apply);
233
- if (value === null || typeof value !== "object")
234
- return value;
235
- const out = {};
236
- for (const path of paths) {
237
- const segments = path.split(".");
238
- const picked = pick(value, segments);
239
- if (picked !== undefined)
240
- out[path] = picked;
241
- }
242
- return out;
243
- };
244
- // A top-level object holding one array of RESULTS is the common shape, so
245
- // select into the array rather than flattening the envelope away.
246
- //
247
- // The array has to hold objects to count. A single job carries an `images`
248
- // array of plain URL strings, and treating that as the result set meant
249
- // `--select id,status` returned the whole job untouched: the envelope was
250
- // preserved, and the only thing selected was a list of strings that have no
251
- // fields to select.
252
- if (data !== null && typeof data === "object" && !Array.isArray(data)) {
253
- const record = data;
254
- const arrayKey = Object.keys(record).find((key) => {
255
- const value = record[key];
256
- return (Array.isArray(value) &&
257
- value.length > 0 &&
258
- value.every((item) => item !== null && typeof item === "object" && !Array.isArray(item)));
259
- });
260
- if (arrayKey)
261
- return { ...record, [arrayKey]: apply(record[arrayKey]) };
262
- }
263
- return apply(data);
264
- }
265
- function emit(data, format, select) {
266
- const shaped = selectFields(data, select);
267
- if (format === "text" && typeof shaped === "string") {
268
- process.stdout.write(shaped.endsWith("\n") ? shaped : `${shaped}\n`);
269
- return;
270
- }
271
- const json = format === "compact" ? JSON.stringify(shaped) : JSON.stringify(shaped, null, 2);
272
- process.stdout.write(`${json}\n`);
273
- }
274
- /** Errors are JSON on stderr, always, so a caller parses one shape. */
275
- export function emitError(error) {
276
- const payload = error instanceof PhotosError
277
- ? error.toJSON()
278
- : { error: error?.message ?? String(error) };
279
- process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
280
- }
281
- /* -------------------------------------------------------------------- help */
282
- function commandName(tool) {
283
- return tool.replace(/_/g, "-");
284
- }
285
- const COLUMN = 34;
286
- /**
287
- * The name this was invoked as, so examples are copy-pasteable.
288
- *
289
- * The package puts two binaries on the same file. Printing `apple-photos-mcp` at
290
- * someone who typed `apple-photos-cli` hands them a command that works but is not
291
- * the one in their fingers.
292
- */
293
- export function binName() {
294
- const name = (process.argv[1] ?? "").split("/").pop() ?? "";
295
- return name.startsWith("apple-photos-cli") ? "apple-photos-cli" : "apple-photos-mcp";
296
- }
297
- function renderToolHelp(spec) {
298
- const flags = flagsFor(spec.schema);
299
- const required = flags.filter((flag) => flag.required);
300
- const optional = flags.filter((flag) => !flag.required);
301
- const usage = [
302
- `${binName()} ${commandName(spec.name)}`,
303
- ...required.map((flag) => `${flag.flag} <${flag.kind}>`),
304
- optional.length > 0 ? "[options]" : "",
305
- ]
306
- .filter(Boolean)
307
- .join(" ");
308
- const lines = [``, spec.description.trim(), ``, `Usage:`, ` ${usage}`, ``];
309
- const describe = (list, heading) => {
310
- if (list.length === 0)
311
- return;
312
- lines.push(`${heading}:`);
313
- for (const flag of list) {
314
- const placeholder = flag.kind === "boolean" ? "" : ` <${flag.choices ? flag.choices.join("|") : flag.kind}>`;
315
- const alias = Object.entries(FLAG_ALIASES).find(([, key]) => key === flag.key)?.[0];
316
- const left = ` ${flag.flag}${alias ? `, --${alias}` : ""}${placeholder}`;
317
- const help = flag.repeatable ? `${flag.help} Repeatable.` : flag.help;
318
- if (!help) {
319
- lines.push(left);
320
- }
321
- else if (left.length < COLUMN) {
322
- lines.push(`${left.padEnd(COLUMN)}${help}`);
323
- }
324
- else {
325
- lines.push(left, `${" ".repeat(COLUMN)}${help}`);
326
- }
327
- }
328
- lines.push(``);
329
- };
330
- describe(required, "Required");
331
- describe(optional, "Options");
332
- lines.push(`Output:`);
333
- lines.push(` --json force JSON`);
334
- lines.push(` --compact force single-line JSON`);
335
- lines.push(` --select <a,b.c> keep only these fields`);
336
- lines.push(` --agent compact JSON. Never implies --confirm.`);
337
- lines.push(``);
338
- lines.push(`Risk: ${spec.risk}`);
339
- lines.push(``);
340
- return lines.join("\n");
341
- }
342
- function renderToolList(tools) {
343
- const width = Math.max(...tools.map((tool) => commandName(tool.name).length)) + 2;
344
- const bin = binName();
345
- const lines = [``, `${bin} commands (${tools.length})`, ``];
346
- for (const tool of tools) {
347
- const mark = tool.risk === "read" ? " " : tool.risk === "destructive" ? "!" : "*";
348
- lines.push(` ${mark} ${commandName(tool.name).padEnd(width)}${tool.title}`);
349
- }
350
- lines.push(``);
351
- lines.push(` * changes the library ! cannot be undone from here, needs --confirm`);
352
- lines.push(``);
353
- lines.push(` ${bin} which "<what you want>" find the right command`);
354
- lines.push(` ${bin} <command> --help what it takes`);
355
- lines.push(` ${bin} schema <command> the JSON schema an MCP client sees`);
356
- lines.push(` ${bin} doctor check the setup`);
357
- lines.push(``);
358
- return lines.join("\n");
359
- }
360
- /* ------------------------------------------------------------------- which */
361
- const STOP_WORDS = new Set([
362
- "a", "an", "the", "my", "me", "i", "to", "of", "for", "in", "on", "and", "or",
363
- "how", "do", "does", "can", "get", "want", "with", "from", "is", "are", "it",
364
- "that", "this", "what", "which", "all", "any", "some", "please",
365
- ]);
366
- /**
367
- * Words people use mapped onto words these tools use.
368
- *
369
- * Without this, "save my photos to disk" matches four browsing tools and never
370
- * export-originals, because nothing in that phrase appears in its name or
371
- * description. Every target below has to be vocabulary a tool actually
372
- * contains; a test asserts it.
373
- */
374
- export const SYNONYMS = {
375
- save: ["export", "originals"],
376
- saving: ["export", "originals"],
377
- download: ["export", "originals"],
378
- copy: ["export", "originals"],
379
- disk: ["export", "originals"],
380
- file: ["export", "originals"],
381
- files: ["export", "originals"],
382
- find: ["search"],
383
- show: ["search", "look"],
384
- see: ["look"],
385
- view: ["look"],
386
- preview: ["look"],
387
- image: ["photos"],
388
- images: ["photos"],
389
- picture: ["photos"],
390
- pictures: ["photos"],
391
- pic: ["photos"],
392
- pics: ["photos"],
393
- video: ["photos"],
394
- videos: ["photos"],
395
- tag: ["keywords"],
396
- tags: ["keywords"],
397
- label: ["keywords"],
398
- labels: ["keywords"],
399
- caption: ["description"],
400
- name: ["title"],
401
- heart: ["favourite", "favorite"],
402
- star: ["favourite", "favorite"],
403
- like: ["favourite", "favorite"],
404
- delete: ["archive"],
405
- remove: ["archive"],
406
- trash: ["archive"],
407
- hide: ["archive"],
408
- count: ["totals", "stats"],
409
- total: ["totals", "stats"],
410
- totals: ["totals"],
411
- many: ["totals"],
412
- size: ["totals"],
413
- metadata: ["metadata"],
414
- camera: ["camera"],
415
- lens: ["lens"],
416
- album: ["album"],
417
- albums: ["album"],
418
- vocabulary: ["vocabulary"],
419
- words: ["vocabulary"],
420
- setup: ["setup"],
421
- broken: ["setup"],
422
- };
423
- function terms(text) {
424
- const words = text
425
- .toLowerCase()
426
- .split(/[^a-z0-9]+/)
427
- .filter((word) => word.length > 1 && !STOP_WORDS.has(word));
428
- const out = new Set();
429
- for (const word of words) {
430
- out.add(word);
431
- for (const mapped of SYNONYMS[word] ?? [])
432
- out.add(mapped);
433
- }
434
- return [...out];
435
- }
436
- /**
437
- * Resolve a capability described in words to the command that does it.
438
- *
439
- * An agent that knows what it wants but not what this tool calls it would
440
- * otherwise read the full tool list to find out, which is the cost the CLI
441
- * exists to avoid. Scoring is deliberately dumb: name and title carry the most
442
- * weight, because a tool called `download_job` should win "download my images"
443
- * without any cleverness.
444
- */
445
- export function whichCommand(query) {
446
- const wanted = terms(query);
447
- if (wanted.length === 0)
448
- return [];
449
- return ALL_TOOLS.map((tool) => {
450
- const name = terms(tool.name);
451
- const whole = tool.name.replace(/_/g, "");
452
- const title = terms(tool.title);
453
- const body = terms(tool.description);
454
- let score = 0;
455
- for (const word of wanted) {
456
- // A term that IS the tool's name is not a hint, it is the answer.
457
- // Without this, "make a picture" scores vary_image over imagine purely
458
- // because `vary_image` contains the word "image".
459
- if (word === tool.name || word === whole)
460
- score += 20;
461
- if (name.includes(word))
462
- score += 6;
463
- else if (name.some((part) => part.startsWith(word) || word.startsWith(part)))
464
- score += 4;
465
- if (title.includes(word))
466
- score += 3;
467
- if (body.includes(word))
468
- score += 1;
469
- }
470
- return { tool, score };
471
- })
472
- .filter((entry) => entry.score > 0)
473
- .sort((a, b) => b.score - a.score)
474
- .slice(0, 5);
475
- }
476
- /* -------------------------------------------------------------- exit codes */
477
- /**
478
- * The house contract, so a script branches on the number the same way for
479
- * every one of these CLIs: 0 ok, 2 usage or a refused write, 3 not found,
480
- * 4 access refused, 5 the engine failed, 10 nothing set up yet.
481
- */
482
- export const EXIT = {
483
- ok: 0, usage: 2, notFound: 3, auth: 4, api: 5, rateLimited: 7, config: 10,
484
- };
485
- /**
486
- * There is no account here, so "auth" is macOS refusing access: Full Disk
487
- * Access for the library, or Automation for Photos itself. The engine reports
488
- * its failures as text, so its words decide once the class has said what it
489
- * can.
490
- */
491
- export function exitCodeFor(error) {
492
- const name = error?.name;
493
- const text = String(error?.message ?? "").toLowerCase();
494
- if (name === "WriteBlockedError")
495
- return EXIT.usage;
496
- // The engine never started: uv or Python is missing, which is setup, not a
497
- // failure of the library.
498
- if (name === "BridgeError" && /could not start/.test(text))
499
- return EXIT.config;
500
- if (/operation not permitted|permission denied|not authori[sz]ed|full disk access|-1743/.test(text))
501
- return EXIT.auth;
502
- if (/at most|too many/.test(text))
503
- return EXIT.usage;
504
- if (/not found|none of those refs resolved|no such/.test(text))
505
- return EXIT.notFound;
506
- return EXIT.api;
507
- }
508
- /* ---------------------------------------------------------------- dispatch */
509
- /** The tools this process exposes, with READ_ONLY applied exactly as the server applies it. */
510
- function visibleTools(guard) {
511
- return ALL_TOOLS.filter((tool) => !guard.readOnly || tool.risk === "read");
512
- }
513
- export function isCliCommand(argv) {
514
- const first = argv[0];
515
- if (!first || first.startsWith("-"))
516
- return false;
517
- if (first === "tools" || first === "schema" || first === "which")
518
- return true;
519
- const name = normalize(first);
520
- return ALL_TOOLS.some((tool) => tool.name === name);
521
- }
522
- export async function runCli(argv) {
523
- const config = loadConfig();
524
- const guard = new WriteGuard(config, "cli");
525
- const tools = visibleTools(guard);
526
- const command = argv[0];
527
- const rest = argv.slice(1);
528
- if (command === "tools") {
529
- process.stdout.write(renderToolList(tools));
530
- return 0;
531
- }
532
- if (command === "which") {
533
- const query = rest.filter((token) => !token.startsWith("-")).join(" ");
534
- const matches = whichCommand(query).filter((entry) => tools.some((tool) => tool.name === entry.tool.name));
535
- if (rest.includes("--json") || rest.includes("--agent")) {
536
- emit(matches.map((entry) => ({ command: commandName(entry.tool.name), title: entry.tool.title, risk: entry.tool.risk, score: entry.score })), "json", []);
537
- }
538
- else if (matches.length === 0) {
539
- process.stderr.write(`No command matches "${query}". Run \`${binName()} tools\` for the full list.\n`);
540
- }
541
- else {
542
- for (const entry of matches) {
543
- process.stdout.write(` ${commandName(entry.tool.name).padEnd(22)}${entry.tool.title}\n`);
544
- }
545
- }
546
- // 2 means no confident match, so a script can branch on it.
547
- return matches.length > 0 ? 0 : 2;
548
- }
549
- if (command === "schema") {
550
- const wanted = normalize(rest[0] ?? "");
551
- const spec = tools.find((tool) => tool.name === wanted);
552
- if (!spec) {
553
- emitError(new Error(`Unknown command '${rest[0] ?? ""}'. Run \`${binName()} tools\`.`));
554
- return 1;
555
- }
556
- // The same shape an MCP client receives, so the two surfaces are provably one.
557
- emit(z.object(spec.schema).describe(spec.description), "json", []);
558
- return 0;
559
- }
560
- const name = normalize(command);
561
- const spec = tools.find((tool) => tool.name === name);
562
- if (!spec) {
563
- const hidden = ALL_TOOLS.find((tool) => tool.name === name);
564
- emitError(new Error(hidden
565
- ? `${name} is unavailable: this server is running with APPLE_PHOTOS_READ_ONLY=1.`
566
- : `Unknown command '${command}'. Run \`${binName()} tools\`.`));
567
- return 1;
568
- }
569
- if (rest.includes("--help") || rest.includes("-h")) {
570
- process.stdout.write(renderToolHelp(spec));
571
- return 0;
572
- }
573
- // `--agent` is one flag for "a program is reading this". It implies compact
574
- // JSON and nothing else.
575
- //
576
- // The equivalent in the reference CLI for this API also implies `--yes`,
577
- // which is right for a read-only tool and wrong here: this one can spend
578
- // money, and a flag an agent passes by habit must never be what authorises a
579
- // charge. Confirmation stays explicit, always.
580
- const agentMode = rest.includes("--agent");
581
- const format = agentMode || rest.includes("--compact")
582
- ? "compact"
583
- : rest.includes("--json")
584
- ? "json"
585
- : "text";
586
- // `--select` is pulled out here rather than declared as a flag, because it is
587
- // an output concern shared by every command, not part of any tool's schema.
588
- const select = [];
589
- const toolArgv = [];
590
- for (let index = 0; index < rest.length; index++) {
591
- const token = rest[index];
592
- if (token === "--json" || token === "--compact" || token === "--agent")
593
- continue;
594
- if (token === "--select") {
595
- const value = rest[++index];
596
- if (value === undefined) {
597
- emitError(new UsageError("--select expects a comma-separated list of fields."));
598
- return 2;
599
- }
600
- select.push(...value.split(",").map((part) => part.trim()).filter(Boolean));
601
- continue;
602
- }
603
- if (token.startsWith("--select=")) {
604
- select.push(...token
605
- .slice("--select=".length)
606
- .split(",")
607
- .map((part) => part.trim())
608
- .filter(Boolean));
609
- continue;
610
- }
611
- toolArgv.push(token);
612
- }
613
- let client;
614
- try {
615
- const parsed = parseArgs(toolArgv, flagsFor(spec.schema));
616
- // Zod is the authority on validity, exactly as it is for an MCP call.
617
- const args = z.object(spec.schema).parse(parsed);
618
- client = new PythonBridge(config);
619
- const ctx = { bridge: client, config, guard };
620
- // runTool applies the same guard the MCP path applies, so --confirm,
621
- // READ_ONLY, ALLOW_DESTRUCTIVE and the audit log behave identically here.
622
- emit(await runTool(spec, args, ctx), format, select);
623
- return 0;
624
- }
625
- catch (error) {
626
- if (error instanceof UsageError) {
627
- emitError(error);
628
- return EXIT.usage;
629
- }
630
- if (error instanceof z.ZodError) {
631
- const first = error.issues[0];
632
- emitError(new Error(first
633
- ? `--${String(first.path.join(".")).replace(/_/g, "-")}: ${first.message}`
634
- : error.message));
635
- return EXIT.usage;
636
- }
637
- emitError(error);
638
- return exitCodeFor(error);
639
- }
640
- finally {
641
- void client?.close();
642
- }
643
- }
644
- //# sourceMappingURL=cli.js.map