@firenet-designs/fnd-cli 2.4.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.
- package/README.md +194 -57
- package/bin/dev.js +1 -1
- package/dist/commands/alt-text.d.ts +105 -0
- package/dist/commands/alt-text.js +616 -0
- package/dist/commands/backfill-project.js +1 -1
- package/dist/commands/create-project.js +48 -5
- package/dist/commands/workspace/index.d.ts +19 -2
- package/dist/commands/workspace/index.js +171 -56
- package/dist/lib/alt-text.d.ts +87 -0
- package/dist/lib/alt-text.js +196 -0
- package/dist/lib/image-filter.d.ts +43 -0
- package/dist/lib/image-filter.js +71 -0
- package/dist/lib/mcp/bracket-args.d.ts +37 -0
- package/dist/lib/mcp/bracket-args.js +65 -0
- package/dist/lib/mcp/define-tool.d.ts +52 -0
- package/dist/lib/mcp/define-tool.js +2 -0
- package/dist/lib/mcp/registry.d.ts +38 -0
- package/dist/lib/mcp/registry.js +98 -0
- package/dist/lib/mcp/server.d.ts +66 -0
- package/dist/lib/mcp/server.js +176 -0
- package/dist/lib/mcp/tools/shopify-common.d.ts +139 -0
- package/dist/lib/mcp/tools/shopify-common.js +167 -0
- package/dist/lib/mcp/tools/shopify-execute.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-execute.js +105 -0
- package/dist/lib/mcp/tools/shopify-file-delete.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-delete.js +49 -0
- package/dist/lib/mcp/tools/shopify-file-replace.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-replace.js +79 -0
- package/dist/lib/mcp/tools/shopify-file-search.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-search.js +199 -0
- package/dist/lib/mcp/tools/shopify-file-upload.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-upload.js +76 -0
- package/dist/lib/shopify/graphql/AccessScopes.graphql +7 -0
- package/dist/lib/shopify/graphql/CurrentBulkOperation.graphql +8 -0
- package/dist/lib/shopify/graphql/FileCreate.graphql +25 -0
- package/dist/lib/shopify/graphql/FileDelete.graphql +11 -0
- package/dist/lib/shopify/graphql/FileReplace.graphql +26 -0
- package/dist/lib/shopify/graphql/FileStatus.graphql +19 -0
- package/dist/lib/shopify/graphql/FilesBulkQuery.graphql +27 -0
- package/dist/lib/shopify/graphql/ProductsBulkQuery.graphql +27 -0
- package/dist/lib/shopify/graphql/SearchFiles.graphql +36 -0
- package/dist/lib/shopify/graphql/StagedUploadsCreate.graphql +20 -0
- package/dist/lib/shopify/graphql/StartBulkQuery.graphql +16 -0
- package/dist/lib/shopify/graphql/UpdateFileAlt.graphql +9 -0
- package/dist/lib/shopify/shopify.d.ts +228 -0
- package/dist/lib/shopify/shopify.js +662 -0
- package/dist/lib/webflow.d.ts +80 -0
- package/dist/lib/webflow.js +122 -0
- package/dist/lib/workspace.d.ts +29 -10
- package/dist/lib/workspace.js +74 -39
- package/oclif.manifest.json +162 -78
- package/package.json +21 -10
- package/dist/commands/workspace/cleanup.d.ts +0 -14
- package/dist/commands/workspace/cleanup.js +0 -84
- package/dist/hooks/init/check-for-updates.d.ts +0 -3
- package/dist/hooks/init/check-for-updates.js +0 -15
- package/dist/lib/kv-flag.d.ts +0 -15
- package/dist/lib/kv-flag.js +0 -75
- package/dist/lib/rpc.d.ts +0 -69
- package/dist/lib/rpc.js +0 -313
|
@@ -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,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,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,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,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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# The body of the product→image bulk query — the selection run in bulk, passed
|
|
2
|
+
# as the $query variable to StartBulkQuery (never via --query-file). Bulk allows
|
|
3
|
+
# two levels of nested connections, which is exactly products -> media; the
|
|
4
|
+
# MediaImage id on each media row is joined back to its product by __parentId in
|
|
5
|
+
# the JSONL result. No `first`/`after` — bulk expands the connections itself.
|
|
6
|
+
{
|
|
7
|
+
products {
|
|
8
|
+
edges {
|
|
9
|
+
node {
|
|
10
|
+
id
|
|
11
|
+
title
|
|
12
|
+
productType
|
|
13
|
+
vendor
|
|
14
|
+
tags
|
|
15
|
+
media {
|
|
16
|
+
edges {
|
|
17
|
+
node {
|
|
18
|
+
... on MediaImage {
|
|
19
|
+
id
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Page through the file library returning metadata only (no bytes) so a search
|
|
2
|
+
# can check for an existing file or find oversized images cheaply. `query` is a
|
|
3
|
+
# Shopify file-search string (e.g. filename:"hero", media_type:IMAGE) or null for
|
|
4
|
+
# everything. Pull the fields from whichever concrete type applies — MediaImage
|
|
5
|
+
# for images (dimensions + originalSource.fileSize), GenericFile otherwise
|
|
6
|
+
# (originalFileSize).
|
|
7
|
+
query SearchFiles($query: String, $first: Int!, $after: String, $sortKey: FileSortKeys, $reverse: Boolean) {
|
|
8
|
+
files(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
|
|
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
|
+
... on GenericFile {
|
|
25
|
+
mimeType
|
|
26
|
+
url
|
|
27
|
+
originalFileSize
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
pageInfo {
|
|
32
|
+
hasNextPage
|
|
33
|
+
endCursor
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Reserve a staging slot for a file the CLI is about to upload. Shopify hands
|
|
2
|
+
# back a short-lived upload `url` (Google Cloud Storage) plus the form
|
|
3
|
+
# `parameters` that authorize the POST, and the `resourceUrl` to hand to
|
|
4
|
+
# fileCreate once the bytes have landed. Counts as a mutation to the CLI.
|
|
5
|
+
mutation StagedUploadsCreate($input: [StagedUploadInput!]!) {
|
|
6
|
+
stagedUploadsCreate(input: $input) {
|
|
7
|
+
stagedTargets {
|
|
8
|
+
url
|
|
9
|
+
resourceUrl
|
|
10
|
+
parameters {
|
|
11
|
+
name
|
|
12
|
+
value
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
userErrors {
|
|
16
|
+
field
|
|
17
|
+
message
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Kick off a bulk query export (the file library, or the product→image map). The
|
|
2
|
+
# query to run in bulk is passed as the $query variable, so it needs no escaping
|
|
3
|
+
# here. Counts as a mutation to the CLI (--allow-mutations) even though it only
|
|
4
|
+
# reads. Only one bulk query can run per store at a time, so callers run these
|
|
5
|
+
# one after another.
|
|
6
|
+
mutation StartBulkQuery($query: String!) {
|
|
7
|
+
bulkOperationRunQuery(query: $query) {
|
|
8
|
+
bulkOperation {
|
|
9
|
+
id
|
|
10
|
+
}
|
|
11
|
+
userErrors {
|
|
12
|
+
field
|
|
13
|
+
message
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|