@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
@@ -1,9 +1,11 @@
1
+ import { collectToolSpecs, resolveWorkspaceTools, withToolUsage } from '#lib/mcp/registry.js';
2
+ import { startMcpServer } from '#lib/mcp/server.js';
3
+ import { reconcileShopifyScopes } from '#lib/mcp/tools/shopify-common.js';
4
+ import { browserDebugInstructions, buildContext, buildMutagenCreateArgs, buildMutagenFlushArgs, buildMutagenTerminateArgs, buildMutagenTerminateSelectorArgs, buildRemoteScript, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, isLocalDebugPortLive, mutagenInstallInstructions, parsePortPair, parseSshTarget, runMutagen, runRemoteCleanup, slugify, } from '#lib/workspace.js';
1
5
  import { Command, Flags } from '@oclif/core';
2
6
  import chalk from 'chalk';
3
7
  import { spawn } from 'node:child_process';
4
8
  import { basename } from 'node:path';
5
- import { hasLocalShell, parseRpcFlag, RPC_FLAG_EXAMPLES, RPC_FLAG_USAGE, startRpcServer } from '../../lib/rpc.js';
6
- import { browserDebugInstructions, buildContext, buildMutagenCreateArgs, buildMutagenFlushArgs, buildMutagenTerminateArgs, buildMutagenTerminateSelectorArgs, buildRemoteScript, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, isLocalDebugPortLive, mutagenInstallInstructions, parsePortPair, parseSshTarget, runMutagen, runRemoteCleanup, slugify, } from '../../lib/workspace.js';
7
9
  export default class Workspace extends Command {
8
10
  static description = 'Open a remote workspace: two-way sync the current directory to a remote Linux box with Mutagen and drop into a shell there, tearing the sync down on exit.\n\nBoth sides keep a real copy on local disk and only deltas cross the network, so the remote reads files at native speed. Conflicting edits on both ends are flagged rather than silently overwritten; pass --source to auto-resolve them in favour of one side. Mutagen connects the normal direction (this machine → remote over SSH) and auto-deploys its agent to the remote, so no local SSH server, reverse tunnel, or authorized_keys trust is required. You need the Mutagen CLI installed on THIS machine.';
9
11
  static examples = [
@@ -13,15 +15,18 @@ export default class Workspace extends Command {
13
15
  '<%= config.bin %> <%= command.id %> --ssh user@host --ignore-vcs',
14
16
  '<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9222',
15
17
  '<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9333:9222',
16
- `<%= config.bin %> <%= command.id %> --ssh user@host --rpc ${RPC_FLAG_EXAMPLES.required}`,
17
- `<%= config.bin %> <%= command.id %> --ssh user@host --rpc ${RPC_FLAG_EXAMPLES.full}`,
18
+ '<%= config.bin %> <%= command.id %> --ssh user@host --with-tool shopify-file-upload --site-id mystore',
19
+ '<%= config.bin %> <%= command.id %> --ssh user@host --with-tool shopify-file-upload --with-tool shopify-file-search --with-tool "shopify-file-delete[ask]" --site-id mystore',
20
+ '<%= config.bin %> <%= command.id %> --ssh user@host --with-tool shopify-file-upload --with-tool shopify-file-replace --with-tool shopify-file-delete --site-id mystore.myshopify.com',
21
+ '<%= config.bin %> <%= command.id %> --ssh user@host --with-tool "shopify-execute[scopes=read_products+read_orders]" --site-id mystore',
22
+ '<%= config.bin %> <%= command.id %> --ssh user@host --with-tool "shopify-execute[ask,scopes=all]" --site-id mystore',
18
23
  '<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir',
19
- '<%= config.bin %> <%= command.id %> --ssh user@host --rpc 7700 --cleanup',
24
+ '<%= config.bin %> <%= command.id %> --ssh user@host --with-tool shopify-file-upload --site-id mystore --cleanup',
20
25
  ];
21
26
  static flags = {
22
27
  cleanup: Flags.boolean({
23
28
  default: false,
24
- description: "tear down a leftover session instead of opening one — don't connect, just terminate this directory's Mutagen sync and (using whatever other flags are set) strip the remote --devtools/--rpc MCP entries and, with --delete-remote-dir, remove the remote dir. Re-run your original command with --cleanup appended after a session that dropped without cleaning up.",
29
+ description: "tear down a leftover session instead of opening one — don't connect, just terminate this directory's Mutagen sync and (using whatever other flags are set) strip the remote --devtools/--with-tool MCP entries and, with --delete-remote-dir, remove the remote dir. Re-run your original command with --cleanup appended after a session that dropped without cleaning up.",
25
30
  }),
26
31
  'delete-remote-dir': Flags.boolean({
27
32
  default: false,
@@ -38,8 +43,8 @@ export default class Workspace extends Command {
38
43
  default: DEFAULT_MOUNT_BASE,
39
44
  description: 'base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>',
40
45
  }),
41
- rpc: Flags.string({
42
- description: `expose a run_local_command MCP tool to Claude on the remote that executes commands back on THIS machine (the one running fnd workspace). Value is ${RPC_FLAG_USAGE} port opens a reverse tunnel (ssh -R <remote>:localhost:<local>) to a command server started here; shell defaults to the shell fnd workspace was called from; profile (default true) controls whether the shell loads its startup files — with it on, POSIX shells run interactively (-i) so rc files like ~/.bashrc or ~/.zshrc are sourced and tools such as nvm work.`,
46
+ 'site-id': Flags.string({
47
+ description: 'the Shopify store (mystore or mystore.myshopify.com) the Shopify --with-tool tools operate on. Required when any Shopify tool (shopify-file-* or shopify-execute) is selected; fixing the store here is a safety boundary the remote AI cannot target another store.',
43
48
  }),
44
49
  source: Flags.string({
45
50
  description: 'which side wins on conflict: "remote" = this server (where the workspace shell runs), "local" = the machine you ran fnd workspace from. Omit to flag conflicts instead of auto-resolving them.',
@@ -49,6 +54,10 @@ export default class Workspace extends Command {
49
54
  description: 'remote to connect to, as user@host',
50
55
  required: true,
51
56
  }),
57
+ 'with-tool': Flags.string({
58
+ description: `expose an fnd tool to Claude on the remote via a loopback MCP server on THIS machine (the one running fnd workspace), reached through a reverse tunnel. Repeatable. Value is <name> or <name>[options]; available: ${withToolUsage()}. e.g. shopify-file-upload (with --site-id mystore) lets the remote AI upload files from the workspace into that Shopify store using your local Shopify CLI; shopify-file-replace and shopify-file-delete add in-place replace and delete; shopify-file-search looks up files by name/size/type/url (metadata only, no image bytes) to dedupe before upload or find oversized images; shopify-execute runs arbitrary Admin GraphQL and REQUIRES a scopes option — shopify-execute[scopes=all] for broad access or shopify-execute[scopes=read_products+write_orders] for an explicit set (which the store is then trimmed to, revoking anything extra, so the AI is held to least privilege). Append ,ask (e.g. shopify-file-delete[ask], shopify-execute[ask,scopes=all]) to make that tool prompt for confirmation before EVERY call, even under the remote's auto-accept/bypass permissions — use it to gate the powerful/destructive tools. Prerequisites (auth, scopes) are checked and set up before connecting.`,
59
+ multiple: true,
60
+ }),
52
61
  };
53
62
  async run() {
54
63
  const { flags } = await this.parse(Workspace);
@@ -56,26 +65,52 @@ export default class Workspace extends Command {
56
65
  const target2 = `${target.user}@${target.host}`;
57
66
  // --cleanup short-circuits everything: no connection, no sync, just tear down
58
67
  // leftovers from a session that dropped before it could clean up. The other
59
- // flags are reused for *what* to clean up (devtools/rpc MCP entries, dir), and
60
- // any that don't matter here are ignored — so `↑` + ` --cleanup` just works.
68
+ // flags are reused for *what* to clean up (devtools/with-tool MCP entries, dir),
69
+ // and any that don't matter here are ignored — so `↑` + ` --cleanup` just works.
61
70
  if (flags.cleanup) {
62
71
  await this.runCleanup(target2, flags);
63
72
  return;
64
73
  }
65
74
  const devtools = flags.devtools === undefined ? undefined : parsePortPair(flags.devtools, '--devtools');
66
- const rpc = flags.rpc === undefined ? undefined : parseRpcFlag(flags.rpc);
67
- if (devtools && rpc && devtools.remote === rpc.ports.remote) {
68
- this.error(`--devtools and --rpc cannot share remote port ${rpc.ports.remote}.`, { code: '1' });
75
+ // Resolve --with-tool selections up front so an unknown tool or a bad argument
76
+ // fails before we touch the network.
77
+ let selections = [];
78
+ try {
79
+ selections = resolveWorkspaceTools(flags['with-tool'] ?? [], { siteId: flags['site-id'] });
80
+ }
81
+ catch (error) {
82
+ this.error(error.message, { code: '1' });
83
+ }
84
+ // Run every prerequisite check (ssh/mutagen, the browser debug port, and each
85
+ // tool's own setup — auth, scopes) BEFORE connecting, so nothing that needs
86
+ // interactive setup surprises the user mid-session.
87
+ await this.preflight(devtools, selections);
88
+ // With tools selected, start the loopback MCP server now so we know the port
89
+ // to tunnel and register on the remote. It's closed in the finally below.
90
+ let mcpServer;
91
+ let tools;
92
+ if (selections.length > 0) {
93
+ const specs = collectToolSpecs(selections, { localCwd: process.cwd() });
94
+ try {
95
+ mcpServer = await startMcpServer(specs);
96
+ }
97
+ catch (error) {
98
+ this.error(`Could not start the local tools MCP server (${error.message}).`, { code: '1' });
99
+ }
100
+ if (devtools && devtools.remote === mcpServer.port) {
101
+ await mcpServer.close().catch(() => { });
102
+ this.error(`--devtools remote port ${devtools.remote} collides with the tools MCP server; use a different --devtools port.`, { code: '1' });
103
+ }
104
+ tools = { names: selections.map((s) => s.raw), port: { local: mcpServer.port, remote: mcpServer.port } };
69
105
  }
70
106
  const ctx = buildContext({
71
107
  cwd: process.cwd(),
72
108
  devtools,
73
109
  ignoreVcs: flags['ignore-vcs'],
74
110
  remoteBase: flags['remote-base'],
75
- rpc,
76
111
  source: flags.source,
112
+ tools,
77
113
  });
78
- await this.preflight(devtools, rpc);
79
114
  this.printPlan(ctx, target2);
80
115
  // Start the two-way sync. Mutagen auto-deploys its agent to the remote over SSH.
81
116
  this.log(chalk.dim('Starting the Mutagen sync session…'));
@@ -84,32 +119,24 @@ export default class Workspace extends Command {
84
119
  this.error(`mutagen sync create failed (exit ${createCode}). Check that the remote is reachable over SSH and try again.`, { code: '1' });
85
120
  }
86
121
  const deleteRemoteDir = flags['delete-remote-dir'];
87
- let rpcServer;
88
122
  let code;
89
123
  try {
124
+ if (ctx.tools) {
125
+ this.log(chalk.dim(`Local tools MCP server listening on 127.0.0.1:${ctx.tools.port.local} (${ctx.tools.names.join(', ')}).`));
126
+ }
90
127
  // Block until the first full sync lands so the files exist before the shell opens.
91
128
  this.log(chalk.dim('Performing the initial sync…'));
92
129
  const flushCode = await runMutagen(buildMutagenFlushArgs(ctx.syncName));
93
130
  if (flushCode !== 0) {
94
131
  this.error(`Initial mutagen sync flush failed (exit ${flushCode}).`, { code: '1' });
95
132
  }
96
- // Start the local command server the --rpc tunnel points back to.
97
- if (ctx.rpc) {
98
- try {
99
- rpcServer = await startRpcServer(ctx.rpc, ctx.localCwd);
100
- this.log(chalk.dim(`Local RPC command server (${ctx.rpc.shell}) listening on 127.0.0.1:${ctx.rpc.ports.local}.`));
101
- }
102
- catch (error) {
103
- this.error(`Could not start the local RPC server on 127.0.0.1:${ctx.rpc.ports.local} (${error.message}). Is the port already in use?`, { code: '1' });
104
- }
105
- }
106
133
  const script = buildRemoteScript(ctx);
107
134
  code = await this.runSsh(target2, script, ctx);
108
135
  }
109
136
  finally {
110
- // Best-effort: stop serving local commands, flush the last edits back, then
111
- // tear the session down.
112
- await rpcServer?.close().catch(() => { });
137
+ // Best-effort: stop serving tools, flush the last edits back, then tear the
138
+ // session down.
139
+ await mcpServer?.close().catch(() => { });
113
140
  this.log('');
114
141
  this.log(chalk.dim('Flushing final changes and stopping the sync…'));
115
142
  await runMutagen(buildMutagenFlushArgs(ctx.syncName)).catch(() => 1);
@@ -125,8 +152,8 @@ export default class Workspace extends Command {
125
152
  : '✓ Workspace closed. Sync stopped; the remote copy is left in place.')
126
153
  : chalk.yellow(`Session ended with exit code ${code}. Cleanup attempted above.`));
127
154
  }
128
- /** Verify this machine can drive the sync before we connect. */
129
- async preflight(devtools, rpc) {
155
+ /** Verify this machine can drive the sync — and each tool's prerequisites — before we connect. */
156
+ async preflight(devtools, selections) {
130
157
  if (!hasSshClient()) {
131
158
  this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
132
159
  }
@@ -149,12 +176,32 @@ export default class Workspace extends Command {
149
176
  this.error('Local browser remote-debugging port is required for --devtools. Aborting.', { code: '1' });
150
177
  }
151
178
  }
152
- // With --rpc, confirm the chosen shell is runnable on THIS machine — it's the
153
- // shell the remote AI's commands will execute under, right here.
154
- if (rpc && !hasLocalShell(rpc.shell, rpc.profile)) {
155
- this.error(`--rpc shell "${rpc.shell}" is not runnable on this machine. Aborting before connecting.`, {
156
- code: '1',
157
- });
179
+ // Each --with-tool tool checks its own prerequisites here the same "verify
180
+ // before we connect" contract as the devtools port above. A tool may do
181
+ // interactive setup (e.g. opening the browser for `shopify store auth`), so
182
+ // this runs while the user is still watching, not mid-session. A thrown check
183
+ // aborts before anything connects.
184
+ for (const { config, tool } of selections) {
185
+ try {
186
+ // eslint-disable-next-line no-await-in-loop -- tools set up sequentially; each may prompt interactively
187
+ await tool.preflight(config, (message) => this.log(chalk.dim(message)));
188
+ }
189
+ catch (error) {
190
+ this.error(`--with-tool ${tool.name}: ${error.message}`, { code: '1' });
191
+ }
192
+ }
193
+ // Reconcile the Shopify store's scopes ONCE across all the selected tools:
194
+ // grant the union they need, and — only when shopify-execute is in the run —
195
+ // revoke anything extra so the arbitrary-GraphQL tool is held to least
196
+ // privilege. Runs after the per-tool checks (so a missing CLI has already
197
+ // aborted with a friendly message) and before we connect, so the browser
198
+ // `shopify store auth` happens while the user is watching. A no-op when no
199
+ // Shopify tool is selected.
200
+ try {
201
+ reconcileShopifyScopes(selections, (message) => this.log(chalk.dim(message)));
202
+ }
203
+ catch (error) {
204
+ this.error(error.message, { code: '1' });
158
205
  }
159
206
  }
160
207
  printPlan(ctx, target) {
@@ -172,8 +219,8 @@ export default class Workspace extends Command {
172
219
  if (ctx.devtools) {
173
220
  this.log(` ${chalk.dim('devtools:')} remote 127.0.0.1:${ctx.devtools.remote} → local browser 127.0.0.1:${ctx.devtools.local}`);
174
221
  }
175
- if (ctx.rpc) {
176
- this.log(` ${chalk.dim('rpc:')} remote 127.0.0.1:${ctx.rpc.ports.remote} → local ${ctx.rpc.shell} commands via 127.0.0.1:${ctx.rpc.ports.local}`);
222
+ if (ctx.tools) {
223
+ this.log(` ${chalk.dim('tools:')} remote 127.0.0.1:${ctx.tools.port.remote} → local MCP server 127.0.0.1:${ctx.tools.port.local} (${ctx.tools.names.join(', ')})`);
177
224
  }
178
225
  this.log('');
179
226
  }
@@ -189,7 +236,7 @@ export default class Workspace extends Command {
189
236
  this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
190
237
  }
191
238
  const removeDevtoolsMcp = flags.devtools !== undefined;
192
- const removeRpcMcp = flags.rpc !== undefined;
239
+ const removeToolsMcp = (flags['with-tool']?.length ?? 0) > 0;
193
240
  const deleteRemoteDir = flags['delete-remote-dir'];
194
241
  const { remoteDir } = buildContext({ cwd: process.cwd(), remoteBase: flags['remote-base'] });
195
242
  const slug = slugify(basename(remoteDir));
@@ -208,13 +255,13 @@ export default class Workspace extends Command {
208
255
  if (removeDevtoolsMcp) {
209
256
  this.log(chalk.dim('Removing any leftover chrome-devtools MCP config on the remote…'));
210
257
  }
211
- if (removeRpcMcp) {
212
- this.log(chalk.dim('Removing any leftover local-shell MCP config on the remote…'));
258
+ if (removeToolsMcp) {
259
+ this.log(chalk.dim('Removing any leftover fnd-tools MCP config on the remote…'));
213
260
  }
214
261
  if (deleteRemoteDir) {
215
262
  this.log(chalk.dim('Deleting the remote workspace directory…'));
216
263
  }
217
- const code = await runRemoteCleanup(target, remoteDir, { deleteRemoteDir, removeDevtoolsMcp, removeRpcMcp });
264
+ const code = await runRemoteCleanup(target, remoteDir, { deleteRemoteDir, removeDevtoolsMcp, removeToolsMcp });
218
265
  if (code === 0) {
219
266
  this.log(chalk.green('✓ Done.'));
220
267
  }
@@ -225,9 +272,9 @@ export default class Workspace extends Command {
225
272
  /** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
226
273
  runSsh(target, script, ctx) {
227
274
  // Reverse tunnels: remote 127.0.0.1:<remote> → this machine's 127.0.0.1:<local>.
228
- // --devtools points one at the local browser's debug port; --rpc points one at
229
- // the local command server, so the remote's MCPs can reach them.
230
- const forwards = [ctx.devtools, ctx.rpc?.ports].filter((f) => f !== undefined);
275
+ // --devtools points one at the local browser's debug port; --with-tool points
276
+ // one at the local tools MCP server, so the remote's MCPs can reach them.
277
+ const forwards = [ctx.devtools, ctx.tools?.port].filter((f) => f !== undefined);
231
278
  const args = [
232
279
  '-t', // allocate a remote PTY for the interactive shell session
233
280
  ...(forwards.length > 0 ? ['-o', 'ExitOnForwardFailure=yes'] : []),
@@ -248,11 +295,11 @@ export default class Workspace extends Command {
248
295
  * logged, not thrown — the local sync is already torn down by this point.
249
296
  */
250
297
  async teardownRemote(ctx, target, deleteRemoteDir) {
251
- if (!ctx.devtools && !ctx.rpc && !deleteRemoteDir)
298
+ if (!ctx.devtools && !ctx.tools && !deleteRemoteDir)
252
299
  return;
253
300
  const actions = [
254
301
  ctx.devtools ? 'removing the remote chrome-devtools MCP config' : undefined,
255
- ctx.rpc ? 'removing the remote local-shell MCP config' : undefined,
302
+ ctx.tools ? 'removing the remote fnd-tools MCP config' : undefined,
256
303
  deleteRemoteDir ? 'deleting the remote dir' : undefined,
257
304
  ].filter(Boolean);
258
305
  this.log(chalk.dim(`${actions.join(' and ')}…`.replace(/^./, (c) => c.toUpperCase())));
@@ -260,7 +307,7 @@ export default class Workspace extends Command {
260
307
  await runRemoteCleanup(target, ctx.remoteDir, {
261
308
  deleteRemoteDir,
262
309
  removeDevtoolsMcp: Boolean(ctx.devtools),
263
- removeRpcMcp: Boolean(ctx.rpc),
310
+ removeToolsMcp: Boolean(ctx.tools),
264
311
  });
265
312
  }
266
313
  catch (error) {
@@ -32,7 +32,30 @@ export interface Skipped {
32
32
  meta: ImageMeta;
33
33
  skipped: true;
34
34
  }
35
- export type Describe = (url: string) => Promise<Description | Skipped>;
35
+ /**
36
+ * `context`, when given, is free-form usage context for this specific image
37
+ * (e.g. the Shopify product it's attached to) that the caller has dug up from
38
+ * somewhere the pixels can't show. It's folded into the prompt as a hint.
39
+ *
40
+ * `onDownloaded` fires once the image is fetched and about to go to the model —
41
+ * the boundary between the two slow phases (download, then inference) — so a
42
+ * caller can update a spinner from "downloading" to "describing". It does NOT
43
+ * fire when the image is filtered out or on a dry run, since neither reaches the
44
+ * model.
45
+ */
46
+ export type Describe = (url: string, context?: string, onDownloaded?: () => void) => Promise<Description | Skipped>;
47
+ /**
48
+ * The `type` a filter expression sees: a bare format token, not a MIME type.
49
+ *
50
+ * Content-Type is trusted first and the extension is the fallback, since CDNs
51
+ * serve plenty of images from extensionless URLs. `jpg` is normalized to `jpeg`
52
+ * and `svg+xml` to `svg` so an expression doesn't have to spell both.
53
+ *
54
+ * Exported so a caller that already knows an image's MIME type (e.g. Shopify's
55
+ * GraphQL `mimeType`) can produce the same `type` token a downloaded image would
56
+ * get, without re-fetching the bytes.
57
+ */
58
+ export declare const detectType: (contentType: string, path: string) => string;
36
59
  /**
37
60
  * Every model pulled on the host that can actually read an image.
38
61
  *
@@ -52,5 +75,13 @@ export declare const listVisionModels: (host: string) => Promise<string[]>;
52
75
  * after the download (see fetchImage) but before inference, and a rejected
53
76
  * image comes back as `{skipped: true}` rather than throwing, because being
54
77
  * filtered out is a normal outcome and not a failure.
78
+ *
79
+ * `dry` short-circuits right after the download: the image is fetched and
80
+ * measured (so the caller can filter on real metadata for images whose size or
81
+ * dimensions weren't known up front) but nothing is sent to the model, and the
82
+ * filter is NOT applied here — a dry run's filtering is the command's job, so it
83
+ * can decide uniformly whether the meta came from this download or from an API
84
+ * that already knew it. The returned Description carries real `meta`/`bytes` and
85
+ * an empty `alt` the caller never reads.
55
86
  */
56
- export declare const createDescriber: (host: string, model: string, filter?: ImageFilter) => Describe;
87
+ export declare const createDescriber: (host: string, model: string, filter?: ImageFilter, dry?: boolean) => Describe;
@@ -12,6 +12,41 @@ export const DEFAULT_OLLAMA_HOST = 'http://localhost:11434';
12
12
  /** How long Ollama keeps the model resident between images, in seconds. */
13
13
  const KEEP_ALIVE = 60;
14
14
  const PROMPT = "Make alt text for this image. The alt text should be no more than 1 sentence. Don't be overly descriptive. Include any text in the image in your description. Verbs should be in present tense.";
15
+ /**
16
+ * The part of an image URL worth showing the model: the file name, without the
17
+ * directory, query string, or extension. A name like `sample-normal-wax` says
18
+ * what the pixels can't — that yellow cube is a wax melt — while the rest of a
19
+ * CDN URL (shard digits, `?v=…`) is noise the model shouldn't have to wade
20
+ * through. Returns '' when there's nothing usable to pass along.
21
+ */
22
+ const urlHint = (url) => {
23
+ try {
24
+ const last = new URL(url).pathname.split('/').pop() ?? '';
25
+ return decodeURIComponent(last).replace(/\.[a-z0-9]+$/i, '').trim();
26
+ }
27
+ catch {
28
+ return '';
29
+ }
30
+ };
31
+ /**
32
+ * The prompt for one image: the base instructions, the file name, and whatever
33
+ * usage context the caller passed. All of it is framed as a hint on purpose —
34
+ * file names are often stale, generic (`IMG_1234`), or plain wrong, and a
35
+ * product an image is filed under isn't guaranteed to be what the image shows —
36
+ * so the model is told to lean on the hints only where they agree with the
37
+ * pixels, and never to copy them into the alt text verbatim.
38
+ */
39
+ const buildPrompt = (url, context) => {
40
+ const hints = [];
41
+ const hint = urlHint(url);
42
+ if (hint)
43
+ hints.push(`Its file name is "${hint}".`);
44
+ if (context)
45
+ hints.push(`${context}.`);
46
+ if (hints.length === 0)
47
+ return PROMPT;
48
+ return `${PROMPT} Extra context about this image: ${hints.join(' ')} Use this context only where it agrees with what you see, and never copy the file name, URL, or these details verbatim into the alt text.`;
49
+ };
15
50
  /**
16
51
  * Everything the model sees is PNG.
17
52
  *
@@ -49,8 +84,12 @@ const rasterToPng = async (bytes) => {
49
84
  * Content-Type is trusted first and the extension is the fallback, since CDNs
50
85
  * serve plenty of images from extensionless URLs. `jpg` is normalized to `jpeg`
51
86
  * and `svg+xml` to `svg` so an expression doesn't have to spell both.
87
+ *
88
+ * Exported so a caller that already knows an image's MIME type (e.g. Shopify's
89
+ * GraphQL `mimeType`) can produce the same `type` token a downloaded image would
90
+ * get, without re-fetching the bytes.
52
91
  */
53
- const detectType = (contentType, path) => {
92
+ export const detectType = (contentType, path) => {
54
93
  const fromHeader = contentType.split(';')[0].trim().toLowerCase();
55
94
  const subtype = fromHeader.startsWith('image/') ? fromHeader.slice('image/'.length) : '';
56
95
  const raw = subtype || (path.includes('.') ? path.split('.').pop() : '');
@@ -110,19 +149,32 @@ export const listVisionModels = async (host) => {
110
149
  * after the download (see fetchImage) but before inference, and a rejected
111
150
  * image comes back as `{skipped: true}` rather than throwing, because being
112
151
  * filtered out is a normal outcome and not a failure.
152
+ *
153
+ * `dry` short-circuits right after the download: the image is fetched and
154
+ * measured (so the caller can filter on real metadata for images whose size or
155
+ * dimensions weren't known up front) but nothing is sent to the model, and the
156
+ * filter is NOT applied here — a dry run's filtering is the command's job, so it
157
+ * can decide uniformly whether the meta came from this download or from an API
158
+ * that already knew it. The returned Description carries real `meta`/`bytes` and
159
+ * an empty `alt` the caller never reads.
113
160
  */
114
- export const createDescriber = (host, model, filter) => {
161
+ export const createDescriber = (host, model, filter, dry = false) => {
115
162
  const ollama = new Ollama({ host });
116
- return async (url) => {
163
+ return async (url, context, onDownloaded) => {
117
164
  const started = performance.now();
118
165
  const { meta, png } = await fetchImage(url);
166
+ if (dry) {
167
+ return { alt: '', bytes: png.byteLength, meta, ms: performance.now() - started, skipped: false, tokens: 0 };
168
+ }
119
169
  if (filter && !filter(meta))
120
170
  return { meta, skipped: true };
171
+ // Downloaded and kept: from here the model runs, which is the slow part.
172
+ onDownloaded?.();
121
173
  const images = [Buffer.from(png).toString("base64")];
122
174
  const resp = await ollama.chat({
123
175
  // eslint-disable-next-line camelcase
124
176
  keep_alive: KEEP_ALIVE,
125
- messages: [{ content: PROMPT, images, role: 'user' }],
177
+ messages: [{ content: buildPrompt(url, context), images, role: 'user' }],
126
178
  model,
127
179
  stream: false,
128
180
  // think: true,
@@ -0,0 +1,37 @@
1
+ /**
2
+ * A small, reusable parser for a `--with-tool` selection's bracketed argument.
3
+ *
4
+ * The registry already splits `name[...]` into the tool name and the raw text
5
+ * between the brackets (see registry.ts `parseSpec`); this turns that raw text
6
+ * into named options. The grammar is a comma-separated list where each item is
7
+ * either a bare **flag** (`ask`) or a **key=value** option (`scopes=all`):
8
+ *
9
+ * shopify-file-delete[ask]
10
+ * shopify-execute[ask,scopes=read_products+write_orders]
11
+ *
12
+ * A tool declares which flags and value-keys it accepts, so a typo (`aks`, or a
13
+ * value option a tool doesn't understand) fails up front with a clear message
14
+ * rather than being silently ignored. Multi-valued options (e.g. a scope list)
15
+ * keep their value as one opaque string here — the tool splits it however it
16
+ * likes — because comma is already the option separator (so a list uses `+` or
17
+ * spaces internally, not commas).
18
+ */
19
+ /** What a tool allows inside its brackets: bare flags and/or key=value options. */
20
+ export interface BracketSpec {
21
+ /** Allowed bare flags, lower-cased. Present in the parse result => set to true. */
22
+ flags?: readonly string[];
23
+ /** Allowed key=value option keys, lower-cased. */
24
+ values?: readonly string[];
25
+ }
26
+ /** The parsed brackets: which flags were present, and each key=value pair. */
27
+ export interface BracketArgs {
28
+ flags: Set<string>;
29
+ values: Map<string, string>;
30
+ }
31
+ /**
32
+ * Parse a selection's bracket text against `spec`. An undefined/empty arg yields
33
+ * an empty result (no flags, no values). Throws — with a message the registry
34
+ * prefixes with the tool name — on an unknown flag/key, a duplicate, or an empty
35
+ * value (`scopes=`), so the user learns exactly what they mistyped.
36
+ */
37
+ export declare const parseBracketArgs: (arg: string | undefined, spec?: BracketSpec) => BracketArgs;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * A small, reusable parser for a `--with-tool` selection's bracketed argument.
3
+ *
4
+ * The registry already splits `name[...]` into the tool name and the raw text
5
+ * between the brackets (see registry.ts `parseSpec`); this turns that raw text
6
+ * into named options. The grammar is a comma-separated list where each item is
7
+ * either a bare **flag** (`ask`) or a **key=value** option (`scopes=all`):
8
+ *
9
+ * shopify-file-delete[ask]
10
+ * shopify-execute[ask,scopes=read_products+write_orders]
11
+ *
12
+ * A tool declares which flags and value-keys it accepts, so a typo (`aks`, or a
13
+ * value option a tool doesn't understand) fails up front with a clear message
14
+ * rather than being silently ignored. Multi-valued options (e.g. a scope list)
15
+ * keep their value as one opaque string here — the tool splits it however it
16
+ * likes — because comma is already the option separator (so a list uses `+` or
17
+ * spaces internally, not commas).
18
+ */
19
+ /**
20
+ * Parse a selection's bracket text against `spec`. An undefined/empty arg yields
21
+ * an empty result (no flags, no values). Throws — with a message the registry
22
+ * prefixes with the tool name — on an unknown flag/key, a duplicate, or an empty
23
+ * value (`scopes=`), so the user learns exactly what they mistyped.
24
+ */
25
+ export const parseBracketArgs = (arg, spec = {}) => {
26
+ const flags = new Set();
27
+ const values = new Map();
28
+ const allowedFlags = new Set(spec.flags ?? []);
29
+ const allowedValues = new Set(spec.values ?? []);
30
+ const raw = arg?.trim();
31
+ if (!raw)
32
+ return { flags, values };
33
+ for (const part of raw.split(',')) {
34
+ const token = part.trim();
35
+ if (!token)
36
+ continue; // tolerate a stray/trailing comma
37
+ const eq = token.indexOf('=');
38
+ if (eq === -1) {
39
+ // A bare flag, e.g. `ask`.
40
+ const name = token.toLowerCase();
41
+ if (!allowedFlags.has(name)) {
42
+ const hint = allowedFlags.size > 0 ? ` supported flags: ${[...allowedFlags].join(', ')}.` : '';
43
+ throw new Error(`does not support the "${token}" option.${hint}`);
44
+ }
45
+ if (flags.has(name))
46
+ throw new Error(`option "${name}" was given more than once.`);
47
+ flags.add(name);
48
+ }
49
+ else {
50
+ // A key=value option, e.g. `scopes=all`.
51
+ const key = token.slice(0, eq).trim().toLowerCase();
52
+ const value = token.slice(eq + 1).trim();
53
+ if (!allowedValues.has(key)) {
54
+ const hint = allowedValues.size > 0 ? ` supported: ${[...allowedValues].map((v) => `${v}=…`).join(', ')}.` : '';
55
+ throw new Error(`does not support the "${key}=" option.${hint}`);
56
+ }
57
+ if (values.has(key))
58
+ throw new Error(`option "${key}=" was given more than once.`);
59
+ if (!value)
60
+ throw new Error(`option "${key}=" needs a value.`);
61
+ values.set(key, value);
62
+ }
63
+ }
64
+ return { flags, values };
65
+ };
@@ -0,0 +1,52 @@
1
+ import type { McpToolSpec } from './server.js';
2
+ /**
3
+ * The defineTool framework for `--with-tool`.
4
+ *
5
+ * A tool module builds itself with defineTool() and imports nothing from the
6
+ * registry — the registry imports the tools, never the other way round. Keeping
7
+ * the framework here (not in registry.ts) is what breaks that cycle: a tool can
8
+ * `import { defineTool }` without pulling the registry, and its own tool, back
9
+ * into its initialization.
10
+ */
11
+ /** Runtime facts a tool's handler may need — resolved once the workspace is known. */
12
+ export interface ToolRuntime {
13
+ /** Absolute path of the synced directory on THIS machine (the tool handler's cwd). */
14
+ localCwd: string;
15
+ }
16
+ /**
17
+ * Command-level context a tool's `parse` may need, beyond its own bracketed
18
+ * [arg]. Some tools take a shared flag rather than a per-tool argument — the
19
+ * Shopify tools read the store from `--site-id`, so they all target one store —
20
+ * and this is how that flag reaches them.
21
+ */
22
+ export interface ParseContext {
23
+ /** The `--site-id` flag value, if given (the Shopify store for the Shopify tools). */
24
+ siteId?: string;
25
+ }
26
+ /**
27
+ * A tool selectable via `--with-tool`. `C` is the parsed config a selection
28
+ * produces (e.g. a normalized store domain). The lifecycle is:
29
+ * parse(arg, context) — validate the [arg] and any command-level flags
30
+ * it depends on, up front, before anything else
31
+ * preflight(config, log) — verify prerequisites & do interactive setup
32
+ * (auth, scope grants) BEFORE the remote connects;
33
+ * throw with a friendly message to abort
34
+ * build(config, runtime) — the MCP tool spec(s) the server serves
35
+ *
36
+ * `argHint` is undefined for a tool that takes no bracketed [arg] (the Shopify
37
+ * tools — their store comes from `--site-id`), which the registry uses both to
38
+ * reject a stray `[arg]` and to print bare-name usage.
39
+ */
40
+ export interface WorkspaceTool<C = unknown> {
41
+ /** What the bracketed argument means, for usage text; undefined if the tool takes no [arg]. */
42
+ argHint?: string;
43
+ /** Whether `[arg]` is required. Only meaningful when `argHint` is set. */
44
+ argRequired?: boolean;
45
+ build: (config: C, runtime: ToolRuntime) => McpToolSpec[];
46
+ /** Registry key and the name used in `--with-tool <name>`. */
47
+ name: string;
48
+ parse: (arg: string | undefined, context: ParseContext) => C;
49
+ preflight: (config: C, log: (message: string) => void) => Promise<void> | void;
50
+ }
51
+ /** Identity helper that pins a tool's config type — the defineTool framework. */
52
+ export declare const defineTool: <C>(tool: WorkspaceTool<C>) => WorkspaceTool<C>;
@@ -0,0 +1,2 @@
1
+ /** Identity helper that pins a tool's config type — the defineTool framework. */
2
+ export const defineTool = (tool) => tool;
@@ -0,0 +1,38 @@
1
+ import type { ParseContext, ToolRuntime, WorkspaceTool } from './define-tool.js';
2
+ import type { McpToolSpec } from './server.js';
3
+ /**
4
+ * The `--with-tool` registry.
5
+ *
6
+ * `fnd workspace --with-tool <name>[<arg>]` exposes tools to Claude on the remote
7
+ * through the loopback MCP server (see server.ts). Each entry here is a
8
+ * WorkspaceTool built with defineTool (see define-tool.ts): it knows how to parse
9
+ * its bracketed argument, check its prerequisites BEFORE we connect (the same
10
+ * shape as the --devtools browser-port check), and build the MCP tool spec(s) the
11
+ * server actually serves. Adding a tool to the AI is: write one defineTool module
12
+ * and register it in TOOL_REGISTRY. Nothing else changes.
13
+ */
14
+ /** Every tool `--with-tool` can name, keyed by tool name. */
15
+ export declare const TOOL_REGISTRY: Record<string, WorkspaceTool>;
16
+ /** A resolved `--with-tool` selection: the tool, its parsed config, and the raw spec. */
17
+ export interface ToolSelection {
18
+ config: unknown;
19
+ raw: string;
20
+ tool: WorkspaceTool;
21
+ }
22
+ /** One-line usage listing the registered tools and their argument shape. */
23
+ export declare const withToolUsage: () => string;
24
+ /**
25
+ * Resolve each `--with-tool` value into a {tool, config} selection, validating
26
+ * the tool name and its argument up front. `context` carries command-level flags
27
+ * a tool's parse may depend on (e.g. `--site-id` for the Shopify tools). Throws
28
+ * on an unknown tool, a stray/missing argument, a parse failure (a required flag
29
+ * absent), or the same tool selected twice (its MCP tool names would collide on
30
+ * the server).
31
+ */
32
+ export declare const resolveWorkspaceTools: (specs: string[], context: ParseContext) => ToolSelection[];
33
+ /**
34
+ * The MCP tool specs for a set of selections. Rejects two tools that would
35
+ * advertise the same MCP tool name — the server dispatches by name, so a
36
+ * collision would make one unreachable.
37
+ */
38
+ export declare const collectToolSpecs: (selections: ToolSelection[], runtime: ToolRuntime) => McpToolSpec[];