@firenet-designs/fnd-cli 2.4.0 → 2.6.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.
@@ -157,6 +157,29 @@ export const shQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
157
157
  const DEVTOOLS_MCP_NAME = 'chrome-devtools';
158
158
  /** MCP server name registered for the --rpc local-command tunnel. */
159
159
  const RPC_MCP_NAME = 'local-shell';
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,22 +187,25 @@ 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
@@ -190,15 +216,15 @@ const claudeDevtoolsAddScript = (remotePort, okMessage) => {
190
216
  */
191
217
  const claudeRpcAddScript = (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 ${RPC_MCP_NAME} >/dev/null 2>&1 || true`,
222
+ `${cli} mcp add --transport http ${RPC_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 local-shell 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
@@ -242,9 +269,17 @@ export const buildRemoteScript = (ctx) => {
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
  });
@@ -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
  */
@@ -1,5 +1,150 @@
1
1
  {
2
2
  "commands": {
3
+ "alt-text": {
4
+ "aliases": [
5
+ "caption"
6
+ ],
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.",
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/')\""
17
+ ],
18
+ "flags": {
19
+ "api-key": {
20
+ "dependsOn": [
21
+ "webflow"
22
+ ],
23
+ "description": "Webflow API token (site-scoped). Prompted for if omitted.",
24
+ "name": "api-key",
25
+ "hasDynamicHelp": false,
26
+ "multiple": false,
27
+ "type": "option"
28
+ },
29
+ "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.",
34
+ "name": "cms",
35
+ "allowNo": false,
36
+ "type": "boolean"
37
+ },
38
+ "filter": {
39
+ "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
+ "name": "filter",
41
+ "hasDynamicHelp": false,
42
+ "multiple": false,
43
+ "type": "option"
44
+ },
45
+ "limit": {
46
+ "description": "stop after this many images have been transcribed (counted across assets and CMS together)",
47
+ "name": "limit",
48
+ "hasDynamicHelp": false,
49
+ "multiple": false,
50
+ "type": "option"
51
+ },
52
+ "ollama-host": {
53
+ "description": "base URL of the Ollama server. Prompted for if omitted.",
54
+ "name": "ollama-host",
55
+ "hasDynamicHelp": false,
56
+ "multiple": false,
57
+ "type": "option"
58
+ },
59
+ "ollama-model": {
60
+ "description": "the model used to describe the images. Omit it to pick from the vision-capable models pulled on the host.",
61
+ "name": "ollama-model",
62
+ "hasDynamicHelp": false,
63
+ "multiple": false,
64
+ "type": "option"
65
+ },
66
+ "only": {
67
+ "dependsOn": [
68
+ "cms"
69
+ ],
70
+ "description": "only touch these CMS collections, matched case-insensitively against a collection's name or slug. Repeat the flag for more than one.",
71
+ "exclusive": [
72
+ "select"
73
+ ],
74
+ "name": "only",
75
+ "hasDynamicHelp": false,
76
+ "multiple": true,
77
+ "type": "option"
78
+ },
79
+ "output-stats": {
80
+ "description": "also write the run stats to this file as Markdown: totals, a row per image (url, description, tokens, time, size), and the failures. Parent directories are created.",
81
+ "name": "output-stats",
82
+ "hasDynamicHelp": false,
83
+ "multiple": false,
84
+ "type": "option"
85
+ },
86
+ "select": {
87
+ "dependsOn": [
88
+ "cms"
89
+ ],
90
+ "description": "fetch the CMS collections and pick which ones to process interactively",
91
+ "exclusive": [
92
+ "only"
93
+ ],
94
+ "name": "select",
95
+ "allowNo": false,
96
+ "type": "boolean"
97
+ },
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
+ "site-id": {
108
+ "dependsOn": [
109
+ "webflow"
110
+ ],
111
+ "description": "Webflow site ID. Prompted for if omitted.",
112
+ "name": "site-id",
113
+ "hasDynamicHelp": false,
114
+ "multiple": false,
115
+ "type": "option"
116
+ },
117
+ "skip": {
118
+ "description": "leave images that already have alt text alone instead of overwriting them",
119
+ "name": "skip",
120
+ "allowNo": false,
121
+ "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
+ }
132
+ },
133
+ "hasDynamicHelp": false,
134
+ "hiddenAliases": [],
135
+ "id": "alt-text",
136
+ "pluginAlias": "@firenet-designs/fnd-cli",
137
+ "pluginName": "@firenet-designs/fnd-cli",
138
+ "pluginType": "core",
139
+ "strict": true,
140
+ "enableJsonFlag": false,
141
+ "isESM": true,
142
+ "relativePath": [
143
+ "dist",
144
+ "commands",
145
+ "alt-text.js"
146
+ ]
147
+ },
3
148
  "backfill-project": {
4
149
  "aliases": [],
5
150
  "args": {
@@ -80,7 +225,7 @@
80
225
  "required": false
81
226
  }
82
227
  },
83
- "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.",
228
+ "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.",
84
229
  "examples": [
85
230
  "<%= config.bin %> <%= command.id %>",
86
231
  "<%= config.bin %> <%= command.id %> my-store",
@@ -183,76 +328,6 @@
183
328
  "token.js"
184
329
  ]
185
330
  },
186
- "workspace:cleanup": {
187
- "aliases": [],
188
- "args": {},
189
- "description": "Tear down a leftover workspace — use this if a `workspace` session dropped before it could clean up after itself.\n\nTerminates any Mutagen sync sessions this machine started for the directory. Pass --devtools or --rpc to also strip the matching MCP entries from the remote (only do this if the dropped session used those flags). With no --remote-dir, it targets the same path `workspace` would use for the current directory. The synced files themselves are left in place unless you pass --delete-remote-dir.",
190
- "examples": [
191
- "<%= config.bin %> <%= command.id %> --ssh user@host",
192
- "<%= config.bin %> <%= command.id %> --ssh user@host --remote-dir /home/fnd/cole/fnd-cli",
193
- "<%= config.bin %> <%= command.id %> --ssh user@host --devtools",
194
- "<%= config.bin %> <%= command.id %> --ssh user@host --rpc",
195
- "<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir"
196
- ],
197
- "flags": {
198
- "delete-remote-dir": {
199
- "description": "also delete the remote workspace directory (by default the synced files are left in place)",
200
- "name": "delete-remote-dir",
201
- "allowNo": false,
202
- "type": "boolean"
203
- },
204
- "devtools": {
205
- "description": "also strip the chrome-devtools MCP entry from the remote (only if the dropped session used --devtools)",
206
- "name": "devtools",
207
- "allowNo": false,
208
- "type": "boolean"
209
- },
210
- "remote-base": {
211
- "description": "base dir on the remote, used to derive the default remote directory path",
212
- "name": "remote-base",
213
- "default": "/home/fnd",
214
- "hasDynamicHelp": false,
215
- "multiple": false,
216
- "type": "option"
217
- },
218
- "remote-dir": {
219
- "description": "exact remote directory to target (defaults to the current dir mapping)",
220
- "name": "remote-dir",
221
- "hasDynamicHelp": false,
222
- "multiple": false,
223
- "type": "option"
224
- },
225
- "rpc": {
226
- "description": "also strip the local-shell MCP entry from the remote (only if the dropped session used --rpc)",
227
- "name": "rpc",
228
- "allowNo": false,
229
- "type": "boolean"
230
- },
231
- "ssh": {
232
- "description": "remote to connect to, as user@host",
233
- "name": "ssh",
234
- "required": true,
235
- "hasDynamicHelp": false,
236
- "multiple": false,
237
- "type": "option"
238
- }
239
- },
240
- "hasDynamicHelp": false,
241
- "hiddenAliases": [],
242
- "id": "workspace:cleanup",
243
- "pluginAlias": "@firenet-designs/fnd-cli",
244
- "pluginName": "@firenet-designs/fnd-cli",
245
- "pluginType": "core",
246
- "strict": true,
247
- "enableJsonFlag": false,
248
- "isESM": true,
249
- "relativePath": [
250
- "dist",
251
- "commands",
252
- "workspace",
253
- "cleanup.js"
254
- ]
255
- },
256
331
  "workspace": {
257
332
  "aliases": [],
258
333
  "args": {},
@@ -266,9 +341,16 @@
266
341
  "<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9333:9222",
267
342
  "<%= config.bin %> <%= command.id %> --ssh user@host --rpc port=7777:7700",
268
343
  "<%= config.bin %> <%= command.id %> --ssh user@host --rpc port=7777:7700,profile=false,shell=zsh",
269
- "<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir"
344
+ "<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir",
345
+ "<%= config.bin %> <%= command.id %> --ssh user@host --rpc 7700 --cleanup"
270
346
  ],
271
347
  "flags": {
348
+ "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.",
350
+ "name": "cleanup",
351
+ "allowNo": false,
352
+ "type": "boolean"
353
+ },
272
354
  "delete-remote-dir": {
273
355
  "description": "on exit, delete the remote workspace directory instead of leaving the synced copy in place",
274
356
  "name": "delete-remote-dir",
@@ -340,5 +422,5 @@
340
422
  ]
341
423
  }
342
424
  },
343
- "version": "2.4.0"
425
+ "version": "2.6.0"
344
426
  }
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.4.0",
4
+ "version": "2.6.0",
5
5
  "author": "Cole Denslow",
6
6
  "contributors": [
7
7
  "Justin Schellenberg"
@@ -11,11 +11,15 @@
11
11
  },
12
12
  "bugs": "https://github.com/FireNet-Designs/fnd-cli/issues",
13
13
  "dependencies": {
14
+ "@inquirer/prompts": "^7.10.1",
14
15
  "@oclif/core": "^4",
15
16
  "@oclif/plugin-help": "^6",
16
17
  "@oclif/plugin-plugins": "^5",
18
+ "@resvg/resvg-js": "^2.6.2",
17
19
  "chalk": "^5.6.2",
20
+ "ollama": "^0.6.3",
18
21
  "ora": "^9.3.0",
22
+ "sharp": "^0.34.5",
19
23
  "simple-git": "^3.32.2",
20
24
  "zod": "^4.4.3"
21
25
  },
@@ -1,14 +0,0 @@
1
- import { Command } from '@oclif/core';
2
- export default class WorkspaceCleanup extends Command {
3
- static description: string;
4
- static examples: string[];
5
- static flags: {
6
- 'delete-remote-dir': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
- devtools: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
- 'remote-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
- 'remote-dir': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
- rpc: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
- ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
- };
13
- run(): Promise<void>;
14
- }
@@ -1,84 +0,0 @@
1
- import { Command, Flags } from '@oclif/core';
2
- import chalk from 'chalk';
3
- import { basename } from 'node:path';
4
- import { buildContext, buildMutagenTerminateSelectorArgs, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, parseSshTarget, runMutagen, runRemoteCleanup, slugify, } from '../../lib/workspace.js';
5
- export default class WorkspaceCleanup extends Command {
6
- static description = 'Tear down a leftover workspace — use this if a `workspace` session dropped before it could clean up after itself.\n\nTerminates any Mutagen sync sessions this machine started for the directory. Pass --devtools or --rpc to also strip the matching MCP entries from the remote (only do this if the dropped session used those flags). With no --remote-dir, it targets the same path `workspace` would use for the current directory. The synced files themselves are left in place unless you pass --delete-remote-dir.';
7
- static examples = [
8
- '<%= config.bin %> <%= command.id %> --ssh user@host',
9
- '<%= config.bin %> <%= command.id %> --ssh user@host --remote-dir /home/fnd/cole/fnd-cli',
10
- '<%= config.bin %> <%= command.id %> --ssh user@host --devtools',
11
- '<%= config.bin %> <%= command.id %> --ssh user@host --rpc',
12
- '<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir',
13
- ];
14
- static flags = {
15
- 'delete-remote-dir': Flags.boolean({
16
- default: false,
17
- description: 'also delete the remote workspace directory (by default the synced files are left in place)',
18
- }),
19
- devtools: Flags.boolean({
20
- default: false,
21
- description: 'also strip the chrome-devtools MCP entry from the remote (only if the dropped session used --devtools)',
22
- }),
23
- 'remote-base': Flags.string({
24
- default: DEFAULT_MOUNT_BASE,
25
- description: 'base dir on the remote, used to derive the default remote directory path',
26
- }),
27
- 'remote-dir': Flags.string({
28
- description: 'exact remote directory to target (defaults to the current dir mapping)',
29
- }),
30
- rpc: Flags.boolean({
31
- default: false,
32
- description: 'also strip the local-shell MCP entry from the remote (only if the dropped session used --rpc)',
33
- }),
34
- ssh: Flags.string({
35
- description: 'remote to connect to, as user@host',
36
- required: true,
37
- }),
38
- };
39
- async run() {
40
- const { flags } = await this.parse(WorkspaceCleanup);
41
- if (!hasSshClient()) {
42
- this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
43
- }
44
- const target = parseSshTarget(flags.ssh);
45
- const target2 = `${target.user}@${target.host}`;
46
- const remoteDir = flags['remote-dir'] ??
47
- buildContext({ cwd: process.cwd(), remoteBase: flags['remote-base'] }).remoteDir;
48
- const slug = slugify(basename(remoteDir));
49
- this.log(chalk.bold('Cleaning up workspace'));
50
- this.log(` ${chalk.dim('remote:')} ${target2}`);
51
- this.log(` ${chalk.dim('remote dir:')} ${remoteDir}`);
52
- this.log('');
53
- // Terminate any lingering sync sessions for this directory (a local Mutagen op).
54
- if (hasMutagen()) {
55
- this.log(chalk.dim('Terminating any leftover Mutagen sync sessions…'));
56
- await runMutagen(buildMutagenTerminateSelectorArgs(slug)).catch(() => 1);
57
- }
58
- else {
59
- this.log(chalk.yellow('Mutagen CLI not found on PATH — skipping sync termination.'));
60
- }
61
- // Strip any MCP config this workspace left on the remote (only when asked),
62
- // and optionally delete the synced directory itself.
63
- if (flags.devtools) {
64
- this.log(chalk.dim('Removing any leftover chrome-devtools MCP config on the remote…'));
65
- }
66
- if (flags.rpc) {
67
- this.log(chalk.dim('Removing any leftover local-shell MCP config on the remote…'));
68
- }
69
- if (flags['delete-remote-dir']) {
70
- this.log(chalk.dim('Deleting the remote workspace directory…'));
71
- }
72
- const code = await runRemoteCleanup(target2, remoteDir, {
73
- deleteRemoteDir: flags['delete-remote-dir'],
74
- removeDevtoolsMcp: flags.devtools,
75
- removeRpcMcp: flags.rpc,
76
- });
77
- if (code === 0) {
78
- this.log(chalk.green('✓ Done.'));
79
- }
80
- else {
81
- this.error(`Remote cleanup ssh session exited with code ${code}.`, { code: '1' });
82
- }
83
- }
84
- }