moshcode 0.73.0 → 0.75.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/src/mirror.mjs CHANGED
@@ -56,6 +56,28 @@ export function pressKey(name, rl = null, stdin = process.stdin) {
56
56
  const FLUSH_MS = 150; // batch writes so a busy render is one request, not fifty
57
57
  const MAX_BUFFER = 16000; // flush early once a batch gets big
58
58
 
59
+ // Where a child process's output should be copied while a mirror is watching.
60
+ //
61
+ // Module-level rather than threaded through every call, because "is anyone
62
+ // watching this pit" is one fact about the process and the launchers that need
63
+ // it are scattered: the shell, the installers, the upgrader, the plugin/skill/
64
+ // MCP hand-offs. Passing it down by hand is what left most of them writing
65
+ // straight to the tty with the session page showing nothing — each new launcher
66
+ // had to remember, and none of them did. src/pty.mjs reads this as its default,
67
+ // so capture is what a launcher gets for free and opting out is the deliberate
68
+ // act.
69
+ let activeSink = null;
70
+
71
+ /** Point child capture at this mirror (or null when the pit stops mirroring). */
72
+ export function setActiveSink(sink) {
73
+ activeSink = typeof sink === "function" ? sink : null;
74
+ }
75
+
76
+ /** The sink a child's output should be copied to, or null when unmirrored. */
77
+ export function activeChildSink() {
78
+ return activeSink;
79
+ }
80
+
59
81
  export function createMirror({
60
82
  version = "",
61
83
  cwd = process.cwd(),
package/src/payments.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
  // `/payments connect stripe` records a *reference* — vault and key name — and
23
23
  // says out loud where the secret should go.
24
24
  import { spawnSync } from "node:child_process";
25
+ import { captureSpec } from "./pty.mjs";
25
26
 
26
27
  import { loadBusiness, updateBusiness } from "./business-store.mjs";
27
28
  import { parseFields } from "./clients.mjs";
@@ -178,7 +179,10 @@ function connectGateway(args, write, run) {
178
179
  return 1;
179
180
  }
180
181
  write(info(`handing you to ${bone(gateway.bin)} — it owns its own session`));
181
- const result = run(gateway.bin, gateway.connect, { stdio: "inherit" });
182
+ const launch = captureSpec({ cmd: gateway.bin, args: gateway.connect });
183
+ let result;
184
+ try { result = run(launch.cmd, launch.args, { stdio: "inherit" }); }
185
+ finally { launch.stop(); }
182
186
  if (result?.error) { write(err(String(result.error.message || result.error))); return 1; }
183
187
  if (result?.status) {
184
188
  write(err(`${gateway.bin} ${gateway.connect.join(" ")} exited ${result.status} — nothing recorded`));
@@ -0,0 +1,195 @@
1
+ // Installer for the tools that only ship through a system package manager.
2
+ //
3
+ // ffmpeg and ImageMagick are the odd ones in TOOLS: they are not a vendor's CLI
4
+ // with a `curl … | sh` of its own, and they are not a static binary on a GitHub
5
+ // release either. They are distro packages, which is why they are installed the
6
+ // way a distro package is installed — and why this is a separate file from
7
+ // release-install.mjs rather than another descriptor in it.
8
+ //
9
+ // Static builds do exist for both. They are third-party redistributions of
10
+ // somebody else's codec stack, unsigned, and updated by nobody in particular.
11
+ // Downloading one to avoid a sudo prompt would be trading a password for a
12
+ // binary we cannot vouch for, on the two tools most likely to be pointed at a
13
+ // file from the internet.
14
+ //
15
+ // Everything that decides *what to run* is a pure function so the per-manager
16
+ // argv (which differ in irritating ways) is unit-tested offline; the only
17
+ // impure part is the loop at the bottom that runs it.
18
+ import { spawnSync } from "node:child_process";
19
+ import { realpathSync } from "node:fs";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ import { findEscalator } from "./escalate.mjs";
23
+
24
+ /**
25
+ * How each manager installs, non-interactively.
26
+ *
27
+ * Non-interactive is the point: this runs inside `moshcode install` and inside
28
+ * `moshcode upgrade tools`, and a manager that stops to ask "Do you want to
29
+ * continue? [Y/n]" inside an upgrade sweep parks the whole plan.
30
+ *
31
+ * apt refreshes first because its index goes stale on its own: a box that has
32
+ * not run `apt-get update` in a few months gets a 404 on the archive rather
33
+ * than a package, and the error names a URL instead of the actual problem.
34
+ */
35
+ export const MANAGERS = {
36
+ brew: {
37
+ // Never escalated. Homebrew refuses to run as root and says so at length.
38
+ root: false,
39
+ steps: (pkg) => [["brew", ["install", pkg]]],
40
+ },
41
+ "apt-get": {
42
+ root: true,
43
+ steps: (pkg) => [
44
+ ["apt-get", ["update", "-qq"]],
45
+ ["apt-get", ["install", "-y", "--no-install-recommends", pkg]],
46
+ ],
47
+ },
48
+ dnf: { root: true, steps: (pkg) => [["dnf", ["install", "-y", pkg]]] },
49
+ zypper: { root: true, steps: (pkg) => [["zypper", ["--non-interactive", "install", pkg]]] },
50
+ pacman: { root: true, steps: (pkg) => [["pacman", ["-S", "--needed", "--noconfirm", pkg]]] },
51
+ apk: { root: true, steps: (pkg) => [["apk", ["add", "--no-cache", pkg]]] },
52
+ };
53
+
54
+ /** The order managers are probed in. brew first, and only because of macOS. */
55
+ export const MANAGER_ORDER = ["brew", "apt-get", "dnf", "zypper", "pacman", "apk"];
56
+
57
+ /**
58
+ * Package names per tool, per manager, in the order they are worth trying.
59
+ *
60
+ * Two entries have more than one name and both are facts about somebody else's
61
+ * archive rather than hedging:
62
+ *
63
+ * Fedora ships `ffmpeg-free` in the main repositories and the full `ffmpeg`
64
+ * only from RPM Fusion, so a box without that repo enabled has exactly one of
65
+ * the two names and `dnf install ffmpeg` fails outright on it.
66
+ *
67
+ * `imagemagick` is one name for two different programs: on Ubuntu up to
68
+ * 24.04 it depends on the 6.x package and puts `convert` on PATH, and from
69
+ * 25.04 it depends on the 7.x one and puts `magick` there instead. The
70
+ * package name is stable, which is why this table has one entry and the
71
+ * tool's `bin` has two.
72
+ */
73
+ export const PACKAGES = {
74
+ ffmpeg: {
75
+ brew: ["ffmpeg"],
76
+ "apt-get": ["ffmpeg"],
77
+ dnf: ["ffmpeg", "ffmpeg-free"],
78
+ zypper: ["ffmpeg"],
79
+ pacman: ["ffmpeg"],
80
+ apk: ["ffmpeg"],
81
+ },
82
+ imagemagick: {
83
+ brew: ["imagemagick"],
84
+ "apt-get": ["imagemagick"],
85
+ dnf: ["ImageMagick"],
86
+ zypper: ["ImageMagick"],
87
+ pacman: ["imagemagick"],
88
+ apk: ["imagemagick"],
89
+ },
90
+ };
91
+
92
+ function defaultProbe(tool) {
93
+ return spawnSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" }).status === 0;
94
+ }
95
+
96
+ /** Resolve a name to its package table, or throw. Own properties only. */
97
+ export function resolvePackage(tool) {
98
+ const key = String(tool ?? "").trim().toLowerCase();
99
+ if (!Object.hasOwn(PACKAGES, key)) {
100
+ throw new Error(
101
+ `unknown package ${JSON.stringify(tool)} — expected one of ${Object.keys(PACKAGES).join(", ")}`,
102
+ );
103
+ }
104
+ return [key, PACKAGES[key]];
105
+ }
106
+
107
+ /** Which package manager this machine has, or null. */
108
+ export function findManager({ probe = defaultProbe, order = MANAGER_ORDER } = {}) {
109
+ for (const name of order) {
110
+ if (probe(name)) return name;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * The commands that install one package name with one manager.
117
+ *
118
+ * Escalation is applied here rather than by the caller because whether a step
119
+ * needs it is a property of the manager: brew must not be escalated, the rest
120
+ * must be unless we are already root. A `null` escalator on a manager that
121
+ * needs one yields the bare command, which fails with the manager's own
122
+ * permission message — better advice than anything we would write.
123
+ */
124
+ export function installSteps(manager, pkg, { escalator = null, isRoot = false } = {}) {
125
+ const spec = MANAGERS[manager];
126
+ if (!spec) throw new Error(`unknown package manager ${JSON.stringify(manager)}`);
127
+ const escalate = spec.root && !isRoot && escalator;
128
+ return spec.steps(pkg).map(([cmd, args]) =>
129
+ escalate ? { cmd: escalator, args: [cmd, ...args] } : { cmd, args },
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Install a tool through whichever package manager is here.
135
+ *
136
+ * Package names are tried in order and the first that installs wins, because a
137
+ * name that is absent from this box's archive is a normal outcome (see the
138
+ * Fedora note above) rather than a failure to report. Only when every candidate
139
+ * has failed is there something to say.
140
+ */
141
+ export function installPackage(tool, { run = spawnSync, probe = defaultProbe, log = console.log } = {}) {
142
+ const [key, table] = resolvePackage(tool);
143
+ const manager = findManager({ probe });
144
+ if (!manager) {
145
+ throw new Error(
146
+ `no supported package manager found (${MANAGER_ORDER.join(", ")}) — install ${key} yourself and re-run`,
147
+ );
148
+ }
149
+
150
+ const candidates = table[manager];
151
+ if (!candidates?.length) {
152
+ throw new Error(`${key} has no known package name for ${manager} — install it yourself and re-run`);
153
+ }
154
+
155
+ const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
156
+ const escalator = MANAGERS[manager].root && !isRoot ? findEscalator({ probe }) : null;
157
+
158
+ const failures = [];
159
+ for (const pkg of candidates) {
160
+ log(`↓ ${manager} ${pkg}`);
161
+ let ok = true;
162
+ for (const step of installSteps(manager, pkg, { escalator, isRoot })) {
163
+ const result = run(step.cmd, step.args, { stdio: "inherit" });
164
+ if (result?.error || result?.status !== 0) {
165
+ failures.push(`${pkg}: ${step.cmd} ${step.args.join(" ")} ${result?.error ? `(${result.error.message})` : `exited ${result?.status}`}`);
166
+ ok = false;
167
+ break;
168
+ }
169
+ }
170
+ if (ok) {
171
+ log(`✓ ${key} installed with ${manager}`);
172
+ return { manager, pkg };
173
+ }
174
+ }
175
+
176
+ throw new Error(`could not install ${key} with ${manager}:\n ${failures.join("\n ")}`);
177
+ }
178
+
179
+ /** True when this file was executed directly rather than imported. */
180
+ function invokedDirectly() {
181
+ try {
182
+ return realpathSync(process.argv[1] || "") === realpathSync(fileURLToPath(import.meta.url));
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ if (invokedDirectly()) {
189
+ try {
190
+ installPackage(process.argv[2]);
191
+ } catch (e) {
192
+ console.error(`install failed: ${e.message}`);
193
+ process.exit(1);
194
+ }
195
+ }
package/src/pty.mjs CHANGED
@@ -19,8 +19,11 @@
19
19
  // `script` disagree on both flag names and argument order, and anything we
20
20
  // cannot positively identify falls back to today's plain `inherit`.
21
21
  import { spawnSync } from "node:child_process";
22
- import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
22
+ import { closeSync, existsSync, mkdtempSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import path from "node:path";
23
25
  import { StringDecoder } from "node:string_decoder";
26
+ import { activeChildSink } from "./mirror.mjs";
24
27
 
25
28
  /**
26
29
  * POSIX single-quote escaping, for argv that has to survive being flattened
@@ -178,3 +181,57 @@ export function ptyEnabled(sink, flavor = scriptFlavor()) {
178
181
  if (process.env.MOSHCODE_MIRROR_PTY === "0") return false;
179
182
  return Boolean(flavor);
180
183
  }
184
+
185
+ /**
186
+ * Wrap a spawn spec so a copy of everything the child prints reaches `onOutput`
187
+ * while the child still owns the real terminal.
188
+ *
189
+ * The whole capture dance in one place — temp transcript, the flavour-specific
190
+ * `script` argv, the follower, the banner strip, the cleanup — because every
191
+ * launcher in the pit needs it, and each one growing its own copy is how a
192
+ * shell command ended up invisible in the mirror while `/agents claude` was
193
+ * captured: both spawn `inherit`, and only one of them had been taught this.
194
+ *
195
+ * `onOutput` defaults to whatever the live mirror is (src/mirror.mjs), so a
196
+ * launcher gets capture without having to know the mirror exists — the reverse
197
+ * of how this started, where each launcher had to be taught separately and only
198
+ * two ever were. Pass `null` to opt a launch out.
199
+ *
200
+ * Returns `{ cmd, args, stop }`. With nothing watching, or on a box with no
201
+ * `script(1)` we can drive, `cmd`/`args` come back exactly as passed in and
202
+ * `stop` is a no-op — the caller spawns what it always spawned. `stop()` must
203
+ * be called once the child exits: it drains the tail of the transcript (the
204
+ * last lines of a command are usually the ones you were waiting for) and
205
+ * removes the temp dir.
206
+ */
207
+ export function captureSpec({ cmd, args = [] }, onOutput = activeChildSink(), { flavor = scriptFlavor() } = {}) {
208
+ const plain = { cmd, args, stop: () => {} };
209
+ if (!ptyEnabled(onOutput, flavor)) return plain;
210
+ let workDir = null;
211
+ try {
212
+ workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
213
+ const transcript = path.join(workDir, "transcript");
214
+ writeFileSync(transcript, "");
215
+ const wrapped = ptySpec(cmd, args, transcript, flavor);
216
+ if (!wrapped) throw new Error("no script(1) spec for this flavour");
217
+ let first = true;
218
+ const stopFollow = followFile(transcript, (chunk) => {
219
+ const clean = stripScriptBanner(chunk, first);
220
+ first = false;
221
+ if (clean) onOutput(clean);
222
+ });
223
+ const dir = workDir;
224
+ return {
225
+ cmd: wrapped.cmd,
226
+ args: wrapped.args,
227
+ stop() {
228
+ try { stopFollow(); } catch { /* nothing left to drain */ }
229
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir */ }
230
+ },
231
+ };
232
+ } catch {
233
+ // Capture is a nicety; never let it stop a command from running.
234
+ if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
235
+ return plain;
236
+ }
237
+ }
@@ -29,6 +29,8 @@ import { fileURLToPath } from "node:url";
29
29
  * - supabase also publishes version-less asset aliases, so `latest/download`
30
30
  * resolves without asking the API for a tag first.
31
31
  * - doctl separates its asset fields with "-" instead of "_".
32
+ * - yt-dlp publishes the executable itself rather than an archive, so there
33
+ * is nothing to unpack; `bare` is what says so.
32
34
  */
33
35
  export const RELEASES = {
34
36
  gh: {
@@ -54,6 +56,27 @@ export const RELEASES = {
54
56
  asset: ({ version, platform, arch }) => `doctl-${version}-${platform}-${arch}.tar.gz`,
55
57
  binPath: () => "doctl",
56
58
  },
59
+ "yt-dlp": {
60
+ repo: "yt-dlp/yt-dlp",
61
+ binary: "yt-dlp",
62
+ // The asset IS the executable — a PyInstaller bundle, so it needs no
63
+ // python on the box, and there is no archive around it to unpack.
64
+ bare: true,
65
+ // `unversioned` is not a convenience here, it is required: yt-dlp tags
66
+ // releases by date with no leading "v" (2025.08.11), so the versioned URL
67
+ // this builds otherwise — /download/v2025.08.11/ — is a 404. The
68
+ // /releases/latest/download/ alias sidesteps the tag spelling entirely.
69
+ unversioned: true,
70
+ // macOS gets one universal2 build for both architectures; Linux names arm64
71
+ // "aarch64" while every other vendor here calls it arm64.
72
+ asset: ({ platform, arch }) =>
73
+ platform === "darwin"
74
+ ? "yt-dlp_macos"
75
+ : arch === "arm64"
76
+ ? "yt-dlp_linux_aarch64"
77
+ : "yt-dlp_linux",
78
+ binPath: () => "yt-dlp",
79
+ },
57
80
  };
58
81
 
59
82
  // Node's process.arch names differ from the ones release assets use.
@@ -151,14 +174,18 @@ export async function installRelease(tool, { fetchImpl = fetch } = {}) {
151
174
  try {
152
175
  const archive = path.join(work, path.posix.basename(new URL(url).pathname));
153
176
  writeFileSync(archive, Buffer.from(await res.arrayBuffer()));
154
- const unpacked = path.join(work, "unpacked");
155
- mkdirSync(unpacked);
156
- extract(archive, unpacked);
157
-
158
- const relative = spec.binPath(target);
159
- const from = path.join(unpacked, relative);
160
- if (!existsSync(from)) {
161
- throw new Error(`${spec.binary} was not at ${relative} inside ${path.basename(archive)} — the vendor's archive layout changed`);
177
+
178
+ let from = archive;
179
+ if (!spec.bare) {
180
+ const unpacked = path.join(work, "unpacked");
181
+ mkdirSync(unpacked);
182
+ extract(archive, unpacked);
183
+
184
+ const relative = spec.binPath(target);
185
+ from = path.join(unpacked, relative);
186
+ if (!existsSync(from)) {
187
+ throw new Error(`${spec.binary} was not at ${relative} inside ${path.basename(archive)} — the vendor's archive layout changed`);
188
+ }
162
189
  }
163
190
 
164
191
  const dir = installDir();
@@ -74,6 +74,28 @@ export const SYNCED_FILES = [
74
74
  // reported as skipped rather than failing the snapshot, which is the right
75
75
  // answer for a list that got big by importing somebody else's.
76
76
  { path: "news.opml", json: false, label: "news subscriptions" },
77
+ // Herd's notification preferences — which states nag you and whether they
78
+ // ask. `rules.json` above has been carried since the first version and this
79
+ // sat next to it, unsynced, which made "my herd settings came across" true
80
+ // of half of them.
81
+ { path: "herd/config.json", json: true, label: "herd notifications" },
82
+ // Per-model price overrides for `/cost`. Nothing writes this file; a person
83
+ // types it, once, from a pricing page — which is exactly the kind of work
84
+ // `/save` exists so you only do once.
85
+ { path: "pricing.json", json: true, label: "cost model prices" },
86
+ // The DNS filter's policy: categories, and your own allow and block lists.
87
+ // A curated decision, not machine state — the blocklists it names are a
88
+ // cache that re-downloads itself, and `dns-filter/stats.json` next to it is
89
+ // browsing history and named below as never-synced.
90
+ { path: "dns-filter/filter.json", json: true, label: "dns filter policy" },
91
+ // Clients, teams, rates, invoices. The largest single "start from nothing"
92
+ // on a new machine, and safe to carry because payment gateways are stored
93
+ // here as references into a vault rather than as keys (see payments.mjs).
94
+ //
95
+ // Last on purpose. The total cap is spent in this order, so the file most
96
+ // likely to grow past it goes after the small ones — otherwise a year of
97
+ // invoices silently pushes your aliases out of the snapshot.
98
+ { path: "business.json", json: true, label: "clients, rates and invoices" },
77
99
  ];
78
100
 
79
101
  /**
@@ -91,13 +113,63 @@ export const NEVER_SYNCED = [
91
113
  "sync.json",
92
114
  "herd/sessions.json",
93
115
  "herd/hook.json",
116
+ // A billing ledger, not a preference. Two machines both appending hours and
117
+ // then both saving means the later `/save` drops the earlier one's entries,
118
+ // and it grows without bound — the two properties that make a file wrong for
119
+ // a last-write-wins sync.
120
+ "timers.json",
121
+ // "the numbers you last saw here", so that `add 3` means something. Carrying
122
+ // them would make `add 3` on another machine refer to a listing it never saw.
123
+ "news-last.json",
124
+ "news-found.json",
125
+ // Counters, and `recent[]` — the last twenty domains this machine was
126
+ // blocked from reaching. That is browsing history, and it has no business in
127
+ // a settings snapshot even one belonging to the person who generated it.
128
+ "dns-filter/stats.json",
129
+ // One box's daemon.
130
+ "moshpit-dns.pid",
131
+ "moshpit-dns.log",
132
+ ];
133
+
134
+ /**
135
+ * Whole subtrees that never sync, matched by prefix.
136
+ *
137
+ * `NEVER_SYNCED` is an exact-string list, so a directory named there would be
138
+ * inert — the entries under it would not match it and would fall through. Any
139
+ * rule about a directory has to live here to have an effect.
140
+ */
141
+ export const NEVER_SYNCED_PREFIXES = [
142
+ // The program itself. ~/.moshcode is also the install directory.
143
+ "pkg/",
144
+ // Hook reports, remote polls and per-session task ledgers: all pinned to one
145
+ // runtime, and the task ledgers carry prompt text and output artifacts.
146
+ "herd/status/",
147
+ "herd/remote/",
148
+ "herd/tasks/",
149
+ // Cached copies of published feed lists, re-fetched on demand.
150
+ "lists/",
151
+ // Downloaded blocklists. Megabytes, and self-renewing.
152
+ "dns-filter/lists/",
94
153
  ];
95
154
 
155
+ /**
156
+ * The pty substrate, by extension.
157
+ *
158
+ * The header comment above has claimed since the first version that `*.sock`
159
+ * and `*.pid` are excluded "because they describe processes on exactly one
160
+ * box". That was true of the intent and never of the code: nothing enforced
161
+ * it, and the allowlist alone happened to be doing the work. Now it is a rule.
162
+ * A transcript is the one that matters — it is a full screen capture and will
163
+ * hold whatever was typed into that session.
164
+ */
165
+ export const NEVER_SYNCED_SUFFIXES = [".transcript", ".stdin", ".exit", ".sock", ".pid", ".log"];
166
+
96
167
  /** True for a path this build is willing to read or write. */
97
168
  export function isSyncable(relative) {
98
169
  const name = String(relative ?? "");
99
170
  if (NEVER_SYNCED.includes(name)) return false;
100
- if (name.startsWith("pkg/")) return false;
171
+ if (NEVER_SYNCED_PREFIXES.some((prefix) => name.startsWith(prefix))) return false;
172
+ if (NEVER_SYNCED_SUFFIXES.some((suffix) => name.endsWith(suffix))) return false;
101
173
  return SYNCED_FILES.some((f) => f.path === name);
102
174
  }
103
175
 
package/src/timer.mjs CHANGED
@@ -45,8 +45,12 @@ export function parseDuration(text) {
45
45
  export function humanDuration(seconds) {
46
46
  const total = Math.max(0, Math.round(Number(seconds) || 0));
47
47
  if (total < 60) return `${total}s`;
48
- const h = Math.floor(total / 3600);
49
- const m = Math.round((total % 3600) / 60);
48
+ // Round to whole minutes first, then split — computing hours and minutes off
49
+ // the raw seconds lets a remainder that rounds up to 60 (59m30s..59m59s) print
50
+ // as "60m"/"1h 60m" instead of carrying into the hour it belongs in.
51
+ const minutes = Math.round(total / 60);
52
+ const h = Math.floor(minutes / 60);
53
+ const m = minutes % 60;
50
54
  if (!h) return `${m}m`;
51
55
  return m ? `${h}h ${m}m` : `${h}h`;
52
56
  }
package/src/tools.mjs CHANGED
@@ -18,6 +18,12 @@ import { isInstalled, openPassthrough } from "./engines.mjs";
18
18
  const RELEASE_INSTALLER = path.join(path.dirname(fileURLToPath(import.meta.url)), "release-install.mjs");
19
19
  const releaseInstall = (tool) => ({ cmd: process.execPath, args: [RELEASE_INSTALLER, tool] });
20
20
 
21
+ // ffmpeg and ImageMagick ship as distro packages and nothing else — no vendor
22
+ // installer, no release binary we would trust. See src/pkg-install.mjs for why
23
+ // the static rebuilds floating around are not an option here.
24
+ const PACKAGE_INSTALLER = path.join(path.dirname(fileURLToPath(import.meta.url)), "pkg-install.mjs");
25
+ const packageInstall = (tool) => ({ cmd: process.execPath, args: [PACKAGE_INSTALLER, tool] });
26
+
21
27
  export const TOOLS = {
22
28
  ugig: {
23
29
  desc: "UGig — freelance marketplace CLI for humans and agents",
@@ -354,6 +360,55 @@ export const TOOLS = {
354
360
  // there is deliberately no `upgrade` key.
355
361
  install: { cmd: "npm", args: ["install", "-g", "@elevenlabs/cli"] },
356
362
  },
363
+ "yt-dlp": {
364
+ desc: "yt-dlp — download video and audio from a URL (a thousand sites, not just YouTube)",
365
+ bin: "yt-dlp",
366
+ // The three tools below are not workflow CLIs like everything above: they
367
+ // are the media toolchain `cli-tools` builds on. `dl` is a front for
368
+ // yt-dlp, `vid` for ffmpeg and `img` for ImageMagick, and all three used to
369
+ // tell you to go and install a system package by hand. Now the same
370
+ // registry that installs cli-tools can install what it runs on.
371
+ //
372
+ // A PyInstaller bundle from the project's own releases, so it needs no
373
+ // python and no package manager, and it lands in ~/.local/bin like
374
+ // gh/supabase/doctl. Distro packages of yt-dlp are the one thing worth
375
+ // avoiding here: extractors break whenever a site changes, upstream ships a
376
+ // fix within days, and a distro package is frozen for the life of a release.
377
+ install: releaseInstall("yt-dlp"),
378
+ // Which is also why the upgrade is yt-dlp's own `-U` rather than a
379
+ // re-download: it is the update path the project documents, it checks
380
+ // before it fetches, and it is the one an operator will reach for anyway.
381
+ // On a yt-dlp that came from a package manager instead, `-U` declines and
382
+ // says so, which is the correct answer rather than a failure.
383
+ upgrade: { cmd: "yt-dlp", args: ["-U"] },
384
+ // Same gap turso, gradient and kimi have: nothing appends to PATH.
385
+ binDirs: [path.join(homedir(), ".local", "bin")],
386
+ },
387
+ ffmpeg: {
388
+ desc: "ffmpeg — convert, cut, scale and inspect audio and video",
389
+ bin: "ffmpeg",
390
+ // Through the distro package manager, which means root everywhere but
391
+ // macOS, where Homebrew refuses to run as root at all. Same shape as
392
+ // tailscale, and for the same reason: get the password prompt out of the
393
+ // way before a sweep starts rather than partway through one.
394
+ needsRoot: { except: ["darwin"] },
395
+ install: packageInstall("ffmpeg"),
396
+ // No upgrade key: `apt-get install` / `brew install` on a package that is
397
+ // already there upgrades it, so re-running the install IS the upgrade —
398
+ // the same reasoning as mcpjam and railway, and toolUpgradeSpec falls back
399
+ // to install on its own.
400
+ },
401
+ imagemagick: {
402
+ desc: "ImageMagick — resize, convert and composite images from the command line",
403
+ // Two names, deliberately. The command is `magick` on ImageMagick 7 and
404
+ // `convert` on 6, and both are current: Ubuntu 24.04 and earlier ship 6,
405
+ // 25.04 and later ship 7, and the package is called `imagemagick` on both.
406
+ // A single name would report a perfectly good install as missing on
407
+ // whichever half of the fleet has the other one.
408
+ bin: ["magick", "convert"],
409
+ needsRoot: { except: ["darwin"] },
410
+ install: packageInstall("imagemagick"),
411
+ },
357
412
  };
358
413
 
359
414
  /** Resolve a name to `[key, tool]`, or null. */
@@ -377,7 +432,7 @@ export function toolStatus() {
377
432
 
378
433
  export function toolList() {
379
434
  return Object.entries(TOOLS)
380
- .map(([key, tool]) => ` ${key.padEnd(10)} ${tool.desc}`)
435
+ .map(([key, tool]) => ` ${key.padEnd(11)} ${tool.desc}`)
381
436
  .join("\n");
382
437
  }
383
438