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