diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..592c8db --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# The sh shim must keep LF or Git Bash fails with "$'\r': command not found". +resources/cli/filesmith text eol=lf +resources/cli/*.cmd text eol=crlf +resources/cli/*.ps1 text eol=crlf +# Skill files (Task 17): their frontmatter is matched with LF in tests. +resources/skill/** text eol=lf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e490ad4..a210cf0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,11 @@ on: - 'scripts/verify-bundle.mjs' - 'electron-builder.yml' - 'package.json' + - 'src/cli/**' + - 'resources/cli/**' + - 'resources/skill/**' + - 'build/installer.nsh' + - 'build/installer/**' workflow_dispatch: concurrency: @@ -110,6 +115,30 @@ jobs: - name: Verify bundled tools (packed app) run: node scripts/verify-bundle.mjs dist/win-unpacked/resources --manifest dist/packed-manifest.txt + # The command line in the packed app (spec 7.3): the shim must run the + # app's own exe in Node mode, print the package.json version, pass + # doctor's core checks and convert one file. This also fails loudly if the + # runAsNode Electron fuse is ever turned off. + - name: Smoke-test the packed CLI + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $false + $shim = 'dist\win-unpacked\resources\cli\filesmith.cmd' + $v = (& $shim --version | Out-String).Trim() + if ($v -ne '${{ steps.ver.outputs.version }}') { throw "filesmith --version printed '$v'" } + $env:FILESMITH_USER_DATA = Join-Path $env:RUNNER_TEMP 'fs-ud' + $events = & $shim doctor --json | ForEach-Object { $_ | ConvertFrom-Json } + $bad = @($events | Where-Object { $_.event -eq 'check' -and $_.group -eq 'core' -and $_.status -eq 'fail' }) + if ($bad.Count) { throw "doctor core checks failed: $($bad.id -join ', ')" } + $png = Join-Path $env:RUNNER_TEMP 'smoke.png' + [IO.File]::WriteAllBytes($png, [Convert]::FromBase64String('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==')) + & $shim convert $png --to webp --json | Out-Host + if ($LASTEXITCODE -ne 0) { throw "filesmith convert exited $LASTEXITCODE" } + if (-not (Test-Path (Join-Path $env:RUNNER_TEMP 'smoke.webp'))) { throw 'smoke.webp was not written' } + "- packed CLI ``$v``: doctor core checks ok, convert ok" >> $env:GITHUB_STEP_SUMMARY + $global:LASTEXITCODE = 0 + - name: Check installer id: exe shell: pwsh diff --git a/.prettierignore b/.prettierignore index 304cfe1..0e252b7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,6 @@ docs/mockups # The NSIS setup kit keeps its own (Prism-derived) formatting. build/installer patches + +# Design and plan documents: their code blocks are partial snippets, not code. +docs/superpowers diff --git a/CLAUDE.md b/CLAUDE.md index be5207b..795eb0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,10 @@ src/ views/ (Generate, Tools, Completed, Settings), ui/ (primitives), icons/, theme/ (tokens, CSS) shared/ types.ts — Job, ToolId, FileKind, Options, progress events tabs.ts — the VERB-first navigation model (rail tabs + Tools cards) + cli/ the filesmith command line: parse, plan, run, report resources/bin/ bundled CLI binaries (gitignored; fetched by scripts, packed by electron-builder) +resources/cli/ PATH shims + path.ps1 +resources/skill/ Claude Code skill ``` ## Build, test, run @@ -80,6 +83,8 @@ resources/bin/ bundled CLI binaries (gitignored; fetched by scripts, packed by e - `npm run package` — electron-vite build + electron-builder NSIS installer to `dist/`. - `npm run test:e2e` — Playwright end-to-end (launches the built app via `_electron`; run `npm run build` first). Covers the preload/IPC/engine chain unit tests can't reach. +- `npm run cli -- ` runs the command line from `out/main/cli.js` (build first). Reference: + `docs/cli.md`. The engine never imports `electron`; it reads paths from `src/main/env.ts`. - Releases: push to main runs `.github/workflows/release.yml` (gates, then requires a NEW `package.json` version, builds with `fetch-binaries --pinned`, publishes `v`). Bump the version in the PR. Tool versions + SHA-256 live in `scripts/pinned-tools.mjs`; PRs touching diff --git a/build/installer.nsh b/build/installer.nsh index 42b6fd8..bc124d8 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -16,6 +16,19 @@ ManifestDPIAware true !macroend +; PATH entry for the command line (spec 7.2). Outside the guard: the uninstaller +; compiles from this file and removes the entry. +!include "installer\path.nsh" + +; Uninstall drops the PATH entry, except during an update: electron-builder +; runs the old uninstaller with --updated, and the new version re-adds the same +; folder at once, so the entry should not blink out in between. +!macro customUnInstall + ${ifNot} ${isUpdated} + !insertmacro filesmithPathRemove + ${endIf} +!macroend + ; The uninstaller is compiled from this same script with BUILD_UNINSTALLER set, ; and it has none of these pages. Without the guard its pass would resize a ; window it never draws, and warn about every function it does not call. diff --git a/build/installer/pages.nsh b/build/installer/pages.nsh index df6bb5d..f2cff07 100644 --- a/build/installer/pages.nsh +++ b/build/installer/pages.nsh @@ -25,7 +25,9 @@ ; When the section ends, autoclose walks on to the finish page by itself: the ; Next button it would otherwise wait for has been hidden since .onGUIInit. +; Before that, put the command line on the per-user PATH (path.nsh). !macro customInstall + !insertmacro filesmithPathAdd SetAutoClose true !macroend diff --git a/build/installer/path.nsh b/build/installer/path.nsh new file mode 100644 index 0000000..2feded0 --- /dev/null +++ b/build/installer/path.nsh @@ -0,0 +1,21 @@ +; +; Filesmith on the per-user PATH (spec 7.2). The edit itself is path.ps1, which +; keeps REG_EXPAND_SZ and never truncates a long PATH. Only resources\cli goes +; on PATH: putting resources\bin there would shadow the user's own ffmpeg, +; magick and 7z. +; +!include "WinMessages.nsh" + +!define FILESMITH_PS '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\cli\path.ps1"' + +!macro filesmithPathAdd + nsExec::ExecToLog '${FILESMITH_PS} add "$INSTDIR\resources\cli"' + Pop $0 + SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000 +!macroend + +!macro filesmithPathRemove + nsExec::ExecToLog '${FILESMITH_PS} remove "$INSTDIR\resources\cli"' + Pop $0 + SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000 +!macroend diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..b4729d3 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,229 @@ +# Filesmith command line + +## What it is + +`filesmith` runs every Filesmith operation from a terminal: convert, compress, resize, upscale, +removebg, generate and the PDF tools, plus the helpers `formats`, `doctor`, `setup` and `skill`. It +uses the same engine and the same bundled tools as the app (ffmpeg, ImageMagick, mutool, CaesiumCLT, +7-Zip, Ghostscript, LibreOffice, Real-ESRGAN), so results match the app's. It works offline, except for +the one-time setup of the AI tools. It never overwrites anything: outputs get a collision-free name next +to each source or in `--out`. It runs fine while the app is open, and its jobs never appear in the app's +queue or Completed view. + +## Install and PATH + +- The installer puts the command line in `\resources\cli` (by default + `%LOCALAPPDATA%\Programs\Filesmith\resources\cli`) and adds that folder to the per-user PATH. Terminals + opened after the install see it; terminals that were already open do not. +- cmd and PowerShell run `filesmith.cmd`. Git Bash, MSYS and Claude Code's Bash tool run the + extensionless `filesmith` sh shim (LF endings). Both start the installed `Filesmith.exe` in Node mode + (`ELECTRON_RUN_AS_NODE=1`) with `--use-system-ca` and `NODE_USE_ENV_PROXY=1`. +- cmd expands `%NAME%` inside arguments, even quoted. For file names that contain `%`, use PowerShell or + Git Bash. +- Ctrl+C cancels: see [Ctrl+C and unfinished outputs](#ctrlc-and-unfinished-outputs) for how it works + in the installed command and its one limit (output redirected in cmd or PowerShell). +- Windows PowerShell 5.1 decodes captured native output with the OEM code page, so non-ASCII paths in + `--json` output come out garbled. Run `[Console]::OutputEncoding = [Text.Encoding]::UTF8` first, or use + Git Bash. PowerShell 7 is not affected. +- Installing, updating or uninstalling Filesmith closes every running `Filesmith.exe`, a running CLI job + included. + +## Grammar + +``` +filesmith [options] +filesmith pdf [options] +filesmith generate "" [options] +filesmith [args] [options] +``` + +- **Shape.** Verbs are the sidebar verbs: `convert`, `compress`, `resize`, `upscale`, `removebg` + (also `remove-bg`, `remove-background`), `generate`. PDF tools: `merge`, `split`, `burst`, + `extract-text`, `to-images`, `extract-images`, `compress`. Helpers: `formats`, `doctor`, `setup`, + `skill`, `help`. Options may come before, between or after inputs; `--` ends options, so a file named + `-x.png` is `filesmith resize --percent 50 -- -x.png`. Long flags only, plus `-o` (`--out`) and `-h` + (`--help`); `--name value` and `--name=value` both work; verbs, flags and values are case-insensitive. + A bare `filesmith` prints the help. +- **Inputs.** Files, folders (their own files; `--recursive` descends; files the verb cannot take are + `skipped`; dot-files, `Thumbs.db` and `desktop.ini` are ignored), globs (`*.png`, `**/*.jpg`, expanded + by Filesmith itself, case-insensitive, so they also work in cmd and PowerShell) and `-` (paths from + stdin, one per line). Duplicates are dropped and the order is kept (it matters for `pdf merge`). A + missing path is `NOT_FOUND`, a glob without matches is `NO_MATCH`. +- **Options mirror the app.** Each flag is the app's setting label in kebab-case, each value the label + the app shows (`--quality smaller|balanced|best`, `--gpu full|balanced`). Units are optional + (`--bitrate 192k`, `--factor 4x`, `--percent 50%`). Defaults are the app's defaults, except that + `convert` requires `--to`. `filesmith formats [verb]` lists every target, value and model. +- **Never overwrite, and dry runs.** Output names follow the app's rule: `name.ext` if free, else + `name (tag).ext`, else `name (tag 2).ext`; folders `base`, `base (2)`. There is no `--force`. + `--dry-run` validates everything, prints the planned outputs and writes and downloads nothing. Planned + names are predictions; the real names are in the `done` events. `--out` creates the folder (with + parents) when it is missing; `generate` writes to the current folder unless `--out` is given. +- **Unfinished outputs never get the final name.** While a job runs, its final name holds an empty + placeholder and the tool writes `name (tag).filesmith-part.ext` (folders: `base.filesmith-part`) in the + same folder; only a job that succeeds renames it onto the final name. A failed or canceled job removes + both. Treat `*.filesmith-part*` entries as work in progress, never as results. +- **Output formats.** Human output by default: one line per file (`ok`, `skip`, `fail`) and a summary on + stdout, a progress line on stderr when it is a terminal. `--json` prints NDJSON events on stdout only + (see below). +- **Exit codes.** `0` all ok or skipped; `1` some job failed (or `doctor` found a failure, or `setup` + failed); `2` usage error or a requirement that fails for every input, nothing ran; `130` canceled + with Ctrl+C. + +### Ctrl+C and unfinished outputs + +- **Ctrl+C** (once) cancels the run: every job emits `canceled`, the tools (ffmpeg and the rest) are + stopped, unfinished outputs are removed, the summary is printed and the exit code is 130. In cmd there + is no "Terminate batch job (Y/N)?" prompt any more. A second Ctrl+C leaves at once, still stopping the + tools and removing this run's unfinished outputs. +- **How, in the installed command.** `Filesmith.exe` in Node mode is a GUI-subsystem program that Electron + attaches to the terminal's console after start-up, and in that process Windows never delivers Ctrl+C + to Node's `SIGINT` handler: the default handler ends the process at once (0xC000013A). So the CLI reads + the console itself in raw mode, where the Ctrl+C key arrives as the byte 0x03 instead of a signal + (`src/cli/consoleCtrlC.ts`). Plain Node (`npm run cli`) uses `SIGINT` as usual. +- **Ctrl+Break, closing the window, a hard kill** (`taskkill /F`, or Windows sending Ctrl+C + programmatically with `GenerateConsoleCtrlEvent`) still end `Filesmith.exe` at once, with no summary and + no exit 130. A small detached watchdog (`src/cli/watchdog.ts`, started with the first output) then stops + the tools the run left running and removes its part files and placeholders, so nothing half-written + stays behind. +- **Limit: output redirected in cmd or PowerShell** (`filesmith ... > out.txt`, `| findstr`). Then + Windows starts `Filesmith.exe` without a console, so the Ctrl+C key never reaches it: the run finishes + (cmd then asks "Terminate batch job (Y/N)?"). Close the run with `taskkill /IM Filesmith.exe /F` if you + must; the watchdog cleans up. Git Bash pipes and Claude Code's Bash tool are not affected by this, as + they do not cancel with a key press anyway. +- **Manual check** (also automated in `e2e/cli-packed.spec.ts`): in a new cmd window run + `filesmith compress "" --codec h265`, press Ctrl+C at about 20 %: `stop`, `1 canceled`, + `echo %ERRORLEVEL%` prints 130, no `ffmpeg.exe` in Task Manager, and only the source in the folder. + +### Examples + +From the help pages (`filesmith --help`): + +``` +filesmith convert *.heic --to jpg +filesmith convert book.pdf --to cbz --resolution 200 --page-format png +filesmith convert comics\ --to cbz --compression normal --out D:\Out + +filesmith compress *.jpg --quality 70 +filesmith compress lecture.mov --codec h265 --scale 50 +filesmith compress report.pdf --level smallest --greyscale + +filesmith resize *.png --percent 25 +filesmith resize hero.jpg --width 1920 +filesmith resize photos\ --recursive --height 1080 --out D:\Small + +filesmith upscale old.jpg --factor 2 +filesmith upscale frame.png --model pid --dry-run +filesmith upscale scans\*.png --model anime --gpu balanced --json + +filesmith removebg product.jpg --fill white +filesmith removebg portrait.png --image beach.jpg +filesmith removebg logo.png --color "#1e1e1e" --dry-run + +filesmith generate "a lighthouse at dusk" --count 4 --size 1216x832 +filesmith generate "a red kettle" --model flux1-dev --seed 42 --json +filesmith generate "a paper boat" --style anime --out D:\Art --dry-run + +filesmith pdf merge cover.pdf body.pdf appendix.pdf +filesmith pdf split thesis.pdf --pages 1-3,10 +filesmith pdf to-images slides.pdf --resolution 200 --out D:\Frames +``` + +## JSON events + +With `--json`, stdout carries one JSON object per line (UTF-8, `\n`), and stderr carries nothing an +agent needs. Every line has `v` (1), `event` and `ts` (ISO time). Paths are absolute, sizes in bytes; +fields that do not apply are left out, never `null` (except `pct`). + +| `event` | Fields | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `run` | `command`, `version`, `dryRun`, `inputs`, `options` | +| `plan` | `id`, `input` (array for merge), `inSize`, `op`, `output`, `outputKind`, `ready`, `code`, `message`, `hint` | +| `start` | `id`, `input`, `inSize`, `op` | +| `progress` | `id`, `pct` (or null), `etaSec`, `message` | +| `done` | files: `id`, `input`, `output`, `outputKind`, `inSize`, `outSize` or `files`, `ms`, `seed` (generate); setup: `tool`, `path`, `alreadyDone`; skill: `path`, `updated`, `previousVersion` | +| `skipped` | `id`, `input`, `code`, `message` | +| `error` | `id`, `input` (both absent for run-level errors), `code`, `message`, `hint` | +| `warning` | `code`, `message` | +| `canceled` | `id`, `input` | +| `check` | doctor, setup: `id`, `group`, `status` (ok, warn, fail, skip), `detail`, `fix` | +| `step` | setup: `step`, `pct`, `bytes`, `totalBytes`, `etaSec`, `detail` (dry run) | +| `heartbeat` | setup: `step`, `elapsedSec` (every 5 s while a step has no percentage) | +| `formats` | `data` | +| `version` | `version` | +| `summary` | `ok`, `failed`, `skipped`, `canceled`, `inBytes`, `outBytes`, `ms`, `exitCode` | + +`id` is the 1-based job number in input order, the same in a dry run and the real run. Error codes: +`USAGE`, `NOT_FOUND`, `NO_MATCH`, `UNSUPPORTED_KIND`, `SAME_FORMAT`, `OUT_DIR_MISSING`, `TOOL_MISSING`, +`SETUP_REQUIRED`, `GPU_UNSUPPORTED`, `RAR_MISSING`, `PASSWORD`, `TOOL_FAILED`, `CANCELED`, `INTERNAL`. + +Schema v1 is additive-only: new events, fields and codes may appear; renaming or removing one bumps `v`. +The source of this table is `resources/skill/filesmith/reference.md` (Events section). + +## AI tools + +Jobs never download anything. `filesmith setup ` is the only command that does, with byte progress, +ETA, heartbeats and a cross-process lock (the app and the CLI never install the same tool at once). Every +setup takes `--dry-run`, which lists the steps, sizes, disk and GPU verdicts. + +| Command | Downloads, and where | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `setup removebg` | uv (if absent) into `%APPDATA%\Filesmith\uv`, rembg as a uv tool into `%APPDATA%\Filesmith\uv-tools`, the model into `%APPDATA%\Filesmith\models\rembg`. CPU only. | +| `setup pid` | The PiD upscaler (repo, Python env, weights) into `%APPDATA%\Filesmith\pid`, about 6 GB. Reuses weights found in ComfyUI. | +| `setup spandrel [--comfy ]` | Nothing when a ComfyUI Python already has torch and spandrel; otherwise the shared engine env in `%APPDATA%\Filesmith\pid`. | +| `setup comfy --folder ` | Nothing. Records the ComfyUI folder (or `--url `) and rescans its models. | +| `setup generate --model ` | The missing companion files for one model, sha256-checked. Never installs ComfyUI itself. | +| `setup realesrgan` | Nothing (bundled). Prints the models and the user model folder. | + +- **GPU gates.** PiD and spandrel need an NVIDIA GPU with compute capability 7.5 or higher and driver + 525 or newer; a failing gate is `GPU_UNSUPPORTED` (exit 2). Real-ESRGAN needs any Vulkan GPU, which + is not probed up front (`doctor --deep` runs a tiny upscale). Generate has no gate; removebg is CPU. +- **Removing.** `setup remove ` moves the folders to the Recycle Bin, up to 5 GB + and 5,000 files (the Recycle Bin's practical limit). Larger folders (a PiD install) need + `--permanent`, otherwise the command stops with exit 1 and names the size and the flag. +- `filesmith doctor` (read-only) shows what is installed, what is missing and the exact `fix` command. + +## For agents + +Filesmith ships a Claude Code skill (`resources/skill/filesmith`: `SKILL.md` with the working rules and +`reference.md` with every flag, event and code). Install it into `%USERPROFILE%\.claude\skills\filesmith` +with `filesmith skill install` (or `--dry-run` to preview; `filesmith skill status` checks it), or with +the **Install Claude skill** button in Settings > CLAUDE. Both write the absolute shim path into the skill +as a fallback for sessions started before PATH changed, and stamp the app version +(`metadata.filesmith-version`). Files other than `SKILL.md` and `reference.md` in that folder are left +alone; replaced files go to the Recycle Bin. The installer never installs the skill on its own. + +## Implementation notes + +Verified 2026-10-04 on this machine, source: running `node_modules/electron/dist/electron.exe` with +`ELECTRON_RUN_AS_NODE=1`: + +- Electron 43 runs Node 24.18. +- `fs.globSync` exists (the CLI's glob expansion uses it). +- `--use-system-ca` is accepted, so downloads trust the Windows certificate store. +- `NODE_USE_ENV_PROXY=1` routes `fetch` through `HTTPS_PROXY`. +- `process.resourcesPath` is the install's `resources` folder in Node mode. +- `require('electron')` returns a path string in Node mode, so the engine never imports `electron`; it + reads its paths from `src/main/env.ts` (set by `src/main/index.ts` for the app and by + `src/cli/bootstrap.ts` for the CLI). +- Ctrl+C never reaches a `SIGINT` / `SIGBREAK` handler in Node mode: the process ends with 0xC000013A. + Measured 2026-10-05 with a 6-line script under `electron.exe` and plain `node.exe`; the same loss + reproduces in any GUI-subsystem process (pythonw) that registers a console control handler before + `AttachConsole`, while one registered after it works. Raw-mode console input does reach the process + (see Ctrl+C above). With stdout redirected, Electron does not attach a console at all. +- The `runAsNode` Electron fuse must stay on, or the shims stop working (the release workflow's packed-CLI + smoke test fails loudly if it is ever turned off). + +## Development + +- `npm run build && npm run cli -- ` runs the CLI from `out/main/cli.js` with your system Node + (for example `npm run cli -- convert photo.png --to webp --json`). +- Unit tests: `test/cli-*.test.ts` (parser, catalog, help, options, inputs, planner, runner, reporters, + helpers), plus `test/env.test.ts`, `test/skill.test.ts`, `test/path-ps1.test.ts` and + `test/cli-graph.test.ts` (the CLI's import graph never reaches `electron`). Run `npm test`. +- Process-level tests: `e2e/cli.spec.ts` runs `out/main/cli.js` end to end (build first), and + `e2e/skill.spec.ts` covers the Settings > CLAUDE button. Run `npm run test:e2e`. +- `e2e/cli-packed.spec.ts` runs the shims in the install layout and skips unless `dist/win-unpacked` + exists. It includes the console Ctrl+C test: `e2e/ctrlc-console.ps1` runs the cmd shim in a new + (minimized) console, writes a Ctrl+C key record into the console input and reports the exit code and + the console text. Build it with `npx electron-builder --win dir --publish never` (after `npm run build`), then + `npx playwright test e2e/cli-packed.spec.ts`. diff --git a/docs/mockups/cli-settings-skill.png b/docs/mockups/cli-settings-skill.png new file mode 100644 index 0000000..7f48ded Binary files /dev/null and b/docs/mockups/cli-settings-skill.png differ diff --git a/docs/superpowers/plans/2026-10-04-cli-and-skill.md b/docs/superpowers/plans/2026-10-04-cli-and-skill.md new file mode 100644 index 0000000..0f0ab07 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-cli-and-skill.md @@ -0,0 +1,9754 @@ +# Command Line and Claude Skill Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a `filesmith` command that runs every Filesmith operation (convert, compress, resize, upscale, removebg, generate, the PDF tools, plus `formats`, `doctor`, `setup`, `skill`) from any terminal with the app's engine, option names and never-overwrite rule, and a Claude Code skill that teaches an agent to drive it safely. + +**Architecture:** The installed `Filesmith.exe` runs a second build entry (`out/main/cli.js`) as plain Node (`ELECTRON_RUN_AS_NODE=1`) behind `filesmith.cmd` and an extensionless sh shim that the per-user installer puts on PATH. The engine stops importing `electron` (one `EngineEnv` provider, set by `index.ts` for the app and by `src/cli/bootstrap.ts` for the CLI), so the CLI reuses `JobQueue`, the tool modules and the bundled tools unchanged, never touches the single-instance lock, and runs next to an open app. The CLI is layered as pure, unit-tested modules (catalog, parser, options, inputs, planner, event writers) under thin command adapters whose engine access is injected. + +**Tech Stack:** Electron 43 (its Node is 24.18; verified 2026-10-04 on this machine: `fs.globSync` exists, `--use-system-ca` is accepted, `NODE_USE_ENV_PROXY=1` routes `fetch` through `HTTPS_PROXY`), TypeScript strict, electron-vite 5 (Vite/Rollup), Node's `util.parseArgs`, Vitest 3, Playwright Test, electron-builder 26 NSIS, Windows PowerShell 5.1 (PATH edit only). + +**Spec:** `docs/superpowers/specs/2026-10-04-cli-and-skill-design.md` (read it fully before any task; section numbers below refer to it). + +## Owner decisions this plan builds + +The spec's open questions O1 to O11 are built with the spec's recommended answers. They are approved together with this plan (single approval). If the owner answers one differently, only the tasks listed change: + +| # | Built as | Tasks that change if the answer differs | +| --- | ------------------------------------------------------------------ | --------------------------------------- | +| O1 | Node mode behind `filesmith.cmd` + sh shim | 1, 2, 11, 16, 18 (whole runtime) | +| O2 | `--use-system-ca` + `NODE_USE_ENV_PROXY=1` in the shims | 16, 15 (doctor proxy check) | +| O3 | Accept "Terminate batch job (Y/N)?"; exit 130 on Ctrl+C | 6, 11, 16 | +| O4 | `generate` writes to the current folder unless `--out` | 13 | +| O5 | Explicit rembg install with a pinned model folder, app and CLI | 5, 14 | +| O6 | `--out` folder is created (with parents) when missing | 11, 13 | +| O7 | Folder input takes its own files; `--recursive` descends | 9 | +| O8 | Same-format file is `skipped`, exit 0 | 10 | +| O9 | Settings button built from existing primitives, approved by screenshot | 17 | +| O10 | No `filesmith extract` in v1 | none | +| O11 | A requirement missing for every input is exit 2 (nothing ran) | 10, 11 (`reduceExit` and the e2e removebg case) | + +## Spec deviations decided in this plan (covered by the same approval) + +- **D-a** The Rollup input is `src/cli/bootstrap.ts` (it owns `process`), not `src/cli/main.ts`; `main.ts` stays the injectable `main(io, deps)`. The output is still `out/main/cli.js`. +- **D-b** Dry-run planners live in one new module `src/main/tools/plan.ts` (`planOutput(tool, file, options, outDir, claimed)`) instead of a method on each `ToolModule`, so the 1384-line `tools/registry.ts` does not grow. Same contract as spec M4. +- **D-c** M6's "the app shows a one-time Set up step": the renderer stays untouched (the hard rule wins). The app's first removebg job runs the same installer inline, with step messages in the row, exactly where today's first job downloads silently. +- **D-d** Hidden files skipped in folder inputs are dot-files, `Thumbs.db` and `desktop.ini` (Node cannot read the Windows hidden attribute without a native call). +- **D-e** `setup remove ` moves folders to the Recycle Bin up to 5 GB and 5,000 files (the Recycle Bin's practical limit, same as the owner's `trash` rule). Larger folders (a PiD install is about 6 GB) need an explicit `--permanent`, otherwise exit 1 with the size and the flag. Additive flag. +- **D-f** `skipped` events for folder members that the verb cannot take carry no `id` (they are not jobs). `step` events gain an optional `detail` (used by `setup --dry-run`). Both additive to schema v1. +- **D-g** LibreOffice presence is not checked at plan time (a PATH-only install cannot be detected without spawning it); a missing LibreOffice fails those files at run time with `TOOL_MISSING`, as the spec requires. +- **D-h** `filesmith` with no arguments prints the root help on stdout and exits 0. +- **D-i** The README stays logo and badges only (the owner stripped it on purpose in `e2c7b39`); the user documentation is `docs/cli.md`, linked from CLAUDE.md. Spec 12 asked for a README section; the PR raises it. +- **D-j** `removebg:status.uvAvailable` is now always true (setup bootstraps uv itself), so the existing Settings and Remove BG wording stays correct without touching the renderer. +- **D-k** The Settings button e2e lives in a new `e2e/skill.spec.ts` (it needs its own app launch with a temporary `USERPROFILE`), not in `e2e/ui.spec.ts`. + +## Global Constraints + +- **No em-dashes anywhere**: code, comments, help text, skill text, docs, commit messages, PR text. Use commas, en-dashes or rephrase. A test in Task 7 scans every new file for U+2014. +- **Renderer boundary:** the only renderer change is the Settings `CLAUDE` group (Task 17). No existing IPC channel, preload signature or renderer component changes; `skill:install`, `skill:status`, `installSkill()`, `skillStatus()` are additive. +- **Never overwrite:** outputs come from `reserveOutPath` / `reserveFileInDir` / `uniqueOutDir` at run time and from `planFileInDir` / `planOutDir` in dry runs. No `--force` flag exists anywhere. +- **No auto-download from the CLI:** only `filesmith setup` downloads. Engine jobs run with `allowDownload: false` in the CLI. +- **The CLI never imports `electron`:** a test walks the import graph from `src/cli/bootstrap.ts` (Task 11), and an e2e test greps `out/main/cli.js` and its chunks. +- **JSON schema v1 is additive-only:** every line carries `v: 1`, `event`, `ts`; fields that do not apply are omitted, never `null` (except `pct`). +- **Exit codes:** `0` all ok or skipped, `1` some failed, `2` usage or run-level requirement failure (nothing ran), `130` Ctrl+C. +- **Option names mirror the app:** flags are generated in `src/cli/catalog.ts` from the `@shared` catalogs; a test pins every app option key to a flag and every default to `DEFAULT_OPTIONS` in `src/renderer/src/state.ts`. +- **userData** is `%APPDATA%\Filesmith` (or `FILESMITH_USER_DATA`), shared with the app. +- **Tests:** unit tests `test/*.test.ts` (Vitest, `npx vitest run test/`); process-level tests `e2e/cli.spec.ts` (Playwright Test, after `npm run build`). Tests that spawn bundled binaries skip when `resources/bin` is empty (CI unit job). +- **Delivery:** one issue, branch `feat/-cli`, one PR, version 0.5.2 to **0.6.0** inside the PR, squash-merge only after the owner's explicit "merge" for this PR. Commits `type(scope): subject` ending with `Co-Authored-By: Claude Opus 5.5 `. +- **Deleting files:** `trash ""` for anything outside this session's scratchpad or its own build output (`dist/`, `out/`); `git rm` for tracked files the change removes. +- **Style:** prettier (`semi: false`, `singleQuote: true`, `printWidth: 100`, `trailingComma: none`); `.tsx` files export components only. + +## Review Focus + +- **Two inputs that map to one output name in a single run** (`photo.png` and `photo.jpg` with `--to webp`): the dry run must predict `photo.webp` and `photo (converted).webp`, and the real run must produce exactly that set of names, neither overwriting the other. Which input gets the untagged name may differ, because jobs run in parallel and each reserves its name when it starts (spec 2.4). Pinned in Task 3 (`claimed` set) and Task 11 (e2e compares the sets, not the id mapping). +- **Paths with spaces, `&`, non-ASCII letters, and a file named like a flag** (`-x.png` after `--`): they must reach the engine byte for byte, through the parser and both shims. Pinned in Task 7 (parser), Task 9 (inputs) and Task 16 (shim smoke test). +- **stdout closed early** (`filesmith convert *.png --to webp --json | head -1`): no stack trace, running jobs are canceled, exit code 130, no orphaned tool processes. Pinned in Task 11 (bootstrap EPIPE handling, e2e). +- **`--out` pointing at an existing file, or at a folder that cannot be created:** exit 2 with `OUT_DIR_MISSING` before any job starts, nothing written. Pinned in Task 11. +- **Ctrl+C while jobs are still queued:** `JobQueue.cancelAll()` drops queued jobs silently, so the runner itself must emit `canceled` for every job that never started; the summary counts must add up to the job count and the process must exit. Pinned in Task 11 (runner test with a queue that models the silent drop). + +## File Structure + +``` +src/main/ + env.ts NEW EngineEnv provider: userData, resourcesDir, downloadsDir, fetch, host + boot.ts NEW bootEngine(): magick env, temp sweep, registry user layers + atomicWrite.ts NEW writeFileAtomic(path, data) (pid-suffixed temp + rename) + locks.ts NEW cross-process lock files under userData/locks + recycle.ts NEW moveToRecycleBin(path), folderStats(path), RECYCLE_LIMIT + uvInstall.ts NEW ensureUv() (moved out of pid/install.ts, now public) + skill.ts NEW installSkill / skillStatus (shared by CLI and app) + rembg/paths.ts NEW rembg tool dir, exe, pinned model folder, readiness probes + rembg/setup.ts NEW setupRembg(): uv tool install + model warm-up + tools/plan.ts NEW planOutput(): dry-run output prediction per tool + tools/readiness.ts NEW upscale/removebg readiness, setupHint, notReadyMessage + output.ts + planFileInDir / planOutDir (shared candidate generator) + run.ts RunOptions.env + jobQueue.ts allowDownload option -> ToolContext + tools/tool.ts ToolContext.allowDownload + tools/registry.ts removebg uses installed rembg + U2NET_HOME; CLI-aware messages + toolResolver.ts paths from env; resolveRembg/removebgStatus via rembg/paths + pid/paths.ts, pid/install.ts env paths; file lock; signal + byte progress; ensureUv moved + net/download.ts fetch from env; onBytes + net/integrity.ts userData from env; atomic write + comfy/store.ts userData from env; atomic write + generate/index.ts outDir option (M3); slug exported + generate/comfy.ts userData from env; comfy-live.json (M8) + generate/companions.ts file lock; signal + bytes + registry/load.ts, channel.ts paths and fetch from env + tools/ncnnModels.ts userData from env + index.ts setEngineEnv(app), bootEngine() + ipc.ts + skill:install, skill:status +src/cli/ + bootstrap.ts NEW process entry: env, boot, SIGINT/SIGBREAK, EPIPE, exit code + env.ts NEW cliEngineEnv(facts) (pure) + version.ts NEW VERSION (build-time define) + main.ts NEW main(io, deps): parse, dispatch, usage errors + exit.ts NEW EXIT codes, ErrorCode, CliError, UsageError, reduceExit + events.ts NEW schema v1 types, Reporter, JsonReporter (rate-limited) + human.ts NEW HumanReporter (result lines, summary, TTY progress) + catalog.ts NEW CommandSpec / FlagSpec table from @shared catalogs + parse.ts NEW parseArgv(): util.parseArgs + router + aliases + help.ts NEW renderHelp / renderRootHelp / renderGroupHelp + options.ts NEW coerce(), buildOptions(), buildGenerateFlags() + inputs.ts NEW expandInputs(): globs, folders, stdin, de-dup, natural order + plan.ts NEW planJobs(): routing, skips, per-file errors, predicted outputs + planPdf.ts NEW planPdfJobs(): merge as one job, pdf tools + io.ts NEW CliIO: everything main() touches of the process + runner.ts NEW runPlanned(): JobQueue -> events, cancel, classifyError + deps.ts NEW defaultDeps(): engine bindings for every command + commands/files.ts NEW convert, compress, resize, upscale, removebg, pdf + commands/generate.ts NEW generate adapter + commands/setup.ts NEW setup , StepReporter + commands/formats.ts NEW formats [verb] + commands/doctor.ts NEW doctor checks + commands/skill.ts NEW skill install | status +src/preload/index.ts + installSkill(), skillStatus() +src/shared/ipc.ts + SkillStatus, SkillInstallResult +src/shared/generate.ts GenerateOptions.outDir +src/main/generate/comfy.ts + firstLiveComfy() (doctor) +src/renderer/src/components/views/ + ClaudeSkill.tsx NEW Settings CLAUDE group body + SettingsView.tsx + +resources/cli/ NEW filesmith.cmd, filesmith (sh, LF), path.ps1 +resources/skill/filesmith/NEW SKILL.md, reference.md +build/installer/path.nsh NEW PATH add/remove macros +build/installer.nsh include path.nsh, customUnInstall +build/installer/pages.nsh customInstall calls the PATH add +electron-builder.yml extraResources cli, skill; RunAsNode fuse note +electron.vite.config.ts cli input, __APP_VERSION__ define +vitest.config.ts setupFiles, __APP_VERSION__ define +.gitattributes NEW LF for the sh shim, CRLF for .cmd/.ps1 +.github/workflows/release.yml packed-CLI smoke step, paths filter +scripts/verify-bundle.mjs shims and skill files required +test/setup/engineEnv.ts NEW Vitest setup: setEngineEnv for tests +test/helpers/importGraph.ts NEW static import walker +test/*.test.ts NEW env, boot, engine-graph, plan-output, locks, comfy-live, readiness, + rembg-setup, recycle, path-ps1, skill, no-em-dash, cli-* (version, + exit, events, human, catalog, parse, help, options, inputs, plan, + plan-pdf, runner, main, graph, generate, setup, formats, doctor) +e2e/cli.spec.ts NEW process-level CLI tests (node out/main/cli.js) +e2e/cli-packed.spec.ts NEW the shims in the unpacked install layout +e2e/skill.spec.ts NEW Settings > CLAUDE button +docs/cli.md NEW user and agent reference, verified runtime facts +CLAUDE.md CLI entry (README unchanged, D-i) +package.json 0.6.0, script cli +``` + +--- + +### Task 0: Issue, branch and design record + +**Files:** +- Create: `docs/superpowers/plans/2026-10-04-cli-and-skill.md` (this file, already written) +- Track: `docs/superpowers/specs/2026-10-04-cli-and-skill-design.md` (currently untracked) + +**Interfaces:** +- Consumes: nothing. +- Produces: issue number `` and branch `feat/-cli`, used by every later commit and the PR. + +- [ ] **Step 1: Create the issue** + +```bash +gh issue create --title "Command-line tool and Claude skill" --body "$(cat <<'EOF' +Ship a `filesmith` command bundled with the app (verb-first grammar mirroring the sidebar, --json events, --dry-run everywhere, never overwrite, exit codes 0/1/2/130), plus a Claude Code skill installed by `filesmith skill install` and a Settings button. + +Spec: docs/superpowers/specs/2026-10-04-cli-and-skill-design.md +Plan: docs/superpowers/plans/2026-10-04-cli-and-skill.md +EOF +)" +``` + +Expected: a URL ending in `/issues/`. Note ``. + +- [ ] **Step 2: Branch from an up-to-date main** + +```bash +git switch main && git pull --ff-only && git switch -c feat/-cli +``` + +Expected: `Switched to a new branch 'feat/-cli'`. The untracked spec comes along. + +- [ ] **Step 3: Keep prettier off the plan documents** + +The plan's code blocks hold partial snippets (single indented lines, fragments of a function). `prettier --write` on this file dedents them and rewrites inline code spans, which corrupts the instructions, and `npx prettier --check .` (the PR gate and release.yml) fails on it as it stands. Append to `.prettierignore`: + +``` +# Design and plan documents: their code blocks are partial snippets, not code. +docs/superpowers +``` + +Run: `npx prettier --check .` +Expected: `All matched files use Prettier code style!` + +- [ ] **Step 4: Commit the spec and plan** + +```bash +git add .prettierignore docs/superpowers/specs/2026-10-04-cli-and-skill-design.md docs/superpowers/plans/2026-10-04-cli-and-skill.md +git commit -m "docs(cli): design and implementation plan for the command line and skill" -m "Refs #" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 1: CLI build entry and the Node-mode proof (M11) + +This task de-risks the whole design first (spec section 9, row 2): it proves that the packed `Filesmith.exe` runs a script from inside `app.asar` in Node mode and that `process.resourcesPath` points at the install's `resources` folder. + +**Files:** +- Create: `src/cli/version.ts`, `src/cli/bootstrap.ts` (temporary probe body, replaced in Task 11), `test/cli-version.test.ts` +- Modify: `electron.vite.config.ts` (main input + define), `vitest.config.ts` (define), `tsconfig.node.json` (include `src/cli`), `eslint.config.mjs` (node globals for `src/cli`), `package.json` (script `cli`) + +**Interfaces:** +- Consumes: nothing. +- Produces: `VERSION: string` from `src/cli/version.ts`; build output `out/main/cli.js`; the global compile-time constant `__APP_VERSION__` in both the main build and Vitest. + +- [ ] **Step 1: Write the failing test** + +`test/cli-version.test.ts`: + +```ts +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { describe, expect, it } from 'vitest' +import { VERSION } from '../src/cli/version' + +describe('VERSION', () => { + it('is the package.json version, injected at build time', () => { + const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8')) as { + version: string + } + expect(VERSION).toBe(pkg.version) + }) +}) +``` + +- [ ] **Step 2: Run it and watch it fail** + +Run: `npx vitest run test/cli-version.test.ts` +Expected: FAIL, `Failed to resolve import "../src/cli/version"`. + +- [ ] **Step 3: Implement** + +`src/cli/version.ts`: + +```ts +// Replaced at build time by electron-vite (main build) and Vitest (tests) with +// the package.json version. The fallback only shows if a third bundler forgets +// the define, which makes the mistake visible instead of silent. +declare const __APP_VERSION__: string | undefined + +export const VERSION: string = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : '0.0.0-dev' +``` + +`src/cli/bootstrap.ts` (probe body, replaced in Task 11): + +```ts +import { VERSION } from './version' + +// Task 1 probe: proves Node mode inside app.asar. Replaced in Task 11. +process.stdout.write(`${VERSION} ${process.resourcesPath ?? '(no resourcesPath)'}\n`) +``` + +`electron.vite.config.ts`, replace the `main` block and add the version read at the top: + +```ts +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')) as { + version: string +} + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + resolve: { + alias: { '@shared': resolve('src/shared') } + }, + define: { __APP_VERSION__: JSON.stringify(pkg.version) }, + build: { + rollupOptions: { + input: { + index: resolve(__dirname, 'src/main/index.ts'), + // The command line (spec 4.1). Runs as plain Node under + // ELECTRON_RUN_AS_NODE, so nothing it reaches may import electron. + cli: resolve(__dirname, 'src/cli/bootstrap.ts') + } + } + } + }, +``` + +(the `preload` and `renderer` blocks stay as they are). + +`vitest.config.ts`, add the version define (keep everything else): + +```ts +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { defineConfig } from 'vitest/config' + +const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')) as { + version: string +} + +export default defineConfig({ + define: { __APP_VERSION__: JSON.stringify(pkg.version) }, + resolve: { + alias: { + '@shared': resolve(__dirname, 'src/shared'), + // Main-process modules import { app } from 'electron' at module scope; + // this minimal stub lets unit tests load them without a running Electron. + electron: resolve(__dirname, 'test/mocks/electron.ts') + } + }, + test: { + include: ['test/**/*.test.ts'] + } +}) +``` + +`tsconfig.node.json`: add `"src/cli/**/*"` to `include` (after `"src/main/**/*"`). + +`eslint.config.mjs`: change the node-globals block's `files` to `['src/main/**/*.ts', 'src/cli/**/*.ts', 'src/preload/**/*.ts', 'scripts/**/*.mjs']`. + +`package.json` scripts: add `"cli": "node out/main/cli.js"` after `"start"`. + +- [ ] **Step 4: Run the test** + +Run: `npx vitest run test/cli-version.test.ts` +Expected: PASS (1 test). + +- [ ] **Step 5: Prove the dev build under plain Node and under Electron's Node** + +```bash +npm run build +node out/main/cli.js +ELECTRON_RUN_AS_NODE=1 node_modules/electron/dist/electron.exe out/main/cli.js +``` + +Expected: `0.5.2 (no resourcesPath)` from node; `0.5.2 C:\...\node_modules\electron\dist\resources` from Electron. + +- [ ] **Step 6: Prove the packed layout (the spec's first-task risk)** + +```bash +npx electron-builder --win dir --publish never +ELECTRON_RUN_AS_NODE=1 dist/win-unpacked/Filesmith.exe dist/win-unpacked/resources/app.asar/out/main/cli.js +ELECTRON_RUN_AS_NODE=1 dist/win-unpacked/Filesmith.exe --use-system-ca dist/win-unpacked/resources/app.asar/out/main/cli.js +``` + +Expected (both runs): `0.5.2 C:\...\Filesmith\dist\win-unpacked\resources`. electron-builder only warns about missing `resources/bin` etc. here; that is fine for this proof. + +If Electron cannot load the script from inside `app.asar`: add to `electron-builder.yml` under `files:` a sibling key `asarUnpack: ['out/main/cli.js', 'out/main/chunks/**']`, rebuild, and use `resources/app.asar.unpacked/out/main/cli.js` as the script path everywhere this plan says `app.asar/out/main/cli.js` (Tasks 16 and 18). Record which path won in the commit message. + +- [ ] **Step 7: Verify and commit** + +```bash +npm run typecheck && npm run lint && npm test +git add src/cli electron.vite.config.ts vitest.config.ts tsconfig.node.json eslint.config.mjs package.json test/cli-version.test.ts +git commit -m "build(cli): second main entry out/main/cli.js with build-time version" -m "Proved: packed Filesmith.exe runs app.asar/out/main/cli.js under ELECTRON_RUN_AS_NODE, resourcesPath = /resources." -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Engine environment provider and shared boot (M1, M2) + +**Files:** +- Create: `src/main/env.ts`, `src/main/boot.ts`, `src/main/atomicWrite.ts`, `src/cli/env.ts`, `test/setup/engineEnv.ts`, `test/helpers/importGraph.ts`, `test/env.test.ts`, `test/boot.test.ts`, `test/engine-graph.test.ts` +- Modify: `vitest.config.ts` (`setupFiles`), `src/main/index.ts:1-40,204-215`, `src/main/toolResolver.ts:3,28-32,75-78,100-103,157-161`, `src/main/pid/paths.ts:3,19-21,46-60`, `src/main/tools/ncnnModels.ts:3,22-24`, `src/main/comfy/store.ts:1-3,20-22,36-38`, `src/main/generate/comfy.ts:5,108`, `src/main/generate/index.ts:1,119`, `src/main/net/download.ts:14,24-39`, `src/main/net/integrity.ts:3,32-38,50-63`, `src/main/registry/load.ts:3,33-60`, `src/main/registry/channel.ts:4,59-63,129-131` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `src/main/env.ts`: `interface EngineEnv { userData: string; resourcesDir: string; downloadsDir: string; fetch: (url: string, init?: RequestInit) => Promise; host: 'app' | 'cli' }`, `setEngineEnv(e: EngineEnv): void`, `engineEnv(): EngineEnv` (throws `Error('engine env not configured')`), `resourcePath(...parts: string[]): string`, `userDataPath(...parts: string[]): string`, `resetEngineEnvForTests(): void`. + - `src/main/boot.ts`: `sweepStaleTempDirs(now?: number, dir?: string): void`, `bootEngine(): void`. + - `src/main/atomicWrite.ts`: `writeFileAtomic(path: string, data: string | Buffer): void`. + - `src/cli/env.ts`: `interface ProcessFacts { execPath: string; resourcesPath?: string; env: Record; homedir: string; moduleDir: string }`, `cliEngineEnv(f: ProcessFacts, fetchImpl: EngineEnv['fetch']): EngineEnv`. + - `test/helpers/importGraph.ts`: `importGraph(entry: string): { files: string[]; externals: Set }`. + +- [ ] **Step 1: Write the failing tests** + +`test/env.test.ts`: + +```ts +import { afterEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + engineEnv, + resetEngineEnvForTests, + resourcePath, + setEngineEnv, + userDataPath, + type EngineEnv +} from '../src/main/env' +import { cliEngineEnv } from '../src/cli/env' +import { resolveTool } from '../src/main/toolResolver' + +const saved = engineEnv() +afterEach(() => setEngineEnv(saved)) + +const fakeFetch: EngineEnv['fetch'] = () => Promise.reject(new Error('no network in tests')) + +describe('engineEnv', () => { + it('throws a clear error when nothing configured it', () => { + resetEngineEnvForTests() + expect(() => engineEnv()).toThrow('engine env not configured') + }) + + it('joins resource and userData paths from the configured env', () => { + setEngineEnv({ ...saved, resourcesDir: 'R:\\res', userData: 'U:\\data' }) + expect(resourcePath('bin', 'ffmpeg.exe')).toBe(join('R:\\res', 'bin', 'ffmpeg.exe')) + expect(userDataPath('pid')).toBe(join('U:\\data', 'pid')) + }) + + it('resolveTool finds a bundled binary under resourcesDir/bin', () => { + const root = mkdtempSync(join(tmpdir(), 'fs-env-')) + try { + mkdirSync(join(root, 'bin')) + writeFileSync(join(root, 'bin', 'ffmpeg.exe'), '') + setEngineEnv({ ...saved, resourcesDir: root }) + expect(resolveTool('ffmpeg')).toBe(join(root, 'bin', 'ffmpeg.exe')) + expect(resolveTool('nope')).toBe('nope') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) + +describe('cliEngineEnv', () => { + const base = { + env: { APPDATA: 'C:\\Users\\a\\AppData\\Roaming' }, + homedir: 'C:\\Users\\a', + moduleDir: 'D:\\repo\\out\\main' + } + + it('packaged: Filesmith.exe uses process.resourcesPath and %APPDATA%\\Filesmith', () => { + const e = cliEngineEnv( + { + ...base, + execPath: 'C:\\Users\\a\\AppData\\Local\\Programs\\Filesmith\\Filesmith.exe', + resourcesPath: 'C:\\Users\\a\\AppData\\Local\\Programs\\Filesmith\\resources' + }, + fakeFetch + ) + expect(e.resourcesDir).toBe('C:\\Users\\a\\AppData\\Local\\Programs\\Filesmith\\resources') + expect(e.userData).toBe(join('C:\\Users\\a\\AppData\\Roaming', 'Filesmith')) + expect(e.downloadsDir).toBe(join('C:\\Users\\a', 'Downloads')) + expect(e.host).toBe('cli') + }) + + it('packaged without resourcesPath falls back to \\resources', () => { + const e = cliEngineEnv({ ...base, execPath: 'C:\\P\\Filesmith\\FILESMITH.EXE' }, fakeFetch) + expect(e.resourcesDir).toBe(join('C:\\P\\Filesmith', 'resources')) + }) + + it('dev (node or electron.exe): the repo resources folder two levels above out/main', () => { + const e = cliEngineEnv({ ...base, execPath: 'C:\\node\\node.exe' }, fakeFetch) + expect(e.resourcesDir).toBe(join('D:\\repo', 'resources')) + }) + + it('FILESMITH_USER_DATA overrides the data folder (e2e isolation)', () => { + const e = cliEngineEnv( + { ...base, env: { ...base.env, FILESMITH_USER_DATA: 'T:\\ud' }, execPath: 'node.exe' }, + fakeFetch + ) + expect(e.userData).toBe('T:\\ud') + }) +}) +``` + +`test/boot.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { sweepStaleTempDirs } from '../src/main/boot' + +describe('sweepStaleTempDirs', () => { + it('removes only filesmith- dirs older than an hour', () => { + const root = mkdtempSync(join(tmpdir(), 'fs-sweep-')) + try { + const old = join(root, 'filesmith-old') + const fresh = join(root, 'filesmith-fresh') + const other = join(root, 'someone-else') + for (const d of [old, fresh, other]) mkdirSync(d) + const now = Date.now() + const twoHoursAgo = (now - 2 * 60 * 60 * 1000) / 1000 + utimesSync(old, twoHoursAgo, twoHoursAgo) + utimesSync(other, twoHoursAgo, twoHoursAgo) + sweepStaleTempDirs(now, root) + expect(existsSync(old)).toBe(false) + expect(existsSync(fresh)).toBe(true) + expect(existsSync(other)).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) +``` + +`test/helpers/importGraph.ts`: + +```ts +import { existsSync, readFileSync } from 'fs' +import { dirname, join, resolve } from 'path' + +const ROOT = resolve(__dirname, '..', '..') +const IMPORT_RE = + /^\s*(import|export)\s+(type\s+)?[^'"]*?\sfrom\s+['"]([^'"]+)['"]|^\s*import\s+['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/gm + +function resolveSpec(from: string, spec: string): string | null { + let base: string | null = null + if (spec.startsWith('@shared/')) base = join(ROOT, 'src', 'shared', spec.slice('@shared/'.length)) + else if (spec.startsWith('.')) base = resolve(dirname(from), spec) + if (!base) return null + for (const cand of [base + '.ts', base + '.tsx', join(base, 'index.ts'), base]) + if (existsSync(cand) && cand.match(/\.tsx?$/)) return cand + return null +} + +/** Every source file reachable from `entry` through value imports (type-only + * imports are erased by the compiler and ignored here), plus the bare module + * names it pulls in (node builtins, 'electron', npm packages). */ +export function importGraph(entry: string): { files: string[]; externals: Set } { + const seen = new Set() + const externals = new Set() + const stack = [resolve(entry)] + while (stack.length) { + const file = stack.pop() as string + if (seen.has(file)) continue + seen.add(file) + const src = readFileSync(file, 'utf-8') + for (const m of src.matchAll(IMPORT_RE)) { + if (m[2]) continue // import type ... from + const spec = m[3] ?? m[4] ?? m[5] + if (!spec) continue + const target = resolveSpec(file, spec) + if (target) stack.push(target) + else if (!spec.startsWith('.') && !spec.startsWith('@shared/')) externals.add(spec) + } + } + return { files: [...seen], externals } +} +``` + +`test/engine-graph.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { resolve } from 'path' +import { importGraph } from './helpers/importGraph' + +// The CLI runs as plain Node, where require('electron') is a path string and +// every app/net call throws. These are the engine roots the CLI will import. +const ROOTS = [ + 'src/main/env.ts', + 'src/main/boot.ts', + 'src/main/jobQueue.ts', + 'src/main/generate/index.ts', + 'src/main/pid/install.ts', + 'src/main/comfy/discover.ts', + 'src/main/toolResolver.ts' +] + +describe('engine import graph', () => { + for (const root of ROOTS) + it(`${root} never reaches electron`, () => { + const { externals } = importGraph(resolve(__dirname, '..', root)) + expect([...externals]).not.toContain('electron') + }) +}) +``` + +- [ ] **Step 2: Run them and watch them fail** + +Run: `npx vitest run test/env.test.ts test/boot.test.ts test/engine-graph.test.ts` +Expected: FAIL, unresolved imports `../src/main/env`, `../src/main/boot`, `../src/cli/env`. + +- [ ] **Step 3: Implement the provider, boot and atomic write** + +`src/main/env.ts`: + +```ts +import { join } from 'path' + +/** + * Everything the engine needs from its host, in one place (spec 4.2, M1). + * + * The app sets it from Electron (`app.getPath`, `net.fetch`); the command line + * sets it from plain Node, where `electron.app` does not exist. No engine module + * may import 'electron' itself: a test walks the CLI's import graph. + */ +export interface EngineEnv { + /** %APPDATA%\Filesmith, shared by the app and the CLI. */ + userData: string + /** The folder holding bin/, libreoffice/, ghostscript/, realesrgan/, registry/ ... */ + resourcesDir: string + /** The app's Generate output folder. */ + downloadsDir: string + /** Electron's net.fetch in the app (system proxy, Windows trust store); Node's + * fetch in the CLI (the shims add --use-system-ca and NODE_USE_ENV_PROXY=1). */ + fetch: (url: string, init?: RequestInit) => Promise + host: 'app' | 'cli' +} + +let current: EngineEnv | null = null + +export function setEngineEnv(e: EngineEnv): void { + current = e +} + +export function engineEnv(): EngineEnv { + if (!current) throw new Error('engine env not configured') + return current +} + +export function resourcePath(...parts: string[]): string { + return join(engineEnv().resourcesDir, ...parts) +} + +export function userDataPath(...parts: string[]): string { + return join(engineEnv().userData, ...parts) +} + +/** Tests only: simulate a host that forgot to configure the engine. */ +export function resetEngineEnvForTests(): void { + current = null +} +``` + +`src/main/atomicWrite.ts`: + +```ts +import { mkdirSync, renameSync, rmSync, writeFileSync } from 'fs' +import { dirname } from 'path' + +/** + * Write-then-rename, with a temp name unique to this process. The app and the + * CLI can now write the same small state files (comfy-upscalers.json, + * integrity.json) at the same time; a fixed `.part` name let one process rename + * the other's half-written temp file into place. + */ +export function writeFileAtomic(path: string, data: string | Buffer): void { + mkdirSync(dirname(path), { recursive: true }) + const tmp = `${path}.${process.pid}.tmp` + try { + writeFileSync(tmp, data) + renameSync(tmp, path) + } catch (e) { + rmSync(tmp, { force: true }) + throw e + } +} +``` + +`src/main/boot.ts`: + +```ts +import { readdirSync, rmSync, statSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { configureBundledMagickEnv } from './toolResolver' +import { ensureUserLayers } from './registry/load' + +/** + * Remove temp dirs orphaned by a previous HARD crash (normal runs delete their + * own in a finally). Guarded by age so a concurrent app or CLI's in-use temp dir + * is never swept out from under an active job. Best effort; never throws. + */ +export function sweepStaleTempDirs(now = Date.now(), dir = tmpdir()): void { + try { + const cutoff = now - 60 * 60 * 1000 + for (const name of readdirSync(dir)) { + if (!name.startsWith('filesmith-')) continue + const p = join(dir, name) + try { + if (statSync(p).mtimeMs < cutoff) rmSync(p, { recursive: true, force: true }) + } catch { + /* in use or already gone */ + } + } + } catch { + /* ignore */ + } +} + +/** Startup shared by the app and the CLI (spec M2). The magick env is the one + * that matters: without it every image job fails on a clean install with + * "no decode delegate". */ +export function bootEngine(): void { + sweepStaleTempDirs() + configureBundledMagickEnv() + ensureUserLayers() +} +``` + +`src/cli/env.ts`: + +```ts +import { basename, dirname, join } from 'path' +import type { EngineEnv } from '../main/env' + +export interface ProcessFacts { + execPath: string + resourcesPath?: string + env: Record + homedir: string + /** The folder holding cli.js (out/main in dev, app.asar/out/main packed). */ + moduleDir: string +} + +/** The CLI's engine env (spec 4.2). Pure, so packaged and dev layouts are tested + * without either being present. */ +export function cliEngineEnv(f: ProcessFacts, fetchImpl: EngineEnv['fetch']): EngineEnv { + const packaged = basename(f.execPath).toLowerCase() === 'filesmith.exe' + const resourcesDir = packaged + ? (f.resourcesPath ?? join(dirname(f.execPath), 'resources')) + : join(f.moduleDir, '..', '..', 'resources') + const appData = f.env.APPDATA ?? join(f.homedir, 'AppData', 'Roaming') + return { + userData: f.env.FILESMITH_USER_DATA || join(appData, 'Filesmith'), + resourcesDir, + downloadsDir: join(f.homedir, 'Downloads'), + fetch: fetchImpl, + host: 'cli' + } +} +``` + +`test/setup/engineEnv.ts`: + +```ts +import { join, resolve } from 'path' +import { tmpdir } from 'os' +import { setEngineEnv } from '../../src/main/env' + +// Every unit test runs against an explicit engine env: the repo's resources and +// a per-worker userData (the old electron mock's layout, so per-worker isolation +// is unchanged). Before M1 the engine silently fell back to cwd paths here. +const root = join(tmpdir(), `filesmith-test-userdata-${process.pid}`) +setEngineEnv({ + userData: join(root, 'userData'), + resourcesDir: resolve(__dirname, '..', '..', 'resources'), + downloadsDir: join(root, 'downloads'), + fetch: (url, init) => fetch(url, init), + host: 'app' +}) +``` + +`vitest.config.ts`: in `test`, add `setupFiles: ['test/setup/engineEnv.ts']`. + +- [ ] **Step 4: Rewire every engine call site** + +Make each edit below and remove the module's `import ... from 'electron'` line. + +`src/main/toolResolver.ts`: add `import { resourcePath } from './env'` and replace: + +```ts +function bundledDir(): string { + return resourcePath('bin') +} +``` + +```ts + const loRoot = resourcePath('libreoffice') +``` + +```ts + const gsRoot = resourcePath('ghostscript') +``` + +```ts +export function realesrganDir(): string { + return resourcePath('realesrgan') +} +``` + +`src/main/pid/paths.ts`: add `import { resourcePath, userDataPath } from '../env'` and replace: + +```ts +export function pidRoot(): string { + return userDataPath('pid') +} +``` + +```ts +export function pidServerScript(): string { + return resourcePath('pid', 'pid_server.py') +} +``` + +```ts +export function spandrelServerScript(): string { + return resourcePath('spandrel', 'spandrel_server.py') +} +``` + +`src/main/tools/ncnnModels.ts`: add `import { userDataPath } from '../env'`; + +```ts +export function userNcnnDir(): string { + return userDataPath('models', 'realesrgan') +} +``` + +`src/main/comfy/store.ts`: imports become `import { existsSync, readFileSync } from 'fs'`, `import { userDataPath } from '../env'`, `import { writeFileAtomic } from '../atomicWrite'` (drop `join`); + +```ts +function storePath(): string { + return userDataPath('comfy-upscalers.json') +} +``` + +```ts +export function writeComfyStore(store: ComfyStore): void { + writeFileAtomic(storePath(), JSON.stringify(store, null, 2)) +} +``` + +`src/main/generate/comfy.ts`: add `import { userDataPath } from '../env'`; line 108 becomes `const file = userDataPath('comfy-extra-model-paths.yaml')`. + +`src/main/generate/index.ts`: add `import { engineEnv } from '../env'`; line 119 becomes `const base = join(engineEnv().downloadsDir, \`${slug(opts.prompt)}.png\`)` (Task 13 replaces this line again for `outDir`). + +`src/main/net/download.ts`: add `import { engineEnv } from '../env'`; replace `httpFetch`: + +```ts +/** The host's fetch: Electron's net.fetch in the app (system proxy, Windows + * trust store); Node's fetch in the CLI, whose shims add --use-system-ca and + * NODE_USE_ENV_PROXY=1 for the same two reasons (spec 4.6). */ +function httpFetch(url: string, init: RequestInit): Promise { + return engineEnv().fetch(url, init) +} +``` + +`src/main/net/integrity.ts`: imports become `import { existsSync, readFileSync } from 'fs'`, `import { userDataPath } from '../env'`, `import { writeFileAtomic } from '../atomicWrite'`; + +```ts +function ledgerPath(): string { + return userDataPath('integrity.json') +} + +function read(): Ledger { + const p = ledgerPath() + if (!existsSync(p)) return {} + try { + const data = JSON.parse(readFileSync(p, 'utf-8')) as Ledger + return data && typeof data === 'object' ? data : {} + } catch { + return {} // a corrupt ledger degrades to "no history", never to a crash + } +} + +function write(l: Ledger): void { + try { + writeFileAtomic(ledgerPath(), JSON.stringify(l, null, 2)) + } catch { + /* best effort: a read-only profile still downloads, just without history */ + } +} +``` + +`src/main/registry/load.ts`: add `import { engineEnv } from '../env'`; replace `electronPath`, `builtinDir`, `userRegistryRoot`: + +```ts +function builtinDir(): string { + return join(engineEnv().resourcesDir, 'registry') +} + +function userRegistryRoot(): string | null { + return join(engineEnv().userData, 'registry') +} +``` + +(delete `electronPath` and its comment; `layerDir` keeps its `string | null` signature). + +`src/main/registry/channel.ts`: add `import { engineEnv, userDataPath } from '../env'`; `stampPath` returns `userDataPath('registry', 'channel', '.last-check')` (keep the `string | null` type); replace the two lines at 130-131 with: + +```ts + const res = await engineEnv().fetch(CHANNEL_URL, { signal: AbortSignal.timeout(15_000) }) +``` + +`src/main/index.ts`: change the electron import to `import { app, nativeTheme, net, protocol, screen, shell, BrowserWindow } from 'electron'`, remove the `readdirSync, rmSync` and `tmpdir` imports and the local `sweepStaleTempDirs` function, remove the `configureBundledMagickEnv` and `ensureUserLayers` imports, add `import { setEngineEnv } from './env'` and `import { bootEngine } from './boot'`. Right after the `FILESMITH_USER_DATA` override: + +```ts +// The engine's view of its host (spec M1). Read after the e2e userData override +// so tests that seed a session still get their temp folder. +setEngineEnv({ + userData: app.getPath('userData'), + resourcesDir: app.isPackaged ? process.resourcesPath : join(app.getAppPath(), 'resources'), + downloadsDir: app.getPath('downloads'), + fetch: (url, init) => net.fetch(url, init), + host: 'app' +}) +``` + +and in `whenReady` replace the three startup calls with: + +```ts + // Magick env, stale temp sweep, registry user layers (shared with the CLI). + bootEngine() + // Background, non-blocking, at most once a day, silent-fail-to-cache: the + // lever that fixes a dead model URL for every install without a release. + scheduleChannelRefresh() +``` + +- [ ] **Step 5: Run the new tests, then the whole suite** + +Run: `npx vitest run test/env.test.ts test/boot.test.ts test/engine-graph.test.ts` +Expected: PASS (15 tests: 7 env, 1 boot, 7 engine-graph roots). + +Run: `npm test` +Expected: PASS, same count as before plus 15. If `test/registry*.test.ts` or `test/user-registry.test.ts` fail on paths, they were relying on the removed cwd fallback; the setup file's `resourcesDir` is the same `resources` folder, so compare the failing path with `resolve('resources')` and fix the test's expectation, not the engine. + +- [ ] **Step 6: Verify the app still runs** + +Run: `npm run typecheck && npm run lint && npm run build && npx playwright test e2e/smoke.spec.ts` +Expected: all pass (the app's paths are unchanged values from a new place). + +- [ ] **Step 7: Commit** + +```bash +git add src/main src/cli/env.ts test vitest.config.ts +git commit -m "refactor(engine): EngineEnv provider replaces electron imports in the engine" -m "toolResolver, pid/paths, ncnnModels, comfy/store, generate, net, registry read paths and fetch from one provider set by the app (and later the CLI). Startup moves to boot.ts. comfy-upscalers.json and integrity.json are written atomically per process." -m "Refs #" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Dry-run output planners (M4) + +**Files:** +- Create: `src/main/tools/plan.ts`, `test/plan-output.test.ts` +- Modify: `src/main/output.ts` (shared candidate generator, two planners) + +**Interfaces:** +- Consumes: `reserveFileInDir`, `uniqueOutDir` (existing), `audioOutputExt(codec, sourceExt)` from `src/main/tools/compress.ts`. +- Produces: + - `output.ts`: `planFileInDir(dir: string, name: string, ext: string, tag: string, claimed?: Set): string`, `planOutDir(dir: string, base: string, claimed?: Set): string`. `claimed` holds lower-cased paths already predicted earlier in the same run. + - `tools/plan.ts`: `interface PlannedOutput { path: string; kind: 'file' | 'dir' }`, `planOutput(tool: ToolId, file: FileInfo, options: JobOptions, outDir: string | undefined, claimed: Set): PlannedOutput` (throws `Error` for an unknown tool or op). + +- [ ] **Step 1: Write the failing test** + +`test/plan-output.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { planFileInDir, planOutDir, reserveFileInDir } from '../src/main/output' +import { planOutput } from '../src/main/tools/plan' +import { fileKind } from '@shared/fileKind' +import type { FileInfo } from '@shared/types' + +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'filesmith-plan-')) +}) +afterEach(() => rmSync(dir, { recursive: true, force: true })) + +const info = (name: string): FileInfo => { + const ext = name.slice(name.lastIndexOf('.')).toLowerCase() + return { path: join(dir, name), name, ext, kind: fileKind(ext), size: 10 } +} + +describe('planFileInDir', () => { + it('predicts the same names as reserveFileInDir and creates nothing', () => { + writeFileSync(join(dir, 'photo.webp'), 'x') + writeFileSync(join(dir, 'photo (converted).webp'), 'x') + const before = readdirSync(dir).sort() + const planned = planFileInDir(dir, 'photo', '.webp', 'converted') + expect(readdirSync(dir).sort()).toEqual(before) + const real = reserveFileInDir(dir, 'photo', '.webp', 'converted') + expect(planned).toBe(real) + expect(planned).toBe(join(dir, 'photo (converted 2).webp')) + }) + + it('treats names claimed earlier in the same run as taken (case-insensitive)', () => { + const claimed = new Set() + const a = planFileInDir(dir, 'photo', 'webp', 'converted', claimed) + const b = planFileInDir(dir, 'PHOTO', '.webp', 'converted', claimed) + expect(a).toBe(join(dir, 'photo.webp')) + expect(b).toBe(join(dir, 'PHOTO (converted).webp')) + }) +}) + +describe('planOutDir', () => { + it('base, then base (2), honouring claims', () => { + mkdirSync(join(dir, 'doc (pages)')) + const claimed = new Set() + expect(planOutDir(dir, 'doc (pages)', claimed)).toBe(join(dir, 'doc (pages) (2)')) + expect(planOutDir(dir, 'doc (pages)', claimed)).toBe(join(dir, 'doc (pages) (3)')) + }) +}) + +describe('planOutput', () => { + const c = (): Set => new Set() + it.each([ + ['convert', 'a.png', { format: '.webp' }, 'a.webp'], + ['convert', 'a.pdf', { format: '.txt' }, 'a.txt'], + ['archive', 'a.cbz', { op: 'repack', format: '.cb7' }, 'a.cb7'], + ['archive', 'a.cbz', { op: 'to-pdf' }, 'a.pdf'], + ['archive', 'a.pdf', { op: 'from-pdf', format: '.cbz' }, 'a.cbz'], + ['compress', 'a.jpg', { imageFormat: 'keep' }, 'a (compressed).jpg'], + ['compress', 'a.png', { imageFormat: 'avif' }, 'a.avif'], + ['compress', 'a.mov', {}, 'a.mp4'], + ['compress', 'a.flac', { audioCodec: 'keep' }, 'a (compressed).flac'], + ['compress', 'a.mp3', { audioCodec: 'opus' }, 'a.opus'], + ['compress', 'a.pdf', { pdfLevel: 'smallest' }, 'a (compressed).pdf'], + ['resize', 'a.gif', {}, 'a (resized).gif'], + ['upscale', 'a.jpg', {}, 'a.png'], + ['removebg', 'a.png', {}, 'a (no-bg).png'], + ['pdf', 'a.pdf', { op: 'merge' }, 'a (merged).pdf'], + ['pdf', 'a.pdf', { op: 'split-range', range: '1-2' }, 'a (pages).pdf'], + ['pdf', 'a.pdf', { op: 'extract-text' }, 'a.txt'] + ] as const)('%s %s %j -> %s (file)', (tool, name, options, expected) => { + writeFileSync(join(dir, name), 'x') + const out = planOutput(tool, info(name), { ...options }, undefined, c()) + expect(out).toEqual({ path: join(dir, expected), kind: 'file' }) + }) + + it.each([ + ['split-pages', 'a (split)'], + ['extract-images', 'a (images)'], + ['pages-to-images', 'a (pages)'] + ])('pdf %s -> folder %s', (op, expected) => { + const out = planOutput('pdf', info('a.pdf'), { op }, undefined, c()) + expect(out).toEqual({ path: join(dir, expected), kind: 'dir' }) + }) + + it('honours outDir', () => { + const out = join(dir, 'out') + mkdirSync(out) + expect(planOutput('resize', info('a.png'), {}, out, c()).path).toBe(join(out, 'a.png')) + }) + + it('rejects an unknown pdf op', () => { + expect(() => planOutput('pdf', info('a.pdf'), { op: 'nope' }, undefined, c())).toThrow( + 'Unknown pdf operation: nope' + ) + }) +}) +``` + +- [ ] **Step 2: Run it and watch it fail** + +Run: `npx vitest run test/plan-output.test.ts` +Expected: FAIL, `planFileInDir is not a function` / unresolved `../src/main/tools/plan`. + +- [ ] **Step 3: Implement** + +`src/main/output.ts`, replace `reserveFileInDir` and `uniqueOutDir` with a shared generator plus the planners (keep the file's header comment and `reserveOutPath` / `resolveOutDir` unchanged): + +```ts +/** Candidate names in collision order: `name.ext`, `name (tag).ext`, + * `name (tag 2).ext`, ... One generator for the real reservation and the + * dry-run prediction, so the two cannot drift. */ +function* fileCandidates(dir: string, name: string, ext: string, tag: string): Generator { + const e = ext.startsWith('.') ? ext : '.' + ext + yield join(dir, name + e) + yield join(dir, `${name} (${tag})${e}`) + for (let n = 2; ; n++) yield join(dir, `${name} (${tag} ${n})${e}`) +} + +function* dirCandidates(dir: string, base: string): Generator { + yield join(dir, base) + for (let n = 2; ; n++) yield join(dir, `${base} (${n})`) +} + +/** + * A collision-free file path that ATOMICALLY claims the chosen name by creating + * an empty placeholder (openSync 'wx', exclusive create). Two jobs running + * concurrently can otherwise pick the same free name before either has written + * it; the exclusive create makes the second job skip to the next candidate. The + * tool that runs next overwrites the placeholder. Callers MUST remove the + * placeholder if the tool then fails (see the direct-write cleanup in registry). + */ +export function reserveFileInDir(dir: string, name: string, ext: string, tag: string): string { + for (const cand of fileCandidates(dir, name, ext, tag)) { + try { + closeSync(openSync(cand, 'wx')) + return cand + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + } + } + throw new Error('unreachable') +} + +/** The name reserveFileInDir WOULD pick, without creating anything (dry run). + * `claimed` holds lower-cased paths predicted earlier in the same run, so two + * sources that land on one name are predicted as the real run will name them. + * A prediction: another process may take a name before the real run. */ +export function planFileInDir( + dir: string, + name: string, + ext: string, + tag: string, + claimed: Set = new Set() +): string { + for (const cand of fileCandidates(dir, name, ext, tag)) { + if (existsSync(cand) || claimed.has(cand.toLowerCase())) continue + claimed.add(cand.toLowerCase()) + return cand + } + throw new Error('unreachable') +} + +/** Collision-free directory: `base` -> `base (2)` -> `base (3)` ... */ +export function uniqueOutDir(dir: string, base: string): string { + for (const cand of dirCandidates(dir, base)) if (!existsSync(cand)) return cand + throw new Error('unreachable') +} + +/** The folder uniqueOutDir WOULD pick, honouring earlier claims in the run. */ +export function planOutDir(dir: string, base: string, claimed: Set = new Set()): string { + for (const cand of dirCandidates(dir, base)) { + if (existsSync(cand) || claimed.has(cand.toLowerCase())) continue + claimed.add(cand.toLowerCase()) + return cand + } + throw new Error('unreachable') +} +``` + +`src/main/tools/plan.ts`: + +```ts +import { basename, dirname, extname } from 'path' +import type { FileInfo, JobOptions, ToolId } from '@shared/types' +import type { AudioCodec } from '@shared/compress' +import { isSameFormat, normalizeExt } from '@shared/convert' +import { planFileInDir, planOutDir } from '../output' +import { audioOutputExt } from './compress' + +export interface PlannedOutput { + path: string + kind: 'file' | 'dir' +} + +/** + * The output a tool's run() would produce, predicted without touching disk + * (spec M4). Mirrors the reserveOutPath / uniqueOutDir call in each branch of + * tools/registry.ts; test/plan-output.test.ts pins every branch, and the CLI + * e2e suite checks a dry run against the real run's names. + */ +export function planOutput( + tool: ToolId, + file: FileInfo, + options: JobOptions, + outDir: string | undefined, + claimed: Set +): PlannedOutput { + const dir = outDir ?? dirname(file.path) + const name = basename(file.path, extname(file.path)) + const f = (ext: string, tag: string): PlannedOutput => ({ + path: planFileInDir(dir, name, ext, tag, claimed), + kind: 'file' + }) + const d = (suffix: string): PlannedOutput => ({ + path: planOutDir(dir, `${name} (${suffix})`, claimed), + kind: 'dir' + }) + const format = normalizeExt(String(options.format ?? '')) + + switch (tool) { + case 'convert': + return f(file.kind === 'pdf' && isSameFormat(format, '.txt') ? '.txt' : format, 'converted') + case 'archive': { + const op = String(options.op ?? 'repack') + if (op === 'to-pdf') return f('.pdf', 'converted') + if (op === 'repack' || op === 'from-pdf') return f(format, 'converted') + if (op === 'extract') return d('extracted') + throw new Error(`Unknown archive operation: ${op}`) + } + case 'compress': { + if (file.kind === 'pdf') return f('.pdf', 'compressed') + if (file.kind === 'video') return f('.mp4', 'compressed') + if (file.kind === 'audio') + return f( + audioOutputExt(String(options.audioCodec ?? 'keep') as AudioCodec, file.ext), + 'compressed' + ) + const fmt = String(options.imageFormat ?? 'keep') + return f(fmt === 'keep' ? file.ext : `.${fmt}`, 'compressed') + } + case 'resize': + return f(file.ext, 'resized') + case 'upscale': + return f('.png', 'upscaled') + case 'removebg': + return f('.png', 'no-bg') + case 'pdf': { + const op = String(options.op ?? 'extract-text') + if (op === 'merge') return f('.pdf', 'merged') + if (op === 'split-range') return f('.pdf', 'pages') + if (op === 'extract-text') return f('.txt', 'text') + if (op === 'split-pages') return d('split') + if (op === 'extract-images') return d('images') + if (op === 'pages-to-images') return d('pages') + throw new Error(`Unknown pdf operation: ${op}`) + } + default: + throw new Error(`No output planner for ${tool}`) + } +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `npx vitest run test/plan-output.test.ts test/output.test.ts` +Expected: PASS (both files; `output.test.ts` proves the refactor kept the reservation behaviour). + +- [ ] **Step 5: Verify and commit** + +```bash +npm run typecheck && npm run lint && npm test +git add src/main/output.ts src/main/tools/plan.ts test/plan-output.test.ts +git commit -m "feat(engine): dry-run output planners sharing the collision-safe naming" -m "Refs #" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Installer plumbing: cross-process locks, cancel, byte progress, public uv bootstrap, live ComfyUI (M7, M8) + +**Files:** +- Create: `src/main/locks.ts`, `src/main/uvInstall.ts`, `test/locks.test.ts`, `test/comfy-live.test.ts` +- Modify: `src/main/pid/install.ts` (lock, signal, bytes, `ensureUv`/`winTar`/`uvVersionOk` moved out), `src/main/generate/companions.ts:38-` (lock, signal, bytes), `src/main/net/download.ts:75-84,220-230` (`onBytes`), `src/main/uv.ts:37-52` (new candidate), `src/main/generate/comfy.ts` (`comfy-live.json`) + +**Interfaces:** +- Consumes: `userDataPath`, `engineEnv` (Task 2), `writeFileAtomic` (Task 2). +- Produces: + - `locks.ts`: `interface LockInfo { pid: number; host: 'app' | 'cli'; since: number; what: string }`, `lockPath(name: string): string`, `readLock(name: string): LockInfo | null`, `pidAlive(pid: number): boolean`, `isStale(info: LockInfo | null, now?: number, alive?: (pid: number) => boolean): boolean`, `tryAcquire(name: string, what: string, now?: number): (() => void) | null`, `withFileLock(name: string, what: string, fn: () => Promise, opts?: { waitMs?: number; pollMs?: number; onWait?: (holder: LockInfo | null) => void; signal?: AbortSignal }): Promise`. Lock names in use: `pid-env`, `companions`, `rembg`. + - `uvInstall.ts`: `interface InstallProgress { (step: string, pct: number | null): void }`, `interface InstallOpts { signal?: AbortSignal; onBytes?: (got: number, total: number) => void }`, `winTar(): string`, `uvVersionOk(uv: string): Promise`, `ensureUv(onProgress: InstallProgress, opts?: InstallOpts): Promise`. + - `pid/install.ts`: `installPid(backbone: string, onProgress: InstallProgress, opts?: InstallOpts): Promise`, `installComfyEngine(onProgress: InstallProgress, opts?: InstallOpts): Promise`, `pidWeightFiles(backbone: string): { path: string; url: string }[]`; re-exports `InstallProgress`, `InstallOpts`. + - `generate/companions.ts`: `downloadCompanions(modelName: string, onProgress: (p: CompanionProgress) => void, opts?: InstallOpts): Promise`. + - `net/download.ts`: `DownloadOptions.onBytes?: (got: number, total: number) => void`. + - `generate/comfy.ts`: `recordLiveComfy(url: string): void`, `clearLiveComfy(): void`, `liveComfyUrl(): string | null`. + +- [ ] **Step 1: Write the failing tests** + +`test/locks.test.ts`: + +```ts +import { afterEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs' +import { dirname } from 'path' +import { isStale, lockPath, readLock, tryAcquire, withFileLock } from '../src/main/locks' + +const NAME = `test-${process.pid}` +afterEach(() => rmSync(lockPath(NAME), { force: true })) + +describe('file locks', () => { + it('acquires, blocks a second taker, and releases', () => { + const release = tryAcquire(NAME, 'a test') + expect(release).not.toBeNull() + expect(readLock(NAME)).toMatchObject({ pid: process.pid, what: 'a test' }) + expect(tryAcquire(NAME, 'again')).toBeNull() + release?.() + expect(existsSync(lockPath(NAME))).toBe(false) + }) + + it('takes over a lock whose owner process is gone', () => { + mkdirSync(dirname(lockPath(NAME)), { recursive: true }) + writeFileSync( + lockPath(NAME), + JSON.stringify({ pid: 999_999_999, host: 'app', since: Date.now(), what: 'x' }) + ) + const release = tryAcquire(NAME, 'mine') + expect(release).not.toBeNull() + expect(readLock(NAME)?.pid).toBe(process.pid) + release?.() + }) + + it('treats a lock older than six hours as stale even if the pid lives', () => { + const now = Date.now() + const info = { pid: process.pid, host: 'cli' as const, since: now - 7 * 3600_000, what: 'x' } + expect(isStale(info, now, () => true)).toBe(true) + expect(isStale({ ...info, since: now }, now, () => true)).toBe(false) + expect(isStale(null, now)).toBe(true) + }) + + it('withFileLock waits for the holder, then runs', async () => { + const release = tryAcquire(NAME, 'holder') + const waits: number[] = [] + setTimeout(() => release?.(), 50) + const result = await withFileLock(NAME, 'waiter', async () => 'ran', { + pollMs: 10, + onWait: () => waits.push(1) + }) + expect(result).toBe('ran') + expect(waits.length).toBeGreaterThan(0) + expect(existsSync(lockPath(NAME))).toBe(false) + }) + + it('withFileLock gives up after waitMs with a message naming the holder', async () => { + const release = tryAcquire(NAME, 'holder') + await expect( + withFileLock(NAME, 'the engine', async () => 'never', { waitMs: 30, pollMs: 10 }) + ).rejects.toThrow(/still installing the engine/) + release?.() + }) + + it('withFileLock stops waiting when aborted', async () => { + const release = tryAcquire(NAME, 'holder') + const ctrl = new AbortController() + setTimeout(() => ctrl.abort(), 20) + await expect( + withFileLock(NAME, 'x', async () => 'never', { pollMs: 5, signal: ctrl.signal }) + ).rejects.toThrow() + release?.() + }) +}) +``` + +`test/comfy-live.test.ts`: + +```ts +import { afterEach, describe, expect, it } from 'vitest' +import { mkdirSync, writeFileSync } from 'fs' +import { dirname } from 'path' +import { engineEnv, setEngineEnv, userDataPath } from '../src/main/env' +import { + candidateComfyUrls, + clearLiveComfy, + liveComfyUrl, + recordLiveComfy +} from '../src/main/generate/comfy' + +const saved = engineEnv() +afterEach(() => { + clearLiveComfy() + setEngineEnv(saved) +}) + +describe('comfy-live.json (M8)', () => { + it('the app records the ComfyUI it launched and the CLI tries it first', () => { + setEngineEnv({ ...saved, host: 'app' }) + recordLiveComfy('http://127.0.0.1:51234') + expect(liveComfyUrl()).toBe('http://127.0.0.1:51234') + setEngineEnv({ ...saved, host: 'cli' }) + const urls = candidateComfyUrls() + expect(urls.indexOf('http://127.0.0.1:51234')).toBeLessThan( + urls.indexOf('http://127.0.0.1:8188') + ) + }) + + it('a CLI-launched ComfyUI is never advertised (it dies with the CLI)', () => { + setEngineEnv({ ...saved, host: 'cli' }) + recordLiveComfy('http://127.0.0.1:51235') + expect(liveComfyUrl()).toBeNull() + }) + + it('ignores a record whose process is gone', () => { + mkdirSync(dirname(userDataPath('comfy-live.json')), { recursive: true }) + writeFileSync( + userDataPath('comfy-live.json'), + JSON.stringify({ url: 'http://127.0.0.1:1', pid: 999_999_999 }) + ) + expect(liveComfyUrl()).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run them and watch them fail** + +Run: `npx vitest run test/locks.test.ts test/comfy-live.test.ts` +Expected: FAIL, unresolved `../src/main/locks`; `recordLiveComfy` is not exported. + +- [ ] **Step 3: Implement the lock module** + +`src/main/locks.ts`: + +```ts +import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from 'fs' +import { dirname } from 'path' +import { engineEnv, userDataPath } from './env' + +/** + * Cross-process lock files (spec M7). The app and the CLI can now install the + * same engine at the same moment; the old guards (withInstallLock, the IPC + * companion map) only covered one process, and two installers share .part + * files and rmSync the same repo dir. A lock is a file created with 'wx' + * holding the owner's pid; a dead owner or a six-hour-old lock is stale. + */ +export interface LockInfo { + pid: number + host: 'app' | 'cli' + since: number + what: string +} + +const STALE_MS = 6 * 60 * 60 * 1000 + +export function lockPath(name: string): string { + return userDataPath('locks', `${name}.lock`) +} + +export function readLock(name: string): LockInfo | null { + try { + return JSON.parse(readFileSync(lockPath(name), 'utf-8')) as LockInfo + } catch { + return null + } +} + +export function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (e) { + return (e as NodeJS.ErrnoException).code === 'EPERM' + } +} + +export function isStale( + info: LockInfo | null, + now = Date.now(), + alive: (pid: number) => boolean = pidAlive +): boolean { + if (!info || typeof info.pid !== 'number') return true + return !alive(info.pid) || now - info.since > STALE_MS +} + +/** Take the lock, or null when a live owner holds it. Returns the release. */ +export function tryAcquire(name: string, what: string, now = Date.now()): (() => void) | null { + const p = lockPath(name) + mkdirSync(dirname(p), { recursive: true }) + for (let attempt = 0; attempt < 2; attempt++) { + try { + const fd = openSync(p, 'wx') + const info: LockInfo = { pid: process.pid, host: engineEnv().host, since: now, what } + writeSync(fd, JSON.stringify(info)) + closeSync(fd) + return () => { + try { + if (readLock(name)?.pid === process.pid) rmSync(p, { force: true }) + } catch { + /* best effort */ + } + } + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e + if (!isStale(readLock(name), now)) return null + rmSync(p, { force: true }) + } + } + return null +} + +/** Run `fn` holding the lock, waiting (default up to 10 minutes) for a live + * holder to finish. `onWait` is called on every poll so callers can report + * "waiting for the app" and a heartbeat. */ +export async function withFileLock( + name: string, + what: string, + fn: () => Promise, + opts: { + waitMs?: number + pollMs?: number + onWait?: (holder: LockInfo | null) => void + signal?: AbortSignal + } = {} +): Promise { + const waitMs = opts.waitMs ?? 10 * 60_000 + const pollMs = opts.pollMs ?? 1000 + const start = Date.now() + let release = tryAcquire(name, what) + while (!release) { + opts.signal?.throwIfAborted() + if (Date.now() - start > waitMs) { + const h = readLock(name) + throw new Error( + `Another Filesmith ${h?.host ?? 'process'} (pid ${h?.pid ?? '?'}) is still installing ${what}. Try again when it finishes.` + ) + } + opts.onWait?.(readLock(name)) + await new Promise((r) => setTimeout(r, pollMs)) + release = tryAcquire(name, what) + } + try { + return await fn() + } finally { + release() + } +} +``` + +- [ ] **Step 4: Move the uv bootstrap out of the PiD installer** + +`src/main/uvInstall.ts` (body moved from `pid/install.ts`; the download location becomes `%APPDATA%\Filesmith\uv` so removebg setup can use it without PiD): + +```ts +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { run } from './run' +import { userDataPath } from './env' +import { downloadFile } from './net/download' +import { expectedHash, recordHash } from './net/integrity' +import { resolveUv } from './toolResolver' + +export interface InstallProgress { + (step: string, pct: number | null): void +} + +/** Cancel and byte-level progress for an install (spec 5.3). */ +export interface InstallOpts { + signal?: AbortSignal + onBytes?: (got: number, total: number) => void +} + +// PiD's pyproject requires a recent uv, and a stale system uv is worse than +// none: it is found first but cannot satisfy the floor. So a known-good uv is +// bootstrapped when the resolved one is missing or too old. +const UV_VERSION = '0.11.30' +const UV_MIN = [0, 11, 28] as const +const UV_ZIP = `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-pc-windows-msvc.zip` + +/** Windows' bundled bsdtar, by full path: it handles >260-char paths, and a GNU + * tar earlier on PATH (Git's) treats `C:\...` as a remote host. */ +export function winTar(): string { + return join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') +} + +/** True when `uv --version` reports a version at or above UV_MIN. */ +export async function uvVersionOk(uv: string): Promise { + try { + const { code, stdout } = await run(uv, ['--version']) + if (code !== 0) return false + const m = /uv (\d+)\.(\d+)\.(\d+)/.exec(stdout) + if (!m) return false + const v = [Number(m[1]), Number(m[2]), Number(m[3])] as const + for (let i = 0; i < 3; i += 1) { + if (v[i] > UV_MIN[i]) return true + if (v[i] < UV_MIN[i]) return false + } + return true + } catch { + return false + } +} + +/** A uv new enough for PiD and rembg: an installed one, else a pinned + * standalone uv downloaded into %APPDATA%\Filesmith\uv. */ +export async function ensureUv(onProgress: InstallProgress, opts: InstallOpts = {}): Promise { + const found = resolveUv() + if (found && (await uvVersionOk(found))) return found + const uvDir = userDataPath('uv') + const uvExe = join(uvDir, 'uv.exe') + if (existsSync(uvExe) && (await uvVersionOk(uvExe))) return uvExe + + opts.signal?.throwIfAborted() + onProgress('Downloading uv', null) + mkdirSync(userDataPath(), { recursive: true }) + const uvTmp = mkdtempSync(join(userDataPath(), 'uv-')) + try { + const zip = join(uvTmp, 'uv.zip') + const r = await downloadFile(UV_ZIP, zip, { + onPct: (p) => onProgress('Downloading uv', p), + sha256: expectedHash(UV_ZIP), + signal: opts.signal, + onBytes: opts.onBytes + }) + recordHash(r.url, r.sha256, r.bytes) + rmSync(uvDir, { recursive: true, force: true }) + mkdirSync(uvDir, { recursive: true }) + const ex = await run(winTar(), ['-xf', zip, '-C', uvDir], { signal: opts.signal }) + if (ex.code !== 0) throw new Error(`uv extract failed: ${ex.stderr.slice(-400)}`) + } finally { + rmSync(uvTmp, { recursive: true, force: true }) + } + if (!existsSync(uvExe)) throw new Error('uv bootstrap failed (no uv.exe after extract)') + return uvExe +} +``` + +`src/main/uv.ts` `uvCandidates()`: inside the existing `try` (after the `pidRoot()` push) add `out.push(join(userDataPath(), 'uv', 'uv' + EXE))` and import `userDataPath` from `./env`; update the comment above the `try` to "The ones WE downloaded (PiD's older location, then the shared one)". + +`src/main/net/download.ts`: in `DownloadOptions` add + +```ts + /** Bytes so far and the expected total (0 when unknown), for ETA. */ + onBytes?: (got: number, total: number) => void +``` + +and in the `counter` Transform, right after the `onPct` line, add `opts.onBytes?.(got, total)`. + +- [ ] **Step 5: Lock, cancel and byte progress in the PiD installer** + +In `src/main/pid/install.ts`: + +1. Delete `UV_VERSION`, `UV_MIN`, `UV_ZIP`, `winTar`, `uvVersionOk`, `ensureUv` and the `InstallProgress` interface. Add imports `import { ensureUv, winTar, type InstallOpts, type InstallProgress } from '../uvInstall'` and `import { withFileLock } from '../locks'`, drop the now-unused `resolveUv` import, and add `export type { InstallOpts, InstallProgress } from '../uvInstall'` so existing importers keep compiling. +2. Below the imports add: + +```ts +/** The running install's cancel signal and byte reporter. Module scope is safe: + * withInstallLock admits one install per process. */ +let active: InstallOpts = {} + +function runI(cmd: string, args: string[], opts: { cwd?: string } = {}): ReturnType { + return run(cmd, args, { ...opts, signal: active.signal }) +} +``` + +3. Replace every `await run(` inside `ensureRepo`, `ensureEnv`, `ensureSpandrel` (lines 133, 215, 220, 237, 407 before this task) with `await runI(` (the argument lists are unchanged). +4. `ensureUv(onProgress)` calls (lines 211, 401) become `ensureUv(onProgress, active)`. +5. The private `download()` helper passes the signal and bytes: + +```ts + const result = await downloadFile(url, dest, { + onPct, + minBytes, + sha256: expectedHash(url), + signal: active.signal, + onBytes: active.onBytes + }) +``` + +6. Make `active.signal?.throwIfAborted()` the first statement of `ensureRepo`, `ensureEnv`, `ensureWeights` and `ensureSpandrel`. +7. Replace `installPid` and `installComfyEngine`: + +```ts +/** Full one-click PiD install (idempotent, interruption-safe, one per machine). */ +export async function installPid( + backbone: string, + onProgress: InstallProgress, + opts: InstallOpts = {} +): Promise { + return withInstallLock(() => + withFileLock( + 'pid-env', + 'the AI upscaler engine', + async () => { + active = opts + try { + await installPidInner(backbone, onProgress) + } finally { + active = {} + } + }, + { + signal: opts.signal, + onWait: (h) => + onProgress(`Waiting for another Filesmith (${h?.host ?? 'process'}) to finish`, null) + } + ) + ) +} +``` + +```ts +export async function installComfyEngine( + onProgress: InstallProgress, + opts: InstallOpts = {} +): Promise { + // Shares both locks with installPid: both run ensureRepo/ensureEnv, write the + // same temp paths and rmSync the same repo dir. + return withInstallLock(() => + withFileLock( + 'pid-env', + 'the AI upscaler engine', + async () => { + active = opts + try { + await assertCudaCapable() + mkdirSync(pidRoot(), { recursive: true }) + const space = checkDiskSpace(ENV_APPROX_BYTES) + if (!space.ok) throw new Error(space.reason) + await ensureRepo(onProgress) + await ensureEnv(onProgress) + await ensureSpandrel(onProgress) + onProgress('Ready', 100) + } finally { + active = {} + } + }, + { + signal: opts.signal, + onWait: (h) => + onProgress(`Waiting for another Filesmith (${h?.host ?? 'process'}) to finish`, null) + } + ) + ) +} +``` + +8. Export the weight list `doctor --verify` needs (next to `ensureWeights`, using the same `HF_BASE` and backbone fields `ensureWeights` uses): + +```ts +/** The PiD weight files and the URL each was downloaded from (doctor --verify). */ +export function pidWeightFiles(backbone: string): { path: string; url: string }[] { + const bb = PID_BACKBONES[backbone] + if (!bb) return [] + const ckpt = `${bb.checkpointDir}/model_ema_bf16.pth` + return [ + { path: join(pidRepoDir(), ckpt), url: `${HF_BASE}/${ckpt}` }, + { path: join(pidRepoDir(), bb.vaeFile), url: `${HF_BASE}/${bb.vaeFile}` } + ] +} +``` + +Before writing it, open `ensureWeights` and confirm it downloads `${HF_BASE}/${bb.checkpointDir}/model_ema_bf16.pth` and `${HF_BASE}/${bb.vaeFile}`; if it builds the URLs differently, copy its exact expressions here. + +`src/main/generate/companions.ts`: rename the exported function to `async function downloadCompanionsInner(modelName: string, onProgress: (p: CompanionProgress) => void, opts: InstallOpts): Promise`, add `signal: opts.signal, onBytes: opts.onBytes` to its `downloadFile(urls, dest, { ... })` options, add `opts.signal?.throwIfAborted()` at the top of its per-file loop, and add the exported wrapper: + +```ts +/** + * Download every missing companion for `modelName`. Skips files already present + * (idempotent / resumable across runs). One companion download per machine at + * a time: the app and the CLI share the models tree and its .part files. + */ +export async function downloadCompanions( + modelName: string, + onProgress: (p: CompanionProgress) => void, + opts: InstallOpts = {} +): Promise { + return withFileLock( + 'companions', + 'model files', + () => downloadCompanionsInner(modelName, onProgress, opts), + { signal: opts.signal } + ) +} +``` + +with imports `import { withFileLock } from '../locks'` and `import type { InstallOpts } from '../uvInstall'`. + +- [ ] **Step 6: Advertise the app's ComfyUI (M8)** + +In `src/main/generate/comfy.ts`, add imports `readFileSync, rmSync` from `fs`, `engineEnv` from `../env`, `writeFileAtomic` from `../atomicWrite`, `pidAlive` from `../locks`, then add: + +```ts +// The URL of a ComfyUI the APP launched, so a CLI run attaches to it instead of +// starting a second one (about 2x VRAM). The CLI never advertises its own: that +// one dies when the command ends. +function liveFile(): string { + return userDataPath('comfy-live.json') +} + +export function recordLiveComfy(url: string): void { + if (engineEnv().host !== 'app') return + try { + writeFileAtomic(liveFile(), JSON.stringify({ url, pid: process.pid })) + } catch { + /* best effort */ + } +} + +export function clearLiveComfy(): void { + try { + const cur = JSON.parse(readFileSync(liveFile(), 'utf-8')) as { pid?: number } + if (cur.pid === process.pid) rmSync(liveFile(), { force: true }) + } catch { + /* nothing recorded */ + } +} + +export function liveComfyUrl(): string | null { + try { + const cur = JSON.parse(readFileSync(liveFile(), 'utf-8')) as { url?: string; pid?: number } + return cur.url && typeof cur.pid === 'number' && pidAlive(cur.pid) ? cur.url : null + } catch { + return null + } +} +``` + +In `candidateComfyUrls()`, after the `FILESMITH_COMFY_URL` push, add: + +```ts + const live = liveComfyUrl() + if (live) out.push(live.replace(/\/+$/, '')) +``` + +In `ensureComfyServer`, change the readiness loop's success line to `if (await alive(ourUrl)) { recordLiveComfy(ourUrl); return ourUrl }`. In the `proc.on('exit', ...)` handler and in `stopComfyServer()`, call `clearLiveComfy()`. + +- [ ] **Step 7: Run the tests** + +Run: `npx vitest run test/locks.test.ts test/comfy-live.test.ts test/pid.test.ts test/comfy.test.ts test/resolvers.test.ts` +Expected: PASS. `pid.test.ts` and `resolvers.test.ts` still pass because `InstallProgress` is re-exported and `uvCandidates` only gained a candidate. + +- [ ] **Step 8: Verify and commit** + +```bash +npm run typecheck && npm run lint && npm test +git add src/main test/locks.test.ts test/comfy-live.test.ts +git commit -m "feat(engine): cross-process install locks, cancellable installers, live ComfyUI record" -m "installPid, installComfyEngine and companion downloads take a machine-wide lock file, an AbortSignal and a byte-progress callback. ensureUv moves to uvInstall.ts (public, downloads to userData/uv). The app records the ComfyUI it launched in comfy-live.json." -m "Refs #" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Explicit rembg install, readiness checks, no downloads in CLI jobs (M5, M6) + +**Files:** +- Create: `src/main/rembg/paths.ts`, `src/main/rembg/setup.ts`, `src/main/tools/readiness.ts`, `test/rembg-setup.test.ts`, `test/readiness.test.ts` +- Modify: `src/main/run.ts:9-14,39-42` (`env`), `src/main/tools/tool.ts` (`allowDownload`), `src/main/jobQueue.ts:22-35,93-104` (option), `src/main/toolResolver.ts:170-234` (rembg resolution and status), `src/main/tools/registry.ts:722-724,969-1000` (removebg and PiD messages) + +**Interfaces:** +- Consumes: `withFileLock` (Task 4), `ensureUv`, `InstallProgress`, `InstallOpts` (Task 4), `userDataPath`, `engineEnv` (Task 2), `recordHash` (existing). +- Produces: + - `run.ts`: `RunOptions.env?: NodeJS.ProcessEnv`. + - `tools/tool.ts`: `ToolContext.allowDownload?: boolean` (undefined means allowed). + - `jobQueue.ts`: `new JobQueue(emit, concurrency?, opts?: { allowDownload?: boolean })`. + - `rembg/paths.ts`: `REMBG_SPEC`, `rembgToolDir()`, `rembgExe()`, `legacyRembgExe()`, `rembgModelDir()`, `rembgModelFile(model: string)`, `installedRembgExe(): string | null`, `rembgModelPresent(model: string): boolean`, `rembgEnv(): NodeJS.ProcessEnv`. + - `rembg/setup.ts`: `setupRembg(model: string, onProgress: InstallProgress, opts?: InstallOpts): Promise`, `hashFile(path: string): Promise`, `TINY_PNG: Buffer`, `legacyRembgModelFile(model: string, env?, home?): string` (reuses a pre-0.6 `~/.u2net` model instead of downloading it again). + - `tools/readiness.ts`: `type ReadinessCode = 'SETUP_REQUIRED' | 'GPU_UNSUPPORTED' | 'TOOL_MISSING' | 'USAGE'`, `type Readiness = { ok: true } | { ok: false; code: ReadinessCode; message: string; hint?: string }`, `type SetupTool = 'pid' | 'spandrel' | 'removebg'`, `setupHint(tool: SetupTool): string`, `notReadyMessage(tool: SetupTool): string`, `interface ReadinessDeps`, `upscaleReadiness(options: JobOptions, deps: ReadinessDeps): Promise`, `removebgReadiness(options: JobOptions, deps: ReadinessDeps): Readiness`, `defaultReadinessDeps: ReadinessDeps`. + - `toolResolver.ts`: `RembgCommand` gains `env: NodeJS.ProcessEnv`; `resolveRembg()` returns only an installed rembg (never `uv tool run`). + +- [ ] **Step 1: Write the failing tests** + +`test/readiness.test.ts`: + +```ts +import { afterEach, describe, expect, it } from 'vitest' +import { engineEnv, setEngineEnv } from '../src/main/env' +import { + notReadyMessage, + removebgReadiness, + upscaleReadiness, + type ReadinessDeps +} from '../src/main/tools/readiness' + +const saved = engineEnv() +afterEach(() => setEngineEnv(saved)) + +const deps = (over: Partial = {}): ReadinessDeps => ({ + pidInstalled: () => true, + cuda: async () => ({ ok: true }), + comfyEngineReady: () => true, + comfyModelKnown: () => true, + realesrganPresent: () => true, + ncnnNames: () => ['realesrgan-x4plus', 'realesrgan-x4plus-anime'], + rembgInstalled: () => true, + rembgModelPresent: () => true, + ...over +}) + +describe('upscaleReadiness', () => { + it('bundled models are ready', async () => { + expect(await upscaleReadiness({ upscaleModel: 'photo' }, deps())).toEqual({ ok: true }) + expect( + await upscaleReadiness({ upscaleModel: 'esrgan:REALESRGAN-X4PLUS-ANIME' }, deps()) + ).toEqual({ ok: true }) + }) + + it('an unknown Real-ESRGAN name is a usage error listing the installed ones', async () => { + const r = await upscaleReadiness({ upscaleModel: 'esrgan:nope' }, deps()) + expect(r).toMatchObject({ ok: false, code: 'USAGE', hint: 'filesmith formats upscale' }) + expect(r.ok === false && r.message).toContain('realesrgan-x4plus') + }) + + it('missing Real-ESRGAN binary is TOOL_MISSING', async () => { + const r = await upscaleReadiness({}, deps({ realesrganPresent: () => false })) + expect(r).toMatchObject({ ok: false, code: 'TOOL_MISSING', hint: 'filesmith doctor' }) + }) + + it('pid: GPU gate first, then install state', async () => { + const gpu = await upscaleReadiness( + { upscaleModel: 'pid' }, + deps({ cuda: async () => ({ ok: false, reason: 'Driver 470 is too old.' }) }) + ) + expect(gpu).toEqual({ ok: false, code: 'GPU_UNSUPPORTED', message: 'Driver 470 is too old.' }) + const setup = await upscaleReadiness( + { upscaleModel: 'pid' }, + deps({ pidInstalled: () => false }) + ) + expect(setup).toMatchObject({ ok: false, code: 'SETUP_REQUIRED', hint: 'filesmith setup pid' }) + }) + + it('comfy:: engine, then the scanned list', async () => { + const engine = await upscaleReadiness( + { upscaleModel: 'comfy:C:\\m\\x.pth' }, + deps({ comfyEngineReady: () => false }) + ) + expect(engine).toMatchObject({ code: 'SETUP_REQUIRED', hint: 'filesmith setup spandrel' }) + const unknown = await upscaleReadiness( + { upscaleModel: 'comfy:C:\\m\\x.pth' }, + deps({ comfyModelKnown: () => false }) + ) + expect(unknown).toMatchObject({ code: 'SETUP_REQUIRED' }) + }) +}) + +describe('removebgReadiness', () => { + it('needs the installed tool and the model file', () => { + expect(removebgReadiness({}, deps())).toEqual({ ok: true }) + expect(removebgReadiness({}, deps({ rembgModelPresent: () => false }))).toMatchObject({ + ok: false, + code: 'SETUP_REQUIRED', + hint: 'filesmith setup removebg' + }) + }) +}) + +describe('notReadyMessage', () => { + it('names the setup command in the CLI and keeps the app wording in the app', () => { + setEngineEnv({ ...saved, host: 'cli' }) + expect(notReadyMessage('pid')).toBe('PiD is not installed. Run: filesmith setup pid.') + setEngineEnv({ ...saved, host: 'app' }) + expect(notReadyMessage('pid')).toContain('Pick PiD in the options panel') + }) +}) +``` + +`test/rembg-setup.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { dirname, join } from 'path' +import { engineEnv, setEngineEnv } from '../src/main/env' +import { + installedRembgExe, + legacyRembgExe, + rembgEnv, + rembgExe, + rembgModelDir, + rembgModelFile, + rembgModelPresent +} from '../src/main/rembg/paths' +import { legacyRembgModelFile, setupRembg } from '../src/main/rembg/setup' +import { removebgStatus, resolveRembg } from '../src/main/toolResolver' +import { JobQueue } from '../src/main/jobQueue' +import type { JobEvent } from '@shared/types' + +const saved = engineEnv() +const savedAppData = process.env.APPDATA +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'fs-rembg-')) + setEngineEnv({ ...saved, userData: join(root, 'ud') }) + process.env.APPDATA = join(root, 'appdata') +}) +afterEach(() => { + setEngineEnv(saved) + process.env.APPDATA = savedAppData + rmSync(root, { recursive: true, force: true }) +}) + +const touch = (p: string): void => { + mkdirSync(dirname(p), { recursive: true }) + writeFileSync(p, 'x') +} + +describe('rembg paths (M6)', () => { + it('the pinned model folder lives under userData', () => { + expect(rembgModelDir()).toBe(join(root, 'ud', 'models', 'rembg')) + expect(rembgModelFile('birefnet-general')).toBe( + join(root, 'ud', 'models', 'rembg', 'birefnet-general.onnx') + ) + expect(rembgEnv().U2NET_HOME).toBe(rembgModelDir()) + }) + + it('prefers our tool dir, falls back to a pre-0.6 uv tool install', () => { + expect(installedRembgExe()).toBeNull() + touch(legacyRembgExe()) + expect(installedRembgExe()).toBe(legacyRembgExe()) + touch(rembgExe()) + expect(installedRembgExe()).toBe(rembgExe()) + }) + + it('resolveRembg never falls back to uv tool run', () => { + expect(resolveRembg()).toBeNull() + touch(rembgExe()) + expect(resolveRembg()).toEqual({ cmd: rembgExe(), prefix: [], env: rembgEnv() }) + }) + + it('removebg:status is ready only with the tool AND the default model', async () => { + touch(rembgExe()) + expect((await removebgStatus()).ready).toBe(false) + touch(rembgModelFile('birefnet-general')) + expect((await removebgStatus()).ready).toBe(true) + }) +}) + +describe('setupRembg', () => { + it('reuses a pre-0.6 model from U2NET_HOME instead of downloading it again', async () => { + touch(rembgExe()) + const legacyHome = join(root, 'u2net') + touch(join(legacyHome, 'birefnet-general.onnx')) + const savedU2 = process.env.U2NET_HOME + process.env.U2NET_HOME = legacyHome + try { + expect(legacyRembgModelFile('birefnet-general')).toBe(join(legacyHome, 'birefnet-general.onnx')) + const steps: string[] = [] + await setupRembg('birefnet-general', (s) => steps.push(s)) + expect(rembgModelPresent('birefnet-general')).toBe(true) + expect(steps).toEqual(['Reusing the birefnet-general model already on this PC', 'Ready']) + } finally { + if (savedU2 === undefined) delete process.env.U2NET_HOME + else process.env.U2NET_HOME = savedU2 + } + }) + + it('is a no-op when the tool and model are present (no spawn, no download)', async () => { + touch(rembgExe()) + touch(rembgModelFile('birefnet-general')) + const steps: string[] = [] + await setupRembg('birefnet-general', (s) => steps.push(s)) + expect(steps).toEqual(['Ready']) + expect(rembgModelPresent('birefnet-general')).toBe(true) + }) +}) + +describe('no downloads in CLI jobs (M5)', () => { + it('a removebg job with allowDownload false fails with the setup command', async () => { + setEngineEnv({ ...engineEnv(), host: 'cli' }) + const img = join(root, 'a.png') + writeFileSync(img, 'x') + const done = new Promise((res) => { + const q = new JobQueue( + (e) => { + if (e.status === 'failed' || e.status === 'done') res(e) + }, + 1, + { allowDownload: false } + ) + q.add({ id: '1', tool: 'removebg', input: img, options: {} }) + }) + const ev = await done + expect(ev.status).toBe('failed') + expect(ev.error).toBe('Background removal is not set up yet. Run: filesmith setup removebg.') + }) +}) +``` + +- [ ] **Step 2: Run them and watch them fail** + +Run: `npx vitest run test/readiness.test.ts test/rembg-setup.test.ts` +Expected: FAIL, unresolved `../src/main/tools/readiness` and `../src/main/rembg/paths`. + +- [ ] **Step 3: Implement the engine pieces** + +`src/main/run.ts`: add to `RunOptions` + +```ts + /** Child environment (defaults to this process's). rembg needs U2NET_HOME. */ + env?: NodeJS.ProcessEnv +``` + +and pass it: `const child = spawn(cmd, args, { windowsHide: true, cwd: opts.cwd, env: opts.env })`. + +`src/main/tools/tool.ts`, add to `ToolContext`: + +```ts + /** False in the CLI: a tool that would download a model or runtime must fail + * with a "Run: filesmith setup " message instead (spec M5). */ + allowDownload?: boolean +``` + +`src/main/jobQueue.ts`: the constructor becomes + +```ts + constructor( + private emit: Emit, + concurrency?: number, + private readonly opts: { allowDownload?: boolean } = {} + ) { + this.concurrency = concurrency ?? Math.max(1, Math.min(4, cpus().length - 1)) + } +``` + +and the `tool.run(...)` context gains `allowDownload: this.opts.allowDownload ?? true,` after `outDir,`. + +`src/main/rembg/paths.ts`: + +```ts +import { existsSync } from 'fs' +import { join } from 'path' +import { userDataPath } from '../env' + +// rembg as an explicit, detectable install (spec M6). It used to run through +// `uv tool run`, which installs ~84 packages and then the model on first use, +// invisibly; "ready" could not be known, so the CLI could not refuse. + +const EXE = process.platform === 'win32' ? '.exe' : '' + +/** A RANGE, not an exact pin: the floor keeps the numba/Python-3.13 fix, the + * ceiling keeps a major release from changing the CLI under us. */ +export const REMBG_SPEC = 'rembg[cli,cpu]>=2.0.75,<3' + +/** Our own uv tool dir, so the install never touches the user's uv tools. */ +export function rembgToolDir(): string { + return userDataPath('uv-tools') +} + +export function rembgExe(): string { + return join(rembgToolDir(), 'rembg', 'Scripts', 'rembg' + EXE) +} + +/** Where `uv tool install rembg` put it before 0.6.0 (uv's default dir). */ +export function legacyRembgExe(): string { + return join(process.env.APPDATA ?? '', 'uv', 'tools', 'rembg', 'Scripts', 'rembg' + EXE) +} + +/** The pinned model folder (U2NET_HOME), shared by the app and the CLI. */ +export function rembgModelDir(): string { + return userDataPath('models', 'rembg') +} + +/** rembg's session classes save `.onnx` under U2NET_HOME. */ +export function rembgModelFile(model: string): string { + return join(rembgModelDir(), `${model}.onnx`) +} + +export function installedRembgExe(): string | null { + for (const p of [rembgExe(), legacyRembgExe()]) if (existsSync(p)) return p + return null +} + +export function rembgModelPresent(model: string): boolean { + return existsSync(rembgModelFile(model)) +} + +export function rembgEnv(): NodeJS.ProcessEnv { + return { ...process.env, U2NET_HOME: rembgModelDir() } +} +``` + +`src/main/rembg/setup.ts`: + +```ts +import { createHash } from 'crypto' +import { + copyFileSync, + createReadStream, + existsSync, + mkdirSync, + mkdtempSync, + renameSync, + rmSync, + statSync, + writeFileSync +} from 'fs' +import { homedir, tmpdir } from 'os' +import { join } from 'path' +import { run } from '../run' +import { withFileLock } from '../locks' +import { recordHash } from '../net/integrity' +import { ensureUv, type InstallOpts, type InstallProgress } from '../uvInstall' +import { + REMBG_SPEC, + installedRembgExe, + rembgEnv, + rembgExe, + rembgModelDir, + rembgModelFile, + rembgModelPresent, + rembgToolDir +} from './paths' + +/** A 1x1 PNG: the warm-up input that makes rembg fetch its model. */ +export const TINY_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +) + +export function hashFile(path: string): Promise { + return new Promise((resolve, reject) => { + const h = createHash('sha256') + createReadStream(path) + .on('data', (d) => h.update(d)) + .on('error', reject) + .on('end', () => resolve(h.digest('hex'))) + }) +} + +const lastLine = (s: string): string => s.trim().split('\n').pop()?.trim() ?? '' + +/** Where pre-0.6 `uv tool run rembg` left its models: rembg's default + * U2NET_HOME (~/.u2net), or the user's own U2NET_HOME. Reusing that file saves + * an existing user a second download of about 1 GB. */ +export function legacyRembgModelFile(model: string, env = process.env, home = homedir()): string { + return join(env.U2NET_HOME || join(home, '.u2net'), `${model}.onnx`) +} + +/** + * One-time background-removal setup (spec 5.3, M6): uv (bootstrapped when + * absent), `uv tool install` of rembg into our own tool dir, then a 1x1 warm-up + * run with U2NET_HOME pinned so the model lands in a known file we can check + * and hash. Idempotent; one setup per machine at a time. + */ +export async function setupRembg( + model: string, + onProgress: InstallProgress, + opts: InstallOpts = {} +): Promise { + await withFileLock( + 'rembg', + 'background removal', + async () => { + if (!installedRembgExe()) { + const uv = await ensureUv(onProgress, opts) + onProgress('Installing the background-removal engine (rembg)', null) + const res = await run(uv, ['tool', 'install', '--python', '3.11', REMBG_SPEC], { + signal: opts.signal, + env: { + ...process.env, + UV_TOOL_DIR: rembgToolDir(), + UV_TOOL_BIN_DIR: join(rembgToolDir(), 'bin') + } + }) + if (res.code !== 0) throw new Error(`rembg install failed: ${lastLine(res.stderr)}`) + if (!existsSync(rembgExe())) throw new Error('rembg install finished but rembg.exe is missing.') + } + const legacy = legacyRembgModelFile(model) + if (!rembgModelPresent(model) && legacy !== rembgModelFile(model) && existsSync(legacy)) { + mkdirSync(rembgModelDir(), { recursive: true }) + onProgress(`Reusing the ${model} model already on this PC`, null) + const part = `${rembgModelFile(model)}.${process.pid}.tmp` + copyFileSync(legacy, part) + renameSync(part, rembgModelFile(model)) + } + if (!rembgModelPresent(model)) { + mkdirSync(rembgModelDir(), { recursive: true }) + const tmp = mkdtempSync(join(tmpdir(), 'filesmith-rembg-')) + const step = `Downloading the ${model} model` + try { + const src = join(tmp, 'in.png') + writeFileSync(src, TINY_PNG) + onProgress(step, null) + const res = await run(installedRembgExe() as string, ['i', '-m', model, src, join(tmp, 'out.png')], { + signal: opts.signal, + env: rembgEnv(), + // pooch draws a tqdm bar: " 42%|#### |" + onStderr: (s) => { + const m = /(\d{1,3})%\|/.exec(s) + if (m) onProgress(step, Number(m[1])) + } + }) + if (res.code !== 0 || !rembgModelPresent(model)) + throw new Error(`The ${model} model could not be downloaded: ${lastLine(res.stderr)}`) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + const file = rembgModelFile(model) + recordHash(`rembg-model:${model}`, await hashFile(file), statSync(file).size) + } + onProgress('Ready', 100) + }, + { + signal: opts.signal, + onWait: () => onProgress('Waiting for another Filesmith to finish setting up', null) + } + ) +} +``` + +`src/main/tools/readiness.ts`: + +```ts +import { existsSync } from 'fs' +import type { JobOptions } from '@shared/types' +import { engineEnv } from '../env' +import { resolveRealesrgan, toolMissingMessage } from '../toolResolver' +import { cudaTierSupport, detectNvidia } from '../pid/gpu' +import { comfyEngineReady, pidInstalled } from '../pid/paths' +import { comfyPythonReady } from '../comfy/pythonEnv' +import { comfyModelByPath } from '../comfy/store' +import { listNcnnModels } from './ncnnModels' +import { bgModelOf } from './removebg' +import { installedRembgExe, rembgModelPresent } from '../rembg/paths' + +export type ReadinessCode = 'SETUP_REQUIRED' | 'GPU_UNSUPPORTED' | 'TOOL_MISSING' | 'USAGE' +export type Readiness = { ok: true } | { ok: false; code: ReadinessCode; message: string; hint?: string } +export type SetupTool = 'pid' | 'spandrel' | 'removebg' + +const CLI_WORDING: Record = { + pid: 'PiD is not installed.', + spandrel: 'The ComfyUI upscaler engine (spandrel) is not set up.', + removebg: 'Background removal is not set up yet.' +} +const APP_WORDING: Record = { + pid: 'PiD is not installed. Pick PiD in the options panel and click Download first.', + spandrel: 'The ComfyUI upscaler engine is not set up yet. Set it up from the Upscale options.', + removebg: 'Background removal is not set up yet.' +} + +export function setupHint(tool: SetupTool): string { + return `filesmith setup ${tool}` +} + +/** The job-time message when an AI tool is missing: the CLI names its exact + * setup command (the runner lifts it into the event's `hint`). */ +export function notReadyMessage(tool: SetupTool): string { + return engineEnv().host === 'cli' + ? `${CLI_WORDING[tool]} Run: ${setupHint(tool)}.` + : APP_WORDING[tool] +} + +export interface ReadinessDeps { + pidInstalled(): boolean + cuda(): Promise<{ ok: boolean; reason?: string }> + comfyEngineReady(): boolean + comfyModelKnown(path: string): boolean + realesrganPresent(): boolean + ncnnNames(): string[] + rembgInstalled(): boolean + rembgModelPresent(model: string): boolean +} + +const setup = (tool: SetupTool): Readiness => ({ + ok: false, + code: 'SETUP_REQUIRED', + message: CLI_WORDING[tool], + hint: setupHint(tool) +}) + +/** Pre-flight for upscale (spec 5.1). Never downloads. */ +export async function upscaleReadiness( + options: JobOptions, + deps: ReadinessDeps +): Promise { + const model = String(options.upscaleModel ?? 'photo') + if (model === 'pid' || model.startsWith('comfy:')) { + const gpu = await deps.cuda() + if (!gpu.ok) + return { + ok: false, + code: 'GPU_UNSUPPORTED', + message: gpu.reason ?? 'This GPU cannot run the CUDA upscalers.' + } + if (model === 'pid') return deps.pidInstalled() ? { ok: true } : setup('pid') + if (!deps.comfyEngineReady()) return setup('spandrel') + if (!deps.comfyModelKnown(model.slice('comfy:'.length))) + return { + ok: false, + code: 'SETUP_REQUIRED', + message: 'That ComfyUI model is not in the scanned list.', + hint: 'filesmith setup spandrel --comfy ""' + } + return { ok: true } + } + if (model === 'comfy') + return { + ok: false, + code: 'USAGE', + message: 'Name a ComfyUI model: --model comfy:.', + hint: 'filesmith formats upscale' + } + if (!deps.realesrganPresent()) + return { + ok: false, + code: 'TOOL_MISSING', + message: toolMissingMessage('realesrgan-ncnn-vulkan'), + hint: 'filesmith doctor' + } + const names = deps.ncnnNames() + const wanted = model.startsWith('esrgan:') ? model.slice('esrgan:'.length) : null + if (wanted && !names.some((n) => n.toLowerCase() === wanted.toLowerCase())) + return { + ok: false, + code: 'USAGE', + message: `No Real-ESRGAN model named "${wanted}". Installed: ${names.join(', ') || 'none'}.`, + hint: 'filesmith formats upscale' + } + return { ok: true } +} + +/** Pre-flight for removebg: the installed tool AND the chosen model file. */ +export function removebgReadiness(options: JobOptions, deps: ReadinessDeps): Readiness { + const model = bgModelOf(options) + return deps.rembgInstalled() && deps.rembgModelPresent(model) ? { ok: true } : setup('removebg') +} + +export const defaultReadinessDeps: ReadinessDeps = { + pidInstalled: () => pidInstalled('flux'), + cuda: async () => cudaTierSupport(await detectNvidia()), + comfyEngineReady: () => comfyEngineReady() || comfyPythonReady(), + comfyModelKnown: (p) => comfyModelByPath(p) != null, + realesrganPresent: () => existsSync(resolveRealesrgan()), + ncnnNames: () => listNcnnModels().map((m) => m.name), + rembgInstalled: () => installedRembgExe() != null, + rembgModelPresent +} +``` + +`src/main/toolResolver.ts`: delete `REMBG_SPEC`, `rembgInstalledPath` and the long `uv tool run` comment block above them; import `{ installedRembgExe, rembgEnv, rembgModelPresent }` from `./rembg/paths` and `{ BG_DEFAULTS }` from `@shared/removebg`; change the uv import to `import { findUv } from './uv'` (keep `findUvAsync` exported from `uv.ts`; doctor uses it); replace the rembg section with: + +```ts +export interface RembgCommand { + cmd: string + /** Prefix args before rembg's own arguments. */ + prefix: string[] + /** U2NET_HOME pinned to the shared model folder. */ + env: NodeJS.ProcessEnv +} + +export interface RembgStatus { + /** rembg AND its default model are installed: the next run downloads nothing. */ + ready: boolean + /** A first-use setup can proceed. Always true now: setupRembg bootstraps a + * pinned uv itself (uvInstall.ts), so the Settings and Remove BG panels' + * "needs uv" branch no longer applies and the renderer stays untouched. */ + uvAvailable: boolean +} + +export async function removebgStatus(): Promise { + return { + ready: installedRembgExe() != null && rembgModelPresent(BG_DEFAULTS.bgModel), + uvAvailable: true + } +} + +/** The installed rembg (spec M6), or null. Never `uv tool run`: that installs + * packages and a model on first use, invisibly. setupRembg installs it. */ +export function resolveRembg(): RembgCommand | null { + const exe = installedRembgExe() + return exe ? { cmd: exe, prefix: [], env: rembgEnv() } : null +} +``` + +`src/main/tools/registry.ts`: + +1. `upscaleWithPid`: `throw new Error(notReadyMessage('pid'))` (import `notReadyMessage` from `./readiness`). +2. `removebgTool.run`: replace the `resolveRembg()` / uv error block at its top with + +```ts + const model = bgModelOf(options) + let rembg = resolveRembg() + if (!rembg || !rembgModelPresent(model)) { + // The CLI never downloads from a job (spec M5); the app sets up inline, + // where its first removebg job used to download invisibly. + if (ctx.allowDownload === false) throw new Error(notReadyMessage('removebg')) + ctx.onProgress(undefined, 'Setting up background removal (one time)...') + await setupRembg(model, (step, pct) => ctx.onProgress(pct ?? undefined, step), { + signal: ctx.signal + }) + rembg = resolveRembg() + if (!rembg) throw new Error('Background removal could not be set up.') + } +``` + +and give the rembg `run(...)` call `env: rembg.env` in its options (next to `signal`). Imports: `bgModelOf` from `./removebg`, `rembgModelPresent` from `../rembg/paths`, `setupRembg` from `../rembg/setup`. `rembg` is now `let` inside the function; TypeScript narrows it after the guard. + +- [ ] **Step 4: Run the tests** + +Run: `npx vitest run test/readiness.test.ts test/rembg-setup.test.ts test/removebg.test.ts` +Expected: PASS. + +- [ ] **Step 5: Prove the real install once on this machine** + +```bash +npm run build +``` + +Then in the app (`npm run dev`), run Remove BG on one PNG. Expected: the row shows "Setting up background removal (one time)..." and the download steps, then finishes; `%APPDATA%\Filesmith\models\rembg\birefnet-general.onnx` and `%APPDATA%\Filesmith\uv-tools\rembg\Scripts\rembg.exe` exist. If the model file has a different name, rembg's naming changed: update `rembgModelFile` to the observed name and re-run the tests. + +- [ ] **Step 6: Verify and commit** + +```bash +npm run typecheck && npm run lint && npm test +git add src/main test/readiness.test.ts test/rembg-setup.test.ts +git commit -m "feat(engine): explicit rembg install with a pinned model folder; AI readiness checks" -m "removebg runs an installed rembg with U2NET_HOME=%APPDATA%\\Filesmith\\models\\rembg; removebg:status.ready is now true only when the tool and model exist. Jobs take allowDownload; with false (the CLI) a missing AI runtime fails with 'Run: filesmith setup '." -m "Refs #" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Exit codes, the event schema and both reporters + +**Files:** +- Create: `src/cli/exit.ts`, `src/cli/events.ts`, `src/cli/human.ts`, `test/cli-exit.test.ts`, `test/cli-events.test.ts`, `test/cli-human.test.ts` + +**Interfaces:** +- Consumes: `formatBytes` (`@shared/compress`), `baseName` (`@shared/fileKind`). +- Produces: + - `exit.ts`: `EXIT = { OK: 0, FAILED: 1, USAGE: 2, CANCELED: 130 }`, `type ErrorCode` (the 14 spec codes), `class CliError extends Error { code: ErrorCode; hint?: string }` (`new CliError(code, message, hint?)`), `class UsageError extends CliError` (`new UsageError(message, commandPath?: string[], hint?)`, `.commandPath`), `interface Counts { ok: number; failed: number; skipped: number; canceled: number }`, `reduceExit(c: Counts): number`. + - `events.ts`: `SCHEMA_VERSION = 1`, `type OutputKind`, `type EventBody` (union below), `interface Out { write(s: string): void }`, `interface Reporter { emit(e: EventBody): void; text(s: string): void; close(): void }`, `class JsonReporter implements Reporter` (`new JsonReporter(out, clock?, now?)`). + - `human.ts`: `class HumanReporter implements Reporter` (`new HumanReporter(stdout, stderr, { color: boolean; stderrTTY: boolean })`), and the pure formatters `pctChange`, `fmtEta`, `resultLine`, `summaryLine`. + +- [ ] **Step 1: Write the failing tests** + +`test/cli-exit.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { CliError, EXIT, UsageError, reduceExit } from '../src/cli/exit' + +describe('reduceExit', () => { + const c = (ok: number, failed: number, skipped: number, canceled: number) => ({ + ok, + failed, + skipped, + canceled + }) + it.each([ + [c(3, 0, 0, 0), 0], + [c(0, 0, 2, 0), 0], + [c(2, 1, 0, 0), 1], + [c(1, 1, 0, 1), 130], + [c(0, 0, 0, 0), 0] + ])('%j -> %i', (counts, code) => expect(reduceExit(counts)).toBe(code)) +}) + +describe('errors', () => { + it('UsageError is a CliError with code USAGE and the command path', () => { + const e = new UsageError('bad', ['pdf', 'split']) + expect(e).toBeInstanceOf(CliError) + expect(e.code).toBe('USAGE') + expect(e.commandPath).toEqual(['pdf', 'split']) + expect(EXIT.USAGE).toBe(2) + }) +}) +``` + +`test/cli-events.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { JsonReporter, type EventBody } from '../src/cli/events' + +function collect(): { out: { write: (s: string) => void }; lines: () => Record[] } { + let buf = '' + return { + out: { write: (s) => (buf += s) }, + lines: () => + buf + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l) as Record) + } +} + +const fixedNow = (): Date => new Date('2026-10-04T12:00:00.000Z') + +describe('JsonReporter', () => { + it('writes one envelope per line: v, event, ts first, undefined fields omitted', () => { + const c = collect() + const r = new JsonReporter(c.out, () => 0, fixedNow) + r.emit({ event: 'error', code: 'NOT_FOUND', message: 'nope', input: 'C:\\a.png' }) + let raw = '' + const r2 = new JsonReporter({ write: (s) => (raw += s) }, () => 0, fixedNow) + r2.emit({ event: 'done', id: '1', input: 'a', output: 'b', outputKind: 'file', inSize: 1, ms: 5 }) + expect(raw.endsWith('\n')).toBe(true) + expect(raw.indexOf('"v":1')).toBeLessThan(raw.indexOf('"event"')) + expect(c.lines()).toEqual([ + { + v: 1, + event: 'error', + ts: '2026-10-04T12:00:00.000Z', + code: 'NOT_FOUND', + message: 'nope', + input: 'C:\\a.png' + } + ]) + expect(raw).not.toContain('outSize') + }) + + it('rate-limits progress to one per 250 ms per job, but always passes a new tenth', () => { + const c = collect() + let t = 0 + const r = new JsonReporter(c.out, () => t, fixedNow) + const p = (id: string, pct: number | null): EventBody => ({ event: 'progress', id, pct }) + r.emit(p('1', 1)) // first: passes + t = 100 + r.emit(p('1', 2)) // same tenth, 100 ms: dropped + r.emit(p('2', 2)) // other job: passes + t = 150 + r.emit(p('1', 10)) // new tenth: passes + t = 300 + r.emit(p('1', 11)) // 150 ms since last: dropped + t = 401 + r.emit(p('1', null)) // 251 ms since last: passes + expect(c.lines().map((l) => [l.id, l.pct])).toEqual([ + ['1', 1], + ['2', 2], + ['1', 10], + ['1', null] + ]) + }) + + it('text() writes nothing in JSON mode', () => { + const c = collect() + new JsonReporter(c.out).text('hello') + expect(c.lines()).toEqual([]) + }) +}) +``` + +`test/cli-human.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { HumanReporter, fmtEta, pctChange, summaryLine } from '../src/cli/human' + +function sink(): { out: { write: (s: string) => void }; text: () => string } { + let buf = '' + return { out: { write: (s) => (buf += s) }, text: () => buf } +} + +describe('formatters', () => { + it('pctChange', () => { + expect(pctChange(2_400_000, 310_000)).toBe('-87%') + expect(pctChange(100, 112)).toBe('+12%') + expect(pctChange(0, 5)).toBeNull() + }) + it('fmtEta', () => { + expect(fmtEta(4.4)).toBe('4s') + expect(fmtEta(125)).toBe('2m05s') + }) + it('summaryLine', () => { + expect(summaryLine({ ok: 1, failed: 1, skipped: 1, canceled: 0, ms: 4200 }, false)).toBe( + '3 files: 1 ok, 1 skipped, 1 failed (4.2 s)\n' + ) + expect(summaryLine({ ok: 1, failed: 0, skipped: 0, canceled: 0, ms: 0 }, true)).toBe( + '1 file: 1 would run, 0 skipped, 0 would fail (0.0 s)\n' + ) + }) +}) + +describe('HumanReporter', () => { + it('prints result lines and the summary on stdout, warnings on stderr, no colour', () => { + const o = sink() + const e = sink() + const r = new HumanReporter(o.out, e.out, { color: false, stderrTTY: false }) + r.emit({ event: 'run', command: 'convert', version: '0.6.0', dryRun: false, inputs: 3, options: {} }) + r.emit({ event: 'start', id: '1', input: 'C:\\x\\photo.png', inSize: 2_400_000, op: 'convert' }) + r.emit({ event: 'progress', id: '1', pct: 50 }) + r.emit({ + event: 'done', + id: '1', + input: 'C:\\x\\photo.png', + output: 'C:\\x\\photo.webp', + outputKind: 'file', + inSize: 2_400_000, + outSize: 310_000, + ms: 900 + }) + r.emit({ event: 'skipped', id: '2', input: 'C:\\x\\logo.webp', code: 'SAME_FORMAT', message: 'already webp' }) + r.emit({ + event: 'error', + id: '3', + input: 'C:\\x\\broken.jpg', + code: 'TOOL_FAILED', + message: 'magick: improper image header' + }) + r.emit({ event: 'warning', code: 'QUALITY_IGNORED', message: '--quality has no effect here' }) + r.emit({ + event: 'summary', + ok: 1, + failed: 1, + skipped: 1, + canceled: 0, + inBytes: 0, + outBytes: 0, + ms: 4200, + exitCode: 1 + }) + // label column 8, name column 34, one space, then the right-hand detail + const row = (label: string, left: string, right: string): string => + `${label.padEnd(8)}${left.padEnd(34)} ${right}` + expect(o.text()).toBe( + [ + row('ok', 'photo.png -> photo.webp', '2.3 MB -> 303 KB (-87%)'), + row('skip', 'logo.webp', 'already webp'), + row('fail', 'broken.jpg', 'magick: improper image header'), + '3 files: 1 ok, 1 skipped, 1 failed (4.2 s)', + '' + ].join('\n') + ) + expect(e.text()).toBe('warn: --quality has no effect here\n') + }) + + it('draws one redrawn progress line on a TTY stderr and clears it before results', () => { + const o = sink() + const e = sink() + const r = new HumanReporter(o.out, e.out, { color: false, stderrTTY: true }) + r.emit({ event: 'run', command: 'compress', version: '0.6.0', dryRun: false, inputs: 2, options: {} }) + r.emit({ event: 'start', id: '2', input: 'C:\\a.mp4', inSize: 1, op: 'compress' }) + r.emit({ event: 'progress', id: '2', pct: 62, etaSec: 4 }) + r.emit({ event: 'canceled', id: '2', input: 'C:\\a.mp4' }) + expect(e.text()).toBe('\r\x1b[2K[2/2] a.mp4 62% (4s)\r\x1b[2K') + expect(o.text()).toBe('stop a.mp4\n') + }) + + it('prints a run-level error and its hint on stderr', () => { + const o = sink() + const e = sink() + const r = new HumanReporter(o.out, e.out, { color: false, stderrTTY: false }) + r.emit({ event: 'error', code: 'SETUP_REQUIRED', message: 'PiD is not installed.', hint: 'filesmith setup pid' }) + expect(e.text()).toBe('filesmith: PiD is not installed.\n hint: filesmith setup pid\n') + expect(o.text()).toBe('') + }) + + it('colours only the status word, only when asked', () => { + const o = sink() + const r = new HumanReporter(o.out, sink().out, { color: true, stderrTTY: false }) + r.emit({ event: 'skipped', id: '1', input: 'a.webp', code: 'SAME_FORMAT', message: 'already webp' }) + expect(o.text().startsWith('\x1b[33mskip \x1b[0m')).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run them and watch them fail** + +Run: `npx vitest run test/cli-exit.test.ts test/cli-events.test.ts test/cli-human.test.ts` +Expected: FAIL, unresolved imports. + +- [ ] **Step 3: Implement** + +`src/cli/exit.ts`: + +```ts +export const EXIT = { OK: 0, FAILED: 1, USAGE: 2, CANCELED: 130 } as const + +/** Stable error codes (spec 2.6). Adding one is fine; renaming one bumps `v`. */ +export type ErrorCode = + | 'USAGE' + | 'NOT_FOUND' + | 'NO_MATCH' + | 'UNSUPPORTED_KIND' + | 'SAME_FORMAT' + | 'OUT_DIR_MISSING' + | 'TOOL_MISSING' + | 'SETUP_REQUIRED' + | 'GPU_UNSUPPORTED' + | 'RAR_MISSING' + | 'PASSWORD' + | 'TOOL_FAILED' + | 'CANCELED' + | 'INTERNAL' + +/** A run-level failure decided before any file is touched: exit 2. */ +export class CliError extends Error { + readonly code: ErrorCode + readonly hint?: string + constructor(code: ErrorCode, message: string, hint?: string) { + super(message) + this.name = 'CliError' + this.code = code + this.hint = hint + } +} + +/** Bad arguments. `commandPath` picks the usage line printed after it. */ +export class UsageError extends CliError { + readonly commandPath: string[] + constructor(message: string, commandPath: string[] = [], hint?: string) { + super('USAGE', message, hint) + this.name = 'UsageError' + this.commandPath = commandPath + } +} + +export interface Counts { + ok: number + failed: number + skipped: number + canceled: number +} + +/** Spec 2.7: Ctrl+C wins, then any failure, else success (skips are success). */ +export function reduceExit(c: Counts): number { + if (c.canceled > 0) return EXIT.CANCELED + if (c.failed > 0) return EXIT.FAILED + return EXIT.OK +} +``` + +`src/cli/events.ts`: + +```ts +import type { ErrorCode } from './exit' + +export const SCHEMA_VERSION = 1 + +export type OutputKind = 'file' | 'dir' + +/** Every event the CLI writes (spec 2.6), without the `v`/`ts` envelope. */ +export type EventBody = + | { + event: 'run' + command: string + version: string + dryRun: boolean + inputs: number + options: Record + } + | { + event: 'plan' + id: string + input: string | string[] + inSize: number + op: string + output?: string + outputKind?: OutputKind + ready: boolean + code?: ErrorCode + message?: string + hint?: string + } + | { event: 'start'; id: string; input: string; inSize: number; op: string } + | { event: 'progress'; id: string; pct: number | null; etaSec?: number; message?: string } + | { + event: 'done' + id: string + input: string + output: string + outputKind: OutputKind + inSize: number + outSize?: number + files?: number + ms: number + seed?: number + } + | { event: 'done'; tool: string; path?: string; alreadyDone: boolean } + | { event: 'done'; path: string; updated: boolean; previousVersion?: string } + | { event: 'skipped'; id?: string; input: string; code: ErrorCode; message: string } + | { event: 'error'; id?: string; input?: string; code: ErrorCode; message: string; hint?: string } + | { event: 'warning'; code: string; message: string; id?: string } + | { event: 'canceled'; id: string; input: string } + | { + event: 'summary' + ok: number + failed: number + skipped: number + canceled: number + inBytes: number + outBytes: number + ms: number + exitCode: number + } + | { event: 'version'; version: string } + | { event: 'formats'; data: Record } + | { + event: 'check' + id: string + group: string + status: 'ok' | 'warn' | 'fail' | 'skip' + detail: string + fix?: string + } + | { + event: 'step' + step: string + pct: number | null + bytes?: number + totalBytes?: number + etaSec?: number + detail?: string + } + | { event: 'heartbeat'; step: string; elapsedSec: number } + +export interface Out { + write(s: string): void +} + +/** Commands only ever emit events; a reporter decides what the user sees. */ +export interface Reporter { + emit(e: EventBody): void + /** Free text for human mode (formats tables, help-like output); dropped in JSON. */ + text(s: string): void + close(): void +} + +/** NDJSON on stdout (spec 2.5): one line per event, flushed per write. */ +export class JsonReporter implements Reporter { + private last = new Map() + + constructor( + private readonly out: Out, + private readonly clock: () => number = Date.now, + private readonly now: () => Date = () => new Date() + ) {} + + emit(e: EventBody): void { + if (e.event === 'progress' && !this.allow(`p:${e.id}`, e.pct)) return + if (e.event === 'step' && !this.allow(`s:${e.step}`, e.pct)) return + const { event, ...rest } = e + this.out.write( + JSON.stringify({ v: SCHEMA_VERSION, event, ts: this.now().toISOString(), ...rest }) + '\n' + ) + } + + text(): void { + /* JSON mode carries only events */ + } + + close(): void {} + + /** One progress line per job per 250 ms, plus every whole 10%. */ + private allow(key: string, pct: number | null): boolean { + const t = this.clock() + const tenth = pct == null ? -1 : Math.floor(pct / 10) + const prev = this.last.get(key) + if (!prev || tenth > prev.tenth || t - prev.t >= 250) { + this.last.set(key, { t, tenth: Math.max(tenth, prev?.tenth ?? -1) }) + return true + } + return false + } +} +``` + +`src/cli/human.ts`: + +```ts +import { formatBytes } from '@shared/compress' +import { baseName } from '@shared/fileKind' +import type { EventBody, Out, Reporter } from './events' + +type Label = 'ok' | 'skip' | 'fail' | 'stop' | 'plan' +const COLOR: Partial> = { ok: '\x1b[32m', skip: '\x1b[33m', fail: '\x1b[31m' } +const RESET = '\x1b[0m' +const CLEAR = '\r\x1b[2K' + +export function pctChange(inSize: number, outSize: number): string | null { + if (!(inSize > 0)) return null + const d = Math.round(((outSize - inSize) / inSize) * 100) + return `${d > 0 ? '+' : ''}${d}%` +} + +export function fmtEta(sec: number): string { + const s = Math.max(0, Math.round(sec)) + return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` +} + +export function resultLine(label: Label, left: string, right: string, color: boolean): string { + const tag = label.padEnd(8) + const c = color ? COLOR[label] : undefined + return `${c ? `${c}${tag}${RESET}` : tag}${left.padEnd(34)} ${right}`.trimEnd() + '\n' +} + +export function summaryLine( + s: { ok: number; failed: number; skipped: number; canceled: number; ms: number }, + dryRun: boolean +): string { + const n = s.ok + s.failed + s.skipped + s.canceled + const parts = dryRun + ? [`${s.ok} would run`, `${s.skipped} skipped`, `${s.failed} would fail`] + : [`${s.ok} ok`, `${s.skipped} skipped`, `${s.failed} failed`] + if (s.canceled) parts.push(`${s.canceled} canceled`) + return `${n} ${n === 1 ? 'file' : 'files'}: ${parts.join(', ')} (${(s.ms / 1000).toFixed(1)} s)\n` +} + +const nameOf = (input: string | string[]): string => + Array.isArray(input) + ? `${baseName(input[0] ?? '')}${input.length > 1 ? ` +${input.length - 1}` : ''}` + : baseName(input) + +/** Human output (spec 2.5): results on stdout, progress and warnings on stderr. */ +export class HumanReporter implements Reporter { + private names = new Map() + private total = 0 + private dryRun = false + private progressShown = false + private lastStep = '' + + constructor( + private readonly stdout: Out, + private readonly stderr: Out, + private readonly opts: { color: boolean; stderrTTY: boolean } + ) {} + + private out(s: string): void { + this.clearProgress() + this.stdout.write(s) + } + + private err(s: string): void { + this.clearProgress() + this.stderr.write(s) + } + + private clearProgress(): void { + if (!this.progressShown) return + this.stderr.write(CLEAR) + this.progressShown = false + } + + private redraw(s: string): void { + this.stderr.write(CLEAR + s) + this.progressShown = true + } + + emit(e: EventBody): void { + const color = this.opts.color + switch (e.event) { + case 'run': + this.dryRun = e.dryRun + this.total = e.inputs + return + case 'plan': { + const left = e.output + ? `${nameOf(e.input)} -> ${baseName(e.output)}${e.outputKind === 'dir' ? '\\' : ''}` + : nameOf(e.input) + if (e.ready) this.out(resultLine('plan', left, `${e.op}${e.message ? ` ${e.message}` : ''}`, color)) + else { + this.out(resultLine('fail', nameOf(e.input), `would fail: ${e.message ?? ''}`, color)) + if (e.hint) this.out(` hint: ${e.hint}\n`) + } + return + } + case 'start': + this.names.set(e.id, baseName(e.input)) + return + case 'progress': { + if (!this.opts.stderrTTY) return + const pct = e.pct == null ? 'working' : `${Math.round(e.pct)}%` + const eta = e.etaSec != null ? ` (${fmtEta(e.etaSec)})` : '' + this.redraw(`[${e.id}/${this.total}] ${this.names.get(e.id) ?? ''} ${pct}${eta}`) + return + } + case 'done': { + if ('tool' in e) { + this.out(resultLine('ok', e.tool, e.alreadyDone ? 'already set up' : 'set up', color)) + return + } + if ('updated' in e) { + const prev = e.previousVersion ? ` (was ${e.previousVersion})` : '' + this.out(resultLine('ok', 'skill', `${e.updated ? 'updated' : 'installed'} at ${e.path}${prev}`, color)) + return + } + const left = `${baseName(e.input)} -> ${baseName(e.output)}${e.outputKind === 'dir' ? '\\' : ''}` + let right = '' + if (e.outputKind === 'dir') right = `${e.files ?? 0} files` + else if (e.outSize != null) { + const change = pctChange(e.inSize, e.outSize) + right = `${formatBytes(e.inSize)} -> ${formatBytes(e.outSize)}${change ? ` (${change})` : ''}` + } + this.out(resultLine('ok', left, right, color)) + return + } + case 'skipped': + this.out(resultLine('skip', baseName(e.input), e.message, color)) + return + case 'error': + if (e.id || e.input) { + this.out(resultLine('fail', e.input ? baseName(e.input) : `#${e.id}`, e.message, color)) + if (e.hint) this.out(` hint: ${e.hint}\n`) + } else { + this.err(`filesmith: ${e.message}\n`) + if (e.hint) this.err(` hint: ${e.hint}\n`) + } + return + case 'warning': + this.err(`warn: ${e.message}\n`) + return + case 'canceled': + this.out(resultLine('stop', baseName(e.input), '', color)) + return + case 'summary': + this.out(summaryLine(e, this.dryRun)) + return + case 'version': + this.out(`${e.version}\n`) + return + case 'check': + this.out(` ${e.status.padEnd(6)}${e.id.padEnd(18)}${e.detail}\n`) + if (e.fix && e.status !== 'ok') this.out(` fix: ${e.fix}\n`) + return + case 'step': { + const pct = e.pct == null ? '' : ` ${Math.round(e.pct)}%` + const eta = e.etaSec != null ? ` (${fmtEta(e.etaSec)})` : '' + if (e.detail) this.out(` - ${e.step}: ${e.detail}\n`) + else if (this.opts.stderrTTY) this.redraw(`${e.step}${pct}${eta}`) + else if (e.step !== this.lastStep) this.err(`${e.step}\n`) + this.lastStep = e.step + return + } + case 'heartbeat': + if (this.opts.stderrTTY) this.redraw(`${e.step} (${fmtEta(e.elapsedSec)})`) + return + case 'formats': + return + } + } + + text(s: string): void { + this.out(s) + } + + close(): void { + this.clearProgress() + } +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `npx vitest run test/cli-exit.test.ts test/cli-events.test.ts test/cli-human.test.ts` +Expected: PASS. If the `ok` line's byte figures differ, `formatBytes` uses binary units (2_400_000 B is `2.3 MB`); fix the test literal to what `formatBytes` prints, never the formatter (the app shows the same numbers). + +- [ ] **Step 5: Verify and commit** + +```bash +npm run typecheck && npm run lint && npm test +git add src/cli/exit.ts src/cli/events.ts src/cli/human.ts test/cli-exit.test.ts test/cli-events.test.ts test/cli-human.test.ts +git commit -m "feat(cli): exit codes, JSON event schema v1 and the human reporter" -m "Refs #" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Command catalog, argument parser and help + +**Files:** +- Create: `src/cli/catalog.ts`, `src/cli/parse.ts`, `src/cli/help.ts`, `test/cli-catalog.test.ts`, `test/cli-parse.test.ts`, `test/cli-help.test.ts`, `test/no-em-dash.test.ts` + +**Interfaces:** +- Consumes: `UsageError` (Task 6); `@shared/{convert,compress,resize,removebg,generate,tabs}` catalogs. +- Produces: + - `catalog.ts`: `type FlagType = 'bool' | 'enum' | 'int' | 'number' | 'text' | 'path' | 'format' | 'enumOrInt'`; `interface FlagSpec { name: string; aliases?: string[]; short?: string; type: FlagType; values?: readonly string[]; numeric?: boolean; min?: number; max?: number; step?: number; suffix?: string; key?: string; map?: Readonly>; def?: string | number | boolean; required?: boolean; group?: string; valueName?: string; help: string }`; `type CommandId` (18 ids below); `interface CommandSpec { id: CommandId; path: string[]; args: string; summary: string; inputs: 'files' | 'prompt' | 'words' | 'none'; flags: FlagSpec[]; examples: string[] }`; `COMMANDS: CommandSpec[]`; `SUBCOMMANDS: Record<'pdf' | 'skill', string[]>`; `findCommand(path: string[]): CommandSpec | undefined`; `flagOf(cmd: CommandSpec, name: string): FlagSpec`; `CONVERT_TARGETS`, `VIDEO_CODEC_VALUES`, `AUDIO_CODEC_VALUES`. + - `parse.ts`: `interface ParsedArgs { kind: 'run' | 'help' | 'version'; command: CommandSpec | null; group?: 'pdf' | 'skill'; positionals: string[]; values: Record; json: boolean; dryRun: boolean }`; `parseArgv(argv: string[]): ParsedArgs` (throws `UsageError`); `detectJson(argv: string[]): boolean`. + - `help.ts`: `usageLine(cmd)`, `renderHelp(cmd)`, `renderRootHelp()`, `renderGroupHelp(group)`, `wrap(text, width, indent)`. + +`CommandId` values: `convert`, `compress`, `resize`, `upscale`, `removebg`, `generate`, `pdf merge`, `pdf split`, `pdf burst`, `pdf extract-text`, `pdf to-images`, `pdf extract-images`, `pdf compress`, `formats`, `doctor`, `setup`, `skill install`, `skill status`. + +- [ ] **Step 1: Write the failing tests** + +`test/cli-catalog.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { COMMANDS, findCommand, flagOf } from '../src/cli/catalog' +import { DEFAULT_OPTIONS } from '../src/renderer/src/state' +import type { ToolId } from '@shared/types' + +// Every app option key a user can set has a CLI flag, and its default is the +// app's default (spec 2.3). Hidden keys are the ones the app never shows. +const HIDDEN: Partial> = { + removebg: ['bgModel', 'bgAlpha', 'bgAlphaFg', 'bgAlphaBg', 'bgErode', 'bgOnlyMask', 'bgPostProcess'], + pdf: ['op'], + archive: ['op'], + generate: ['prompt'] +} +const COMMAND_FOR: Record = { + convert: ['convert'], + archive: ['convert'], + compress: ['compress'], + resize: ['resize'], + upscale: ['upscale'], + removebg: ['removebg'], + generate: ['generate'], + pdf: ['pdf split', 'pdf to-images'] +} + +describe('catalog mirrors the app', () => { + for (const [tool, opts] of Object.entries(DEFAULT_OPTIONS) as [ToolId, Record][]) + it(`every visible ${tool} option has a flag`, () => { + const keys = new Set( + COMMAND_FOR[tool].flatMap((id) => + (COMMANDS.find((c) => c.id === id)?.flags ?? []).map((f) => f.key) + ) + ) + for (const key of Object.keys(opts)) + if (!HIDDEN[tool]?.includes(key)) expect(keys, `${tool}.${key}`).toContain(key) + }) + + it.each([ + ['convert', 'quality', 'convert', 'quality'], + ['convert', 'resolution', 'archive', 'dpi'], + ['convert', 'page-format', 'archive', 'pageFormat'], + ['convert', 'page-quality', 'archive', 'pageQuality'], + ['compress', 'quality', 'compress', 'quality'], + ['compress', 'format', 'compress', 'imageFormat'], + ['compress', 'video-codec', 'compress', 'videoCodec'], + ['compress', 'scale', 'compress', 'scale'], + ['compress', 'audio-codec', 'compress', 'audioCodec'], + ['compress', 'bitrate', 'compress', 'audioBitrate'], + ['compress', 'level', 'compress', 'pdfLevel'], + ['compress', 'greyscale', 'compress', 'pdfGray'], + ['resize', 'percent', 'resize', 'percent'], + ['resize', 'fit', 'resize', 'fit'], + ['upscale', 'factor', 'upscale', 'upscaleFactor'], + ['upscale', 'model', 'upscale', 'upscaleModel'], + ['removebg', 'fill', 'removebg', 'bgFill'], + ['removebg', 'color', 'removebg', 'bgCustomColor'], + ['generate', 'negative', 'generate', 'negative'], + ['generate', 'style', 'generate', 'style'], + ['generate', 'count', 'generate', 'count'], + ['pdf to-images', 'resolution', 'pdf', 'dpi'] + ] as const)('%s --%s defaults to %s.%s', (cmd, flag, tool, key) => { + const f = flagOf(findCommand(cmd.split(' '))!, flag) + const def = f.numeric || f.type === 'int' ? Number(f.def) : f.def + expect(def).toEqual(DEFAULT_OPTIONS[tool][key]) + }) + + it('convert --compression defaults to store, which the app stores as store: true', () => { + expect(flagOf(findCommand(['convert'])!, 'compression').def).toBe('store') + expect(DEFAULT_OPTIONS.archive.store).toBe(true) + }) + + it('--gpu uses the shown words and maps balanced to the stored background', () => { + const gpu = flagOf(findCommand(['upscale'])!, 'gpu') + expect(gpu.values).toEqual(['full', 'balanced']) + expect(gpu.map?.balanced).toBe('background') + }) + + it('every command has a summary, an args line and at least one example', () => { + for (const c of COMMANDS) { + expect(c.summary.length, c.id).toBeGreaterThan(5) + expect(c.examples.length, c.id).toBeGreaterThan(0) + } + }) + + it('no two flags of one command share a name, alias or short', () => { + for (const c of COMMANDS) { + const names = c.flags.flatMap((f) => [f.name, ...(f.aliases ?? []), ...(f.short ? [`-${f.short}`] : [])]) + expect(new Set(names).size, c.id).toBe(names.length) + } + }) +}) +``` + +`test/cli-parse.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { detectJson, parseArgv } from '../src/cli/parse' +import { UsageError } from '../src/cli/exit' + +const p = (s: string[]): ReturnType => parseArgv(s) + +describe('parseArgv', () => { + it('verb, inputs and options in any order; both value syntaxes', () => { + const a = p(['convert', 'a.png', '--to', 'webp', 'b.png', '--quality=best']) + expect(a.kind).toBe('run') + expect(a.command?.id).toBe('convert') + expect(a.positionals).toEqual(['a.png', 'b.png']) + expect(a.values).toEqual({ to: 'webp', quality: 'best' }) + }) + + it('verbs and flag names are case-insensitive; values and paths keep case', () => { + const a = p(['CONVERT', 'C:\\X\\A.PNG', '--TO', 'WebP']) + expect(a.command?.id).toBe('convert') + expect(a.positionals).toEqual(['C:\\X\\A.PNG']) + expect(a.values.to).toBe('WebP') + }) + + it('aliases: remove-bg, --dpi, --grayscale, --no-', () => { + expect(p(['remove-bg', 'x.png']).command?.id).toBe('removebg') + expect(p(['remove-background', 'x.png']).command?.id).toBe('removebg') + expect(p(['convert', 'a.pdf', '--to', 'cbz', '--dpi', '200']).values.resolution).toBe('200') + expect(p(['compress', 'a.pdf', '--grayscale']).values.greyscale).toBe(true) + expect(p(['compress', 'a.pdf', '--no-greyscale']).values.greyscale).toBe(false) + }) + + it('-o is --out, -h is help', () => { + expect(p(['resize', 'a.png', '-o', 'D:\\Out']).values.out).toBe('D:\\Out') + expect(p(['resize', '-h']).kind).toBe('help') + }) + + it('pdf tools nest; a bare pdf is a usage error naming the tools', () => { + expect(p(['pdf', 'merge', 'a.pdf', 'b.pdf']).command?.id).toBe('pdf merge') + expect(() => p(['pdf'])).toThrow(/merge, split, burst/) + expect(() => p(['pdf', 'nope'])).toThrow(UsageError) + expect(p(['pdf', '--help'])).toMatchObject({ kind: 'help', command: null, group: 'pdf' }) + }) + + it('-- ends options: a file named like a flag', () => { + const a = p(['resize', '--percent', '50', '--', '-x.png', '--json']) + expect(a.positionals).toEqual(['-x.png', '--json']) + expect(a.json).toBe(false) + }) + + it('keeps spaces, ampersands and non-ASCII letters in paths byte for byte', () => { + const path = 'C:\\My Files\\Ä & b (1).png' + expect(p(['convert', path, '--to', 'webp']).positionals).toEqual([path]) + }) + + it('the last of a repeated flag wins', () => { + expect(p(['convert', 'a', '--to', 'png', '--to', 'webp']).values.to).toBe('webp') + }) + + it('unknown flags name the flag and suggest the closest one', () => { + expect(() => p(['convert', 'a', '--bogus'])).toThrow(/Unknown option --bogus for filesmith convert/) + expect(() => p(['compress', 'a', '--levle', 'x'])).toThrow(/Did you mean --level\?/) + expect(() => p(['convert', 'a.pdf', '--to', 'png', '--level', 'x'])).toThrow( + /--level is an option of: compress, pdf compress/ + ) + }) + + it('a flag without its value is a usage error', () => { + expect(() => p(['convert', 'a', '--to'])).toThrow(/--to needs a value/) + }) + + it('help routing at every level', () => { + expect(p([])).toMatchObject({ kind: 'help', command: null }) + expect(p(['--help'])).toMatchObject({ kind: 'help', command: null }) + expect(p(['help'])).toMatchObject({ kind: 'help', command: null }) + expect(p(['help', 'pdf', 'merge']).command?.id).toBe('pdf merge') + expect(p(['help', 'pdf'])).toMatchObject({ kind: 'help', group: 'pdf' }) + expect(p(['pdf', 'merge', '--help']).command?.id).toBe('pdf merge') + expect(() => p(['help', 'nope'])).toThrow(/Unknown command: nope/) + }) + + it('--version anywhere', () => { + expect(p(['--version']).kind).toBe('version') + expect(p(['convert', '--version']).kind).toBe('version') + }) + + it('commands without inputs refuse positionals', () => { + expect(() => p(['doctor', 'extra'])).toThrow(/filesmith doctor takes no arguments/) + }) + + it('unknown commands', () => { + expect(() => p(['frobnicate'])).toThrow(/Unknown command: frobnicate/) + }) + + it('json and dry-run are read from the parsed flags', () => { + expect(p(['doctor', '--json'])).toMatchObject({ json: true, dryRun: false }) + expect(p(['resize', 'a.png', '--DRY-RUN'])).toMatchObject({ dryRun: true }) + }) +}) + +describe('detectJson', () => { + it('finds --json before -- only, case-insensitively', () => { + expect(detectJson(['convert', '--JSON'])).toBe(true) + expect(detectJson(['convert', '--', '--json'])).toBe(false) + expect(detectJson(['convert'])).toBe(false) + }) +}) +``` + +`test/cli-help.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { COMMANDS, findCommand } from '../src/cli/catalog' +import { renderGroupHelp, renderHelp, renderRootHelp, wrap } from '../src/cli/help' + +describe('help', () => { + it('compress help: usage, grouped options with values and defaults, examples', () => { + const h = renderHelp(findCommand(['compress'])!) + expect(h.startsWith('Usage: filesmith compress [options]\n')).toBe(true) + expect(h).toContain('\nIMAGE:\n') + expect(h).toContain('--quality <10-100>') + expect(h).toContain('(default 80)') + expect(h).toContain('-o, --out ') + expect(h).toContain('-h, --help') + expect(h).toContain('\nExamples:\n filesmith compress *.jpg --quality 70\n') + }) + + it('every help page fits 80 columns', () => { + const pages = [ + renderRootHelp(), + renderGroupHelp('pdf'), + renderGroupHelp('skill'), + ...COMMANDS.map(renderHelp) + ] + for (const page of pages) + for (const line of page.split('\n')) expect(line.length, line).toBeLessThanOrEqual(80) + }) + + it('root help lists every top-level command and the pdf group', () => { + const h = renderRootHelp() + for (const v of ['convert', 'compress', 'resize', 'upscale', 'removebg', 'generate', 'formats', 'doctor', 'setup']) + expect(h).toContain(` ${v}`) + expect(h).toContain(' pdf ') + expect(h).toContain(' skill ') + }) + + it('pdf group help lists its tools', () => { + const h = renderGroupHelp('pdf') + for (const t of ['merge', 'split', 'burst', 'extract-text', 'to-images', 'extract-images', 'compress']) + expect(h).toContain(` ${t}`) + }) + + it('wrap keeps words whole and indents continuation lines', () => { + expect(wrap('aa bb cc', 10, 4)).toEqual(['aa', ' bb', ' cc']) + }) +}) +``` + +`test/no-em-dash.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import { join, resolve } from 'path' + +// Owner rule: no em-dashes anywhere in this work. Scans everything this change +// adds; a file that does not exist yet is simply skipped. +const ROOT = resolve(__dirname, '..') +const TARGETS = [ + 'src/cli', + 'src/main/env.ts', + 'src/main/boot.ts', + 'src/main/atomicWrite.ts', + 'src/main/locks.ts', + 'src/main/recycle.ts', + 'src/main/uvInstall.ts', + 'src/main/skill.ts', + 'src/main/rembg', + 'src/main/tools/plan.ts', + 'src/main/tools/readiness.ts', + 'src/renderer/src/components/views/ClaudeSkill.tsx', + 'resources/cli', + 'resources/skill', + 'build/installer/path.nsh', + 'docs/cli.md', + 'docs/superpowers/plans/2026-10-04-cli-and-skill.md' +] + +function files(p: string): string[] { + if (!existsSync(p)) return [] + if (statSync(p).isFile()) return [p] + return readdirSync(p).flatMap((n) => files(join(p, n))) +} + +describe('no em-dashes', () => { + it('none of the new files contain U+2014', () => { + const offenders = TARGETS.flatMap((t) => files(join(ROOT, t))).filter((f) => + readFileSync(f, 'utf-8').includes('\u2014') + ) + expect(offenders).toEqual([]) + }) +}) +``` + +- [ ] **Step 2: Run them and watch them fail** + +Run: `npx vitest run test/cli-catalog.test.ts test/cli-parse.test.ts test/cli-help.test.ts test/no-em-dash.test.ts` +Expected: FAIL, unresolved `../src/cli/catalog` etc. (`no-em-dash` passes already; keep it). + +- [ ] **Step 3: Implement the catalog** + +`src/cli/catalog.ts`: + +```ts +import { familyFormats } from '@shared/convert' +import { + AUDIO_BITRATES, + AUDIO_CODECS, + IMAGE_FORMATS as COMPRESS_FORMATS, + PDF_LEVELS, + SCALE_MAX, + SCALE_MIN, + SCALE_STEP, + UPSCALE_FACTORS, + VIDEO_CODECS +} from '@shared/compress' +import { RESIZE_FITS } from '@shared/resize' +import { BG_DEFAULTS, BG_FILLS } from '@shared/removebg' +import { GEN_DEFAULTS, GEN_MAX_COUNT, GEN_STYLES } from '@shared/generate' +import { TABS, TOOL_CARDS } from '@shared/tabs' + +// The single table every part of the CLI reads: the parser (which flags exist), +// the option builder (values, ranges, defaults, app keys) and the help pages. +// Values come from the same @shared catalogs the app's option panels use, so +// the CLI cannot drift from the app (spec 2.3, 2.9). + +export type FlagType = 'bool' | 'enum' | 'int' | 'number' | 'text' | 'path' | 'format' | 'enumOrInt' + +export interface FlagSpec { + name: string + aliases?: string[] + short?: string + type: FlagType + values?: readonly string[] + /** enum values that are numbers (factor, bitrate) become numbers. */ + numeric?: boolean + min?: number + max?: number + step?: number + /** A unit the app shows that the CLI also accepts: '4x', '192k', '50%'. */ + suffix?: string + /** The app's JobOptions key. Absent for CLI-only flags. */ + key?: string + /** Shown word -> stored value, where the app stores a different word. */ + map?: Readonly> + def?: string | number | boolean + required?: boolean + /** Help group title, the app's own (FORMAT, IMAGE, VIDEO, ...). */ + group?: string + valueName?: string + help: string +} + +export type CommandId = + | 'convert' + | 'compress' + | 'resize' + | 'upscale' + | 'removebg' + | 'generate' + | 'pdf merge' + | 'pdf split' + | 'pdf burst' + | 'pdf extract-text' + | 'pdf to-images' + | 'pdf extract-images' + | 'pdf compress' + | 'formats' + | 'doctor' + | 'setup' + | 'skill install' + | 'skill status' + +export interface CommandSpec { + id: CommandId + path: string[] + args: string + summary: string + inputs: 'files' | 'prompt' | 'words' | 'none' + flags: FlagSpec[] + examples: string[] +} + +const exts = (list: { ext: string }[]): string[] => list.map((f) => f.ext.slice(1)) + +/** Every --to value any source can reach. */ +export const CONVERT_TARGETS: readonly string[] = [ + ...new Set([ + ...exts(familyFormats('image', '.png')), + ...exts(familyFormats('video', '.mp4')), + ...exts(familyFormats('audio', '.mp3')), + ...exts(familyFormats('pdf', '.pdf')), + ...exts(familyFormats('document', '.xlsx')), + ...exts(familyFormats('document', '.pptx')), + ...exts(familyFormats('archive', '.zip')) + ]) +] +export const VIDEO_CODEC_VALUES: readonly string[] = VIDEO_CODECS.map((c) => c.value) +export const AUDIO_CODEC_VALUES: readonly string[] = AUDIO_CODECS.map((c) => c.value) + +const OUT: FlagSpec = { + name: 'out', + short: 'o', + type: 'path', + valueName: '', + help: 'Output folder, created if missing (default: next to each file)' +} +const RECURSIVE: FlagSpec = { + name: 'recursive', + type: 'bool', + help: 'With a folder input, also take files in its subfolders' +} +const DRY: FlagSpec = { name: 'dry-run', type: 'bool', help: 'Show what would happen, write nothing' } +const JSON_FLAG: FlagSpec = { name: 'json', type: 'bool', help: 'Machine-readable events on stdout' } +const FILE_COMMON = [OUT, RECURSIVE, DRY, JSON_FLAG] + +const tabDesc = (id: string): string => TABS.find((t) => t.id === id)?.desc ?? '' +const cardDesc = (op: string): string => TOOL_CARDS.find((c) => c.opKey === op)?.desc ?? '' + +const LEVEL: FlagSpec = { + name: 'level', + type: 'enum', + values: PDF_LEVELS.map((l) => l.value), + def: 'balanced', + key: 'pdfLevel', + group: 'PDF', + help: 'PDF compression level' +} +const GREYSCALE: FlagSpec = { + name: 'greyscale', + aliases: ['grayscale'], + type: 'bool', + def: false, + key: 'pdfGray', + group: 'PDF', + help: 'Convert to greyscale (not with --level lossless)' +} +const DPI: FlagSpec = { + name: 'resolution', + aliases: ['dpi'], + type: 'int', + min: 36, + max: 600, + def: 150, + key: 'dpi', + group: 'PAGES', + valueName: '', + help: 'Page render resolution, 36-600' +} + +export const COMMANDS: CommandSpec[] = [ + { + id: 'convert', + path: ['convert'], + args: ' --to [options]', + summary: `${tabDesc('convert')}. Images, video, audio, documents, PDF and archives.`, + inputs: 'files', + flags: [ + { + name: 'to', + type: 'format', + values: CONVERT_TARGETS, + required: true, + key: 'format', + group: 'FORMAT', + valueName: '', + help: 'Target format, e.g. webp, jpg, mp4, pdf, cbz (jpeg, tif and a leading dot are fine)' + }, + { + name: 'quality', + type: 'enumOrInt', + values: ['smaller', 'balanced', 'best'], + min: 1, + max: 100, + def: 'balanced', + key: 'quality', + group: 'FORMAT', + valueName: '', + help: 'Image quality for jpg, webp, avif and jxl targets: smaller, balanced, best or 1-100' + }, + { + name: 'compression', + type: 'enum', + values: ['store', 'normal'], + def: 'store', + key: 'store', + group: 'FORMAT', + help: 'Archive to archive: store (fast, no recompression) or normal' + }, + DPI, + { + name: 'page-format', + type: 'enum', + values: ['jpg', 'png'], + def: 'jpg', + key: 'pageFormat', + group: 'PAGES', + help: 'PDF to comic: page image format' + }, + { + name: 'page-quality', + type: 'int', + min: 1, + max: 100, + def: 100, + key: 'pageQuality', + group: 'PAGES', + help: 'PDF to comic: jpg page quality' + }, + ...FILE_COMMON + ], + examples: [ + 'filesmith convert *.heic --to jpg', + 'filesmith convert book.pdf --to cbz --resolution 200 --page-format png', + 'filesmith convert comics\\ --to cbz --compression normal --out D:\\Out' + ] + }, + { + id: 'compress', + path: ['compress'], + args: ' [options]', + summary: `${tabDesc('compress')}. Images, video, audio and PDF.`, + inputs: 'files', + flags: [ + { + name: 'format', + type: 'enum', + values: COMPRESS_FORMATS.map((f) => f.value), + def: 'keep', + key: 'imageFormat', + group: 'IMAGE', + help: 'Image format: keep, webp or avif' + }, + { + name: 'quality', + type: 'int', + min: 10, + max: 100, + def: 80, + key: 'quality', + group: 'IMAGE', + help: 'Image and video quality, higher keeps more detail' + }, + { + name: 'codec', + type: 'enum', + values: [...VIDEO_CODEC_VALUES, ...AUDIO_CODEC_VALUES], + group: 'VIDEO', + valueName: '', + help: 'Video codec (h264, h265, av1) or audio codec (keep, mp3, aac, opus), checked per file (default h264 / keep)' + }, + { + name: 'video-codec', + type: 'enum', + values: VIDEO_CODEC_VALUES, + def: 'h264', + key: 'videoCodec', + group: 'VIDEO', + help: 'Video codec, unambiguous form of --codec' + }, + { + name: 'scale', + type: 'int', + min: SCALE_MIN, + max: SCALE_MAX, + step: SCALE_STEP, + suffix: '%', + def: 100, + key: 'scale', + group: 'VIDEO', + valueName: '', + help: `Video size in percent of the source, ${SCALE_MIN}-${SCALE_MAX} in steps of ${SCALE_STEP}` + }, + { + name: 'audio-codec', + type: 'enum', + values: AUDIO_CODEC_VALUES, + def: 'keep', + key: 'audioCodec', + group: 'AUDIO', + help: 'Audio codec, unambiguous form of --codec' + }, + { + name: 'bitrate', + type: 'enum', + values: AUDIO_BITRATES.map(String), + numeric: true, + suffix: 'k', + def: 192, + key: 'audioBitrate', + group: 'AUDIO', + valueName: '', + help: `Audio bitrate in kbps: ${AUDIO_BITRATES.join(', ')} (192k is fine)` + }, + LEVEL, + GREYSCALE, + ...FILE_COMMON + ], + examples: [ + 'filesmith compress *.jpg --quality 70', + 'filesmith compress lecture.mov --codec h265 --scale 50', + 'filesmith compress report.pdf --level smallest --greyscale' + ] + }, + { + id: 'resize', + path: ['resize'], + args: ' [options]', + summary: `${tabDesc('resize')}. Images only.`, + inputs: 'files', + flags: [ + { + name: 'percent', + type: 'number', + min: 0.01, + max: 10000, + suffix: '%', + def: 50, + key: 'percent', + group: 'SIZE', + valueName: '', + help: 'Scale by percent (50 or 50%)' + }, + { name: 'width', type: 'int', min: 1, max: 100000, key: 'width', group: 'SIZE', valueName: '', help: 'Width in pixels; leave out to scale by the height' }, + { name: 'height', type: 'int', min: 1, max: 100000, key: 'height', group: 'SIZE', valueName: '', help: 'Height in pixels; leave out to scale by the width' }, + { + name: 'fit', + type: 'enum', + values: RESIZE_FITS.map((f) => f.value), + def: 'contain', + key: 'fit', + group: 'SIZE', + help: 'contain keeps the aspect (the app\'s "Keep aspect"); stretch uses both sizes' + }, + { + name: 'mode', + type: 'enum', + values: ['percent', 'dimensions'], + key: 'mode', + group: 'SIZE', + help: 'Inferred from --percent or --width/--height; rarely needed' + }, + ...FILE_COMMON + ], + examples: ['filesmith resize *.png --percent 25', 'filesmith resize hero.jpg --width 1920'] + }, + { + id: 'upscale', + path: ['upscale'], + args: ' [options]', + summary: `${tabDesc('upscale')}. Output is always PNG; one image at a time.`, + inputs: 'files', + flags: [ + { + name: 'factor', + type: 'enum', + values: UPSCALE_FACTORS.map(String), + numeric: true, + suffix: 'x', + def: 4, + key: 'upscaleFactor', + group: 'MODEL', + valueName: '<2|3|4>', + help: 'Scale factor (4x is fine)' + }, + { + name: 'model', + type: 'text', + def: 'photo', + key: 'upscaleModel', + group: 'MODEL', + valueName: '', + help: 'photo, anime, a Real-ESRGAN model name, pid, or comfy:; list them with: filesmith formats upscale' + }, + { + name: 'gpu', + type: 'enum', + values: ['full', 'balanced'], + map: { full: 'full', balanced: 'background' }, + def: 'full', + key: 'gpuMode', + group: 'PERFORMANCE', + help: 'GPU mode: full, or balanced to leave room for other apps' + }, + ...FILE_COMMON + ], + examples: ['filesmith upscale old.jpg --factor 2', 'filesmith upscale frame.png --model pid --dry-run'] + }, + { + id: 'removebg', + path: ['removebg'], + args: ' [options]', + summary: `${tabDesc('removebg')}. Output is always PNG. Needs: filesmith setup removebg.`, + inputs: 'files', + flags: [ + { + name: 'fill', + type: 'enum', + values: BG_FILLS.map((f) => f.value), + def: BG_DEFAULTS.bgFill, + key: 'bgFill', + group: 'BACKGROUND', + valueName: '', + help: 'Background: transparent, white, black, green, custom or image' + }, + { + name: 'color', + type: 'text', + def: BG_DEFAULTS.bgCustomColor, + key: 'bgCustomColor', + group: 'BACKGROUND', + valueName: '<#hex>', + help: 'Custom colour as #rrggbb; implies --fill custom' + }, + { + name: 'image', + type: 'path', + key: 'bgImagePath', + group: 'BACKGROUND', + valueName: '', + help: 'Background image, cover fit; implies --fill image' + }, + ...FILE_COMMON + ], + examples: ['filesmith removebg product.jpg --fill white', 'filesmith removebg portrait.png --image beach.jpg'] + }, + { + id: 'generate', + path: ['generate'], + args: '"" [options]', + summary: `${tabDesc('generate')} with your ComfyUI models. Writes to the current folder unless --out is given.`, + inputs: 'prompt', + flags: [ + { name: 'model', type: 'text', key: 'model', group: 'MODEL', valueName: '', help: 'A model from: filesmith formats generate (default: the first runnable one)' }, + { name: 'negative', type: 'text', def: GEN_DEFAULTS.negative, key: 'negative', group: 'PROMPT', valueName: '', help: 'Negative prompt' }, + { + name: 'style', + type: 'enum', + values: GEN_STYLES.map((s) => s.id), + def: GEN_DEFAULTS.style, + key: 'style', + group: 'PROMPT', + valueName: '