@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.
Files changed (60) hide show
  1. package/README.md +194 -57
  2. package/bin/dev.js +1 -1
  3. package/dist/commands/alt-text.d.ts +105 -0
  4. package/dist/commands/alt-text.js +616 -0
  5. package/dist/commands/backfill-project.js +1 -1
  6. package/dist/commands/create-project.js +48 -5
  7. package/dist/commands/workspace/index.d.ts +19 -2
  8. package/dist/commands/workspace/index.js +171 -56
  9. package/dist/lib/alt-text.d.ts +87 -0
  10. package/dist/lib/alt-text.js +196 -0
  11. package/dist/lib/image-filter.d.ts +43 -0
  12. package/dist/lib/image-filter.js +71 -0
  13. package/dist/lib/mcp/bracket-args.d.ts +37 -0
  14. package/dist/lib/mcp/bracket-args.js +65 -0
  15. package/dist/lib/mcp/define-tool.d.ts +52 -0
  16. package/dist/lib/mcp/define-tool.js +2 -0
  17. package/dist/lib/mcp/registry.d.ts +38 -0
  18. package/dist/lib/mcp/registry.js +98 -0
  19. package/dist/lib/mcp/server.d.ts +66 -0
  20. package/dist/lib/mcp/server.js +176 -0
  21. package/dist/lib/mcp/tools/shopify-common.d.ts +139 -0
  22. package/dist/lib/mcp/tools/shopify-common.js +167 -0
  23. package/dist/lib/mcp/tools/shopify-execute.d.ts +2 -0
  24. package/dist/lib/mcp/tools/shopify-execute.js +105 -0
  25. package/dist/lib/mcp/tools/shopify-file-delete.d.ts +2 -0
  26. package/dist/lib/mcp/tools/shopify-file-delete.js +49 -0
  27. package/dist/lib/mcp/tools/shopify-file-replace.d.ts +2 -0
  28. package/dist/lib/mcp/tools/shopify-file-replace.js +79 -0
  29. package/dist/lib/mcp/tools/shopify-file-search.d.ts +2 -0
  30. package/dist/lib/mcp/tools/shopify-file-search.js +199 -0
  31. package/dist/lib/mcp/tools/shopify-file-upload.d.ts +2 -0
  32. package/dist/lib/mcp/tools/shopify-file-upload.js +76 -0
  33. package/dist/lib/shopify/graphql/AccessScopes.graphql +7 -0
  34. package/dist/lib/shopify/graphql/CurrentBulkOperation.graphql +8 -0
  35. package/dist/lib/shopify/graphql/FileCreate.graphql +25 -0
  36. package/dist/lib/shopify/graphql/FileDelete.graphql +11 -0
  37. package/dist/lib/shopify/graphql/FileReplace.graphql +26 -0
  38. package/dist/lib/shopify/graphql/FileStatus.graphql +19 -0
  39. package/dist/lib/shopify/graphql/FilesBulkQuery.graphql +27 -0
  40. package/dist/lib/shopify/graphql/ProductsBulkQuery.graphql +27 -0
  41. package/dist/lib/shopify/graphql/SearchFiles.graphql +36 -0
  42. package/dist/lib/shopify/graphql/StagedUploadsCreate.graphql +20 -0
  43. package/dist/lib/shopify/graphql/StartBulkQuery.graphql +16 -0
  44. package/dist/lib/shopify/graphql/UpdateFileAlt.graphql +9 -0
  45. package/dist/lib/shopify/shopify.d.ts +228 -0
  46. package/dist/lib/shopify/shopify.js +662 -0
  47. package/dist/lib/webflow.d.ts +80 -0
  48. package/dist/lib/webflow.js +122 -0
  49. package/dist/lib/workspace.d.ts +29 -10
  50. package/dist/lib/workspace.js +74 -39
  51. package/oclif.manifest.json +162 -78
  52. package/package.json +21 -10
  53. package/dist/commands/workspace/cleanup.d.ts +0 -14
  54. package/dist/commands/workspace/cleanup.js +0 -84
  55. package/dist/hooks/init/check-for-updates.d.ts +0 -3
  56. package/dist/hooks/init/check-for-updates.js +0 -15
  57. package/dist/lib/kv-flag.d.ts +0 -15
  58. package/dist/lib/kv-flag.js +0 -75
  59. package/dist/lib/rpc.d.ts +0 -69
  60. package/dist/lib/rpc.js +0 -313
@@ -1,15 +1,15 @@
1
+ import { findShopifyBin, generateClaudeMd, GITIGNORE_CONTENT, initPromptPath, run, SHOPIFYIGNORE_CONTENT, which, } from '#lib/scaffold.js';
1
2
  import { Args, Command } from '@oclif/core';
2
3
  import chalk from 'chalk';
3
4
  import { spawnSync } from 'node:child_process';
4
5
  import { existsSync, writeFileSync } from 'node:fs';
5
6
  import { basename, dirname, join } from 'node:path';
6
7
  import { simpleGit } from 'simple-git';
7
- import { findShopifyBin, generateClaudeMd, GITIGNORE_CONTENT, initPromptPath, run, SHOPIFYIGNORE_CONTENT, which, } from '../lib/scaffold.js';
8
8
  export default class CreateProject extends Command {
9
9
  static args = {
10
10
  shop: Args.string({ description: 'Shopify store handle — pulls the live theme from <shop>.myshopify.com (omit to skip)', required: false }),
11
11
  };
12
- static description = 'Scaffold a new client project: git on branch production, ignore files, Shopify theme pull, a Claude-generated CLAUDE.md, then a private GitHub repo under the FireNet-Designs org.\n\nRequires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.\n\nGitHub auth comes from YOUR environment — run `gh auth login` once, or export GH_TOKEN in your shell profile. This CLI never stores credentials. Override the org with FND_GH_ORG.';
12
+ static description = 'Scaffold a new client project: git on branch production, ignore files, Shopify theme pull, a Claude-generated CLAUDE.md, then a private GitHub repo under the FireNet-Designs org with production and staging branches pushed.\n\nRequires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.\n\nGitHub auth comes from YOUR environment — run `gh auth login` once, or export GH_TOKEN in your shell profile. This CLI never stores credentials. Override the org with FND_GH_ORG.';
13
13
  static examples = [
14
14
  '<%= config.bin %> <%= command.id %>',
15
15
  '<%= config.bin %> <%= command.id %> my-store',
@@ -106,17 +106,60 @@ export default class CreateProject extends Command {
106
106
  if (createCode !== 0) {
107
107
  this.error('gh repo create failed — see output above', { code: '1' });
108
108
  }
109
+ // gh pointed 'origin' at the URL GitHub returned, which carries the name
110
+ // the repo was ACTUALLY created under — GitHub normalizes names
111
+ // server-side ('my client' → 'my-client'). Recover it before forcing SSH
112
+ // so the remote, the default-branch edit, and the printed link all track
113
+ // the real repo, not the raw directory name.
114
+ const originUrl = (await git.raw(['remote', 'get-url', 'origin']).catch(() => '')).trim();
115
+ const canonicalRepo = /github\.com[/:](.+?)(?:\.git)?$/.exec(originUrl)?.[1] ?? repo;
109
116
  // Force SSH so the push authenticates with the user's key, not the
110
117
  // create-only token.
111
- await git.raw(['remote', 'set-url', 'origin', `git@github.com:${repo}.git`]);
118
+ await git.raw(['remote', 'set-url', 'origin', `git@github.com:${canonicalRepo}.git`]);
112
119
  this.log(chalk.blue("⬆️ Pushing 'production' to origin over SSH…"));
113
120
  const pushCode = await run('git', ['push', '-u', 'origin', 'production']);
114
121
  if (pushCode === 0) {
115
- spawnSync('gh', ['repo', 'edit', repo, '--default-branch', 'production'], { stdio: 'ignore' });
116
- this.log(chalk.green(`✅ Created + pushed. origin = git@github.com:${repo}.git`));
122
+ spawnSync('gh', ['repo', 'edit', canonicalRepo, '--default-branch', 'production'], { stdio: 'ignore' });
123
+ this.log(chalk.green(`✅ Created + pushed. origin = git@github.com:${canonicalRepo}.git`));
124
+ // 6. Staging branch cut explicitly from 'production' — HEAD isn't a safe
125
+ // start point because a pre-existing repo (init step skipped) may be
126
+ // checked out elsewhere. No checkout, so the working tree stays put.
127
+ // A pre-existing 'staging' is pushed as-is (force-resetting it could
128
+ // destroy work), with divergence from production called out instead of
129
+ // silently published. try/catch so a git failure here can't swallow the
130
+ // URL line below — the repo exists either way.
131
+ try {
132
+ const { all: localBranches } = await git.branchLocal();
133
+ const stagingExisted = localBranches.includes('staging');
134
+ if (!stagingExisted) {
135
+ await git.raw(['branch', 'staging', 'production']);
136
+ }
137
+ this.log(chalk.blue("⬆️ Pushing 'staging' to origin over SSH…"));
138
+ const stagingPushCode = await run('git', ['push', '-u', 'origin', 'staging']);
139
+ if (stagingPushCode !== 0) {
140
+ this.warn("push of 'staging' failed — create it manually: git push -u origin staging");
141
+ }
142
+ else if (stagingExisted) {
143
+ this.log(chalk.green("✅ pre-existing 'staging' branch pushed"));
144
+ const [stagingSha, productionSha] = (await git.raw(['rev-parse', 'staging', 'production'])).trim().split('\n');
145
+ if (stagingSha !== productionSha) {
146
+ this.warn("'staging' does not match 'production' — it was pushed at its current commit");
147
+ }
148
+ }
149
+ else {
150
+ this.log(chalk.green("✅ 'staging' branch created + pushed"));
151
+ }
152
+ }
153
+ catch {
154
+ this.warn("could not create 'staging' — create it manually: git branch staging production && git push -u origin staging");
155
+ }
117
156
  }
118
157
  else {
119
158
  this.warn(`push failed — check your SSH access to the ${org} org`);
120
159
  }
160
+ // https form, not the SSH remote, and printed even when a push failed (the
161
+ // repo itself exists by now) — this line is for copy-pasting into Jira
162
+ // tickets and browsers.
163
+ this.log(`🔗 GitHub: ${chalk.green(`https://github.com/${canonicalRepo}`)}`);
121
164
  }
122
165
  }
@@ -3,18 +3,35 @@ export default class Workspace extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
+ cleanup: import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
7
  'delete-remote-dir': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
8
  devtools: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
9
  'ignore-vcs': import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  'remote-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
- rpc: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'site-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
12
  source: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
13
  ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
+ 'with-tool': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
15
  };
14
16
  run(): Promise<void>;
15
- /** Verify this machine can drive the sync before we connect. */
17
+ /** Verify this machine can drive the sync — and each tool's prerequisites — before we connect. */
16
18
  private preflight;
17
19
  private printPlan;
20
+ /**
21
+ * Tear down leftovers from a session that dropped before cleaning up (--cleanup).
22
+ * Terminates this directory's Mutagen sync sessions locally, then reaches the
23
+ * remote to strip the MCP entries the matching flags imply and, with
24
+ * --delete-remote-dir, remove the synced dir. devtools/rpc are read for
25
+ * presence only — their port values are irrelevant to removal.
26
+ */
27
+ private runCleanup;
18
28
  /** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
19
29
  private runSsh;
30
+ /**
31
+ * Best-effort: reach back to the remote after the session to strip any MCP
32
+ * config this run registered and, with --delete-remote-dir, remove the synced
33
+ * dir. No-op when the session left nothing behind to clean. A failure here is
34
+ * logged, not thrown — the local sync is already torn down by this point.
35
+ */
36
+ private teardownRemote;
20
37
  }
@@ -1,8 +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
- import { hasLocalShell, parseRpcFlag, RPC_FLAG_EXAMPLES, RPC_FLAG_USAGE, startRpcServer } from '../../lib/rpc.js';
5
- import { browserDebugInstructions, buildContext, buildMutagenCreateArgs, buildMutagenFlushArgs, buildMutagenTerminateArgs, buildRemoteScript, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, isLocalDebugPortLive, mutagenInstallInstructions, parsePortPair, parseSshTarget, runMutagen, runRemoteCleanup, } from '../../lib/workspace.js';
8
+ import { basename } from 'node:path';
6
9
  export default class Workspace extends Command {
7
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.';
8
11
  static examples = [
@@ -12,11 +15,19 @@ export default class Workspace extends Command {
12
15
  '<%= config.bin %> <%= command.id %> --ssh user@host --ignore-vcs',
13
16
  '<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9222',
14
17
  '<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9333:9222',
15
- `<%= config.bin %> <%= command.id %> --ssh user@host --rpc ${RPC_FLAG_EXAMPLES.required}`,
16
- `<%= 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',
17
23
  '<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir',
24
+ '<%= config.bin %> <%= command.id %> --ssh user@host --with-tool shopify-file-upload --site-id mystore --cleanup',
18
25
  ];
19
26
  static flags = {
27
+ cleanup: Flags.boolean({
28
+ default: false,
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.",
30
+ }),
20
31
  'delete-remote-dir': Flags.boolean({
21
32
  default: false,
22
33
  description: 'on exit, delete the remote workspace directory instead of leaving the synced copy in place',
@@ -32,8 +43,8 @@ export default class Workspace extends Command {
32
43
  default: DEFAULT_MOUNT_BASE,
33
44
  description: 'base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>',
34
45
  }),
35
- rpc: Flags.string({
36
- 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.',
37
48
  }),
38
49
  source: Flags.string({
39
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.',
@@ -43,25 +54,63 @@ export default class Workspace extends Command {
43
54
  description: 'remote to connect to, as user@host',
44
55
  required: true,
45
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
+ }),
46
61
  };
47
62
  async run() {
48
63
  const { flags } = await this.parse(Workspace);
49
64
  const target = parseSshTarget(flags.ssh);
50
65
  const target2 = `${target.user}@${target.host}`;
66
+ // --cleanup short-circuits everything: no connection, no sync, just tear down
67
+ // leftovers from a session that dropped before it could clean up. The other
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.
70
+ if (flags.cleanup) {
71
+ await this.runCleanup(target2, flags);
72
+ return;
73
+ }
51
74
  const devtools = flags.devtools === undefined ? undefined : parsePortPair(flags.devtools, '--devtools');
52
- const rpc = flags.rpc === undefined ? undefined : parseRpcFlag(flags.rpc);
53
- if (devtools && rpc && devtools.remote === rpc.ports.remote) {
54
- 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 } };
55
105
  }
56
106
  const ctx = buildContext({
57
107
  cwd: process.cwd(),
58
108
  devtools,
59
109
  ignoreVcs: flags['ignore-vcs'],
60
110
  remoteBase: flags['remote-base'],
61
- rpc,
62
111
  source: flags.source,
112
+ tools,
63
113
  });
64
- await this.preflight(devtools, rpc);
65
114
  this.printPlan(ctx, target2);
66
115
  // Start the two-way sync. Mutagen auto-deploys its agent to the remote over SSH.
67
116
  this.log(chalk.dim('Starting the Mutagen sync session…'));
@@ -70,56 +119,31 @@ export default class Workspace extends Command {
70
119
  this.error(`mutagen sync create failed (exit ${createCode}). Check that the remote is reachable over SSH and try again.`, { code: '1' });
71
120
  }
72
121
  const deleteRemoteDir = flags['delete-remote-dir'];
73
- let rpcServer;
74
122
  let code;
75
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
+ }
76
127
  // Block until the first full sync lands so the files exist before the shell opens.
77
128
  this.log(chalk.dim('Performing the initial sync…'));
78
129
  const flushCode = await runMutagen(buildMutagenFlushArgs(ctx.syncName));
79
130
  if (flushCode !== 0) {
80
131
  this.error(`Initial mutagen sync flush failed (exit ${flushCode}).`, { code: '1' });
81
132
  }
82
- // Start the local command server the --rpc tunnel points back to.
83
- if (ctx.rpc) {
84
- try {
85
- rpcServer = await startRpcServer(ctx.rpc, ctx.localCwd);
86
- this.log(chalk.dim(`Local RPC command server (${ctx.rpc.shell}) listening on 127.0.0.1:${ctx.rpc.ports.local}.`));
87
- }
88
- catch (error) {
89
- 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' });
90
- }
91
- }
92
133
  const script = buildRemoteScript(ctx);
93
134
  code = await this.runSsh(target2, script, ctx);
94
135
  }
95
136
  finally {
96
- // Best-effort: stop serving local commands, flush the last edits back, then
97
- // tear the session down.
98
- 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(() => { });
99
140
  this.log('');
100
141
  this.log(chalk.dim('Flushing final changes and stopping the sync…'));
101
142
  await runMutagen(buildMutagenFlushArgs(ctx.syncName)).catch(() => 1);
102
143
  await runMutagen(buildMutagenTerminateArgs(ctx.syncName)).catch(() => 1);
103
144
  // Reach back to the remote if we left MCP config there to remove, or if we
104
145
  // were asked to delete the synced directory.
105
- if (ctx.devtools || ctx.rpc || deleteRemoteDir) {
106
- const actions = [
107
- ctx.devtools ? 'removing the remote chrome-devtools MCP config' : undefined,
108
- ctx.rpc ? 'removing the remote local-shell MCP config' : undefined,
109
- deleteRemoteDir ? 'deleting the remote dir' : undefined,
110
- ].filter(Boolean);
111
- this.log(chalk.dim(`${actions.join(' and ')}…`.replace(/^./, (c) => c.toUpperCase())));
112
- try {
113
- await runRemoteCleanup(target2, ctx.remoteDir, {
114
- deleteRemoteDir,
115
- removeDevtoolsMcp: Boolean(ctx.devtools),
116
- removeRpcMcp: Boolean(ctx.rpc),
117
- });
118
- }
119
- catch (error) {
120
- this.log(chalk.yellow(`Could not reach the remote to clean up (${error.message}).`));
121
- }
122
- }
146
+ await this.teardownRemote(ctx, target2, deleteRemoteDir);
123
147
  }
124
148
  this.log('');
125
149
  this.log(code === 0
@@ -128,8 +152,8 @@ export default class Workspace extends Command {
128
152
  : '✓ Workspace closed. Sync stopped; the remote copy is left in place.')
129
153
  : chalk.yellow(`Session ended with exit code ${code}. Cleanup attempted above.`));
130
154
  }
131
- /** Verify this machine can drive the sync before we connect. */
132
- 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) {
133
157
  if (!hasSshClient()) {
134
158
  this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
135
159
  }
@@ -152,12 +176,32 @@ export default class Workspace extends Command {
152
176
  this.error('Local browser remote-debugging port is required for --devtools. Aborting.', { code: '1' });
153
177
  }
154
178
  }
155
- // With --rpc, confirm the chosen shell is runnable on THIS machine — it's the
156
- // shell the remote AI's commands will execute under, right here.
157
- if (rpc && !hasLocalShell(rpc.shell, rpc.profile)) {
158
- this.error(`--rpc shell "${rpc.shell}" is not runnable on this machine. Aborting before connecting.`, {
159
- code: '1',
160
- });
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' });
161
205
  }
162
206
  }
163
207
  printPlan(ctx, target) {
@@ -175,17 +219,62 @@ export default class Workspace extends Command {
175
219
  if (ctx.devtools) {
176
220
  this.log(` ${chalk.dim('devtools:')} remote 127.0.0.1:${ctx.devtools.remote} → local browser 127.0.0.1:${ctx.devtools.local}`);
177
221
  }
178
- if (ctx.rpc) {
179
- 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(', ')})`);
180
224
  }
181
225
  this.log('');
182
226
  }
227
+ /**
228
+ * Tear down leftovers from a session that dropped before cleaning up (--cleanup).
229
+ * Terminates this directory's Mutagen sync sessions locally, then reaches the
230
+ * remote to strip the MCP entries the matching flags imply and, with
231
+ * --delete-remote-dir, remove the synced dir. devtools/rpc are read for
232
+ * presence only — their port values are irrelevant to removal.
233
+ */
234
+ async runCleanup(target, flags) {
235
+ if (!hasSshClient()) {
236
+ this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
237
+ }
238
+ const removeDevtoolsMcp = flags.devtools !== undefined;
239
+ const removeToolsMcp = (flags['with-tool']?.length ?? 0) > 0;
240
+ const deleteRemoteDir = flags['delete-remote-dir'];
241
+ const { remoteDir } = buildContext({ cwd: process.cwd(), remoteBase: flags['remote-base'] });
242
+ const slug = slugify(basename(remoteDir));
243
+ this.log(chalk.bold('Cleaning up workspace'));
244
+ this.log(` ${chalk.dim('remote:')} ${target}`);
245
+ this.log(` ${chalk.dim('remote dir:')} ${remoteDir}`);
246
+ this.log('');
247
+ // Terminate any lingering sync sessions for this directory (a local Mutagen op).
248
+ if (hasMutagen()) {
249
+ this.log(chalk.dim('Terminating any leftover Mutagen sync sessions…'));
250
+ await runMutagen(buildMutagenTerminateSelectorArgs(slug)).catch(() => 1);
251
+ }
252
+ else {
253
+ this.log(chalk.yellow('Mutagen CLI not found on PATH — skipping sync termination.'));
254
+ }
255
+ if (removeDevtoolsMcp) {
256
+ this.log(chalk.dim('Removing any leftover chrome-devtools MCP config on the remote…'));
257
+ }
258
+ if (removeToolsMcp) {
259
+ this.log(chalk.dim('Removing any leftover fnd-tools MCP config on the remote…'));
260
+ }
261
+ if (deleteRemoteDir) {
262
+ this.log(chalk.dim('Deleting the remote workspace directory…'));
263
+ }
264
+ const code = await runRemoteCleanup(target, remoteDir, { deleteRemoteDir, removeDevtoolsMcp, removeToolsMcp });
265
+ if (code === 0) {
266
+ this.log(chalk.green('✓ Done.'));
267
+ }
268
+ else {
269
+ this.error(`Remote cleanup ssh session exited with code ${code}.`, { code: '1' });
270
+ }
271
+ }
183
272
  /** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
184
273
  runSsh(target, script, ctx) {
185
274
  // Reverse tunnels: remote 127.0.0.1:<remote> → this machine's 127.0.0.1:<local>.
186
- // --devtools points one at the local browser's debug port; --rpc points one at
187
- // the local command server, so the remote's MCPs can reach them.
188
- 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);
189
278
  const args = [
190
279
  '-t', // allocate a remote PTY for the interactive shell session
191
280
  ...(forwards.length > 0 ? ['-o', 'ExitOnForwardFailure=yes'] : []),
@@ -199,4 +288,30 @@ export default class Workspace extends Command {
199
288
  child.once('close', (code) => resolve(code ?? 0));
200
289
  });
201
290
  }
291
+ /**
292
+ * Best-effort: reach back to the remote after the session to strip any MCP
293
+ * config this run registered and, with --delete-remote-dir, remove the synced
294
+ * dir. No-op when the session left nothing behind to clean. A failure here is
295
+ * logged, not thrown — the local sync is already torn down by this point.
296
+ */
297
+ async teardownRemote(ctx, target, deleteRemoteDir) {
298
+ if (!ctx.devtools && !ctx.tools && !deleteRemoteDir)
299
+ return;
300
+ const actions = [
301
+ ctx.devtools ? 'removing the remote chrome-devtools MCP config' : undefined,
302
+ ctx.tools ? 'removing the remote fnd-tools MCP config' : undefined,
303
+ deleteRemoteDir ? 'deleting the remote dir' : undefined,
304
+ ].filter(Boolean);
305
+ this.log(chalk.dim(`${actions.join(' and ')}…`.replace(/^./, (c) => c.toUpperCase())));
306
+ try {
307
+ await runRemoteCleanup(target, ctx.remoteDir, {
308
+ deleteRemoteDir,
309
+ removeDevtoolsMcp: Boolean(ctx.devtools),
310
+ removeToolsMcp: Boolean(ctx.tools),
311
+ });
312
+ }
313
+ catch (error) {
314
+ this.log(chalk.yellow(`Could not reach the remote to clean up (${error.message}).`));
315
+ }
316
+ }
202
317
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Alt-text generation: fetch an image, make it something a vision model can
3
+ * read, and ask a locally-hosted Ollama model to describe it.
4
+ *
5
+ * Everything runs against the user's own Ollama host, so no image ever leaves
6
+ * their network and there is no per-image API cost — which is also why the
7
+ * caller drives this strictly sequentially: a single local model gains nothing
8
+ * from concurrent requests.
9
+ */
10
+ import type { ImageFilter, ImageMeta } from './image-filter.js';
11
+ export declare const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
12
+ /**
13
+ * One image's worth of result. The caller already knows the URL, so this is
14
+ * everything else worth recording about the round trip.
15
+ *
16
+ * `bytes` is the size of what was actually sent to the model, i.e. after the
17
+ * PNG conversion below — not `meta.fileSize`, which is the (usually much
18
+ * smaller) WebP the site serves. `ms` is wall clock for the whole thing
19
+ * (download, convert, inference), because that is what a run's duration is
20
+ * actually made of.
21
+ */
22
+ export interface Description {
23
+ alt: string;
24
+ bytes: number;
25
+ meta: ImageMeta;
26
+ ms: number;
27
+ skipped: false;
28
+ tokens: number;
29
+ }
30
+ /** An image the filter rejected. It was downloaded, but never sent to the model. */
31
+ export interface Skipped {
32
+ meta: ImageMeta;
33
+ skipped: true;
34
+ }
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;
59
+ /**
60
+ * Every model pulled on the host that can actually read an image.
61
+ *
62
+ * /api/tags (`list`) is the only endpoint that enumerates models, but it says
63
+ * nothing about what a model can do — the capability list lives on /api/show
64
+ * (`show`), so each tag is asked individually. The calls are local and run
65
+ * concurrently; a model whose show() fails (pulled but broken, or removed
66
+ * between the two calls) is simply left out rather than failing the run.
67
+ */
68
+ export declare const listVisionModels: (host: string) => Promise<string[]>;
69
+ /**
70
+ * Bind a describer to one Ollama host and model. The client is created once and
71
+ * reused so the connection (and the loaded model, via keep_alive) survives
72
+ * across images.
73
+ *
74
+ * `filter`, when given, decides whether an image is worth describing — it runs
75
+ * after the download (see fetchImage) but before inference, and a rejected
76
+ * image comes back as `{skipped: true}` rather than throwing, because being
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.
86
+ */
87
+ export declare const createDescriber: (host: string, model: string, filter?: ImageFilter, dry?: boolean) => Describe;