@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
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Minimal Webflow Data API v2 client — only what `fnd alt-text` needs.
3
+ *
4
+ * Two kinds of images live in a Webflow site and they are updated through
5
+ * completely different endpoints:
6
+ *
7
+ * site asset library /v2/sites/:site/assets -> PATCH /v2/assets/:id {altText}
8
+ * CMS image fields /v2/collections/:id/items -> PATCH /v2/collections/:id/items {items[].fieldData}
9
+ *
10
+ * Read endpoints are async generators that paginate internally, so callers just
11
+ * `for await` and never deal with offsets.
12
+ *
13
+ * SECURITY: the API key is a site-wide bearer token. It is only ever put in an
14
+ * Authorization header — never logged, never included in an error message (we
15
+ * report the URL pathname, not the full URL, in case a token ever ends up in a
16
+ * query string).
17
+ */
18
+ export interface WebflowAuth {
19
+ apiKey: string;
20
+ siteId: string;
21
+ }
22
+ export interface Asset {
23
+ altText: null | string;
24
+ contentType: string;
25
+ displayName: string;
26
+ hostedUrl: string;
27
+ id: string;
28
+ originalFileName: string;
29
+ siteId: string;
30
+ }
31
+ export interface Collection {
32
+ displayName: string;
33
+ id: string;
34
+ singularName: string;
35
+ slug: string;
36
+ }
37
+ /** A single CMS image value. `alt` is null until someone (or this command) fills it in. */
38
+ export interface ImageField {
39
+ alt: null | string;
40
+ fileId: string;
41
+ url: string;
42
+ }
43
+ export interface CollectionItem {
44
+ fieldData: Record<string, ImageField | ImageField[] | unknown>;
45
+ id: string;
46
+ isArchived: boolean;
47
+ isDraft: boolean;
48
+ }
49
+ /**
50
+ * Every asset in the site's asset library, oldest page first.
51
+ *
52
+ * @yields each asset, one page of 100 at a time.
53
+ */
54
+ export declare function getAssets(auth: WebflowAuth, limit?: number): AsyncGenerator<Asset>;
55
+ /** Every CMS collection on the site. Not paginated by Webflow. */
56
+ export declare const getCollections: (auth: WebflowAuth) => Promise<Collection[]>;
57
+ /**
58
+ * Every item in a collection.
59
+ *
60
+ * Reads from STAGING by default (the `/live` endpoint is opt-in) to match
61
+ * `updateCollectionItem`, which also writes to staging — so a run's changes
62
+ * need publishing in Webflow before they show on the live site.
63
+ *
64
+ * @yields each item in the collection.
65
+ */
66
+ export declare function getCollectionItems(auth: WebflowAuth, collectionId: string, { limit, staging }?: {
67
+ limit?: number;
68
+ staging?: boolean;
69
+ }): AsyncGenerator<CollectionItem>;
70
+ /** Set the alt text on a site asset. */
71
+ export declare const updateAssetAltText: (auth: WebflowAuth, assetId: string, altText: string) => Promise<void>;
72
+ /** Patch one item's fieldData. Only the fields present in `fieldData` are touched. */
73
+ export declare const updateCollectionItem: (auth: WebflowAuth, collectionId: string, itemId: string, fieldData: Record<string, unknown>) => Promise<void>;
74
+ /**
75
+ * Webflow hands back untyped `fieldData`, so image fields are duck-typed: a
76
+ * single image is an object with a `url`, a multi-image field is an array of
77
+ * those (an empty array counts — it is still an image field, just empty).
78
+ */
79
+ export declare const isImageField: (value: unknown) => value is ImageField;
80
+ export declare const isImagesField: (value: unknown) => value is ImageField[];
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Minimal Webflow Data API v2 client — only what `fnd alt-text` needs.
3
+ *
4
+ * Two kinds of images live in a Webflow site and they are updated through
5
+ * completely different endpoints:
6
+ *
7
+ * site asset library /v2/sites/:site/assets -> PATCH /v2/assets/:id {altText}
8
+ * CMS image fields /v2/collections/:id/items -> PATCH /v2/collections/:id/items {items[].fieldData}
9
+ *
10
+ * Read endpoints are async generators that paginate internally, so callers just
11
+ * `for await` and never deal with offsets.
12
+ *
13
+ * SECURITY: the API key is a site-wide bearer token. It is only ever put in an
14
+ * Authorization header — never logged, never included in an error message (we
15
+ * report the URL pathname, not the full URL, in case a token ever ends up in a
16
+ * query string).
17
+ */
18
+ const API = 'https://api.webflow.com/v2';
19
+ /** How many times a 429 is retried before the request is allowed to fail. */
20
+ const RATE_LIMIT_RETRIES = 3;
21
+ /** Fallback wait when Webflow rate-limits us without a Retry-After header. */
22
+ const RATE_LIMIT_FALLBACK_MS = 15_000;
23
+ const sleep = (ms) => new Promise((resolve) => {
24
+ setTimeout(resolve, ms);
25
+ });
26
+ /**
27
+ * One request against the Webflow API, with a bounded retry on 429.
28
+ *
29
+ * A full site run is hundreds of sequential requests spread over however long
30
+ * the vision model takes, so hitting the per-minute cap is a matter of site
31
+ * size, not of anything the caller did wrong — dying on it would throw away all
32
+ * the work done so far.
33
+ */
34
+ const request = async (auth, url, init = {}) => {
35
+ const { pathname } = new URL(url);
36
+ const method = init.method ?? 'GET';
37
+ for (let attempt = 0;; attempt++) {
38
+ // eslint-disable-next-line no-await-in-loop
39
+ const resp = await fetch(url, {
40
+ ...init,
41
+ headers: { Authorization: `Bearer ${auth.apiKey}`, ...init.headers },
42
+ });
43
+ // eslint-disable-next-line no-await-in-loop
44
+ if (resp.ok)
45
+ return (await resp.json());
46
+ if (resp.status === 429 && attempt < RATE_LIMIT_RETRIES) {
47
+ const retryAfter = Number(resp.headers.get('retry-after'));
48
+ // eslint-disable-next-line no-await-in-loop
49
+ await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : RATE_LIMIT_FALLBACK_MS);
50
+ continue;
51
+ }
52
+ // eslint-disable-next-line no-await-in-loop
53
+ const body = await resp.text().catch(() => '');
54
+ throw new Error(`Webflow ${method} ${pathname} failed (${resp.status} ${resp.statusText})${body ? `: ${body.slice(0, 300)}` : ''}`);
55
+ }
56
+ };
57
+ /**
58
+ * Every asset in the site's asset library, oldest page first.
59
+ *
60
+ * @yields each asset, one page of 100 at a time.
61
+ */
62
+ export async function* getAssets(auth, limit = 100) {
63
+ for (let page = 0;; page++) {
64
+ const url = new URL(`${API}/sites/${auth.siteId}/assets`);
65
+ url.searchParams.set('offset', `${page * limit}`);
66
+ url.searchParams.set('limit', `${limit}`);
67
+ // eslint-disable-next-line no-await-in-loop
68
+ const data = await request(auth, url);
69
+ yield* data.assets;
70
+ if (page + 1 >= Math.ceil(data.pagination.total / data.pagination.limit))
71
+ return;
72
+ }
73
+ }
74
+ /** Every CMS collection on the site. Not paginated by Webflow. */
75
+ export const getCollections = async (auth) => {
76
+ const data = await request(auth, `${API}/sites/${auth.siteId}/collections`);
77
+ return data.collections;
78
+ };
79
+ /**
80
+ * Every item in a collection.
81
+ *
82
+ * Reads from STAGING by default (the `/live` endpoint is opt-in) to match
83
+ * `updateCollectionItem`, which also writes to staging — so a run's changes
84
+ * need publishing in Webflow before they show on the live site.
85
+ *
86
+ * @yields each item in the collection.
87
+ */
88
+ export async function* getCollectionItems(auth, collectionId, { limit = 100, staging = true } = {}) {
89
+ for (let page = 0;; page++) {
90
+ const url = new URL(`${API}/collections/${collectionId}/items${staging ? '' : '/live'}`);
91
+ url.searchParams.set('offset', `${page * limit}`);
92
+ url.searchParams.set('limit', `${limit}`);
93
+ // eslint-disable-next-line no-await-in-loop
94
+ const data = await request(auth, url);
95
+ yield* data.items;
96
+ if (page + 1 >= Math.ceil(data.pagination.total / data.pagination.limit))
97
+ return;
98
+ }
99
+ }
100
+ /** Set the alt text on a site asset. */
101
+ export const updateAssetAltText = async (auth, assetId, altText) => {
102
+ await request(auth, `${API}/assets/${assetId}`, {
103
+ body: JSON.stringify({ altText }),
104
+ headers: { 'Content-Type': 'application/json' },
105
+ method: 'PATCH',
106
+ });
107
+ };
108
+ /** Patch one item's fieldData. Only the fields present in `fieldData` are touched. */
109
+ export const updateCollectionItem = async (auth, collectionId, itemId, fieldData) => {
110
+ await request(auth, `${API}/collections/${collectionId}/items`, {
111
+ body: JSON.stringify({ items: [{ fieldData, id: itemId }] }),
112
+ headers: { 'Content-Type': 'application/json' },
113
+ method: 'PATCH',
114
+ });
115
+ };
116
+ /**
117
+ * Webflow hands back untyped `fieldData`, so image fields are duck-typed: a
118
+ * single image is an object with a `url`, a multi-image field is an array of
119
+ * those (an empty array counts — it is still an image field, just empty).
120
+ */
121
+ export const isImageField = (value) => typeof value === 'object' && value !== null && !Array.isArray(value) && 'url' in value;
122
+ export const isImagesField = (value) => Array.isArray(value) && value.every((entry) => isImageField(entry));
@@ -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,10 +147,18 @@ 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
- /** Run the remote-side teardown over a fresh ssh connection. */
153
+ /**
154
+ * Run the remote-side teardown over a fresh ssh connection. Forces a remote PTY
155
+ * with `-tt`: the MCP-remove step runs under an *interactive* login shell
156
+ * (`$SHELL -lic`, so `claude2` aliases in rc files resolve — see
157
+ * `claudeMcpRemoveScript`), and an interactive bash without a PTY prints
158
+ * "cannot set terminal process group / no job control in this shell". `-tt`
159
+ * forces allocation even when this cleanup runs without a local TTY, mirroring
160
+ * the `-t` the interactive session ssh uses.
161
+ */
143
162
  export declare const runRemoteCleanup: (target: string, remoteDir: string, opts?: RemoteCleanupOptions) => Promise<number>;
144
163
  /**
145
164
  * The remote-side teardown script. Runs from inside the workspace dir so the
@@ -161,7 +180,7 @@ export declare const hasMutagen: () => boolean;
161
180
  * winner. When a `source` is given, it becomes the Mutagen alpha endpoint and
162
181
  * the mode switches to two-way-resolved (alpha always wins conflicts), so
163
182
  * `--source remote` puts the server first and `--source local` puts this
164
- * machine first. Labels let `workspace cleanup` find and terminate orphans.
183
+ * machine first. Labels let `workspace --cleanup` find and terminate orphans.
165
184
  * `ctx.ignores` (from --ignore-vcs) excludes gitignored paths from the sync so
166
185
  * each side keeps its own build artifacts and platform-specific binaries.
167
186
  */
@@ -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,31 @@ 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
+ /**
161
+ * The `claude` CLIs an MCP entry is registered with / removed from. `claude2` is
162
+ * an overflow instance some remotes run alongside `claude`; both need the same
163
+ * project-local MCP config so whichever the user opens sees the workspace tools.
164
+ * Each is guarded by its own `command -v` — a remote without a given CLI just
165
+ * skips it. Keep add and remove over the same list so cleanup is complete.
166
+ *
167
+ * These are emitted as LITERAL command words, never `for cli in …; do "$cli" …`.
168
+ * On some remotes `claude2` is a shell *alias* (defined in ~/.bashrc / ~/.zshrc),
169
+ * and aliases are only expanded when the command word is a literal read by the
170
+ * parser — an alias never expands from a variable like `"$cli"`. Alias support is
171
+ * also why the add/remove blocks run under an *interactive* login shell (see the
172
+ * `-lic` note below): rc files, where the alias lives, are sourced only for
173
+ * interactive shells.
174
+ */
175
+ const CLAUDE_CLIS = ['claude', 'claude2'];
176
+ /**
177
+ * Emit a guarded per-CLI block for each entry in {@link CLAUDE_CLIS}. `cli` is
178
+ * interpolated literally (not via a variable) so a shell-alias `claude2` still
179
+ * expands. `command -v` gates each one so a remote missing a CLI just skips it;
180
+ * `body(cli)` returns the lines to run inside the guard.
181
+ */
182
+ const forEachClaudeCli = (body) => CLAUDE_CLIS.flatMap((cli) => [`if command -v ${cli} >/dev/null 2>&1; then`, ...body(cli).map((l) => ` ${l}`), 'fi']);
160
183
  /**
161
184
  * Bash lines (run on the REMOTE, from inside the workspace dir) that register the
162
185
  * chrome-devtools MCP with the `claude` CLI. Local scope keys off the current
@@ -164,41 +187,44 @@ const RPC_MCP_NAME = 'local-shell';
164
187
  * A prior entry is cleared first so a re-connect after a crashed session is
165
188
  * idempotent. Skips gracefully if the claude CLI is missing.
166
189
  *
167
- * The block runs inside a login shell (`$SHELL -lc`): the outer script arrives as
168
- * a non-login ssh command whose PATH lacks the node/nvm/volta/Homebrew dirs that
169
- * login profiles add, so a bare `command -v claude` misses an installed CLI. A
170
- * login shell reproduces the same PATH the interactive session below gets.
190
+ * The block runs inside an *interactive* login shell (`$SHELL -lic`). Login (`-l`)
191
+ * because the outer script arrives as a non-login ssh command whose PATH lacks the
192
+ * node/nvm/volta/Homebrew dirs that login profiles add, so a bare `command -v
193
+ * claude` would miss an installed CLI. Interactive (`-i`) because on some remotes
194
+ * `claude2` is a shell alias defined in ~/.bashrc / ~/.zshrc, and those rc files
195
+ * are sourced only for interactive shells — a plain `-lc` command shell never sees
196
+ * the alias. The `ssh -t` PTY is what lets `-i` run without job-control warnings.
171
197
  */
172
198
  const claudeDevtoolsAddScript = (remotePort, okMessage) => {
173
199
  const body = [
174
- 'if command -v claude >/dev/null 2>&1; then',
175
- ` claude mcp remove ${DEVTOOLS_MCP_NAME} >/dev/null 2>&1 || true`,
176
- ` claude mcp add ${DEVTOOLS_MCP_NAME} -- npx -y chrome-devtools-mcp@latest --browserUrl http://127.0.0.1:${remotePort}`,
177
- ` echo ${shQuote(okMessage)}`,
178
- 'else',
179
- ' echo "WARNING: claude CLI not found on the remote; skipped chrome-devtools MCP config." >&2',
180
- 'fi',
200
+ 'configured=""',
201
+ ...forEachClaudeCli((cli) => [
202
+ `${cli} mcp remove ${DEVTOOLS_MCP_NAME} >/dev/null 2>&1 || true`,
203
+ `${cli} mcp add ${DEVTOOLS_MCP_NAME} -- npx -y chrome-devtools-mcp@latest --browserUrl http://127.0.0.1:${remotePort}`,
204
+ 'configured=1',
205
+ ]),
206
+ `if [ -n "$configured" ]; then echo ${shQuote(okMessage)}; else echo "WARNING: claude CLI not found on the remote; skipped chrome-devtools MCP config." >&2; fi`,
181
207
  ].join('\n');
182
- return [`"\${SHELL:-bash}" -lc ${shQuote(body)}`];
208
+ return [`"\${SHELL:-bash}" -lic ${shQuote(body)}`];
183
209
  };
184
210
  /**
185
211
  * Bash lines (run on the REMOTE, from inside the workspace dir) that register the
186
- * --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
187
213
  * URL points at the reverse-tunnelled port, which the workspace's `ssh -R`
188
- * 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
189
215
  * idempotency reasoning as `claudeDevtoolsAddScript`.
190
216
  */
191
- const claudeRpcAddScript = (remotePort, okMessage) => {
217
+ const claudeToolsAddScript = (remotePort, okMessage) => {
192
218
  const body = [
193
- 'if command -v claude >/dev/null 2>&1; then',
194
- ` claude mcp remove ${RPC_MCP_NAME} >/dev/null 2>&1 || true`,
195
- ` claude mcp add --transport http ${RPC_MCP_NAME} http://127.0.0.1:${remotePort}/mcp`,
196
- ` echo ${shQuote(okMessage)}`,
197
- 'else',
198
- ' echo "WARNING: claude CLI not found on the remote; skipped local-shell MCP config." >&2',
199
- 'fi',
219
+ 'configured=""',
220
+ ...forEachClaudeCli((cli) => [
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
+ 'configured=1',
224
+ ]),
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`,
200
226
  ].join('\n');
201
- return [`"\${SHELL:-bash}" -lc ${shQuote(body)}`];
227
+ return [`"\${SHELL:-bash}" -lic ${shQuote(body)}`];
202
228
  };
203
229
  /**
204
230
  * Bash lines (run on the REMOTE, from inside the workspace dir) that remove an
@@ -208,12 +234,13 @@ const claudeRpcAddScript = (remotePort, okMessage) => {
208
234
  * `claudeDevtoolsAddScript`.
209
235
  */
210
236
  const claudeMcpRemoveScript = (name) => {
211
- const body = [
212
- 'if command -v claude >/dev/null 2>&1; then',
213
- ` claude mcp remove ${name}`,
214
- 'fi'
215
- ].join('\n');
216
- return [`"\${SHELL:-bash}" -lc ${shQuote(body)}`];
237
+ const body = forEachClaudeCli((cli) => [
238
+ // Tolerate failure: if the entry was never registered (or already gone) the
239
+ // remove exits non-zero — that's success for us, so never let it abort the
240
+ // rest of the teardown (dir deletion, the other CLI, …).
241
+ `${cli} mcp remove ${name} >/dev/null 2>&1 || true`,
242
+ ]).join('\n');
243
+ return [`"\${SHELL:-bash}" -lic ${shQuote(body)}`];
217
244
  };
218
245
  /**
219
246
  * The bash script the remote runs for the interactive session. Mutagen already
@@ -225,8 +252,8 @@ export const buildRemoteScript = (ctx) => {
225
252
  const devtoolsSetup = ctx.devtools
226
253
  ? claudeDevtoolsAddScript(ctx.devtools.remote, `Configured chrome-devtools MCP for this workspace (browser via 127.0.0.1:${ctx.devtools.remote}).`)
227
254
  : [];
228
- const rpcSetup = ctx.rpc
229
- ? 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}).`)
230
257
  : [];
231
258
  return [
232
259
  'set -u',
@@ -236,15 +263,23 @@ export const buildRemoteScript = (ctx) => {
236
263
  'cd "$DIR" || { echo "ERROR: could not enter $DIR" >&2; exit 1; }',
237
264
  // Register the MCPs from inside $DIR: `claude mcp add` local scope keys off cwd.
238
265
  ...devtoolsSetup,
239
- ...rpcSetup,
266
+ ...toolsSetup,
240
267
  'echo "Workspace ready at $DIR — files sync in the background (exit to stop syncing)."',
241
268
  // eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
242
269
  '"${SHELL:-bash}" -l',
243
270
  ].join('\n');
244
271
  };
245
- /** Run the remote-side teardown over a fresh ssh connection. */
272
+ /**
273
+ * Run the remote-side teardown over a fresh ssh connection. Forces a remote PTY
274
+ * with `-tt`: the MCP-remove step runs under an *interactive* login shell
275
+ * (`$SHELL -lic`, so `claude2` aliases in rc files resolve — see
276
+ * `claudeMcpRemoveScript`), and an interactive bash without a PTY prints
277
+ * "cannot set terminal process group / no job control in this shell". `-tt`
278
+ * forces allocation even when this cleanup runs without a local TTY, mirroring
279
+ * the `-t` the interactive session ssh uses.
280
+ */
246
281
  export const runRemoteCleanup = (target, remoteDir, opts = {}) => new Promise((resolve, reject) => {
247
- const child = spawn('ssh', [target, buildCleanupScript(remoteDir, opts)], { stdio: 'inherit' });
282
+ const child = spawn('ssh', ['-tt', target, buildCleanupScript(remoteDir, opts)], { stdio: 'inherit' });
248
283
  child.once('error', reject);
249
284
  child.once('close', (code) => resolve(code ?? 0));
250
285
  });
@@ -261,7 +296,7 @@ export const buildCleanupScript = (remoteDir, opts = {}) => [
261
296
  `DIR=${shQuote(remoteDir)}`,
262
297
  'cd "$DIR" 2>/dev/null || { echo "Nothing to clean up: $DIR is gone." >&2; exit 0; }',
263
298
  ...(opts.removeDevtoolsMcp ? claudeMcpRemoveScript(DEVTOOLS_MCP_NAME) : []),
264
- ...(opts.removeRpcMcp ? claudeMcpRemoveScript(RPC_MCP_NAME) : []),
299
+ ...(opts.removeToolsMcp ? claudeMcpRemoveScript(TOOLS_MCP_NAME) : []),
265
300
  // Remove the workspace dir last: cd out first so we don't rm the cwd out from
266
301
  // under the shell, then delete it. Only when explicitly requested.
267
302
  ...(opts.deleteRemoteDir
@@ -287,7 +322,7 @@ export const hasMutagen = () => {
287
322
  * winner. When a `source` is given, it becomes the Mutagen alpha endpoint and
288
323
  * the mode switches to two-way-resolved (alpha always wins conflicts), so
289
324
  * `--source remote` puts the server first and `--source local` puts this
290
- * machine first. Labels let `workspace cleanup` find and terminate orphans.
325
+ * machine first. Labels let `workspace --cleanup` find and terminate orphans.
291
326
  * `ctx.ignores` (from --ignore-vcs) excludes gitignored paths from the sync so
292
327
  * each side keeps its own build artifacts and platform-specific binaries.
293
328
  */