@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,4 +1,3 @@
1
- import type { RpcConfig } from './rpc.js';
2
1
  /**
3
2
  * Mutagen-backed workspace helpers.
4
3
  *
@@ -23,9 +22,10 @@ import type { RpcConfig } from './rpc.js';
23
22
  * Mutagen auto-deploys its agent to the remote over that same SSH connection;
24
23
  * nothing to install on the remote by hand.
25
24
  *
26
- * The one reverse tunnel that remains is optional and unrelated to files: with
25
+ * The reverse tunnels that remain are optional and unrelated to files: with
27
26
  * --devtools we open `ssh -R` so the remote's chrome-devtools MCP can reach the
28
- * caller's LOCAL browser.
27
+ * caller's LOCAL browser, and with --with-tool we open one so the remote's
28
+ * fnd-tools MCP can reach the tools server running on the caller.
29
29
  */
30
30
  export interface SshTarget {
31
31
  host: string;
@@ -40,6 +40,17 @@ export interface PortPair {
40
40
  }
41
41
  /** For --devtools: `local` is the browser's remote-debugging port on the caller's machine. */
42
42
  export type DevtoolsPorts = PortPair;
43
+ /**
44
+ * The workspace tools MCP (--with-tool): the reverse-tunnel port pair for the
45
+ * loopback MCP server started on the caller, plus the raw `--with-tool` values
46
+ * for the plan display. Undefined when no tool was requested.
47
+ */
48
+ export interface ToolsMcp {
49
+ /** The `--with-tool` values selected, verbatim, for the plan summary. */
50
+ names: string[];
51
+ /** `local` = the caller's MCP server port; `remote` = the port opened on the workspace host. */
52
+ port: PortPair;
53
+ }
43
54
  /**
44
55
  * Which side wins when the same path changed on both ends since the last sync.
45
56
  * `remote` = this server (the box where the workspace shell runs); `local` = the
@@ -61,12 +72,12 @@ export interface WorkspaceContext {
61
72
  localUser: string;
62
73
  /** Where the mirror lives on the REMOTE, e.g. /home/fnd/<localUser>/<localDirName>. */
63
74
  remoteDir: string;
64
- /** Local-command RPC server + tunnel, when --rpc was passed; undefined otherwise. */
65
- rpc?: RpcConfig;
66
75
  /** Which endpoint wins conflicts (the Mutagen alpha in two-way-resolved); undefined flags conflicts instead. */
67
76
  source?: SyncSource;
68
77
  /** Unique Mutagen session name for this workspace. */
69
78
  syncName: string;
79
+ /** Tools MCP server + tunnel, when --with-tool was passed; undefined otherwise. */
80
+ tools?: ToolsMcp;
70
81
  }
71
82
  export declare const DEFAULT_MOUNT_BASE = "/home/fnd";
72
83
  /** Parse a `user@host` string, throwing a friendly error otherwise. */
@@ -91,8 +102,8 @@ export declare const buildContext: (opts: {
91
102
  devtools?: DevtoolsPorts;
92
103
  ignoreVcs?: boolean;
93
104
  remoteBase: string;
94
- rpc?: RpcConfig;
95
105
  source?: SyncSource;
106
+ tools?: ToolsMcp;
96
107
  }) => WorkspaceContext;
97
108
  /**
98
109
  * Translate one .gitignore line into Mutagen ignore patterns. `base` is the
@@ -136,8 +147,8 @@ export interface RemoteCleanupOptions {
136
147
  deleteRemoteDir?: boolean;
137
148
  /** Strip this project's chrome-devtools MCP entry — only when the workspace registered one (--devtools). */
138
149
  removeDevtoolsMcp?: boolean;
139
- /** Strip this project's local-shell MCP entry — only when the workspace registered one (--rpc). */
140
- removeRpcMcp?: boolean;
150
+ /** Strip this project's fnd-tools MCP entry — only when the workspace registered one (--with-tool). */
151
+ removeToolsMcp?: boolean;
141
152
  }
142
153
  /**
143
154
  * Run the remote-side teardown over a fresh ssh connection. Forces a remote PTY
@@ -60,9 +60,9 @@ export const buildContext = (opts) => {
60
60
  localDirName,
61
61
  localUser,
62
62
  remoteDir,
63
- rpc: opts.rpc,
64
63
  source: opts.source,
65
64
  syncName: buildSyncName(localDirName),
65
+ tools: opts.tools,
66
66
  };
67
67
  };
68
68
  /**
@@ -155,8 +155,8 @@ export const collectVcsIgnores = (rootDir) => {
155
155
  export const shQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
156
156
  /** MCP server name registered for the workspace's chrome-devtools tunnel. */
157
157
  const DEVTOOLS_MCP_NAME = 'chrome-devtools';
158
- /** MCP server name registered for the --rpc local-command tunnel. */
159
- const RPC_MCP_NAME = 'local-shell';
158
+ /** MCP server name registered for the --with-tool tools tunnel. */
159
+ const TOOLS_MCP_NAME = 'fnd-tools';
160
160
  /**
161
161
  * The `claude` CLIs an MCP entry is registered with / removed from. `claude2` is
162
162
  * an overflow instance some remotes run alongside `claude`; both need the same
@@ -209,20 +209,20 @@ const claudeDevtoolsAddScript = (remotePort, okMessage) => {
209
209
  };
210
210
  /**
211
211
  * Bash lines (run on the REMOTE, from inside the workspace dir) that register the
212
- * --rpc local-shell MCP with the `claude` CLI as a Streamable HTTP server. The
212
+ * --with-tool fnd-tools MCP with the `claude` CLI as a Streamable HTTP server. The
213
213
  * URL points at the reverse-tunnelled port, which the workspace's `ssh -R`
214
- * forwards back to the RPC server on the calling machine. Same login-shell and
214
+ * forwards back to the tools server on the calling machine. Same login-shell and
215
215
  * idempotency reasoning as `claudeDevtoolsAddScript`.
216
216
  */
217
- const claudeRpcAddScript = (remotePort, okMessage) => {
217
+ const claudeToolsAddScript = (remotePort, okMessage) => {
218
218
  const body = [
219
219
  'configured=""',
220
220
  ...forEachClaudeCli((cli) => [
221
- `${cli} mcp remove ${RPC_MCP_NAME} >/dev/null 2>&1 || true`,
222
- `${cli} mcp add --transport http ${RPC_MCP_NAME} http://127.0.0.1:${remotePort}/mcp`,
221
+ `${cli} mcp remove ${TOOLS_MCP_NAME} >/dev/null 2>&1 || true`,
222
+ `${cli} mcp add --transport http ${TOOLS_MCP_NAME} http://127.0.0.1:${remotePort}/mcp`,
223
223
  'configured=1',
224
224
  ]),
225
- `if [ -n "$configured" ]; then echo ${shQuote(okMessage)}; else echo "WARNING: claude CLI not found on the remote; skipped local-shell MCP config." >&2; fi`,
225
+ `if [ -n "$configured" ]; then echo ${shQuote(okMessage)}; else echo "WARNING: claude CLI not found on the remote; skipped fnd-tools MCP config." >&2; fi`,
226
226
  ].join('\n');
227
227
  return [`"\${SHELL:-bash}" -lic ${shQuote(body)}`];
228
228
  };
@@ -252,8 +252,8 @@ export const buildRemoteScript = (ctx) => {
252
252
  const devtoolsSetup = ctx.devtools
253
253
  ? claudeDevtoolsAddScript(ctx.devtools.remote, `Configured chrome-devtools MCP for this workspace (browser via 127.0.0.1:${ctx.devtools.remote}).`)
254
254
  : [];
255
- const rpcSetup = ctx.rpc
256
- ? claudeRpcAddScript(ctx.rpc.ports.remote, `Configured local-shell MCP for this workspace (runs ${ctx.rpc.shell} commands on the calling machine via 127.0.0.1:${ctx.rpc.ports.remote}).`)
255
+ const toolsSetup = ctx.tools
256
+ ? claudeToolsAddScript(ctx.tools.port.remote, `Configured fnd-tools MCP for this workspace (${ctx.tools.names.join(', ')} on the calling machine via 127.0.0.1:${ctx.tools.port.remote}).`)
257
257
  : [];
258
258
  return [
259
259
  'set -u',
@@ -263,7 +263,7 @@ export const buildRemoteScript = (ctx) => {
263
263
  'cd "$DIR" || { echo "ERROR: could not enter $DIR" >&2; exit 1; }',
264
264
  // Register the MCPs from inside $DIR: `claude mcp add` local scope keys off cwd.
265
265
  ...devtoolsSetup,
266
- ...rpcSetup,
266
+ ...toolsSetup,
267
267
  'echo "Workspace ready at $DIR — files sync in the background (exit to stop syncing)."',
268
268
  // eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
269
269
  '"${SHELL:-bash}" -l',
@@ -296,7 +296,7 @@ export const buildCleanupScript = (remoteDir, opts = {}) => [
296
296
  `DIR=${shQuote(remoteDir)}`,
297
297
  'cd "$DIR" 2>/dev/null || { echo "Nothing to clean up: $DIR is gone." >&2; exit 0; }',
298
298
  ...(opts.removeDevtoolsMcp ? claudeMcpRemoveScript(DEVTOOLS_MCP_NAME) : []),
299
- ...(opts.removeRpcMcp ? claudeMcpRemoveScript(RPC_MCP_NAME) : []),
299
+ ...(opts.removeToolsMcp ? claudeMcpRemoveScript(TOOLS_MCP_NAME) : []),
300
300
  // Remove the workspace dir last: cd out first so we don't rm the cwd out from
301
301
  // under the shell, then delete it. Only when explicitly requested.
302
302
  ...(opts.deleteRemoteDir
@@ -5,36 +5,38 @@
5
5
  "caption"
6
6
  ],
7
7
  "args": {},
8
- "description": "Generate alt text for a site's images with a local Ollama vision model and write it back.\n\nWalks the Webflow site asset library and (with --cms) the image fields of CMS collection items, describes every image that needs alt text, and PATCHes the description back. Images are fetched and described one at a time — a single local model gains nothing from concurrency, and Webflow rate-limits. Nothing leaves your network except the Webflow API calls.\n\nCMS writes go to STAGING, so publish the site in Webflow to make them live. Any required flag you omit is prompted for.",
8
+ "description": "Generate alt text for a site's images with a local Ollama vision model and write it back.\n\nPick a platform with --platform (webflow or shopify). For Webflow, walks the site asset library and (with --cms) the image fields of CMS collection items and PATCHes each description back through the Data API. For Shopify, walks the store's file library and writes alt text back through the Shopify CLI (which owns the auth — no --api-key). Images are fetched and described one at a time; nothing but the platform API calls leaves your network.\n\nWebflow CMS writes go to STAGING, so publish the site in Webflow to make them live. Any required flag you omit is prompted for.",
9
9
  "examples": [
10
- "<%= config.bin %> <%= command.id %> --webflow --api-key <key> --site-id <id> --ollama-host http://localhost:11434 --ollama-model qwen3-vl:8b",
11
- "<%= config.bin %> <%= command.id %> --webflow --skip --limit 20",
12
- "<%= config.bin %> <%= command.id %> --webflow --cms --select",
13
- "<%= config.bin %> <%= command.id %> --webflow --cms --only products --only sku",
14
- "<%= config.bin %> <%= command.id %> --webflow --output-stats ./alt-text-run.md",
15
- "<%= config.bin %> <%= command.id %> --webflow --filter \"fileSize>=sizes.KB(100) && width>=100 && height>=100\"",
16
- "<%= config.bin %> <%= command.id %> --webflow --filter \"type !== 'svg' && !url.includes('/icons/')\""
10
+ "<%= config.bin %> <%= command.id %> --platform webflow --api-key <key> --site-id <id> --ollama-host http://localhost:11434 --ollama-model qwen3-vl:8b",
11
+ "<%= config.bin %> <%= command.id %> --platform shopify --site-id mystore",
12
+ "<%= config.bin %> <%= command.id %> --platform shopify --site-id mystore --dry",
13
+ "<%= config.bin %> <%= command.id %> --platform webflow --skip --limit 20",
14
+ "<%= config.bin %> <%= command.id %> --platform webflow --cms --select",
15
+ "<%= config.bin %> <%= command.id %> --platform webflow --cms --only products --only sku",
16
+ "<%= config.bin %> <%= command.id %> --platform webflow --output-stats ./alt-text-run.md",
17
+ "<%= config.bin %> <%= command.id %> --platform webflow --filter \"fileSize>=sizes.KB(100) && width>=100 && height>=100\"",
18
+ "<%= config.bin %> <%= command.id %> --platform webflow --filter \"type !== 'svg' && !url.includes('/icons/')\""
17
19
  ],
18
20
  "flags": {
19
21
  "api-key": {
20
- "dependsOn": [
21
- "webflow"
22
- ],
23
- "description": "Webflow API token (site-scoped). Prompted for if omitted.",
22
+ "description": "Webflow API token (site-scoped). Webflow only — Shopify auth comes from the Shopify CLI. Prompted for if omitted.",
24
23
  "name": "api-key",
25
24
  "hasDynamicHelp": false,
26
25
  "multiple": false,
27
26
  "type": "option"
28
27
  },
29
28
  "cms": {
30
- "dependsOn": [
31
- "webflow"
32
- ],
33
- "description": "also add alt text to the images in CMS collection items. Off by default — only the site asset library is walked.",
29
+ "description": "also add alt text to the images in CMS collection items. Webflow only. Off by default — only the site asset library is walked.",
34
30
  "name": "cms",
35
31
  "allowNo": false,
36
32
  "type": "boolean"
37
33
  },
34
+ "dry": {
35
+ "description": "report how many images the run would caption without describing or writing anything. Every image is still downloaded and run through --filter so the count is accurate, but nothing reaches the model. The --ollama-host/--ollama-model and --output-stats flags are ignored in this mode, so --dry can be added to (and removed from) a full command without changing the rest.",
36
+ "name": "dry",
37
+ "allowNo": false,
38
+ "type": "boolean"
39
+ },
38
40
  "filter": {
39
41
  "description": "a JavaScript expression deciding which images are worth describing, e.g. \"fileSize>=sizes.KB(100) && width>=100 && height>=100\". Available: fileSize (bytes), width, height (pixels), url, type (\"webp\", \"png\", \"jpeg\", \"svg\", …), and sizes.KB/MB/GB helpers. Images that fail it are skipped and reported separately. Evaluated after the download, since dimensions can't be known before it.",
40
42
  "name": "filter",
@@ -83,6 +85,17 @@
83
85
  "multiple": false,
84
86
  "type": "option"
85
87
  },
88
+ "platform": {
89
+ "description": "which platform to run against. Prompted for if omitted.",
90
+ "name": "platform",
91
+ "hasDynamicHelp": false,
92
+ "multiple": false,
93
+ "options": [
94
+ "webflow",
95
+ "shopify"
96
+ ],
97
+ "type": "option"
98
+ },
86
99
  "select": {
87
100
  "dependsOn": [
88
101
  "cms"
@@ -95,20 +108,8 @@
95
108
  "allowNo": false,
96
109
  "type": "boolean"
97
110
  },
98
- "shopify": {
99
- "description": "run against a Shopify store (not implemented yet)",
100
- "exclusive": [
101
- "webflow"
102
- ],
103
- "name": "shopify",
104
- "allowNo": false,
105
- "type": "boolean"
106
- },
107
111
  "site-id": {
108
- "dependsOn": [
109
- "webflow"
110
- ],
111
- "description": "Webflow site ID. Prompted for if omitted.",
112
+ "description": "Webflow site ID, or the Shopify store (mystore or mystore.myshopify.com). Prompted for if omitted.",
112
113
  "name": "site-id",
113
114
  "hasDynamicHelp": false,
114
115
  "multiple": false,
@@ -119,15 +120,6 @@
119
120
  "name": "skip",
120
121
  "allowNo": false,
121
122
  "type": "boolean"
122
- },
123
- "webflow": {
124
- "description": "run against a Webflow site",
125
- "exclusive": [
126
- "shopify"
127
- ],
128
- "name": "webflow",
129
- "allowNo": false,
130
- "type": "boolean"
131
123
  }
132
124
  },
133
125
  "hasDynamicHelp": false,
@@ -339,14 +331,17 @@
339
331
  "<%= config.bin %> <%= command.id %> --ssh user@host --ignore-vcs",
340
332
  "<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9222",
341
333
  "<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9333:9222",
342
- "<%= config.bin %> <%= command.id %> --ssh user@host --rpc port=7777:7700",
343
- "<%= config.bin %> <%= command.id %> --ssh user@host --rpc port=7777:7700,profile=false,shell=zsh",
334
+ "<%= config.bin %> <%= command.id %> --ssh user@host --with-tool shopify-file-upload --site-id mystore",
335
+ "<%= 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",
336
+ "<%= 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",
337
+ "<%= config.bin %> <%= command.id %> --ssh user@host --with-tool \"shopify-execute[scopes=read_products+read_orders]\" --site-id mystore",
338
+ "<%= config.bin %> <%= command.id %> --ssh user@host --with-tool \"shopify-execute[ask,scopes=all]\" --site-id mystore",
344
339
  "<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir",
345
- "<%= config.bin %> <%= command.id %> --ssh user@host --rpc 7700 --cleanup"
340
+ "<%= config.bin %> <%= command.id %> --ssh user@host --with-tool shopify-file-upload --site-id mystore --cleanup"
346
341
  ],
347
342
  "flags": {
348
343
  "cleanup": {
349
- "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.",
344
+ "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.",
350
345
  "name": "cleanup",
351
346
  "allowNo": false,
352
347
  "type": "boolean"
@@ -378,9 +373,9 @@
378
373
  "multiple": false,
379
374
  "type": "option"
380
375
  },
381
- "rpc": {
382
- "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 port=<port|remote:local>[,profile=<true|1|false|0>][,shell=<bash|batch|powershell|sh|zsh>] 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.",
383
- "name": "rpc",
376
+ "site-id": {
377
+ "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.",
378
+ "name": "site-id",
384
379
  "hasDynamicHelp": false,
385
380
  "multiple": false,
386
381
  "type": "option"
@@ -403,6 +398,13 @@
403
398
  "hasDynamicHelp": false,
404
399
  "multiple": false,
405
400
  "type": "option"
401
+ },
402
+ "with-tool": {
403
+ "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: shopify-execute[<scopes=all|<list>[,ask]>], shopify-file-delete[[<ask>]], shopify-file-replace[[<ask>]], shopify-file-search[[<ask>]], shopify-file-upload[[<ask>]]. 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.",
404
+ "name": "with-tool",
405
+ "hasDynamicHelp": false,
406
+ "multiple": true,
407
+ "type": "option"
406
408
  }
407
409
  },
408
410
  "hasDynamicHelp": false,
@@ -422,5 +424,5 @@
422
424
  ]
423
425
  }
424
426
  },
425
- "version": "2.6.0"
427
+ "version": "2.7.0"
426
428
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firenet-designs/fnd-cli",
3
3
  "description": "A new CLI generated with oclif",
4
- "version": "2.6.0",
4
+ "version": "2.7.0",
5
5
  "author": "Cole Denslow",
6
6
  "contributors": [
7
7
  "Justin Schellenberg"
@@ -15,6 +15,7 @@
15
15
  "@oclif/core": "^4",
16
16
  "@oclif/plugin-help": "^6",
17
17
  "@oclif/plugin-plugins": "^5",
18
+ "@oclif/plugin-warn-if-update-available": "^3.1.71",
18
19
  "@resvg/resvg-js": "^2.6.2",
19
20
  "chalk": "^5.6.2",
20
21
  "ollama": "^0.6.3",
@@ -53,6 +54,12 @@
53
54
  "./oclif.manifest.json"
54
55
  ],
55
56
  "homepage": "https://github.com/FireNet-Designs/fnd-cli",
57
+ "imports": {
58
+ "#*": {
59
+ "development": "./src/*",
60
+ "default": "./dist/*"
61
+ }
62
+ },
56
63
  "keywords": [
57
64
  "oclif"
58
65
  ],
@@ -67,14 +74,14 @@
67
74
  "bin": "fnd",
68
75
  "dirname": "fnd-cli",
69
76
  "commands": "./dist/commands",
70
- "hooks": {
71
- "init": [
72
- "./dist/hooks/init/check-for-updates"
73
- ]
74
- },
75
77
  "plugins": [
76
- "@oclif/plugin-help"
78
+ "@oclif/plugin-help",
79
+ "@oclif/plugin-warn-if-update-available"
77
80
  ],
81
+ "warn-if-update-available": {
82
+ "timeoutInDays": 1,
83
+ "message": "💡 Version <%= chalk.greenBright(latest) %> available! Run <%= chalk.greenBright('npm i -g ' + config.name) %> to update to the latest version."
84
+ },
78
85
  "topicSeparator": " ",
79
86
  "topics": {
80
87
  "hello": {
@@ -90,13 +97,13 @@
90
97
  "url": "git+https://github.com/FireNet-Designs/fnd-cli.git"
91
98
  },
92
99
  "scripts": {
93
- "build": "shx rm -rf dist && tsc -b",
100
+ "build": "shx rm -rf dist && tsc -b && shx cp -r src/lib/shopify/graphql dist/lib/shopify/graphql",
94
101
  "lint": "eslint",
95
102
  "pack": "npm run prepack && npm run postpack",
96
103
  "postpack": "shx rm -f oclif.manifest.json",
97
104
  "posttest": "npm run lint",
98
- "prepack": "oclif manifest && oclif readme",
99
- "version": "oclif readme",
105
+ "prepack": "oclif manifest && oclif readme && node scripts/strip-os-from-readme.mjs",
106
+ "version": "oclif readme && node scripts/strip-os-from-readme.mjs",
100
107
  "prerelease": "npm run build && npm run prepack",
101
108
  "release": "npm run build && npm run prepack && npm publish"
102
109
  },
@@ -1,3 +0,0 @@
1
- import { Hook } from '@oclif/core';
2
- declare const hook: Hook<'init'>;
3
- export default hook;
@@ -1,15 +0,0 @@
1
- import chalk from 'chalk';
2
- import { exec } from 'node:child_process';
3
- const hook = async function (opts) {
4
- const newestVersion = await new Promise((resolve, reject) => {
5
- exec(`npm view @firenet-designs/fnd-cli version`, (error, stdout) => {
6
- if (error)
7
- return reject(error);
8
- resolve(stdout.trim());
9
- });
10
- });
11
- if (newestVersion !== opts.config.pjson.version) {
12
- console.log(chalk.yellow('💡 Version', chalk.green(newestVersion), 'available! Run', chalk.green('`npm i -g @firenet-designs/fnd-cli`'), 'to update to the latest version!\n'));
13
- }
14
- };
15
- export default hook;
@@ -1,15 +0,0 @@
1
- import { z } from 'zod';
2
- type KvShape = Record<string, z.ZodType>;
3
- /** Usage string for the schema, required keys first: `a=<...>,b=<...>[,c=<...>]`. */
4
- export declare const kvUsage: (schema: z.ZodObject<KvShape>) => string;
5
- /** Example flag value built from each field's example meta (falls back to its hint), required keys first. */
6
- export declare const kvExample: (schema: z.ZodObject<KvShape>, opts?: {
7
- requiredOnly?: boolean;
8
- }) => string;
9
- /**
10
- * Parse a key=value flag string against the schema. Keys are case-insensitive
11
- * and order-independent; unknown keys, duplicate keys, and missing required
12
- * keys are hard errors naming the flag and its usage.
13
- */
14
- export declare const parseKvFlag: <T extends KvShape>(flag: string, raw: string, schema: z.ZodObject<T>) => z.output<z.ZodObject<T>>;
15
- export {};
@@ -1,75 +0,0 @@
1
- const fieldMeta = (field) => (field.meta() ?? {});
2
- const isOptional = (field) => field.def.type === 'default' || field.def.type === 'optional';
3
- /** Unwrap default/optional/preprocess wrappers down to the schema describing the value. */
4
- const unwrap = (field) => {
5
- const { def } = field;
6
- if (def.type === 'default' || def.type === 'optional') {
7
- return unwrap(def.innerType);
8
- }
9
- if (def.type === 'pipe') {
10
- return unwrap(def.out);
11
- }
12
- return field;
13
- };
14
- /** The <placeholder> for a field's value: explicit hint meta, enum options, or "value". */
15
- const fieldHint = (field) => {
16
- const { hint } = fieldMeta(field);
17
- if (hint)
18
- return hint;
19
- const inner = unwrap(field);
20
- if (inner.def.type === 'enum') {
21
- return Object.values(inner.def.entries).join('|');
22
- }
23
- return 'value';
24
- };
25
- /** Usage string for the schema, required keys first: `a=<...>,b=<...>[,c=<...>]`. */
26
- export const kvUsage = (schema) => {
27
- const fields = Object.entries(schema.shape);
28
- const required = fields.filter(([, f]) => !isOptional(f));
29
- const optional = fields.filter(([, f]) => isOptional(f));
30
- const pair = ([key, field]) => `${key}=<${fieldHint(field)}>`;
31
- return required.map((f) => pair(f)).join(',') + optional.map((f) => `[,${pair(f)}]`).join('');
32
- };
33
- /** Example flag value built from each field's example meta (falls back to its hint), required keys first. */
34
- export const kvExample = (schema, opts) => {
35
- const fields = Object.entries(schema.shape).filter(([, field]) => !(opts?.requiredOnly && isOptional(field)));
36
- return [...fields.filter(([, f]) => !isOptional(f)), ...fields.filter(([, f]) => isOptional(f))]
37
- .map(([key, field]) => `${key}=${fieldMeta(field).example ?? fieldHint(field)}`)
38
- .join(',');
39
- };
40
- /**
41
- * Parse a key=value flag string against the schema. Keys are case-insensitive
42
- * and order-independent; unknown keys, duplicate keys, and missing required
43
- * keys are hard errors naming the flag and its usage.
44
- */
45
- export const parseKvFlag = (flag, raw, schema) => {
46
- const usage = `${flag} takes comma-separated key=value pairs: ${kvUsage(schema)}`;
47
- const known = Object.keys(schema.shape);
48
- const pairs = new Map();
49
- for (const part of raw.trim().split(',')) {
50
- const eq = part.indexOf('=');
51
- if (eq === -1) {
52
- throw new Error(`${usage} (got "${raw}")`);
53
- }
54
- const key = part.slice(0, eq).trim().toLowerCase();
55
- if (!known.includes(key)) {
56
- throw new Error(`${flag} has no "${key}" option. ${usage}`);
57
- }
58
- if (pairs.has(key)) {
59
- throw new Error(`${flag} "${key}" was given more than once (got "${raw}")`);
60
- }
61
- pairs.set(key, part.slice(eq + 1).trim());
62
- }
63
- for (const key of known) {
64
- if (!isOptional(schema.shape[key]) && !pairs.has(key)) {
65
- throw new Error(`${flag} needs "${key}". ${usage}`);
66
- }
67
- }
68
- const result = schema.safeParse(Object.fromEntries(pairs));
69
- if (!result.success) {
70
- const issue = result.error.issues[0];
71
- const at = issue.path.join('.');
72
- throw new Error(`${flag}${at ? ` ${at}` : ''}: ${issue.message}. ${usage}`);
73
- }
74
- return result.data;
75
- };
package/dist/lib/rpc.d.ts DELETED
@@ -1,69 +0,0 @@
1
- import { z } from 'zod';
2
- import { PortPair } from './workspace.js';
3
- /**
4
- * The --rpc local-command server.
5
- *
6
- * Topology: this machine (the one that ran `fnd workspace`) runs a tiny MCP
7
- * server — Streamable HTTP transport, implemented on node:http with no
8
- * dependencies — bound to 127.0.0.1:<local>. The workspace's `ssh -R` reverse
9
- * tunnel exposes it on the REMOTE at 127.0.0.1:<remote>, where the `claude` CLI
10
- * registers it as an HTTP MCP server. When the AI on the remote calls the
11
- * `run_local_command` tool, the command executes HERE, on the calling machine,
12
- * under the shell chosen in the flag.
13
- *
14
- * The server binds loopback only; the sole way in from outside is the reverse
15
- * tunnel, which lives exactly as long as the ssh session.
16
- */
17
- declare const ShellSchema: z.ZodEnum<{
18
- zsh: "zsh";
19
- bash: "bash";
20
- batch: "batch";
21
- powershell: "powershell";
22
- sh: "sh";
23
- }>;
24
- export declare const RPC_SHELLS: ("zsh" | "bash" | "batch" | "powershell" | "sh")[];
25
- export type RpcShell = z.infer<typeof ShellSchema>;
26
- /** Usage and example strings for the --rpc flag, derived from the schema. */
27
- export declare const RPC_FLAG_USAGE: string;
28
- export declare const RPC_FLAG_EXAMPLES: {
29
- full: string;
30
- required: string;
31
- };
32
- export interface RpcConfig {
33
- /** local = port the server binds on this machine; remote = port opened on the workspace host via `ssh -R`. */
34
- ports: PortPair;
35
- /** Whether the shell loads its startup files (rc/profile). POSIX shells run with -i, powershell without -NoProfile. */
36
- profile: boolean;
37
- /** Shell used to execute commands on this machine. */
38
- shell: RpcShell;
39
- }
40
- /** Handle for a running RPC server. */
41
- export interface RpcServer {
42
- close: () => Promise<void>;
43
- }
44
- export declare const RPC_TOOL_NAME = "run_local_command";
45
- /**
46
- * The shell `fnd workspace` was called from: the parent process when it is a
47
- * supported shell, else $SHELL, else undefined (e.g. invoked from a script).
48
- */
49
- export declare const detectCallingShell: () => RpcShell | undefined;
50
- /**
51
- * Parse the --rpc value: comma-separated key=value pairs per RpcFlagSchema.
52
- * port=<port> | port=<remote>:<local> — required; `local` is this machine
53
- * (where commands run), `remote` the port opened on the workspace host.
54
- * The single-port form uses the same port on both ends.
55
- * shell=<bash|batch|powershell|sh|zsh> — optional, defaults to the shell
56
- * `fnd workspace` was called from.
57
- * profile=<true|1|false|0> — optional, default true: the shell loads its
58
- * startup files (rc/profile), so tools like nvm are available.
59
- */
60
- export declare const parseRpcFlag: (raw: string) => RpcConfig;
61
- /** True if the chosen shell is runnable on this machine. */
62
- export declare const hasLocalShell: (shell: RpcShell, profile: boolean) => boolean;
63
- /**
64
- * Start the local RPC (MCP) server on 127.0.0.1:<ports.local>. Commands execute
65
- * with `cwd` as their working directory — the local side of the workspace sync.
66
- * Resolves once the port is bound; rejects if binding fails (e.g. port in use).
67
- */
68
- export declare const startRpcServer: (config: RpcConfig, cwd: string) => Promise<RpcServer>;
69
- export {};