@hraness/slopcamera 3.7.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,6 +39,11 @@ Commands:
39
39
  Buy and inspect hosted generation credits
40
40
  media audio|color Apply local non-destructive audio and video effects
41
41
  outputs Print the agent outputs directory
42
+ outputs list|open|reveal List the newest outputs or open one on this Mac
43
+ status What Slopcamera is doing, credits and newest outputs
44
+ tui [--snapshot] The status screen; --snapshot prints it once
45
+ commands [--json] List every command with what it may change
46
+ legacy retire Stop the old menu bar opening at login
42
47
  recordings list List recording bundles
43
48
  projects list|create List projects or create one from a recording
44
49
  project inspect|add|edit|render
@@ -65,28 +70,6 @@ ${slopcameraSupportHelpLine()}`;
65
70
 
66
71
  const HELP: Readonly<Record<string, string>> = {
67
72
  advanced: `${slopcameraSupportAdvancedHelp()}`,
68
- menubar: `Usage:
69
- slopcamera menubar [--foreground|--background] [--json]
70
- slopcamera menubar install|uninstall|status|start [--json]
71
-
72
- Shows what Slopcamera is doing in the menu bar: a render in progress, how the
73
- last job ended and your last known credits balance, plus your newest outputs.
74
- It only reads that status and the outputs folder (slopcamera outputs).
75
-
76
- menubar Open it now; it closes when this command ends
77
- menubar start Open it now and return (same as --background)
78
- menubar install Open it every time you log in
79
- menubar uninstall Stop opening it at login and remove the installed copy
80
- menubar status Check whether it opens at login and is running
81
-
82
- If it's already in your menu bar, starting it again says so and succeeds.
83
- macOS shows a notice that slopcamera-menubar can open at login. Turn it off any
84
- time in System Settings › General › Login Items & Extensions.
85
-
86
- The menu bar isn't in released packages yet, so it isn't listed in
87
- slopcamera help. In a Slopcamera checkout, build it with
88
- cargo build --release --manifest-path desktop/Cargo.toml and slopcamera finds
89
- it; elsewhere, set SLOPCAMERA_MENUBAR to the built file.`,
90
73
  capabilities: `Usage: slopcamera capabilities [--json]
91
74
 
92
75
  Print the exact statically assembled capability manifest for this CLI build. Each module owns named operation, workflow, MCP-tool, command, and runtime-profile identities. The manifest includes trust classes, resource/effect policies, runtime requirements, qualification status, and a canonical SHA-256. It reads no workspace source, loads no plugin, probes no runtime, and grants no authority. Use doctor separately to inspect this machine's currently available executables.`,
@@ -729,7 +712,7 @@ export function commandHelp(topic: readonly string[]): string {
729
712
 
730
713
  export function completions(words: readonly string[]): readonly string[] {
731
714
  const topLevel = [
732
- "capabilities", "operations", "diagram", "direct", "studio", "image", "html", "style", "workflows", "code", "runs", "doctor", "ai", "credits", "media", "menubar", "support", "outputs", "recordings", "projects", "project", "scene", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
715
+ "capabilities", "operations", "diagram", "direct", "studio", "image", "html", "style", "workflows", "code", "runs", "doctor", "ai", "credits", "media", "support", "outputs", "status", "tui", "commands", "legacy", "recordings", "projects", "project", "scene", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
733
716
  ];
734
717
  if (words.length <= 1) return topLevel;
735
718
  const command = words[0];
@@ -755,7 +738,8 @@ export function completions(words: readonly string[]): readonly string[] {
755
738
  if (words[1] === "direction") return ["check", "plan", "gallery"];
756
739
  if (words[1] === "effects") return ["check", "plan", "bake"];
757
740
  if (words[1] === "behavior") return ["check", "bake", "gallery", "audit"];
758
- return ["init", "check", "inspect", "diff", "patch", "evaluate", "audit", "render-audit", "solve", "review", "camera-track", "generate", "plan", "render", "direction", "effects", "behavior", "temporal-audit"];
741
+ if (words[1] === "design") return ["catalog", "init", "inspect", "set", "compile", "gallery"];
742
+ return ["init", "check", "inspect", "diff", "patch", "evaluate", "audit", "render-audit", "solve", "review", "camera-track", "generate", "plan", "render", "direction", "effects", "behavior", "design", "temporal-audit"];
759
743
  }
760
744
  if (command === "edit") return ["init", "show", "trim", "cut", "speed", "zoom", "overlay", "cursor", "clicks", "keystrokes", "typed-text"];
761
745
  if (command === "analyze") return ["faces", "inactivity", "zooms", "music", "scenes", "speech"];
@@ -767,5 +751,7 @@ export function completions(words: readonly string[]): readonly string[] {
767
751
  if (command === "media") return ["audio", "color"];
768
752
  if (command === "render") return ["plan", "run"];
769
753
  if (command === "assets") return ["emoji"];
754
+ if (command === "outputs") return ["list", "open", "reveal"];
755
+ if (command === "legacy") return ["retire"];
770
756
  return [];
771
757
  }
@@ -0,0 +1,184 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readSync } from "node:fs";
3
+ import { isAbsolute, join } from "node:path";
4
+
5
+ import {
6
+ MAX_LOGIN_ITEM_BYTES,
7
+ retireLegacyLoginItem,
8
+ type LegacyLoginItem,
9
+ type Retired,
10
+ } from "@hraness/desktop-foundation/retire";
11
+
12
+ import { CliError } from "./errors";
13
+
14
+ /**
15
+ * Login items earlier Slopcamera releases wrote to start the retired menu
16
+ * bar. Newest first: the desktop-foundation helper's item, the name that
17
+ * helper used before, and the plist the CLI itself wrote before that.
18
+ */
19
+ export const LEGACY_LOGIN_LABELS = [
20
+ "app.hraness.slopcamera",
21
+ "app.hraness.companion.slopcamera",
22
+ "com.hraness.slopcamera.menubar",
23
+ ] as const;
24
+
25
+ const OLD_CLI_LABEL = "com.hraness.slopcamera.menubar";
26
+ const MENU_BINARY = /\/slopcamera-menubar$/u;
27
+ const LOCAL_APP = /\/Slopcamera\.app\/Contents\/MacOS\/[^/]+$/u;
28
+
29
+ function xml(value: string): string {
30
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
31
+ }
32
+
33
+ function unxml(value: string): string {
34
+ return value.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", '"').replaceAll("&apos;", "'").replaceAll("&amp;", "&");
35
+ }
36
+
37
+ /** The exact plist the old `slopcamera menubar install` wrote. */
38
+ export function oldCliPlist(binary: string): string {
39
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${xml(OLD_CLI_LABEL)}</string><key>ProgramArguments</key><array><string>${xml(binary)}</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><false/><key>ProcessType</key><string>Interactive</string><key>LimitLoadToSessionType</key><string>Aqua</string></dict></plist>\n`;
40
+ }
41
+
42
+ function programArguments(text: string): string[] {
43
+ const array = /<key>ProgramArguments<\/key><array>((?:<string>[^<]*<\/string>)*)<\/array>/u.exec(text);
44
+ if (array === null) return [];
45
+ return [...array[1]!.matchAll(/<string>([^<]*)<\/string>/gu)].map(match => unxml(match[1]!));
46
+ }
47
+
48
+ /**
49
+ * True only for an item Slopcamera wrote for its menu bar. The helper's
50
+ * items carry a header with the SHA-256 of the body, so an edited file is
51
+ * not ours. The old CLI item must match what that CLI wrote byte for byte.
52
+ */
53
+ export function isSlopcameraMenubarItem(item: LegacyLoginItem): boolean {
54
+ if (item.label === OLD_CLI_LABEL) {
55
+ const [program, ...rest] = programArguments(item.text);
56
+ return program !== undefined && rest.length === 0 && MENU_BINARY.test(program) && item.text === oldCliPlist(program);
57
+ }
58
+ const newline = item.text.indexOf("\n");
59
+ if (newline < 0) return false;
60
+ const body = item.text.slice(newline + 1);
61
+ const digest = createHash("sha256").update(body).digest("hex");
62
+ if (item.text.slice(0, newline + 1) !== `<!-- hraness-companion autostart slopcamera sha256:${digest} -->\n`) return false;
63
+ if (!body.includes(`<key>Label</key><string>${xml(item.label)}</string>`)) return false;
64
+ const [program] = programArguments(body);
65
+ return program !== undefined && (MENU_BINARY.test(program) || LOCAL_APP.test(program));
66
+ }
67
+
68
+ export type LegacyLoginState = "none" | "found" | "not-ours";
69
+
70
+ export interface LegacyLoginReport {
71
+ readonly state: LegacyLoginState;
72
+ /** The label of the item found, when there is one. */
73
+ readonly label?: string;
74
+ }
75
+
76
+ function readSmallFile(path: string): string | undefined {
77
+ let fd: number | undefined;
78
+ try {
79
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
80
+ const info = fstatSync(fd);
81
+ if (!info.isFile() || info.size > MAX_LOGIN_ITEM_BYTES || info.uid !== process.getuid?.()) return undefined;
82
+ const bytes = Buffer.alloc(info.size);
83
+ let count = 0;
84
+ while (count < bytes.length) {
85
+ const read = readSync(fd, bytes, count, bytes.length - count, count);
86
+ if (read === 0) break;
87
+ count += read;
88
+ }
89
+ return bytes.subarray(0, count).toString("utf8");
90
+ } catch {
91
+ return undefined;
92
+ } finally {
93
+ if (fd !== undefined) closeSync(fd);
94
+ }
95
+ }
96
+
97
+ function launchAgents(home: string | undefined): string | undefined {
98
+ return home === undefined || home === "" || !isAbsolute(home) ? undefined : join(home, "Library", "LaunchAgents");
99
+ }
100
+
101
+ /** Reads the LaunchAgents folder and reports the old menu bar item. Changes nothing. */
102
+ export function inspectLegacyLogin(home: string | undefined): LegacyLoginReport {
103
+ const directory = launchAgents(home);
104
+ if (directory === undefined) return { state: "none" };
105
+ let foreign: string | undefined;
106
+ for (const label of LEGACY_LOGIN_LABELS) {
107
+ const path = join(directory, `${label}.plist`);
108
+ try {
109
+ lstatSync(path);
110
+ } catch {
111
+ continue;
112
+ }
113
+ const text = readSmallFile(path);
114
+ if (text !== undefined && isSlopcameraMenubarItem({ label, path, text })) return { state: "found", label };
115
+ foreign ??= label;
116
+ }
117
+ return foreign === undefined ? { state: "none" } : { state: "not-ours", label: foreign };
118
+ }
119
+
120
+ export interface RetireDependencies {
121
+ readonly bootout?: (label: string) => Promise<void>;
122
+ readonly now?: () => Date;
123
+ }
124
+
125
+ /**
126
+ * Stops the old menu bar starting at login: boots out its label and renames
127
+ * the plist to `<name>.retired-<ms>`. Nothing is deleted, no process is
128
+ * signalled by pid, and items Slopcamera did not write are left alone.
129
+ */
130
+ export async function retireLegacyLogin(home: string | undefined, dependencies: RetireDependencies = {}): Promise<Retired[]> {
131
+ if (launchAgents(home) === undefined) throw new CliError("unavailable", "HOME must be an absolute directory to look for the old menu bar login item.");
132
+ const retired: Retired[] = [];
133
+ // One call retires the first match; repeat so every old copy is moved aside.
134
+ for (let attempt = 0; attempt < LEGACY_LOGIN_LABELS.length; attempt += 1) {
135
+ const labels = LEGACY_LOGIN_LABELS.filter(label => !retired.some(item => item.label === label));
136
+ const result = await retireLegacyLoginItem({
137
+ home: home!,
138
+ labels,
139
+ accepts: isSlopcameraMenubarItem,
140
+ ...(dependencies.bootout === undefined ? {} : { bootout: dependencies.bootout }),
141
+ ...(dependencies.now === undefined ? {} : { now: dependencies.now }),
142
+ });
143
+ if (result === null) break;
144
+ retired.push(result);
145
+ }
146
+ return retired;
147
+ }
148
+
149
+ export interface RetiredLegacyLogin {
150
+ readonly label: string;
151
+ readonly path: string;
152
+ /** Puts the item back and loads it, undoing `slopcamera legacy retire`. */
153
+ readonly restore: string;
154
+ }
155
+
156
+ function shellQuote(value: string): string {
157
+ return `'${value.replaceAll("'", "'\\''")}'`;
158
+ }
159
+
160
+ /** Login items `legacy retire` moved aside, with the command that restores each. Changes nothing. */
161
+ export function retiredLegacyLogins(home: string | undefined): RetiredLegacyLogin[] {
162
+ const directory = launchAgents(home);
163
+ if (directory === undefined) return [];
164
+ let names: string[];
165
+ try {
166
+ names = readdirSync(directory);
167
+ } catch {
168
+ return [];
169
+ }
170
+ const found: RetiredLegacyLogin[] = [];
171
+ for (const label of LEGACY_LOGIN_LABELS) {
172
+ const prefix = `${label}.plist.retired-`;
173
+ for (const name of names.filter(item => item.startsWith(prefix) && /^[0-9]{1,16}$/u.test(item.slice(prefix.length))).sort()) {
174
+ const path = join(directory, name);
175
+ const original = join(directory, `${label}.plist`);
176
+ found.push({
177
+ label,
178
+ path,
179
+ restore: `mv -n ${shellQuote(path)} ${shellQuote(original)} && launchctl bootstrap gui/$(id -u) ${shellQuote(original)}`,
180
+ });
181
+ }
182
+ }
183
+ return found;
184
+ }
@@ -5,6 +5,7 @@ import { runProductSupportCommand, showProductSupportInvitation, standaloneSuppo
5
5
  import { parseCliArgs } from "./args";
6
6
  import { runCli } from "./commands";
7
7
  import { asCliError, CliError, EXIT_CODE } from "./errors";
8
+ import { isRegistryCommand, runRegistry } from "./registry";
8
9
  import { BunProcessRunner, processIo } from "./io";
9
10
  import { resolveRepositoryPaths } from "./paths";
10
11
  import {
@@ -42,6 +43,13 @@ export async function main(
42
43
  return await runProductSupportCommand(argv.slice(1),
43
44
  supportEnvironment === undefined ? {} : { env: supportEnvironment });
44
45
  }
46
+ if (isRegistryCommand(argv)) {
47
+ // status, tui, commands, legacy retire and outputs list|open|reveal
48
+ // answer in the shared desktop-foundation envelope.
49
+ return await runRegistry(argv, { stdout: process.stdout, stderr: process.stderr, env: process.env }, {
50
+ env: process.env, platform: process.platform, now: () => new Date(),
51
+ });
52
+ }
45
53
  const unifiedArgv = canonicalizeUnifiedCliArgs(argv);
46
54
  const portableExitCode = await runPortableSurface(unifiedArgv, portableDependencies);
47
55
  if (portableExitCode !== undefined) return portableExitCode;
@@ -0,0 +1,19 @@
1
+ import { dirname, join, sep } from "node:path";
2
+
3
+ import type { CliIo } from "./io";
4
+ import { writeJson, writeLine } from "./io";
5
+ import { ensurePrivateDirectory } from "./paths";
6
+
7
+ /** The directory agents write user-facing outputs into. */
8
+ export function outputsDirectory(stateRoot: string): string {
9
+ const productRoot = stateRoot.endsWith(`${sep}cli`) ? dirname(stateRoot) : stateRoot;
10
+ return join(productRoot, "outputs");
11
+ }
12
+
13
+ /** `slopcamera outputs`: creates the private outputs folder and prints its path. */
14
+ export async function reportOutputsRoot(io: CliIo, stateRoot: string, asJson: boolean): Promise<void> {
15
+ const directory = outputsDirectory(stateRoot);
16
+ await ensurePrivateDirectory(directory);
17
+ if (asJson) writeJson(io, { outputs: directory });
18
+ else writeLine(io, directory);
19
+ }
@@ -0,0 +1,314 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ defineRegistry,
6
+ HranessError,
7
+ okEnvelope,
8
+ runCli as runRegistryCli,
9
+ type CliIO,
10
+ type ErrorCode,
11
+ type Registry,
12
+ type Verb,
13
+ } from "@hraness/desktop-foundation/registry";
14
+ import { chooseMode, renderSnapshot, runTui, type TuiIO } from "@hraness/desktop-foundation/tui";
15
+
16
+ import { SLOPCAMERA_VERSION } from "../../../src/version";
17
+ import { listOutputs, OUTPUTS_LIMIT, statusEnvelope, statusViews, STATUS_SCHEMA, type StatusOutputs } from "./desktop-status";
18
+ import type { ProcessRunner } from "./io";
19
+ import { CliError } from "./errors";
20
+ import { BunProcessRunner } from "./io";
21
+ import { readActivityStatus } from "./activity-status";
22
+ import { inspectLegacyLogin, retireLegacyLogin, type RetireDependencies } from "./legacy-login";
23
+ import { outputsDirectory } from "./outputs";
24
+ import { defaultCliStateRoot } from "./paths";
25
+ import { CLI_VERBS, type VerbRow } from "./verbs";
26
+
27
+ /**
28
+ * The verbs `slopcamera` answers through the desktop-foundation registry:
29
+ * `status`, `tui`, `commands`, `legacy retire` and `outputs list|open|reveal`.
30
+ * Every other verb keeps its own parser in `args.ts`; the registry still
31
+ * lists it with its class so `commands --json` covers the whole CLI.
32
+ */
33
+ export const PRODUCT = "slopcamera";
34
+ export const OUTPUTS_LIST_LIMIT = 200;
35
+
36
+ /** First words (and `outputs` sub-verbs) the registry answers. */
37
+ export function isRegistryCommand(argv: readonly string[]): boolean {
38
+ const [first, second] = argv;
39
+ if (first === "status" || first === "tui" || first === "commands" || first === "legacy") return true;
40
+ return first === "outputs" && (second === "list" || second === "open" || second === "reveal");
41
+ }
42
+
43
+ export interface RegistryDependencies {
44
+ readonly env: Readonly<Record<string, string | undefined>>;
45
+ readonly platform: NodeJS.Platform;
46
+ readonly now: () => Date;
47
+ readonly stateRoot?: string;
48
+ readonly version?: string;
49
+ readonly runner?: ProcessRunner;
50
+ readonly retire?: RetireDependencies;
51
+ readonly stdoutIsTerminal?: boolean;
52
+ readonly tuiIo?: TuiIO;
53
+ }
54
+
55
+ function stateRootOf(deps: RegistryDependencies): string {
56
+ return deps.stateRoot ?? defaultCliStateRoot(deps.platform, deps.env);
57
+ }
58
+
59
+ /** Loads everything `status` and `tui` show. Reads only; creates nothing. */
60
+ export function loadStatus(deps: RegistryDependencies) {
61
+ const stateRoot = stateRootOf(deps);
62
+ return statusEnvelope({
63
+ version: deps.version ?? SLOPCAMERA_VERSION,
64
+ status: readActivityStatus(stateRoot),
65
+ outputs: listOutputs(outputsDirectory(stateRoot), OUTPUTS_LIMIT),
66
+ legacyLogin: inspectLegacyLogin(deps.env.HOME),
67
+ now: deps.now(),
68
+ });
69
+ }
70
+
71
+ function schemaFor(path: readonly string[]): string {
72
+ return `${PRODUCT}.${path.join(".")}/1`;
73
+ }
74
+
75
+ /**
76
+ * One output by its file name, as `outputs list` prints it. Only a plain
77
+ * name of a regular, visible file directly in the outputs folder is
78
+ * accepted, so the verb can never open anything else.
79
+ */
80
+ export function outputPath(root: string, name: string | undefined): string {
81
+ if (name === undefined || name === "") throw new HranessError("usage", "Name one output, as `slopcamera outputs list` prints it.");
82
+ if (name.includes("/") || name.includes("\\") || name.startsWith(".") || name.includes("\0") || name.length > 255) {
83
+ throw new HranessError("usage", "Give the output's file name only, as `slopcamera outputs list` prints it.");
84
+ }
85
+ const path = join(root, name);
86
+ let regular = false;
87
+ try {
88
+ regular = lstatSync(path).isFile();
89
+ } catch {
90
+ regular = false;
91
+ }
92
+ if (!regular) {
93
+ throw new HranessError("not-found", `No output named ${JSON.stringify(name)}.`, undefined, [
94
+ { command: `${PRODUCT} outputs list --json`, why: "List the outputs by name.", audience: "agent" },
95
+ ]);
96
+ }
97
+ return path;
98
+ }
99
+
100
+ async function finder(deps: RegistryDependencies, argv: readonly [string, ...string[]]): Promise<void> {
101
+ if (deps.platform !== "darwin") {
102
+ throw new HranessError("unsupported-platform", "Opening Finder works on macOS only. Nothing opened.", undefined, [
103
+ { command: `${PRODUCT} outputs --json`, why: "Print the outputs folder to open it yourself.", audience: "agent" },
104
+ ]);
105
+ }
106
+ const result = await (deps.runner ?? new BunProcessRunner()).run(argv, { maxOutputBytes: 4_096 });
107
+ if (result.exitCode !== 0) throw new HranessError("internal", "Finder didn't open. Try again.");
108
+ }
109
+
110
+ interface OutputsListData {
111
+ readonly root: string;
112
+ readonly total: number;
113
+ readonly outputs: StatusOutputs["latest"];
114
+ }
115
+
116
+ interface OpenedData {
117
+ readonly opened: string;
118
+ readonly reveal: boolean;
119
+ }
120
+
121
+ // defineRegistry takes a heterogeneous verb list typed Verb<any, any>[] upstream.
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
123
+ function implemented(deps: RegistryDependencies): Verb<any, any>[] {
124
+ const stateRoot = () => stateRootOf(deps);
125
+ const status: Verb<Record<string, never>, number> = {
126
+ path: ["status"],
127
+ opClass: "read",
128
+ schema: STATUS_SCHEMA,
129
+ summary: "What Slopcamera is doing, credits, newest outputs and old login items",
130
+ output: "raw",
131
+ input: () => ({}),
132
+ async run(_input, ctx) {
133
+ const envelope = loadStatus(deps);
134
+ if (ctx.json) {
135
+ ctx.io.stdout.write(`${JSON.stringify(envelope)}\n`);
136
+ } else if (envelope.ok) {
137
+ ctx.io.stdout.write(renderSnapshot(statusViews(deps.now), envelope.data));
138
+ for (const next of envelope.next ?? []) ctx.io.stdout.write(`next: ${next.command} (${next.why})\n`);
139
+ }
140
+ return 0;
141
+ },
142
+ };
143
+ const tui: Verb<{ snapshot: boolean; width?: number }, number> = {
144
+ path: ["tui"],
145
+ opClass: "read",
146
+ schema: STATUS_SCHEMA,
147
+ summary: "The status screen; --snapshot prints it once, --json prints the status",
148
+ flags: ["snapshot"],
149
+ valueFlags: ["width"],
150
+ usage: "[--snapshot|--json] [--width N]",
151
+ output: "raw",
152
+ input(argv) {
153
+ if (argv.positionals.length > 0) throw new HranessError("usage", "tui takes no arguments.");
154
+ const width = argv.flags.width;
155
+ if (width === undefined) return { snapshot: argv.flags.snapshot === true };
156
+ if (typeof width !== "string" || !/^[0-9]{1,4}$/u.test(width) || Number(width) < 20 || Number(width) > 400) {
157
+ throw new HranessError("usage", "--width takes a number of columns from 20 to 400.");
158
+ }
159
+ return { snapshot: argv.flags.snapshot === true, width: Number(width) };
160
+ },
161
+ async run(input, ctx) {
162
+ const mode = chooseMode(ctx.json, input.snapshot, deps.stdoutIsTerminal ?? process.stdout.isTTY === true);
163
+ return await runTui({
164
+ load: async () => loadStatus(deps),
165
+ views: statusViews(deps.now),
166
+ mode,
167
+ ...(input.width === undefined ? {} : { width: input.width }),
168
+ io: deps.tuiIo ?? { stdout: { write: (text: string) => ctx.io.stdout.write(text), ...(process.stdout.columns === undefined ? {} : { columns: process.stdout.columns }) }, stdin: process.stdin },
169
+ });
170
+ },
171
+ };
172
+ const legacyRetire: Verb<Record<string, never>, { retired: { label: string; from: string; to: string }[]; loginItem: ReturnType<typeof inspectLegacyLogin> }> = {
173
+ path: ["legacy", "retire"],
174
+ opClass: "operate",
175
+ schema: schemaFor(["legacy", "retire"]),
176
+ summary: "Stop the old menu bar opening at login, keeping its file",
177
+ input(argv) {
178
+ if (argv.positionals.length > 0) throw new HranessError("usage", "legacy retire takes no arguments.");
179
+ return {};
180
+ },
181
+ async run() {
182
+ if (deps.platform !== "darwin") return { retired: [], loginItem: { state: "none" } };
183
+ const retired = await retireLegacyLogin(deps.env.HOME, deps.retire ?? {});
184
+ return { retired, loginItem: inspectLegacyLogin(deps.env.HOME) };
185
+ },
186
+ text(output) {
187
+ const moved = output.retired.map(item => `Moved ${item.from} to ${item.to}. The old menu bar no longer opens at login.`);
188
+ if (output.loginItem.state === "not-ours") moved.push("A menu bar login item was changed outside Slopcamera, so it was left alone.");
189
+ return moved.length === 0 ? "Nothing to retire. The old menu bar doesn't open at login." : moved.join("\n");
190
+ },
191
+ };
192
+ const outputsList: Verb<Record<string, never>, OutputsListData> = {
193
+ path: ["outputs", "list"],
194
+ opClass: "read",
195
+ schema: schemaFor(["outputs", "list"]),
196
+ summary: "List the files in the outputs folder, newest first",
197
+ input(argv) {
198
+ if (argv.positionals.length > 0) throw new HranessError("usage", "outputs list takes no arguments.");
199
+ return {};
200
+ },
201
+ async run() {
202
+ const listed = listOutputs(outputsDirectory(stateRoot()), OUTPUTS_LIST_LIMIT);
203
+ return { root: listed.root, total: listed.total, outputs: listed.latest };
204
+ },
205
+ text(output) {
206
+ if (output.outputs.length === 0) return `No outputs yet in ${output.root}`;
207
+ return output.outputs.map(item => `${item.name}\t${item.bytes}\t${item.modifiedAt}`).join("\n");
208
+ },
209
+ };
210
+ const openVerb = (reveal: boolean): Verb<{ name?: string }, OpenedData> => ({
211
+ path: ["outputs", reveal ? "reveal" : "open"],
212
+ opClass: "operate",
213
+ schema: schemaFor(["outputs", reveal ? "reveal" : "open"]),
214
+ summary: reveal ? "Show one output in Finder" : "Open the outputs folder, or one output, on this Mac",
215
+ usage: reveal ? "<name>" : "[<name>]",
216
+ input(argv) {
217
+ if (argv.positionals.length > 1) throw new HranessError("usage", "Name at most one output.");
218
+ const name = argv.positionals[0];
219
+ if (reveal && name === undefined) throw new HranessError("usage", "Name the output to show, as `slopcamera outputs list` prints it.");
220
+ return name === undefined ? {} : { name };
221
+ },
222
+ async run(input) {
223
+ const root = outputsDirectory(stateRoot());
224
+ if (input.name === undefined) {
225
+ let folder = false;
226
+ try {
227
+ folder = lstatSync(root).isDirectory();
228
+ } catch {
229
+ folder = false;
230
+ }
231
+ if (!folder) {
232
+ throw new HranessError("not-found", "There's no outputs folder yet. Agents create it when they save their first output.", undefined, [
233
+ { command: `${PRODUCT} outputs --json`, why: "Create the outputs folder and print its path.", audience: "agent" },
234
+ ]);
235
+ }
236
+ await finder(deps, ["/usr/bin/open", "--", root]);
237
+ return { opened: root, reveal: false };
238
+ }
239
+ const path = outputPath(root, input.name);
240
+ await finder(deps, reveal ? ["/usr/bin/open", "-R", "--", path] : ["/usr/bin/open", "--", path]);
241
+ return { opened: path, reveal };
242
+ },
243
+ text(output) {
244
+ return output.reveal ? `Showed ${output.opened} in Finder.` : `Opened ${output.opened}.`;
245
+ },
246
+ });
247
+ return [status, tui, legacyRetire, outputsList, openVerb(false), openVerb(true)];
248
+ }
249
+
250
+ /** A verb `args.ts` parses and runs; the registry only lists it. */
251
+ function listed(row: VerbRow): Verb<unknown, unknown> {
252
+ return {
253
+ path: row.path,
254
+ opClass: row.opClass,
255
+ schema: schemaFor(row.path),
256
+ summary: row.summary,
257
+ input: () => {
258
+ throw new HranessError("internal", `slopcamera ${row.path.join(" ")} is run by the main parser.`);
259
+ },
260
+ run: async () => undefined,
261
+ };
262
+ }
263
+
264
+ /** The shared envelope codes a Slopcamera error keeps as is. */
265
+ const SHARED_CODES = new Set<ErrorCode>(["usage", "not-found", "conflict", "internal"]);
266
+
267
+ /**
268
+ * Answers a Slopcamera application error with a declared envelope code: the
269
+ * shared code when there is one, otherwise `slopcamera.<code>`. Without this
270
+ * the registry reports every such error as an unexpected internal failure.
271
+ */
272
+ export function asHranessError(error: unknown): unknown {
273
+ if (!(error instanceof CliError)) return error;
274
+ const shared = error.code as ErrorCode;
275
+ const code: ErrorCode = SHARED_CODES.has(shared) ? shared : `${PRODUCT}.${error.code}`;
276
+ return new HranessError(code, error.message);
277
+ }
278
+
279
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
280
+ function withDeclaredErrors(verb: Verb<any, any>): Verb<any, any> {
281
+ return {
282
+ ...verb,
283
+ input(argv) {
284
+ try {
285
+ return verb.input(argv);
286
+ } catch (error) {
287
+ throw asHranessError(error);
288
+ }
289
+ },
290
+ async run(input, ctx) {
291
+ try {
292
+ return await verb.run(input, ctx);
293
+ } catch (error) {
294
+ throw asHranessError(error);
295
+ }
296
+ },
297
+ };
298
+ }
299
+
300
+ export function slopcameraRegistry(deps: RegistryDependencies): Registry {
301
+ const own = implemented(deps).map(withDeclaredErrors);
302
+ const ownPaths = new Set(own.map(verb => verb.path.join(" ")));
303
+ return defineRegistry(PRODUCT, [
304
+ ...own,
305
+ ...CLI_VERBS.filter(row => !ownPaths.has(row.path.join(" "))).map(listed),
306
+ ]);
307
+ }
308
+
309
+ /** Runs a registry command line and returns its exit status. */
310
+ export async function runRegistry(argv: readonly string[], io: CliIO, deps: RegistryDependencies): Promise<number> {
311
+ return await runRegistryCli(slopcameraRegistry(deps), argv, io);
312
+ }
313
+
314
+ export { okEnvelope };