@firenet-designs/fnd-cli 2.3.3 → 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.
@@ -1,18 +1,28 @@
1
1
  import { Command, Flags } from '@oclif/core';
2
2
  import chalk from 'chalk';
3
3
  import { spawn } from 'node:child_process';
4
- import { browserDebugInstructions, buildContext, buildMutagenCreateArgs, buildMutagenFlushArgs, buildMutagenTerminateArgs, buildRemoteScript, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, isLocalDebugPortLive, mutagenInstallInstructions, parseDevtoolsPort, parseSshTarget, runMutagen, runRemoteCleanup, } from '../../lib/workspace.js';
4
+ 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';
5
7
  export default class Workspace extends Command {
6
8
  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.';
7
9
  static examples = [
8
10
  '<%= config.bin %> <%= command.id %> --ssh user@203.0.113.4',
9
11
  '<%= config.bin %> <%= command.id %> --ssh user@host --source local',
10
12
  '<%= config.bin %> <%= command.id %> --ssh user@host --remote-base /home/fnd',
13
+ '<%= config.bin %> <%= command.id %> --ssh user@host --ignore-vcs',
11
14
  '<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9222',
12
15
  '<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9333:9222',
16
+ `<%= config.bin %> <%= command.id %> --ssh user@host --rpc ${RPC_FLAG_EXAMPLES.required}`,
17
+ `<%= config.bin %> <%= command.id %> --ssh user@host --rpc ${RPC_FLAG_EXAMPLES.full}`,
13
18
  '<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir',
19
+ '<%= config.bin %> <%= command.id %> --ssh user@host --rpc 7700 --cleanup',
14
20
  ];
15
21
  static flags = {
22
+ cleanup: Flags.boolean({
23
+ 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/--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.",
25
+ }),
16
26
  'delete-remote-dir': Flags.boolean({
17
27
  default: false,
18
28
  description: 'on exit, delete the remote workspace directory instead of leaving the synced copy in place',
@@ -20,10 +30,17 @@ export default class Workspace extends Command {
20
30
  devtools: Flags.string({
21
31
  description: 'expose your LOCAL browser to Claude on the remote via the chrome-devtools MCP. Value is "port" (same port both ends) or "remote:local" (local = this machine, where the browser runs). Your browser must already be listening with --remote-debugging-port=<local>.',
22
32
  }),
33
+ 'ignore-vcs': Flags.boolean({
34
+ default: false,
35
+ description: "don't sync paths matched by the project's .gitignore files (node_modules, build output, …) so each side keeps its own platform-specific artifacts. Every .gitignore in the tree is honoured relative to its directory, like git does. The .git directory itself still syncs, so the remote stays a working repo.",
36
+ }),
23
37
  'remote-base': Flags.string({
24
38
  default: DEFAULT_MOUNT_BASE,
25
39
  description: 'base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>',
26
40
  }),
41
+ rpc: Flags.string({
42
+ description: `expose a run_local_command MCP tool to Claude on the remote that executes commands back on THIS machine (the one running fnd workspace). Value is ${RPC_FLAG_USAGE} — port opens a reverse tunnel (ssh -R <remote>:localhost:<local>) to a command server started here; shell defaults to the shell fnd workspace was called from; profile (default true) controls whether the shell loads its startup files — with it on, POSIX shells run interactively (-i) so rc files like ~/.bashrc or ~/.zshrc are sourced and tools such as nvm work.`,
43
+ }),
27
44
  source: Flags.string({
28
45
  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.',
29
46
  options: ['remote', 'local'],
@@ -37,14 +54,28 @@ export default class Workspace extends Command {
37
54
  const { flags } = await this.parse(Workspace);
38
55
  const target = parseSshTarget(flags.ssh);
39
56
  const target2 = `${target.user}@${target.host}`;
40
- const devtools = flags.devtools === undefined ? undefined : parseDevtoolsPort(flags.devtools);
57
+ // --cleanup short-circuits everything: no connection, no sync, just tear down
58
+ // leftovers from a session that dropped before it could clean up. The other
59
+ // flags are reused for *what* to clean up (devtools/rpc MCP entries, dir), and
60
+ // any that don't matter here are ignored — so `↑` + ` --cleanup` just works.
61
+ if (flags.cleanup) {
62
+ await this.runCleanup(target2, flags);
63
+ return;
64
+ }
65
+ const devtools = flags.devtools === undefined ? undefined : parsePortPair(flags.devtools, '--devtools');
66
+ const rpc = flags.rpc === undefined ? undefined : parseRpcFlag(flags.rpc);
67
+ if (devtools && rpc && devtools.remote === rpc.ports.remote) {
68
+ this.error(`--devtools and --rpc cannot share remote port ${rpc.ports.remote}.`, { code: '1' });
69
+ }
41
70
  const ctx = buildContext({
42
71
  cwd: process.cwd(),
43
72
  devtools,
73
+ ignoreVcs: flags['ignore-vcs'],
44
74
  remoteBase: flags['remote-base'],
75
+ rpc,
45
76
  source: flags.source,
46
77
  });
47
- await this.preflight(devtools);
78
+ await this.preflight(devtools, rpc);
48
79
  this.printPlan(ctx, target2);
49
80
  // Start the two-way sync. Mutagen auto-deploys its agent to the remote over SSH.
50
81
  this.log(chalk.dim('Starting the Mutagen sync session…'));
@@ -53,6 +84,7 @@ export default class Workspace extends Command {
53
84
  this.error(`mutagen sync create failed (exit ${createCode}). Check that the remote is reachable over SSH and try again.`, { code: '1' });
54
85
  }
55
86
  const deleteRemoteDir = flags['delete-remote-dir'];
87
+ let rpcServer;
56
88
  let code;
57
89
  try {
58
90
  // Block until the first full sync lands so the files exist before the shell opens.
@@ -61,33 +93,30 @@ export default class Workspace extends Command {
61
93
  if (flushCode !== 0) {
62
94
  this.error(`Initial mutagen sync flush failed (exit ${flushCode}).`, { code: '1' });
63
95
  }
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
+ }
64
106
  const script = buildRemoteScript(ctx);
65
- code = await this.runSsh(target2, script, ctx.devtools);
107
+ code = await this.runSsh(target2, script, ctx);
66
108
  }
67
109
  finally {
68
- // Best-effort: flush the last edits back, then tear the session down.
110
+ // Best-effort: stop serving local commands, flush the last edits back, then
111
+ // tear the session down.
112
+ await rpcServer?.close().catch(() => { });
69
113
  this.log('');
70
114
  this.log(chalk.dim('Flushing final changes and stopping the sync…'));
71
115
  await runMutagen(buildMutagenFlushArgs(ctx.syncName)).catch(() => 1);
72
116
  await runMutagen(buildMutagenTerminateArgs(ctx.syncName)).catch(() => 1);
73
- // Reach back to the remote if we left a devtools MCP config there to remove,
74
- // or if we were asked to delete the synced directory.
75
- if (ctx.devtools || deleteRemoteDir) {
76
- const actions = [
77
- ctx.devtools ? 'removing the remote chrome-devtools MCP config' : undefined,
78
- deleteRemoteDir ? 'deleting the remote dir' : undefined,
79
- ].filter(Boolean);
80
- this.log(chalk.dim(`${actions.join(' and ')}…`.replace(/^./, (c) => c.toUpperCase())));
81
- try {
82
- await runRemoteCleanup(target2, ctx.remoteDir, {
83
- deleteRemoteDir,
84
- removeDevtoolsMcp: Boolean(ctx.devtools),
85
- });
86
- }
87
- catch (error) {
88
- this.log(chalk.yellow(`Could not reach the remote to clean up (${error.message}).`));
89
- }
90
- }
117
+ // Reach back to the remote if we left MCP config there to remove, or if we
118
+ // were asked to delete the synced directory.
119
+ await this.teardownRemote(ctx, target2, deleteRemoteDir);
91
120
  }
92
121
  this.log('');
93
122
  this.log(code === 0
@@ -97,7 +126,7 @@ export default class Workspace extends Command {
97
126
  : chalk.yellow(`Session ended with exit code ${code}. Cleanup attempted above.`));
98
127
  }
99
128
  /** Verify this machine can drive the sync before we connect. */
100
- async preflight(devtools) {
129
+ async preflight(devtools, rpc) {
101
130
  if (!hasSshClient()) {
102
131
  this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
103
132
  }
@@ -120,6 +149,13 @@ export default class Workspace extends Command {
120
149
  this.error('Local browser remote-debugging port is required for --devtools. Aborting.', { code: '1' });
121
150
  }
122
151
  }
152
+ // With --rpc, confirm the chosen shell is runnable on THIS machine — it's the
153
+ // shell the remote AI's commands will execute under, right here.
154
+ if (rpc && !hasLocalShell(rpc.shell, rpc.profile)) {
155
+ this.error(`--rpc shell "${rpc.shell}" is not runnable on this machine. Aborting before connecting.`, {
156
+ code: '1',
157
+ });
158
+ }
123
159
  }
124
160
  printPlan(ctx, target) {
125
161
  const sync = ctx.source === undefined
@@ -130,20 +166,72 @@ export default class Workspace extends Command {
130
166
  this.log(` ${chalk.dim('local dir:')} ${ctx.localCwd}`);
131
167
  this.log(` ${chalk.dim('remote dir:')} ${ctx.remoteDir}`);
132
168
  this.log(` ${chalk.dim('sync:')} ${sync}`);
169
+ if (ctx.ignores) {
170
+ this.log(` ${chalk.dim('ignoring:')} ${ctx.ignores.length} pattern${ctx.ignores.length === 1 ? '' : 's'} from .gitignore files (--ignore-vcs)`);
171
+ }
133
172
  if (ctx.devtools) {
134
173
  this.log(` ${chalk.dim('devtools:')} remote 127.0.0.1:${ctx.devtools.remote} → local browser 127.0.0.1:${ctx.devtools.local}`);
135
174
  }
175
+ if (ctx.rpc) {
176
+ this.log(` ${chalk.dim('rpc:')} remote 127.0.0.1:${ctx.rpc.ports.remote} → local ${ctx.rpc.shell} commands via 127.0.0.1:${ctx.rpc.ports.local}`);
177
+ }
136
178
  this.log('');
137
179
  }
180
+ /**
181
+ * Tear down leftovers from a session that dropped before cleaning up (--cleanup).
182
+ * Terminates this directory's Mutagen sync sessions locally, then reaches the
183
+ * remote to strip the MCP entries the matching flags imply and, with
184
+ * --delete-remote-dir, remove the synced dir. devtools/rpc are read for
185
+ * presence only — their port values are irrelevant to removal.
186
+ */
187
+ async runCleanup(target, flags) {
188
+ if (!hasSshClient()) {
189
+ this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
190
+ }
191
+ const removeDevtoolsMcp = flags.devtools !== undefined;
192
+ const removeRpcMcp = flags.rpc !== undefined;
193
+ const deleteRemoteDir = flags['delete-remote-dir'];
194
+ const { remoteDir } = buildContext({ cwd: process.cwd(), remoteBase: flags['remote-base'] });
195
+ const slug = slugify(basename(remoteDir));
196
+ this.log(chalk.bold('Cleaning up workspace'));
197
+ this.log(` ${chalk.dim('remote:')} ${target}`);
198
+ this.log(` ${chalk.dim('remote dir:')} ${remoteDir}`);
199
+ this.log('');
200
+ // Terminate any lingering sync sessions for this directory (a local Mutagen op).
201
+ if (hasMutagen()) {
202
+ this.log(chalk.dim('Terminating any leftover Mutagen sync sessions…'));
203
+ await runMutagen(buildMutagenTerminateSelectorArgs(slug)).catch(() => 1);
204
+ }
205
+ else {
206
+ this.log(chalk.yellow('Mutagen CLI not found on PATH — skipping sync termination.'));
207
+ }
208
+ if (removeDevtoolsMcp) {
209
+ this.log(chalk.dim('Removing any leftover chrome-devtools MCP config on the remote…'));
210
+ }
211
+ if (removeRpcMcp) {
212
+ this.log(chalk.dim('Removing any leftover local-shell MCP config on the remote…'));
213
+ }
214
+ if (deleteRemoteDir) {
215
+ this.log(chalk.dim('Deleting the remote workspace directory…'));
216
+ }
217
+ const code = await runRemoteCleanup(target, remoteDir, { deleteRemoteDir, removeDevtoolsMcp, removeRpcMcp });
218
+ if (code === 0) {
219
+ this.log(chalk.green('✓ Done.'));
220
+ }
221
+ else {
222
+ this.error(`Remote cleanup ssh session exited with code ${code}.`, { code: '1' });
223
+ }
224
+ }
138
225
  /** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
139
- runSsh(target, script, devtools) {
226
+ runSsh(target, script, ctx) {
227
+ // 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; --rpc points one at
229
+ // the local command server, so the remote's MCPs can reach them.
230
+ const forwards = [ctx.devtools, ctx.rpc?.ports].filter((f) => f !== undefined);
140
231
  const args = [
141
232
  '-t', // allocate a remote PTY for the interactive shell session
142
- // Reverse tunnel for --devtools only: remote 127.0.0.1:<remote> → this
143
- // machine's browser at 127.0.0.1:<local>, so the remote's MCP can reach it.
144
- ...(devtools
145
- ? ['-o', 'ExitOnForwardFailure=yes', '-R', `${devtools.remote}:localhost:${devtools.local}`]
146
- : []),
233
+ ...(forwards.length > 0 ? ['-o', 'ExitOnForwardFailure=yes'] : []),
234
+ ...forwards.flatMap((f) => ['-R', `${f.remote}:localhost:${f.local}`]),
147
235
  target,
148
236
  script,
149
237
  ];
@@ -153,4 +241,30 @@ export default class Workspace extends Command {
153
241
  child.once('close', (code) => resolve(code ?? 0));
154
242
  });
155
243
  }
244
+ /**
245
+ * Best-effort: reach back to the remote after the session to strip any MCP
246
+ * config this run registered and, with --delete-remote-dir, remove the synced
247
+ * dir. No-op when the session left nothing behind to clean. A failure here is
248
+ * logged, not thrown — the local sync is already torn down by this point.
249
+ */
250
+ async teardownRemote(ctx, target, deleteRemoteDir) {
251
+ if (!ctx.devtools && !ctx.rpc && !deleteRemoteDir)
252
+ return;
253
+ const actions = [
254
+ ctx.devtools ? 'removing the remote chrome-devtools MCP config' : undefined,
255
+ ctx.rpc ? 'removing the remote local-shell MCP config' : undefined,
256
+ deleteRemoteDir ? 'deleting the remote dir' : undefined,
257
+ ].filter(Boolean);
258
+ this.log(chalk.dim(`${actions.join(' and ')}…`.replace(/^./, (c) => c.toUpperCase())));
259
+ try {
260
+ await runRemoteCleanup(target, ctx.remoteDir, {
261
+ deleteRemoteDir,
262
+ removeDevtoolsMcp: Boolean(ctx.devtools),
263
+ removeRpcMcp: Boolean(ctx.rpc),
264
+ });
265
+ }
266
+ catch (error) {
267
+ this.log(chalk.yellow(`Could not reach the remote to clean up (${error.message}).`));
268
+ }
269
+ }
156
270
  }
@@ -9,7 +9,7 @@ const hook = async function (opts) {
9
9
  });
10
10
  });
11
11
  if (newestVersion !== opts.config.pjson.version) {
12
- console.log(chalk.yellow('💡 Version', chalk.green(newestVersion), 'available! Run', chalk.green('`npm i -g git@github.com:FireNet-Designs/fnd-cli.git`'), 'to update to the latest version!\n'));
12
+ console.log(chalk.yellow('💡 Version', chalk.green(newestVersion), 'available! Run', chalk.green('`npm i -g @firenet-designs/fnd-cli`'), 'to update to the latest version!\n'));
13
13
  }
14
14
  };
15
15
  export default hook;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Alt-text generation: fetch an image, make it something a vision model can
3
+ * read, and ask a locally-hosted Ollama model to describe it.
4
+ *
5
+ * Everything runs against the user's own Ollama host, so no image ever leaves
6
+ * their network and there is no per-image API cost — which is also why the
7
+ * caller drives this strictly sequentially: a single local model gains nothing
8
+ * from concurrent requests.
9
+ */
10
+ import type { ImageFilter, ImageMeta } from './image-filter.js';
11
+ export declare const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
12
+ /**
13
+ * One image's worth of result. The caller already knows the URL, so this is
14
+ * everything else worth recording about the round trip.
15
+ *
16
+ * `bytes` is the size of what was actually sent to the model, i.e. after the
17
+ * PNG conversion below — not `meta.fileSize`, which is the (usually much
18
+ * smaller) WebP the site serves. `ms` is wall clock for the whole thing
19
+ * (download, convert, inference), because that is what a run's duration is
20
+ * actually made of.
21
+ */
22
+ export interface Description {
23
+ alt: string;
24
+ bytes: number;
25
+ meta: ImageMeta;
26
+ ms: number;
27
+ skipped: false;
28
+ tokens: number;
29
+ }
30
+ /** An image the filter rejected. It was downloaded, but never sent to the model. */
31
+ export interface Skipped {
32
+ meta: ImageMeta;
33
+ skipped: true;
34
+ }
35
+ export type Describe = (url: string) => Promise<Description | Skipped>;
36
+ /**
37
+ * Every model pulled on the host that can actually read an image.
38
+ *
39
+ * /api/tags (`list`) is the only endpoint that enumerates models, but it says
40
+ * nothing about what a model can do — the capability list lives on /api/show
41
+ * (`show`), so each tag is asked individually. The calls are local and run
42
+ * concurrently; a model whose show() fails (pulled but broken, or removed
43
+ * between the two calls) is simply left out rather than failing the run.
44
+ */
45
+ export declare const listVisionModels: (host: string) => Promise<string[]>;
46
+ /**
47
+ * Bind a describer to one Ollama host and model. The client is created once and
48
+ * reused so the connection (and the loaded model, via keep_alive) survives
49
+ * across images.
50
+ *
51
+ * `filter`, when given, decides whether an image is worth describing — it runs
52
+ * after the download (see fetchImage) but before inference, and a rejected
53
+ * image comes back as `{skipped: true}` rather than throwing, because being
54
+ * filtered out is a normal outcome and not a failure.
55
+ */
56
+ export declare const createDescriber: (host: string, model: string, filter?: ImageFilter) => Describe;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Alt-text generation: fetch an image, make it something a vision model can
3
+ * read, and ask a locally-hosted Ollama model to describe it.
4
+ *
5
+ * Everything runs against the user's own Ollama host, so no image ever leaves
6
+ * their network and there is no per-image API cost — which is also why the
7
+ * caller drives this strictly sequentially: a single local model gains nothing
8
+ * from concurrent requests.
9
+ */
10
+ import { Ollama } from 'ollama';
11
+ export const DEFAULT_OLLAMA_HOST = 'http://localhost:11434';
12
+ /** How long Ollama keeps the model resident between images, in seconds. */
13
+ const KEEP_ALIVE = 60;
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
+ * Everything the model sees is PNG.
17
+ *
18
+ * Which formats a vision model can actually decode is not documented and varies
19
+ * by model — WebP in particular gets accepted and then described as if it were
20
+ * noise. Re-encoding every image removes the question: the model only ever
21
+ * receives the one format they all handle. The cost is a decode + encode per
22
+ * image, which is nothing next to the inference that follows.
23
+ *
24
+ * Both converters are native modules with real startup cost, so they're
25
+ * imported lazily — the first image of a run pays for sharp, and a run with no
26
+ * SVGs never loads resvg at all.
27
+ */
28
+ const svgToPng = async (bytes) => {
29
+ const { Resvg } = await import('@resvg/resvg-js');
30
+ // Rendered on black: SVG icons are overwhelmingly dark-on-transparent, which
31
+ // flattens to invisible on the white the model would otherwise see.
32
+ const rendered = new Resvg(Buffer.from(bytes), { background: '#000000' }).render();
33
+ // An SVG has no intrinsic pixel size, so `width`/`height` in a filter mean the
34
+ // size resvg chose to rasterize at — the viewBox, in practice.
35
+ return { height: rendered.height, png: new Uint8Array(rendered.asPng()), width: rendered.width };
36
+ };
37
+ /** Raster → PNG. Covers WebP, JPEG, AVIF, GIF, TIFF and PNG itself. */
38
+ const rasterToPng = async (bytes) => {
39
+ const { default: sharp } = await import('sharp');
40
+ // Alpha is left alone here, unlike the SVG path: a transparent raster image
41
+ // could be light or dark, so there's no background that's safe to guess.
42
+ // resolveWithObject gets the dimensions out of the same decode as the encode.
43
+ const { data, info } = await sharp(Buffer.from(bytes)).png().toBuffer({ resolveWithObject: true });
44
+ return { height: info.height, png: new Uint8Array(data), width: info.width };
45
+ };
46
+ /**
47
+ * The `type` a filter expression sees: a bare format token, not a MIME type.
48
+ *
49
+ * Content-Type is trusted first and the extension is the fallback, since CDNs
50
+ * serve plenty of images from extensionless URLs. `jpg` is normalized to `jpeg`
51
+ * and `svg+xml` to `svg` so an expression doesn't have to spell both.
52
+ */
53
+ const detectType = (contentType, path) => {
54
+ const fromHeader = contentType.split(';')[0].trim().toLowerCase();
55
+ const subtype = fromHeader.startsWith('image/') ? fromHeader.slice('image/'.length) : '';
56
+ const raw = subtype || (path.includes('.') ? path.split('.').pop() : '');
57
+ const token = raw.replace('+xml', '');
58
+ return token === 'jpg' ? 'jpeg' : token;
59
+ };
60
+ /**
61
+ * Download an image, normalize it to bytes the model accepts, and measure it.
62
+ *
63
+ * The measuring has to happen here rather than before the download: width and
64
+ * height aren't knowable without the image itself, and Content-Length is absent
65
+ * often enough that fileSize is taken from the bytes we actually received. So a
66
+ * filter saves inference time — the expensive part — not bandwidth.
67
+ */
68
+ const fetchImage = async (url) => {
69
+ const resp = await fetch(url);
70
+ if (!resp.ok)
71
+ throw new Error(`Could not download image (${resp.status} ${resp.statusText})`);
72
+ const bytes = new Uint8Array(await resp.arrayBuffer());
73
+ const contentType = resp.headers.get('content-type') ?? '';
74
+ const path = new URL(url).pathname.toLowerCase();
75
+ const type = detectType(contentType, path);
76
+ // SVG is vector, so it goes through the rasterizer rather than sharp's decoder.
77
+ const { height, png, width } = type === 'svg' || contentType.includes('svg') || path.endsWith('.svg')
78
+ ? await svgToPng(bytes)
79
+ : await rasterToPng(bytes);
80
+ return { meta: { fileSize: bytes.byteLength, height, type, url, width }, png };
81
+ };
82
+ /**
83
+ * Every model pulled on the host that can actually read an image.
84
+ *
85
+ * /api/tags (`list`) is the only endpoint that enumerates models, but it says
86
+ * nothing about what a model can do — the capability list lives on /api/show
87
+ * (`show`), so each tag is asked individually. The calls are local and run
88
+ * concurrently; a model whose show() fails (pulled but broken, or removed
89
+ * between the two calls) is simply left out rather than failing the run.
90
+ */
91
+ export const listVisionModels = async (host) => {
92
+ const ollama = new Ollama({ host });
93
+ const { models } = await ollama.list();
94
+ const checked = await Promise.all(models.map(async ({ model }) => {
95
+ try {
96
+ const { capabilities } = await ollama.show({ model });
97
+ return capabilities?.includes('vision') ? model : undefined;
98
+ }
99
+ catch {
100
+ }
101
+ }));
102
+ return checked.filter((model) => model !== undefined).sort();
103
+ };
104
+ /**
105
+ * Bind a describer to one Ollama host and model. The client is created once and
106
+ * reused so the connection (and the loaded model, via keep_alive) survives
107
+ * across images.
108
+ *
109
+ * `filter`, when given, decides whether an image is worth describing — it runs
110
+ * after the download (see fetchImage) but before inference, and a rejected
111
+ * image comes back as `{skipped: true}` rather than throwing, because being
112
+ * filtered out is a normal outcome and not a failure.
113
+ */
114
+ export const createDescriber = (host, model, filter) => {
115
+ const ollama = new Ollama({ host });
116
+ return async (url) => {
117
+ const started = performance.now();
118
+ const { meta, png } = await fetchImage(url);
119
+ if (filter && !filter(meta))
120
+ return { meta, skipped: true };
121
+ const images = [Buffer.from(png).toString("base64")];
122
+ const resp = await ollama.chat({
123
+ // eslint-disable-next-line camelcase
124
+ keep_alive: KEEP_ALIVE,
125
+ messages: [{ content: PROMPT, images, role: 'user' }],
126
+ model,
127
+ stream: false,
128
+ // think: true,
129
+ });
130
+ const content = resp.message.content.trim();
131
+ if (!content)
132
+ throw new Error(`${model} returned an empty description`);
133
+ return {
134
+ alt: content,
135
+ bytes: png.byteLength,
136
+ meta,
137
+ ms: performance.now() - started,
138
+ skipped: false,
139
+ // Prompt tokens dominate here — an image is worth hundreds of them, the
140
+ // sentence that comes back is worth a few dozen — so both halves count.
141
+ tokens: (resp.prompt_eval_count ?? 0) + (resp.eval_count ?? 0),
142
+ };
143
+ };
144
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The `--filter` expression for `fnd alt-text`.
3
+ *
4
+ * The expression is evaluated with `eval`, deliberately: it comes from the flag
5
+ * the person is typing into their own shell, so it is already code they are
6
+ * running on their own machine — there is no privilege boundary to cross and no
7
+ * mini-language worth inventing when JavaScript's operators are exactly what a
8
+ * filter needs. It is NOT safe to feed this a string from anywhere else (a
9
+ * config file pulled off the network, a CI variable someone else controls).
10
+ *
11
+ * What the expression can see is the image's metadata plus `sizes`; nothing is
12
+ * passed in from the surrounding scope, because the eval'd text is a standalone
13
+ * function expression whose only bindings are its own parameters.
14
+ */
15
+ /** Everything a filter expression can test. Sizes are bytes, dimensions pixels. */
16
+ export interface ImageMeta {
17
+ /** Bytes as served by the site — not the size of the PNG we convert it to. */
18
+ fileSize: number;
19
+ height: number;
20
+ /** Short format token: `webp`, `png`, `jpeg`, `svg`, `avif`, `gif`, … */
21
+ type: string;
22
+ url: string;
23
+ width: number;
24
+ }
25
+ export type ImageFilter = (meta: ImageMeta) => boolean;
26
+ /**
27
+ * Byte-count helpers for filter expressions: `sizes.KB(100)` is 100 * 1024.
28
+ *
29
+ * Every casing of every unit is registered (`KB`, `Kb`, `kB`, `kb`) and they all
30
+ * mean the same thing — kb is NOT kilobits. Nobody filtering image files means
31
+ * bits, and a silent factor of eight is a worse outcome than a redundant alias.
32
+ * Multipliers are binary (1024), matching what an OS reports for a file.
33
+ */
34
+ export declare const sizes: Record<string, (n: number) => number>;
35
+ /**
36
+ * Compile a filter expression once, up front.
37
+ *
38
+ * Building it eagerly means a typo (`filesize`, a stray paren) fails before the
39
+ * first image is downloaded rather than four hundred images into a run — the
40
+ * expression is both parsed and run once against a probe here, since a bad
41
+ * identifier is a runtime ReferenceError, not a syntax error.
42
+ */
43
+ export declare const createFilter: (expression: string) => ImageFilter;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The `--filter` expression for `fnd alt-text`.
3
+ *
4
+ * The expression is evaluated with `eval`, deliberately: it comes from the flag
5
+ * the person is typing into their own shell, so it is already code they are
6
+ * running on their own machine — there is no privilege boundary to cross and no
7
+ * mini-language worth inventing when JavaScript's operators are exactly what a
8
+ * filter needs. It is NOT safe to feed this a string from anywhere else (a
9
+ * config file pulled off the network, a CI variable someone else controls).
10
+ *
11
+ * What the expression can see is the image's metadata plus `sizes`; nothing is
12
+ * passed in from the surrounding scope, because the eval'd text is a standalone
13
+ * function expression whose only bindings are its own parameters.
14
+ */
15
+ const UNITS = {
16
+ b: 1,
17
+ gb: 1024 ** 3,
18
+ kb: 1024,
19
+ mb: 1024 ** 2,
20
+ tb: 1024 ** 4,
21
+ };
22
+ /**
23
+ * Byte-count helpers for filter expressions: `sizes.KB(100)` is 100 * 1024.
24
+ *
25
+ * Every casing of every unit is registered (`KB`, `Kb`, `kB`, `kb`) and they all
26
+ * mean the same thing — kb is NOT kilobits. Nobody filtering image files means
27
+ * bits, and a silent factor of eight is a worse outcome than a redundant alias.
28
+ * Multipliers are binary (1024), matching what an OS reports for a file.
29
+ */
30
+ export const sizes = Object.fromEntries(Object.entries(UNITS).flatMap(([unit, multiplier]) => {
31
+ const cased = unit.length === 1
32
+ ? [unit, unit.toUpperCase()]
33
+ : [unit, unit.toUpperCase(), unit[0].toUpperCase() + unit[1], unit[0] + unit[1].toUpperCase()];
34
+ return [...new Set(cased)].map((name) => [name, (n) => n * multiplier]);
35
+ }));
36
+ /** A metadata shape used only to smoke-test the expression at startup. */
37
+ const PROBE = {
38
+ fileSize: 0,
39
+ height: 0,
40
+ type: 'png',
41
+ url: 'https://example.com/probe.png',
42
+ width: 0,
43
+ };
44
+ /**
45
+ * Compile a filter expression once, up front.
46
+ *
47
+ * Building it eagerly means a typo (`filesize`, a stray paren) fails before the
48
+ * first image is downloaded rather than four hundred images into a run — the
49
+ * expression is both parsed and run once against a probe here, since a bad
50
+ * identifier is a runtime ReferenceError, not a syntax error.
51
+ */
52
+ export const createFilter = (expression) => {
53
+ let compiled;
54
+ try {
55
+ // Indirect eval: evaluated in global scope, so the expression can't reach
56
+ // anything local to this module even by accident.
57
+ // eslint-disable-next-line no-eval
58
+ compiled = (0, eval)(`(({ fileSize, height, sizes, type, url, width }) => (${expression}))`);
59
+ }
60
+ catch (error) {
61
+ throw new Error(`--filter is not valid JavaScript: ${error.message}`);
62
+ }
63
+ const run = (meta) => Boolean(compiled({ ...meta, sizes }));
64
+ try {
65
+ run(PROBE);
66
+ }
67
+ catch (error) {
68
+ throw new Error(`--filter could not be evaluated: ${error.message}. Available values: fileSize, width, height, url, type, sizes.`);
69
+ }
70
+ return run;
71
+ };
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+ type KvShape = Record<string, z.ZodType>;
3
+ /** Usage string for the schema, required keys first: `a=<...>,b=<...>[,c=<...>]`. */
4
+ export declare const kvUsage: (schema: z.ZodObject<KvShape>) => string;
5
+ /** Example flag value built from each field's example meta (falls back to its hint), required keys first. */
6
+ export declare const kvExample: (schema: z.ZodObject<KvShape>, opts?: {
7
+ requiredOnly?: boolean;
8
+ }) => string;
9
+ /**
10
+ * Parse a key=value flag string against the schema. Keys are case-insensitive
11
+ * and order-independent; unknown keys, duplicate keys, and missing required
12
+ * keys are hard errors naming the flag and its usage.
13
+ */
14
+ export declare const parseKvFlag: <T extends KvShape>(flag: string, raw: string, schema: z.ZodObject<T>) => z.output<z.ZodObject<T>>;
15
+ export {};