@firenet-designs/fnd-cli 2.6.0 → 2.7.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 (54) hide show
  1. package/README.md +103 -63
  2. package/bin/dev.js +1 -1
  3. package/dist/commands/alt-text.d.ts +64 -15
  4. package/dist/commands/alt-text.js +277 -65
  5. package/dist/commands/backfill-project.js +1 -1
  6. package/dist/commands/create-project.js +1 -1
  7. package/dist/commands/workspace/index.d.ts +3 -2
  8. package/dist/commands/workspace/index.js +96 -49
  9. package/dist/lib/alt-text.d.ts +33 -2
  10. package/dist/lib/alt-text.js +56 -4
  11. package/dist/lib/mcp/bracket-args.d.ts +37 -0
  12. package/dist/lib/mcp/bracket-args.js +65 -0
  13. package/dist/lib/mcp/define-tool.d.ts +52 -0
  14. package/dist/lib/mcp/define-tool.js +2 -0
  15. package/dist/lib/mcp/registry.d.ts +38 -0
  16. package/dist/lib/mcp/registry.js +98 -0
  17. package/dist/lib/mcp/server.d.ts +66 -0
  18. package/dist/lib/mcp/server.js +176 -0
  19. package/dist/lib/mcp/tools/shopify-common.d.ts +139 -0
  20. package/dist/lib/mcp/tools/shopify-common.js +167 -0
  21. package/dist/lib/mcp/tools/shopify-execute.d.ts +2 -0
  22. package/dist/lib/mcp/tools/shopify-execute.js +105 -0
  23. package/dist/lib/mcp/tools/shopify-file-delete.d.ts +2 -0
  24. package/dist/lib/mcp/tools/shopify-file-delete.js +49 -0
  25. package/dist/lib/mcp/tools/shopify-file-replace.d.ts +2 -0
  26. package/dist/lib/mcp/tools/shopify-file-replace.js +79 -0
  27. package/dist/lib/mcp/tools/shopify-file-search.d.ts +2 -0
  28. package/dist/lib/mcp/tools/shopify-file-search.js +199 -0
  29. package/dist/lib/mcp/tools/shopify-file-upload.d.ts +2 -0
  30. package/dist/lib/mcp/tools/shopify-file-upload.js +76 -0
  31. package/dist/lib/shopify/graphql/AccessScopes.graphql +7 -0
  32. package/dist/lib/shopify/graphql/CurrentBulkOperation.graphql +8 -0
  33. package/dist/lib/shopify/graphql/FileCreate.graphql +25 -0
  34. package/dist/lib/shopify/graphql/FileDelete.graphql +11 -0
  35. package/dist/lib/shopify/graphql/FileReplace.graphql +26 -0
  36. package/dist/lib/shopify/graphql/FileStatus.graphql +19 -0
  37. package/dist/lib/shopify/graphql/FilesBulkQuery.graphql +27 -0
  38. package/dist/lib/shopify/graphql/ProductsBulkQuery.graphql +27 -0
  39. package/dist/lib/shopify/graphql/SearchFiles.graphql +36 -0
  40. package/dist/lib/shopify/graphql/StagedUploadsCreate.graphql +20 -0
  41. package/dist/lib/shopify/graphql/StartBulkQuery.graphql +16 -0
  42. package/dist/lib/shopify/graphql/UpdateFileAlt.graphql +9 -0
  43. package/dist/lib/shopify/shopify.d.ts +228 -0
  44. package/dist/lib/shopify/shopify.js +662 -0
  45. package/dist/lib/workspace.d.ts +19 -8
  46. package/dist/lib/workspace.js +13 -13
  47. package/oclif.manifest.json +48 -46
  48. package/package.json +17 -10
  49. package/dist/hooks/init/check-for-updates.d.ts +0 -3
  50. package/dist/hooks/init/check-for-updates.js +0 -15
  51. package/dist/lib/kv-flag.d.ts +0 -15
  52. package/dist/lib/kv-flag.js +0 -75
  53. package/dist/lib/rpc.d.ts +0 -69
  54. package/dist/lib/rpc.js +0 -313
@@ -0,0 +1,105 @@
1
+ import { parseBracketArgs } from '#lib/mcp/bracket-args.js';
2
+ import { defineTool } from '#lib/mcp/define-tool.js';
3
+ import { runStoreExecute } from '#lib/shopify/shopify.js';
4
+ import { requireBin, requireStore, shopifyPreflight } from './shopify-common.js';
5
+ /**
6
+ * `--with-tool shopify-execute` — expose a `shopify_execute` MCP tool that runs an
7
+ * ARBITRARY Admin GraphQL operation against the store via the user's Shopify CLI.
8
+ * Store comes from `--site-id` (fixed — the AI can't change it).
9
+ *
10
+ * This is far more powerful than the file tools: it's raw Admin GraphQL, so what
11
+ * the AI can touch is exactly the store's granted scopes. That's why its bracket
12
+ * REQUIRES a `scopes=` option — `scopes=all` for broad access, or an explicit
13
+ * list like `scopes=read_products+write_orders` — and why the command reconciles
14
+ * the store to EXACTLY that set (revoking anything extra) before connecting, so
15
+ * the AI is confined to least privilege (see reconcileShopifyScopes). `ask` is
16
+ * optional and gates every call behind a confirmation prompt on the remote.
17
+ *
18
+ * shopify-execute[scopes=read_products+read_orders]
19
+ * shopify-execute[ask,scopes=all]
20
+ */
21
+ /** The MCP tool name the remote AI sees. (The registry name activates it; this is what it calls.) */
22
+ const MCP_TOOL_NAME = 'shopify_execute';
23
+ /** A trimmed non-empty string from an untyped arg, or undefined. */
24
+ const str = (value) => typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
25
+ /** Split a `scopes=` value into individual scopes. `+` or whitespace separate them (comma is the option separator). */
26
+ const parseScopeList = (raw) => [...new Set(raw.split(/[\s+]+/).map((scope) => scope.trim().toLowerCase()).filter(Boolean))];
27
+ /** A Shopify access scope is `read_x` / `write_x`; reject anything else so a typo fails up front. */
28
+ const SCOPE_SHAPE = /^(read|write)_[a-z_]+$/;
29
+ /**
30
+ * Parse a `shopify-execute` selection. Unlike the file tools, its scopes aren't
31
+ * fixed — they come from the required `scopes=` option, which is what the reconcile
32
+ * step trims the store down to. `[ask]` stays optional.
33
+ */
34
+ const parseExecuteSelection = (arg, context) => {
35
+ const store = requireStore(context);
36
+ const parsed = parseBracketArgs(arg, { flags: ['ask'], values: ['scopes'] });
37
+ const ask = parsed.flags.has('ask');
38
+ const scopesRaw = parsed.values.get('scopes');
39
+ if (!scopesRaw) {
40
+ throw new Error('requires a scopes option, since it runs arbitrary Admin GraphQL: shopify-execute[scopes=all] for broad access, ' +
41
+ 'or an explicit list like shopify-execute[scopes=read_products+write_orders] (add ,ask to confirm every call).');
42
+ }
43
+ // `scopes=all` = grant a broad set and never revoke (see ALL_ADMIN_SCOPES).
44
+ if (scopesRaw.toLowerCase() === 'all') {
45
+ return { ask, grantAll: true, restrictScopes: true, scopes: [], store };
46
+ }
47
+ const scopes = parseScopeList(scopesRaw);
48
+ if (scopes.length === 0) {
49
+ throw new Error('the scopes option is empty; list one or more scopes, e.g. scopes=read_products+write_orders, or scopes=all.');
50
+ }
51
+ const bad = scopes.find((scope) => !SCOPE_SHAPE.test(scope));
52
+ if (bad) {
53
+ throw new Error(`"${bad}" is not a valid Admin scope (expected read_<resource> or write_<resource>, e.g. read_products).`);
54
+ }
55
+ return { ask, grantAll: false, restrictScopes: true, scopes, store };
56
+ };
57
+ export const shopifyExecuteTool = defineTool({
58
+ argHint: 'scopes=all|<list>[,ask]',
59
+ argRequired: true,
60
+ build(config) {
61
+ const { grantAll, scopes, store } = config;
62
+ // preflight/reconcile ran first and resolved the CLI; assert it for the handler.
63
+ const bin = requireBin(config);
64
+ const scopeDesc = grantAll ? 'a broad set of Admin scopes' : (scopes.join(', ') || 'none');
65
+ const spec = {
66
+ description: `Run an arbitrary Admin GraphQL operation against the Shopify store ${store} via \`shopify store execute\` ` +
67
+ `(the user's Shopify CLI, so it uses their existing login — no token passes through here). Provide \`query\` ` +
68
+ `(a GraphQL document with ONE operation) and optional \`variables\`. Reads run by default; a MUTATION must set ` +
69
+ `\`mutate: true\` (the CLI refuses mutations otherwise). This installation is limited to these scopes: ` +
70
+ `${scopeDesc} — an operation needing anything else fails with an access-denied error, so stay within them. ` +
71
+ `The store is fixed to ${store} and cannot be changed here. Returns the raw JSON response, including any ` +
72
+ `GraphQL \`errors\`.`,
73
+ handler(args) {
74
+ const query = str(args.query);
75
+ if (!query)
76
+ return { isError: true, text: 'The "query" argument must be a non-empty GraphQL document string.' };
77
+ const mutate = args.mutate === true;
78
+ const variables = args.variables && typeof args.variables === 'object' && !Array.isArray(args.variables)
79
+ ? args.variables
80
+ : undefined;
81
+ const result = runStoreExecute(bin, store, { mutate, query, variables });
82
+ return { isError: !result.ok, text: result.output };
83
+ },
84
+ inputSchema: {
85
+ properties: {
86
+ mutate: {
87
+ description: 'Set true to allow a mutation; `store execute` refuses mutations unless this is set. Leave false/omitted for read queries.',
88
+ type: 'boolean',
89
+ },
90
+ query: { description: 'The Admin GraphQL document to run (a single operation).', type: 'string' },
91
+ variables: { description: 'Optional variables object for the operation.', type: 'object' },
92
+ },
93
+ required: ['query'],
94
+ type: 'object',
95
+ },
96
+ name: MCP_TOOL_NAME,
97
+ // `[ask]` makes the remote prompt for confirmation before every execute.
98
+ requiresUserInteraction: config.ask,
99
+ };
100
+ return [spec];
101
+ },
102
+ name: 'shopify-execute',
103
+ parse: parseExecuteSelection,
104
+ preflight: shopifyPreflight,
105
+ });
@@ -0,0 +1,2 @@
1
+ import { ShopifyToolConfig } from './shopify-common.js';
2
+ export declare const shopifyFileDeleteTool: import("#lib/mcp/define-tool.js").WorkspaceTool<ShopifyToolConfig>;
@@ -0,0 +1,49 @@
1
+ import { defineTool } from '#lib/mcp/define-tool.js';
2
+ import { deleteFile } from '#lib/shopify/shopify.js';
3
+ import { requireBin, shopifyPreflight, shopifyWriteFileParse } from './shopify-common.js';
4
+ /**
5
+ * `--with-tool shopify-file-delete` — let Claude on the remote delete a file from
6
+ * the store's asset library, named ONLY by its exact `id` (GID). Store comes from
7
+ * `--site-id`. Deletion is permanent, so the tool takes no file bytes and does no
8
+ * filename lookup — a fuzzy name must never resolve to (and delete) the wrong file.
9
+ */
10
+ /** The MCP tool name the remote AI sees. */
11
+ const MCP_TOOL_NAME = 'delete_shopify_file';
12
+ export const shopifyFileDeleteTool = defineTool({
13
+ argHint: 'ask',
14
+ argRequired: false,
15
+ build(config) {
16
+ const { store } = config;
17
+ const bin = requireBin(config);
18
+ const spec = {
19
+ description: `Permanently delete a file from the Shopify store ${store}'s asset library (Content > Files). \`id\` is the ` +
20
+ `file's exact gid://shopify/… GID — no filename lookup, so an ambiguous name can never delete the wrong file. ` +
21
+ `This cannot be undone. Runs through the user's Shopify CLI; the store is fixed to ${store} and cannot be changed here.`,
22
+ async handler(args) {
23
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
24
+ if (id === '') {
25
+ return { isError: true, text: 'The "id" argument must be the exact gid://shopify/… GID of the file to delete.' };
26
+ }
27
+ const deleted = deleteFile(bin, store, id);
28
+ return { text: `Deleted ${deleted} from ${store}.` };
29
+ },
30
+ inputSchema: {
31
+ properties: {
32
+ id: {
33
+ description: 'Exact GID (gid://shopify/…) of the file to delete.',
34
+ type: 'string',
35
+ },
36
+ },
37
+ required: ['id'],
38
+ type: 'object',
39
+ },
40
+ name: MCP_TOOL_NAME,
41
+ // `[ask]` makes the remote prompt for confirmation before every delete.
42
+ requiresUserInteraction: config.ask,
43
+ };
44
+ return [spec];
45
+ },
46
+ name: 'shopify-file-delete',
47
+ parse: shopifyWriteFileParse,
48
+ preflight: shopifyPreflight,
49
+ });
@@ -0,0 +1,2 @@
1
+ import { ShopifyToolConfig } from './shopify-common.js';
2
+ export declare const shopifyFileReplaceTool: import("#lib/mcp/define-tool.js").WorkspaceTool<ShopifyToolConfig>;
@@ -0,0 +1,79 @@
1
+ import { defineTool } from '#lib/mcp/define-tool.js';
2
+ import { replaceFile } from '#lib/shopify/shopify.js';
3
+ import { existsSync } from 'node:fs';
4
+ import { isAbsolute, resolve } from 'node:path';
5
+ import { requireBin, shopifyPreflight, shopifyWriteFileParse } from './shopify-common.js';
6
+ /**
7
+ * `--with-tool shopify-file-replace` — let Claude on the remote replace an
8
+ * existing file's contents in place with a file from the synced workspace. The
9
+ * target file keeps its GID (and typically its filename/theme reference), so any
10
+ * `shopify://shop_images/<filename>` a theme already stores stays valid. Store
11
+ * comes from `--site-id`; the target is named ONLY by its exact `id` (GID) — no
12
+ * filename lookup, so a fuzzy name can never resolve to the wrong file.
13
+ */
14
+ /** The MCP tool name the remote AI sees. */
15
+ const MCP_TOOL_NAME = 'replace_shopify_file';
16
+ export const shopifyFileReplaceTool = defineTool({
17
+ argHint: 'ask',
18
+ argRequired: false,
19
+ build(config, runtime) {
20
+ const { store } = config;
21
+ const bin = requireBin(config);
22
+ const spec = {
23
+ description: `Replace an existing file's contents IN PLACE in the Shopify store ${store}'s asset library. \`id\` is the ` +
24
+ `existing file's exact gid://shopify/… GID (no filename lookup — an ambiguous name must never resolve to the ` +
25
+ `wrong file); \`path\` is the new file on the machine that launched \`fnd workspace\` — relative to the synced ` +
26
+ `workspace dir (${runtime.localCwd}) or absolute. The file keeps its GID (and usually its filename), so a theme ` +
27
+ `shopify://shop_images/<filename> reference stays valid. Returns the file's GID, CDN url, filename, and theme ` +
28
+ `reference. Runs through the user's Shopify CLI; the store is fixed to ${store} and cannot be changed here.`,
29
+ async handler(args) {
30
+ const rawPath = args.path;
31
+ if (typeof rawPath !== 'string' || rawPath.trim() === '') {
32
+ return { isError: true, text: 'The "path" argument must be a non-empty string.' };
33
+ }
34
+ const abs = isAbsolute(rawPath) ? rawPath : resolve(runtime.localCwd, rawPath);
35
+ if (!existsSync(abs)) {
36
+ return { isError: true, text: `No file at ${abs} on the local machine.` };
37
+ }
38
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
39
+ if (id === '') {
40
+ return { isError: true, text: 'The "id" argument must be the exact gid://shopify/… GID of the file to replace.' };
41
+ }
42
+ const alt = typeof args.alt === 'string' ? args.alt : undefined;
43
+ const result = await replaceFile(bin, store, { alt, id, path: abs });
44
+ return {
45
+ text: [
46
+ `Replaced ${id} in ${store} with ${abs}.`,
47
+ `id: ${result.id}`,
48
+ `filename: ${result.filename ?? '(processing — not available yet)'}`,
49
+ `url: ${result.url ?? '(processing — not ready yet; poll the file later)'}`,
50
+ `theme reference: ${result.reference ?? '(available once processing finishes)'}`,
51
+ `status: ${result.status ?? 'unknown'}`,
52
+ ].join('\n'),
53
+ };
54
+ },
55
+ inputSchema: {
56
+ properties: {
57
+ alt: { description: 'Optional new alt text to set on the file.', type: 'string' },
58
+ id: {
59
+ description: 'Exact GID (gid://shopify/…) of the existing file to replace.',
60
+ type: 'string',
61
+ },
62
+ path: {
63
+ description: `Path to the replacement file on the local machine — relative to ${runtime.localCwd} or absolute.`,
64
+ type: 'string',
65
+ },
66
+ },
67
+ required: ['id', 'path'],
68
+ type: 'object',
69
+ },
70
+ name: MCP_TOOL_NAME,
71
+ // `[ask]` makes the remote prompt for confirmation before every replace.
72
+ requiresUserInteraction: config.ask,
73
+ };
74
+ return [spec];
75
+ },
76
+ name: 'shopify-file-replace',
77
+ parse: shopifyWriteFileParse,
78
+ preflight: shopifyPreflight,
79
+ });
@@ -0,0 +1,2 @@
1
+ import { ShopifyToolConfig } from './shopify-common.js';
2
+ export declare const shopifyFileSearchTool: import("#lib/mcp/define-tool.js").WorkspaceTool<ShopifyToolConfig>;
@@ -0,0 +1,199 @@
1
+ import { defineTool } from '#lib/mcp/define-tool.js';
2
+ import { searchFiles } from '#lib/shopify/shopify.js';
3
+ import { requireBin, shopifyPreflight, shopifyReadFileParse } from './shopify-common.js';
4
+ /**
5
+ * `--with-tool shopify-file-search` — let Claude on the remote look up files in
6
+ * the store's asset library by **metadata only** (name / mime type / size / url),
7
+ * never downloading the bytes. Two jobs it's built for:
8
+ * • check whether a file already exists before uploading — search by a similar
9
+ * name, size and type instead of ingesting every image (which burns tokens);
10
+ * • find oversized images to resize — search by a minimum size.
11
+ * Read-only, so it needs just `read_files`. Store comes from `--site-id`.
12
+ */
13
+ /** The MCP tool name the remote AI sees. */
14
+ const MCP_TOOL_NAME = 'search_shopify_files';
15
+ /** Default / max number of matches returned, and how many files a scan will examine. */
16
+ const DEFAULT_LIMIT = 25;
17
+ const MAX_LIMIT = 100;
18
+ const SCAN_CAP = 5000;
19
+ const PAGE_SIZE = 250;
20
+ /**
21
+ * The `sort` input → the Shopify `FileSortKeys` member it maps to. Ordering is
22
+ * server-side, so the scan streams in this order and the `limit` early-break
23
+ * returns the true top-N (e.g. biggest images first, not just the first page
24
+ * re-sorted).
25
+ */
26
+ const SORT_KEYS = {
27
+ created: 'CREATED_AT',
28
+ name: 'FILENAME',
29
+ size: 'ORIGINAL_UPLOAD_SIZE',
30
+ updated: 'UPDATED_AT',
31
+ };
32
+ const DEFAULT_SORT = 'size';
33
+ /** Default direction per sort when `order` is omitted: newest/largest first, but names A→Z. */
34
+ const DEFAULT_DESC = { created: true, name: false, size: true, updated: true };
35
+ /** A compact, human-readable byte size (metadata, so precision beyond ~0.1 unit is noise). */
36
+ const humanBytes = (bytes) => {
37
+ if (bytes === null)
38
+ return 'size?';
39
+ if (bytes < 1024)
40
+ return `${bytes} B`;
41
+ const units = ['KB', 'MB', 'GB'];
42
+ let value = bytes / 1024;
43
+ let unit = 0;
44
+ while (value >= 1024 && unit < units.length - 1) {
45
+ value /= 1024;
46
+ unit += 1;
47
+ }
48
+ return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
49
+ };
50
+ /** The Shopify file-search `query` string built from the name filter (null = whole library). */
51
+ const buildQuery = (name) => {
52
+ const trimmed = name?.trim();
53
+ if (!trimmed)
54
+ return null;
55
+ // `filename:` is a contains match; wrap in quotes so spaces don't split the
56
+ // term, and drop any embedded quotes so the search string can't be broken.
57
+ return `filename:"${trimmed.replaceAll('"', '')}"`;
58
+ };
59
+ /** A trimmed non-empty string from an untyped arg, or undefined. */
60
+ const str = (v) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined);
61
+ /** A finite number from an untyped arg, or undefined. */
62
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
63
+ /** Whether a file matches the client-side filters (size/mime/url) the search grammar can't express. */
64
+ const matches = (file, f) => {
65
+ if (f.type && !(file.mimeType ?? '').toLowerCase().includes(f.type.toLowerCase()))
66
+ return false;
67
+ if (f.url && !file.url.toLowerCase().includes(f.url.toLowerCase()))
68
+ return false;
69
+ // A size bound on a file whose size Shopify hasn't reported can't be verified —
70
+ // exclude it rather than guess, so "larger than X" never returns an unknown.
71
+ if (f.minSize !== undefined || f.maxSize !== undefined) {
72
+ if (file.fileSize === null)
73
+ return false;
74
+ if (f.minSize !== undefined && file.fileSize < f.minSize)
75
+ return false;
76
+ if (f.maxSize !== undefined && file.fileSize > f.maxSize)
77
+ return false;
78
+ }
79
+ return true;
80
+ };
81
+ /** Validate the sort/order args into a searchFiles ordering, or return an error message. */
82
+ const resolveOrdering = (args) => {
83
+ const sort = (str(args.sort) ?? DEFAULT_SORT).toLowerCase();
84
+ if (!(sort in SORT_KEYS))
85
+ return { error: `Unknown sort "${sort}"; use one of: ${Object.keys(SORT_KEYS).join(', ')}.` };
86
+ const orderArg = str(args.order)?.toLowerCase();
87
+ if (orderArg && orderArg !== 'asc' && orderArg !== 'desc')
88
+ return { error: `Unknown order "${orderArg}"; use "asc" or "desc".` };
89
+ // reverse flips the sort key's natural ascending order; default per sort.
90
+ const reverse = orderArg ? orderArg === 'desc' : DEFAULT_DESC[sort];
91
+ return { label: `${sort} ${reverse ? 'desc' : 'asc'}`, reverse, sortKey: SORT_KEYS[sort] };
92
+ };
93
+ /** One result line — everything useful about a file, no bytes. */
94
+ const formatFile = (file) => {
95
+ const dims = file.width && file.height ? `${file.width}×${file.height}` : 'n/a';
96
+ const parts = [
97
+ file.name,
98
+ file.mimeType ?? 'type?',
99
+ humanBytes(file.fileSize),
100
+ dims,
101
+ `id=${file.id}`,
102
+ ];
103
+ if (file.alt)
104
+ parts.push(`alt=${JSON.stringify(file.alt)}`);
105
+ parts.push(`url=${file.url}`);
106
+ return `- ${parts.join(' | ')}`;
107
+ };
108
+ export const shopifyFileSearchTool = defineTool({
109
+ argHint: 'ask',
110
+ argRequired: false,
111
+ build(config) {
112
+ const { store } = config;
113
+ const bin = requireBin(config);
114
+ const spec = {
115
+ description: `Search the Shopify store ${store}'s asset library (Content > Files) by METADATA ONLY — filename, mime type, ` +
116
+ `size, and/or url — and get back each match's name, type, size, dimensions, GID, alt, and url. It never ` +
117
+ `downloads image bytes, so use it INSTEAD of ingesting images: e.g. before an upload, search by a similar ` +
118
+ `\`name\`/\`type\`/size to see if the file already exists; or find images to resize with \`minSize\`. ` +
119
+ `All filters are optional and AND together. \`minSize\`/\`maxSize\` are in BYTES. Order with \`sort\` ` +
120
+ `(size|name|created|updated, default size) and \`order\` (asc|desc; default desc for size/created/updated, asc ` +
121
+ `for name) — ordering is server-side, so \`sort:size order:desc\` returns the genuinely LARGEST files. Returns ` +
122
+ `up to \`limit\` matches (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}); a broad scan examines up to ${SCAN_CAP} ` +
123
+ `files — narrow with \`name\` for an exact-ish lookup. The store is fixed to ${store}.`,
124
+ async handler(args) {
125
+ const filters = { maxSize: num(args.maxSize), minSize: num(args.minSize), type: str(args.type), url: str(args.url) };
126
+ const rawLimit = num(args.limit) ?? DEFAULT_LIMIT;
127
+ const limit = Math.max(1, Math.min(MAX_LIMIT, Math.floor(rawLimit)));
128
+ const query = buildQuery(str(args.name));
129
+ const ordering = resolveOrdering(args);
130
+ if ('error' in ordering)
131
+ return { isError: true, text: ordering.error };
132
+ const found = [];
133
+ let scanned = 0;
134
+ let hitScanCap = false;
135
+ try {
136
+ // Server-side order: the scan streams sorted, so breaking at `limit`
137
+ // yields the true top-N in that order (not just the first page re-sorted).
138
+ for await (const file of searchFiles(bin, store, { pageSize: PAGE_SIZE, query, reverse: ordering.reverse, sortKey: ordering.sortKey })) {
139
+ scanned += 1;
140
+ if (matches(file, filters))
141
+ found.push(file);
142
+ if (found.length >= limit)
143
+ break;
144
+ if (scanned >= SCAN_CAP) {
145
+ hitScanCap = true;
146
+ break;
147
+ }
148
+ }
149
+ }
150
+ catch (error) {
151
+ return { isError: true, text: `Search failed: ${error.message}` };
152
+ }
153
+ if (found.length === 0) {
154
+ return { text: `No files in ${store} matched (scanned ${scanned}${hitScanCap ? `, hit the ${SCAN_CAP}-file scan cap` : ''}).` };
155
+ }
156
+ const notes = [];
157
+ if (found.length >= limit)
158
+ notes.push(`stopped at the ${limit}-result limit — raise \`limit\` or narrow filters for more`);
159
+ if (hitScanCap)
160
+ notes.push(`stopped after examining ${SCAN_CAP} files — add a \`name\` filter to search a subset`);
161
+ return {
162
+ text: [
163
+ `${found.length} match${found.length === 1 ? '' : 'es'} in ${store}, ordered by ${ordering.label} (examined ${scanned} file${scanned === 1 ? '' : 's'}):`,
164
+ ...found.map((f) => formatFile(f)),
165
+ ...(notes.length > 0 ? ['', ...notes.map((n) => `note: ${n}`)] : []),
166
+ ].join('\n'),
167
+ };
168
+ },
169
+ inputSchema: {
170
+ properties: {
171
+ limit: { description: `Max matches to return (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}).`, type: 'number' },
172
+ maxSize: { description: 'Only files at most this many BYTES.', type: 'number' },
173
+ minSize: { description: 'Only files at least this many BYTES (use to find oversized images).', type: 'number' },
174
+ name: {
175
+ description: 'Filename contains this text (server-side search; narrows the scan — give it for an existence check).',
176
+ type: 'string',
177
+ },
178
+ order: { description: 'Sort direction: "asc" or "desc". Default: desc for size/created/updated, asc for name.', enum: ['asc', 'desc'], type: 'string' },
179
+ sort: {
180
+ description: 'Order results by "size" (default), "name", "created", or "updated". Server-side, so size+desc gives the truly largest.',
181
+ enum: ['size', 'name', 'created', 'updated'],
182
+ type: 'string',
183
+ },
184
+ type: { description: 'Mime type contains this text, e.g. "png", "image/jpeg", "svg".', type: 'string' },
185
+ url: { description: 'CDN url contains this text.', type: 'string' },
186
+ },
187
+ required: [],
188
+ type: 'object',
189
+ },
190
+ name: MCP_TOOL_NAME,
191
+ // `[ask]` makes the remote prompt for confirmation before every search.
192
+ requiresUserInteraction: config.ask,
193
+ };
194
+ return [spec];
195
+ },
196
+ name: 'shopify-file-search',
197
+ parse: shopifyReadFileParse,
198
+ preflight: shopifyPreflight,
199
+ });
@@ -0,0 +1,2 @@
1
+ import { ShopifyToolConfig } from './shopify-common.js';
2
+ export declare const shopifyFileUploadTool: import("#lib/mcp/define-tool.js").WorkspaceTool<ShopifyToolConfig>;
@@ -0,0 +1,76 @@
1
+ import { defineTool } from '#lib/mcp/define-tool.js';
2
+ import { uploadFile } from '#lib/shopify/shopify.js';
3
+ import { existsSync } from 'node:fs';
4
+ import { isAbsolute, resolve } from 'node:path';
5
+ import { requireBin, shopifyPreflight, shopifyWriteFileParse } from './shopify-common.js';
6
+ /**
7
+ * `--with-tool shopify-file-upload` — let Claude on the remote upload a file from
8
+ * the synced workspace into the store's asset library. The store comes from
9
+ * `--site-id` (see shopify-common.ts), not a per-tool argument. Its sibling tools
10
+ * are shopify-file-replace and shopify-file-delete; each activates one MCP tool.
11
+ */
12
+ /** The MCP tool name the remote AI sees. (The registry name activates it; this is what it calls.) */
13
+ const MCP_TOOL_NAME = 'upload_shopify_file';
14
+ export const shopifyFileUploadTool = defineTool({
15
+ argHint: 'ask',
16
+ argRequired: false,
17
+ build(config, runtime) {
18
+ const { store } = config;
19
+ // preflight ran first and resolved the CLI; assert it for the handler.
20
+ const bin = requireBin(config);
21
+ const spec = {
22
+ description: `Upload a file from the LOCAL machine into the Shopify store ${store}'s asset library (Content > Files). ` +
23
+ `\`path\` is a file on the machine that launched \`fnd workspace\` — relative to the synced workspace dir ` +
24
+ `(${runtime.localCwd}) or absolute. Returns the created file's GID, CDN url, filename, and the ` +
25
+ `shopify://shop_images/<filename> reference to drop into a theme image_picker setting or liquid. The upload ` +
26
+ `runs through the user's Shopify CLI on that machine, so it uses their existing store login. The store is ` +
27
+ `fixed to ${store} by the workspace command and cannot be changed here.`,
28
+ async handler(args) {
29
+ const rawPath = args.path;
30
+ if (typeof rawPath !== 'string' || rawPath.trim() === '') {
31
+ return { isError: true, text: 'The "path" argument must be a non-empty string.' };
32
+ }
33
+ const abs = isAbsolute(rawPath) ? rawPath : resolve(runtime.localCwd, rawPath);
34
+ if (!existsSync(abs)) {
35
+ return { isError: true, text: `No file at ${abs} on the local machine.` };
36
+ }
37
+ const alt = typeof args.alt === 'string' ? args.alt : undefined;
38
+ const filename = typeof args.filename === 'string' ? args.filename : undefined;
39
+ const result = await uploadFile(bin, store, { alt, filename, path: abs });
40
+ return {
41
+ text: [
42
+ `Uploaded ${abs} to ${store}.`,
43
+ `id: ${result.id}`,
44
+ `filename: ${result.filename ?? '(processing — not available yet)'}`,
45
+ `url: ${result.url ?? '(processing — not ready yet; poll the file later)'}`,
46
+ // The image_picker/theme-settings reference — what to store in settings JSON or liquid.
47
+ `theme reference: ${result.reference ?? '(available once processing finishes)'}`,
48
+ `status: ${result.status ?? 'unknown'}`,
49
+ ].join('\n'),
50
+ };
51
+ },
52
+ inputSchema: {
53
+ properties: {
54
+ alt: { description: 'Optional alt text to attach to the file.', type: 'string' },
55
+ filename: {
56
+ description: 'Optional name to store the file under; defaults to the basename of path.',
57
+ type: 'string',
58
+ },
59
+ path: {
60
+ description: `Path to the file on the local machine — relative to ${runtime.localCwd} or absolute.`,
61
+ type: 'string',
62
+ },
63
+ },
64
+ required: ['path'],
65
+ type: 'object',
66
+ },
67
+ name: MCP_TOOL_NAME,
68
+ // `[ask]` makes the remote prompt for confirmation before every upload.
69
+ requiresUserInteraction: config.ask,
70
+ };
71
+ return [spec];
72
+ },
73
+ name: 'shopify-file-upload',
74
+ parse: shopifyWriteFileParse,
75
+ preflight: shopifyPreflight,
76
+ });
@@ -0,0 +1,7 @@
1
+ query AccessScopes {
2
+ currentAppInstallation {
3
+ accessScopes {
4
+ handle
5
+ }
6
+ }
7
+ }
@@ -0,0 +1,8 @@
1
+ # Poll the status of the running product-export bulk query.
2
+ query CurrentBulkOperation {
3
+ currentBulkOperation(type: QUERY) {
4
+ status
5
+ errorCode
6
+ url
7
+ }
8
+ }
@@ -0,0 +1,25 @@
1
+ # Register an already-staged upload as a file in the store's asset library. The
2
+ # `originalSource` is the resourceUrl stagedUploadsCreate returned. The response
3
+ # type is a union, so pull the CDN url from whichever concrete type applies —
4
+ # MediaImage for images, GenericFile for everything else.
5
+ mutation FileCreate($files: [FileCreateInput!]!) {
6
+ fileCreate(files: $files) {
7
+ files {
8
+ id
9
+ alt
10
+ fileStatus
11
+ ... on MediaImage {
12
+ image {
13
+ url
14
+ }
15
+ }
16
+ ... on GenericFile {
17
+ url
18
+ }
19
+ }
20
+ userErrors {
21
+ field
22
+ message
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,11 @@
1
+ # Delete files from the store's asset library by id, returning the ids actually
2
+ # removed. Counts as a mutation to the CLI (needs --allow-mutations).
3
+ mutation FileDelete($fileIds: [ID!]!) {
4
+ fileDelete(fileIds: $fileIds) {
5
+ deletedFileIds
6
+ userErrors {
7
+ field
8
+ message
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,26 @@
1
+ # Replace an existing file's contents in place: fileUpdate with a fresh
2
+ # `originalSource` (a just-staged upload) swaps the bytes while keeping the same
3
+ # file id/GID, so a theme `shopify://shop_images/<filename>` reference stays
4
+ # valid. The response type is a union — pull the CDN url from whichever concrete
5
+ # type applies (MediaImage for images, GenericFile for everything else).
6
+ mutation FileReplace($files: [FileUpdateInput!]!) {
7
+ fileUpdate(files: $files) {
8
+ files {
9
+ id
10
+ alt
11
+ fileStatus
12
+ ... on MediaImage {
13
+ image {
14
+ url
15
+ }
16
+ }
17
+ ... on GenericFile {
18
+ url
19
+ }
20
+ }
21
+ userErrors {
22
+ field
23
+ message
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,19 @@
1
+ # Re-read a just-created file by id to see whether it has finished processing.
2
+ # fileCreate returns before the CDN url exists, so this is polled until `image.url`
3
+ # (MediaImage) or `url` (GenericFile) is populated and fileStatus is READY.
4
+ query FileStatus($id: ID!) {
5
+ node(id: $id) {
6
+ ... on MediaImage {
7
+ alt
8
+ fileStatus
9
+ image {
10
+ url
11
+ }
12
+ }
13
+ ... on GenericFile {
14
+ alt
15
+ fileStatus
16
+ url
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,27 @@
1
+ # The body of the file-library bulk query — the selection run in bulk, passed as
2
+ # the $query variable to StartBulkQuery (never via --query-file). Bulk queries
3
+ # take no variables, so there is no pagination (`first`/`after`) and no
4
+ # $skipMeta @skip: the metadata rides along unconditionally. A --dry run filters
5
+ # straight from it without downloading; a real run ignores it and measures the
6
+ # bytes it fetches anyway, so the few extra scalars per file are harmless.
7
+ {
8
+ files(query: "media_type:IMAGE") {
9
+ edges {
10
+ node {
11
+ id
12
+ alt
13
+ ... on MediaImage {
14
+ mimeType
15
+ image {
16
+ url
17
+ width
18
+ height
19
+ }
20
+ originalSource {
21
+ fileSize
22
+ }
23
+ }
24
+ }
25
+ }
26
+ }
27
+ }