@hwip/cli 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,17 @@ Codex / …). Safe to re-run: a saved login is reused untouched (no
39
39
  prompts), so a re-run just refreshes the agent integrations. To switch
40
40
  account, `hwip install --relogin` forces a fresh sign-in.
41
41
 
42
+ **Auto session names.** Agents without a title of their own — Codex,
43
+ Cursor, Windsurf, Gemini and pi — name their sessions after what they were
44
+ asked, so the phone shows that instead of a tmux name. The registrations
45
+ `hwip install` writes turn it on (`HWIP_AUTONAME=1` in the MCP entry, the
46
+ `hwip_session_title` tool in the pi extension), so **after updating
47
+ `@hwip/cli`, run `hwip install` once more** to get it on a machine
48
+ installed before; `hwip verify` says which MCP agents still lack it. Claude
49
+ Code and opencode title sessions themselves and are left as they are. To
50
+ hide the names agents choose, turn off *Name sessions automatically* in
51
+ the app's settings — names you set yourself always show.
52
+
42
53
  **Why global, not `npx`?** `hwip cc` (drive a session from your phone)
43
54
  needs `hwip` on your `PATH`, and the agent hooks this installs are
44
55
  `$(command -v hwip || npx …)` — a global binary skips an npx cold-start
@@ -432,17 +443,28 @@ block here.
432
443
  hwip sh --detach # create it, print its name, exit
433
444
  ```
434
445
 
435
- **The mirror fits a detached session to the phone.** A pane is as wide
436
- as the terminal that last drew it, so a desktop's prompt rules and
437
- right-aligned clocks fold in two on a phone. While the phone watches a
438
- session no terminal is attached to (a `hwip sh --detach` shell, or an
439
- agent whose terminal you closed), the listener resizes its window to the
440
- phone's width and gives the old width back when the phone leaves. An
441
- attached session keeps its size: shrinking it would shrink your desktop
442
- terminal, so the phone wraps those rows instead. A window split into
443
- panes is never resized, and one a terminal attaches to while the phone
444
- watches is handed back within a renew (5 s). A listener that died while
445
- a window was fitted restores it the next time it starts.
446
+ **The mirror fits a session to the phone.** A pane is as wide as the
447
+ terminal that last drew it, so a desktop's prompt rules and
448
+ right-aligned clocks fold in two on a phone. Whoever used the session
449
+ last decides its width, the way tmux's `window-size latest` does: typing
450
+ from the phone makes the phone the latest user, and the listener resizes
451
+ the window to the phone's width, and coming back to the mirror later
452
+ (a locked screen, a resubscribe) takes it again unless the desktop was
453
+ used since. Opening the mirror without typing does the same once
454
+ the desktop terminal has been idle for a minute, so a phone reconnecting
455
+ on its own never shrinks a terminal you are typing in. Pressing a key in
456
+ (or attaching) a desktop terminal makes it the latest user, and within a
457
+ renew (5 s) the window is handed back to tmux, which sizes it to that
458
+ terminal. Moving the mouse over a desktop pane or clicking another
459
+ pane is not using it: on macOS the listener reads when a key was last
460
+ pressed (the HID system clock, no permission needed) and counts a
461
+ desktop terminal as used no later than that key or its attach. Elsewhere
462
+ any tmux client activity counts. While the phone holds a session, a desktop terminal still
463
+ attached to it shows it narrower. When the phone leaves, a session no
464
+ terminal is attached to gets its old width back and an attached one is
465
+ sized to its terminal. A window split into panes is never resized. A
466
+ listener that died while a window was fitted restores it the next time
467
+ it starts.
446
468
 
447
469
  `hwip cursor` runs **`cursor-agent`**, Cursor's terminal agent — a
448
470
  separate install from the Cursor IDE (the bare `cursor` on your PATH
@@ -623,8 +645,9 @@ went.
623
645
  - **The prompt** (`--prompt`) is typed once, and only when the agent is the foreground program of the pane and its detection rules read the screen as `idle` — not working, not a dialog. If that does not happen within 90 seconds the prompt is not typed, the session stays, and the phone is told. **Only agents that have an `idle` rule can take a prompt** (today Claude Code, Codex, Gemini CLI and pi): for any other agent nothing separates "ready" from a trust or permission dialog, and the Enter that submits a prompt would answer the dialog's default with nobody there to read it. `hwip schedule add … --prompt` refuses those agents and says which ones work; a schedule without `--prompt` starts any agent. The rules that decide this are the ones shipped in the `hwip` package, not the detection rules the listener updates over the air: a phone's input is typed by someone who has seen the screen, a schedule by nobody, and a rule set that arrives over the air is not signed — it must not be able to call a dialog `idle` and have your prompt and Enter typed into it.
624
646
  - **The cap**: a session that runs longer than `--max-runtime` is asked to end the way the phone's *end session* does — the agent's own quit command, then signals. It is never killed; one that stays keeps the schedule from starting its next run (`overlap`).
625
647
  - **The phone is told** with one push per listener tick, listing each schedule with how many times and why (`<label> · 놓침 31회` — the push is in Korean). A run skipped because the last one is still going counts once until the result changes; a push that does not go out is tried again after 30 seconds, then 1, 2, 4 … minutes up to 30 (only a refusal by the server, such as the daily limit, is not tried again; a timeout, a proxy or a captive portal in the way is), and is dropped after 24 hours. The push has one title per kind of news — skipped or missed, over its runtime, or a prompt not typed — and a general one when a push holds several kinds. The prompt is never in it. A free account sends at most 100 pushes a day (`pushPerDay`), which is why these are batched.
626
- - **State** is files under `~/.local/state/hwip`: one `schedules/<id>.json` per schedule (limit 20) and `schedule-state.json`, written only by the listener, with where each schedule has got to.
627
- - **From the phone**: the app lists the schedules of a computer, makes one for a session this computer has run, and removes one. It names the session, a time and what to do; the directory, the agent's command and its arguments are never the phone's (see *Defense*). A schedule made there needs an agent that can take a prompt, and shows in `hwip schedule` like any other.
648
+ - **Editing**: `hwip schedule edit <id>` changes the time (`--at`, `--days`, `--every`), `--prompt` and `--max-runtime` of a schedule in place; the id, the session, the directory and the agent stay. What a flag does not name is kept — `--at` keeps the days, `--days` the time. A changed time counts from the edit: the times before it are neither started nor counted as missed, and an `--every` schedule runs one period after the edit; changing only the prompt or the run time keeps the schedule on its times. A session already running keeps going. A listener of an older build is restarted first, because it would skip the edited file as unreadable; when it cannot be replaced, a new time is not written (exit 1) and the schedule keeps running as it was.
649
+ - **State** is files under `~/.local/state/hwip`: one `schedules/<id>.json` per schedule (limit 20) and `schedule-state.json`, written only by the listener, with where each schedule has got to. `agent-dirs.json` holds the directories `hwip <agent>` typed in a shell found its agent in: a listener run as a login service has only the PATH its service file baked, so a scheduled launch adds these to it — run an agent from a shell once (`hwip cc`) before scheduling it, or the run is skipped with `'claude' not found on PATH` in `~/.hwip/listener.log`.
650
+ - **From the phone**: the app lists the schedules of a computer, makes one for a session this computer has run, edits one, and removes one. It names the session, a time and what to do; the directory, the agent's command and its arguments are never the phone's (see *Defense*). A schedule made there needs an agent that can take a prompt, and shows in `hwip schedule` like any other.
628
651
 
629
652
  ### Session history
630
653
 
@@ -810,7 +833,7 @@ The phone also reads this machine's schedules (see [Scheduled sessions](#schedul
810
833
  { subtype: 'agent.schedule.list.result', requestId, at, schedules | encrypted | error }
811
834
  ```
812
835
 
813
- `schedules` is the list as a JSON string; `encrypted` is the list sealed for the asking device (purpose `schedule.list`, bound to this machine's device id) as `{ v: 1, requestId, at, schedules: [...], unreadable? }` — the app checks that the `requestId` inside is the one it asked, so an older sealed list is not taken for a newer answer. `unreadable` is how many schedule files could not be read; a directory that cannot be listed at all, or that holds more than 20 files, is the error `read_failed`, never an empty list (and a remove then fails as `schedule_failed`; `hwip schedule rm` still works on the computer). A list too long for one frame has its prompts shortened, step by step (`too_large` only when it does not fit even without them). Each entry holds `id`, `source`, `agentKind`, `project` (the directory's name, not its path), `session`, `when`, `prompt` (cut to 500 characters, shorter when the list would not fit), `maxRuntimeMin`, `nextAt` and the `last` result — never a directory path or the agent's arguments.
836
+ `schedules` is the list as a JSON string; `encrypted` is the list sealed for the asking device (purpose `schedule.list`, bound to this machine's device id) as `{ v: 1, requestId, at, schedules: [...], unreadable? }` — the app checks that the `requestId` inside is the one it asked, so an older sealed list is not taken for a newer answer. `unreadable` is how many schedule files could not be read; a directory that cannot be listed at all, or that holds more than 20 files, is the error `read_failed`, never an empty list (and a remove then fails as `schedule_failed`; `hwip schedule rm` still works on the computer). A list too long for one frame has its prompts shortened, step by step (`too_large` only when it does not fit even without them). Each entry holds `id`, `source`, `agentKind`, `project` (the directory's name, not its path), `session`, `when`, `prompt` (cut to 500 characters, shorter when the list would not fit; `promptLocked: true` says the phone cannot change it — it is not the whole text, or the schedule has agent arguments — so the app leaves it to `hwip schedule edit`), `maxRuntimeMin`, `nextAt` and the `last` result — never a directory path or the agent's arguments.
814
837
 
815
838
  ### Your API key and redirects
816
839
 
@@ -950,7 +973,12 @@ handing the listener a key:
950
973
  session name, its `seq`/`epoch` and that nonce, so a relay that cannot
951
974
  swap in a key of its own (below) can't move one to another stream,
952
975
  replay one from an earlier subscription, or hand one back as input —
953
- or as anything else this listener sealed for the phone. Your
976
+ or as anything else this listener sealed for the phone. A history page
977
+ (scrolling up past the live window) holds the same nonce and `epoch`,
978
+ the request id it answers and where it sits in the scrollback, so a
979
+ relay can't hand an earlier page back under a new request or move
980
+ where the next one is asked for; only the request id rides beside it
981
+ in the clear. Your
954
982
  keystrokes are sealed for this listener as `stream.input` with their
955
983
  `seq`/`epoch` stamps, the stream's start epoch and the phone's device
956
984
  id inside, so a forged restart can't replay them. The start answer (`agent.stream.start.ok`)
@@ -1166,7 +1194,7 @@ device, takes plain commands as before; an unreadable
1166
1194
  forgets the pins, and with them goes back to taking plain commands until a
1167
1195
  device is pinned again.
1168
1196
 
1169
- **A schedule request** — the phone making or removing a scheduled session — rides
1197
+ **A schedule request** — the phone making, changing or removing a scheduled session — rides
1170
1198
  the same `agent.command` push, with a `schedule` field and no `body`. It carries
1171
1199
  a time, a prompt and, if the phone says so, an agent and a run time — and nothing
1172
1200
  else. The directory and the agent come from what this machine recorded for the
@@ -1175,8 +1203,11 @@ schedule at another directory; it cannot send agent arguments, a worktree or a
1175
1203
  label either, and a request that carries one is refused rather than ignored
1176
1204
  (`schedule_invalid`; `schedule_limit` at 20 schedules; `unknown_session` for a
1177
1205
  session this machine never recorded; `schedule_failed` when this machine's own
1178
- files would not read or write). A remove names the schedule's own
1179
- session, so it cannot reach another. Applying a request never types into a
1206
+ files would not read or write). A change (`op: 'update'`) carries the schedule's id
1207
+ and any of the time, the prompt and the run time; what it leaves out stays as it was,
1208
+ and a change with none of them is refused. A schedule made on the computer with agent arguments keeps its prompt: the phone may change its time and run time, not what is typed under those arguments. A change and a remove name the schedule's own
1209
+ session, so they cannot reach another. A listener of an older build refuses a change as
1210
+ `schedule_invalid`. Applying a request never types into a
1180
1211
  session. The request is sealed like any
1181
1212
  command — the whole of it inside the body, only `op` beside it, and the sender,
1182
1213
  label and counter checks above apply, so a replay or a swapped `op` changes
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Where the person's own shell finds their agents (F8). A schedule is started by the listener, and the listener run by
3
+ * launchd or systemd has only the PATH its service file baked: tmux's and node's directories and the system ones. An
4
+ * agent installed anywhere else — `~/.local/bin/claude` — is not on it, and the scheduled session's pane dies at once
5
+ * with "command not found". So every `hwip <agent>` typed in a shell writes down the directory its agent was found in,
6
+ * and a schedule launch adds those directories to its PATH.
7
+ *
8
+ * Builtins only: wrapper.ts writes this on the `hwip cc` hot path.
9
+ */
10
+ export declare const agentDirsPath: () => string;
11
+ /** Writes down the directory of `binary`, an absolute path the shell resolved. Best-effort: a launch never fails on it. */
12
+ export declare const rememberAgentDir: (binary: string) => void;
13
+ /** `path` with every written-down directory it lacks appended: what the listener has first, so nothing it runs changes. */
14
+ export declare const withAgentDirs: (path: string | undefined) => string;
15
+ //# sourceMappingURL=agent-dirs.d.ts.map
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ /**
3
+ * Where the person's own shell finds their agents (F8). A schedule is started by the listener, and the listener run by
4
+ * launchd or systemd has only the PATH its service file baked: tmux's and node's directories and the system ones. An
5
+ * agent installed anywhere else — `~/.local/bin/claude` — is not on it, and the scheduled session's pane dies at once
6
+ * with "command not found". So every `hwip <agent>` typed in a shell writes down the directory its agent was found in,
7
+ * and a schedule launch adds those directories to its PATH.
8
+ *
9
+ * Builtins only: wrapper.ts writes this on the `hwip cc` hot path.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.withAgentDirs = exports.rememberAgentDir = exports.agentDirsPath = void 0;
13
+ const node_fs_1 = require("node:fs");
14
+ const node_path_1 = require("node:path");
15
+ const gate_js_1 = require("./gate.js");
16
+ const private_files_js_1 = require("./private-files.js");
17
+ /** More than anyone has agents in; bounds what a schedule launch puts on PATH. */
18
+ const MAX_DIRS = 16;
19
+ const agentDirsPath = () => (0, node_path_1.join)((0, gate_js_1.stateDir)(), 'agent-dirs.json');
20
+ exports.agentDirsPath = agentDirsPath;
21
+ /**
22
+ * Whether the file is the user's own regular file that only they can write: a directory put on PATH by someone else runs
23
+ * their programs as the agent. 'missing' when there is none yet.
24
+ */
25
+ const fileState = (path) => {
26
+ try {
27
+ const info = (0, node_fs_1.lstatSync)(path);
28
+ const mine = typeof process.getuid !== 'function' || info.uid === process.getuid();
29
+ return info.isFile() && mine && (info.mode & 0o022) === 0 ? 'ok' : 'refused';
30
+ }
31
+ catch {
32
+ return 'missing';
33
+ }
34
+ };
35
+ const readDirs = () => {
36
+ try {
37
+ const path = (0, exports.agentDirsPath)();
38
+ if (fileState(path) !== 'ok')
39
+ return [];
40
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf-8'));
41
+ if (!Array.isArray(parsed))
42
+ return [];
43
+ return parsed.filter((dir) => typeof dir === 'string' && (0, node_path_1.isAbsolute)(dir)).slice(0, MAX_DIRS);
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ };
49
+ /** Writes down the directory of `binary`, an absolute path the shell resolved. Best-effort: a launch never fails on it. */
50
+ const rememberAgentDir = (binary) => {
51
+ if (!(0, node_path_1.isAbsolute)(binary))
52
+ return;
53
+ const dir = (0, node_path_1.dirname)(binary);
54
+ const path = (0, exports.agentDirsPath)();
55
+ // Never written through a link, nor into a file that is not read back: either would be writing for nothing, or elsewhere.
56
+ if (fileState(path) === 'refused')
57
+ return;
58
+ const dirs = readDirs();
59
+ if (dirs.includes(dir))
60
+ return;
61
+ try {
62
+ (0, private_files_js_1.mkdirHandedBack)((0, gate_js_1.stateDir)(), 0o700);
63
+ (0, node_fs_1.writeFileSync)(path, JSON.stringify([...dirs, dir].slice(-MAX_DIRS)), { mode: 0o600 });
64
+ (0, private_files_js_1.handBackToSudoUser)(path);
65
+ }
66
+ catch {
67
+ // The schedule launch then says the agent is not on PATH, which is the truth.
68
+ }
69
+ };
70
+ exports.rememberAgentDir = rememberAgentDir;
71
+ /** `path` with every written-down directory it lacks appended: what the listener has first, so nothing it runs changes. */
72
+ const withAgentDirs = (path) => {
73
+ const have = (path ?? '').split(node_path_1.delimiter).filter(Boolean);
74
+ return [...have, ...readDirs().filter((dir) => !have.includes(dir))].join(node_path_1.delimiter);
75
+ };
76
+ exports.withAgentDirs = withAgentDirs;
package/dist/agents.d.ts CHANGED
@@ -10,6 +10,16 @@
10
10
  * pi 0.85.1): global is `~/.pi/agent/skills/` and `~/.agents/skills/`, while
11
11
  * `.pi/skills/` is the PROJECT dir — scanning `~/.pi/skills` found only three
12
12
  * dangling symlinks and reported pi as having no skills at all.
13
+ *
14
+ * codex reads `$CODEX_HOME/skills` and `~/.agents/skills` (codex-rs
15
+ * `ext/skills/src/host_roots.rs`), and keeps its bundled skills in the hidden
16
+ * `.codex/skills/.system`, which the scanner's dot-skip would otherwise pass
17
+ * over. opencode reads the claude and agents dirs as well as its own
18
+ * (opencode `docs/skills.mdx § Discovery`, 2.0.29). Measured against the agents'
19
+ * own reports on 2026-10-20: codex 59 of `codex app-server` `skills/list` 59;
20
+ * opencode 87 of `opencode serve` `/api/skill` 91 — the rest are two built-ins
21
+ * and two skills it reads out of `~/.claude/skills/.trash/`, which the scanner's
22
+ * dot-skip leaves out on purpose.
13
23
  */
14
24
  export declare const AGENT_SKILL_DIRS: Record<string, string[]>;
15
25
  export interface Agent {
package/dist/agents.js CHANGED
@@ -20,10 +20,21 @@ const path_1 = require("path");
20
20
  * pi 0.85.1): global is `~/.pi/agent/skills/` and `~/.agents/skills/`, while
21
21
  * `.pi/skills/` is the PROJECT dir — scanning `~/.pi/skills` found only three
22
22
  * dangling symlinks and reported pi as having no skills at all.
23
+ *
24
+ * codex reads `$CODEX_HOME/skills` and `~/.agents/skills` (codex-rs
25
+ * `ext/skills/src/host_roots.rs`), and keeps its bundled skills in the hidden
26
+ * `.codex/skills/.system`, which the scanner's dot-skip would otherwise pass
27
+ * over. opencode reads the claude and agents dirs as well as its own
28
+ * (opencode `docs/skills.mdx § Discovery`, 2.0.29). Measured against the agents'
29
+ * own reports on 2026-10-20: codex 59 of `codex app-server` `skills/list` 59;
30
+ * opencode 87 of `opencode serve` `/api/skill` 91 — the rest are two built-ins
31
+ * and two skills it reads out of `~/.claude/skills/.trash/`, which the scanner's
32
+ * dot-skip leaves out on purpose.
23
33
  */
24
34
  exports.AGENT_SKILL_DIRS = {
25
35
  claude: ['.claude/skills'],
26
- codex: ['.codex/skills'],
36
+ codex: ['.codex/skills', '.codex/skills/.system', '.agents/skills'],
37
+ opencode: ['.claude/skills', '.agents/skills', '.config/opencode/skills'],
27
38
  pi: ['.pi/agent/skills', '.agents/skills'],
28
39
  };
29
40
  const HOME = (0, os_1.homedir)();
@@ -1,8 +1,8 @@
1
- /** The table hwip writes into a config that has none. No `env` block, unlike
2
- * the Cursor/Windsurf JSON entry: codex is a terminal app, so the server
3
- * inherits the shell's `HWIP_API_KEY`, and an unexpanded `"${HWIP_API_KEY}"`
4
- * literal would shadow the ~/.hwip/config.json fallback with junk (the same
5
- * reasoning as opencode's). */
1
+ /** The table hwip writes into a config that has none. Its `env` holds only the
2
+ * auto-name flag, unlike the Cursor/Windsurf JSON entry: codex is a terminal
3
+ * app, so the server inherits the shell's `HWIP_API_KEY`, and an unexpanded
4
+ * `"${HWIP_API_KEY}"` literal would shadow the ~/.hwip/config.json fallback
5
+ * with junk (the same reasoning as opencode's). */
6
6
  export declare const CODEX_MCP_TABLE: string;
7
7
  /**
8
8
  * The config with hwip's entry present and current, leaving every other byte
@@ -10,6 +10,9 @@ export declare const CODEX_MCP_TABLE: string;
10
10
  * it, so nothing above can capture the keys.
11
11
  */
12
12
  export declare const upsertHwipMcpTable: (config: string) => string;
13
+ /** Whether hwip's entry turns auto names on — what verify checks to tell an
14
+ * install from before the flag existed. */
15
+ export declare const hasHwipAutoName: (config: string) => boolean;
13
16
  /** The config with hwip's entry and its sub-tables gone, or null when it was
14
17
  * never there (so the caller can report "nothing to remove" rather than
15
18
  * rewriting an untouched file). */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.readHwipMcpArgv = exports.removeHwipMcpTable = exports.upsertHwipMcpTable = exports.CODEX_MCP_TABLE = void 0;
3
+ exports.readHwipMcpArgv = exports.removeHwipMcpTable = exports.hasHwipAutoName = exports.upsertHwipMcpTable = exports.CODEX_MCP_TABLE = void 0;
4
4
  /**
5
5
  * The hwip entry inside `~/.codex/config.toml`, which Codex CLI owns.
6
6
  *
@@ -18,8 +18,10 @@ exports.readHwipMcpArgv = exports.removeHwipMcpTable = exports.upsertHwipMcpTabl
18
18
  * `[mcp_servers.hwip.tools.hwip_ask]` when the user picks "always allow".
19
19
  * Removal takes them along (a child of a deleted server is dead config);
20
20
  * re-install keeps them, since they are the user's answer, not ours.
21
- * - **Only `command` and `args` are ours.** Any other key in the table —
22
- * `cwd`, `startup_timeout_sec`, a comment — survives a re-install.
21
+ * - **Only `command`, `args` and the `HWIP_AUTONAME` env pair are ours.** Any
22
+ * other key in the table — `cwd`, `startup_timeout_sec`, a comment, the
23
+ * user's own env pairs — survives a re-install; the flag joins whichever
24
+ * env the user already keeps.
23
25
  * - **Comments before the next table belong to that table**, not to ours, so
24
26
  * the region we replace or delete stops short of them.
25
27
  *
@@ -31,6 +33,8 @@ const [MCP_BIN, ...MCP_SUBCOMMAND] = mcp_command_js_1.MCP_LAUNCH_ARGV;
31
33
  const HWIP_TABLE_HEADER = /^\[mcp_servers\.(?:hwip|"hwip")\]$/;
32
34
  /** A sub-table of ours: `[mcp_servers.hwip.tools.hwip_ask]` and friends. */
33
35
  const HWIP_SUBTABLE_HEADER = /^\[mcp_servers\.(?:hwip|"hwip")\..+\]$/;
36
+ /** The user's own env table for hwip — where the flag goes when they keep one. */
37
+ const HWIP_ENV_SUBTABLE_HEADER = /^\[mcp_servers\.(?:hwip|"hwip")\.(?:env|"env")\]$/;
34
38
  /**
35
39
  * The dotted/inline form — `mcp_servers.hwip = { command = "npx", … }` — which
36
40
  * is valid TOML and which older hand-written configs use. It has to be found,
@@ -44,12 +48,87 @@ const isHwipDefinition = (line) => HWIP_TABLE_HEADER.test(line.trim()) || HWIP_I
44
48
  const OWNED_KEY = /^(?:command|args)\s*=/;
45
49
  const commandLine = `command = "${MCP_BIN}"`;
46
50
  const argsLine = `args = [${MCP_SUBCOMMAND.map((a) => `"${a}"`).join(', ')}]`;
47
- /** The table hwip writes into a config that has none. No `env` block, unlike
48
- * the Cursor/Windsurf JSON entry: codex is a terminal app, so the server
49
- * inherits the shell's `HWIP_API_KEY`, and an unexpanded `"${HWIP_API_KEY}"`
50
- * literal would shadow the ~/.hwip/config.json fallback with junk (the same
51
- * reasoning as opencode's). */
52
- exports.CODEX_MCP_TABLE = ['[mcp_servers.hwip]', commandLine, argsLine].join('\n');
51
+ const flagPair = `${mcp_command_js_1.AUTONAME_ENV} = "1"`;
52
+ const envLine = `env = { ${flagPair} }`;
53
+ /** `env = { … }` in hwip's table — known by its key, so a comment after it cannot hide it. */
54
+ const INLINE_ENV = /^env\s*=\s*\{/;
55
+ /** `env.KEY = …`, the dotted spelling of the same table. */
56
+ const DOTTED_ENV = /^env\./;
57
+ /** The flag's key, bare or quoted: TOML reads all three as one key. */
58
+ const FLAG = `(?:${mcp_command_js_1.AUTONAME_ENV}|"${mcp_command_js_1.AUTONAME_ENV}"|'${mcp_command_js_1.AUTONAME_ENV}')`;
59
+ const FLAG_KEY = new RegExp(`^${FLAG}\\s*=`);
60
+ const DOTTED_FLAG_KEY = new RegExp(`^env\\.${FLAG}\\s*=`);
61
+ const FLAG_ON = new RegExp(`(?:^|[\\s{,.])${FLAG}\\s*=\\s*["']1["']`);
62
+ /** The table hwip writes into a config that has none. Its `env` holds only the
63
+ * auto-name flag, unlike the Cursor/Windsurf JSON entry: codex is a terminal
64
+ * app, so the server inherits the shell's `HWIP_API_KEY`, and an unexpanded
65
+ * `"${HWIP_API_KEY}"` literal would shadow the ~/.hwip/config.json fallback
66
+ * with junk (the same reasoning as opencode's). */
67
+ exports.CODEX_MCP_TABLE = ['[mcp_servers.hwip]', commandLine, argsLine, envLine].join('\n');
68
+ /**
69
+ * Walks a line of TOML outside its strings: `at(i, depth)` sees every character that is not
70
+ * inside one, with the brace depth before it, and stops the walk by returning true.
71
+ */
72
+ const walkOutsideStrings = (text, from, at) => {
73
+ let depth = 0;
74
+ let quote = null;
75
+ for (let i = from; i < text.length; i += 1) {
76
+ const ch = text[i];
77
+ if (quote) {
78
+ if (ch === '\\' && quote === '"')
79
+ i += 1;
80
+ else if (ch === quote)
81
+ quote = null;
82
+ continue;
83
+ }
84
+ if (ch === '"' || ch === "'") {
85
+ quote = ch;
86
+ continue;
87
+ }
88
+ if (at(i, depth))
89
+ return;
90
+ if (ch === '{' || ch === '[')
91
+ depth += 1;
92
+ else if (ch === '}' || ch === ']')
93
+ depth -= 1;
94
+ }
95
+ };
96
+ /** The inline table's inside and what follows its closing brace (a comment), or null when it does
97
+ * not close on this line. */
98
+ const inlineParts = (line) => {
99
+ const open = line.indexOf('{');
100
+ let parts = null;
101
+ walkOutsideStrings(line, open, (i, depth) => {
102
+ if (line[i] !== '}' || depth !== 1)
103
+ return false;
104
+ parts = { inner: line.slice(open + 1, i), tail: line.slice(i + 1) };
105
+ return true;
106
+ });
107
+ return parts;
108
+ };
109
+ /** The pairs of an inline table, split at its own commas — never one inside a string or a nested value. */
110
+ const inlinePairs = (inner) => {
111
+ const pairs = [];
112
+ let start = 0;
113
+ walkOutsideStrings(inner, 0, (i, depth) => {
114
+ if (inner[i] === ',' && depth === 0) {
115
+ pairs.push(inner.slice(start, i));
116
+ start = i + 1;
117
+ }
118
+ return false;
119
+ });
120
+ pairs.push(inner.slice(start));
121
+ return pairs.map((pair) => pair.trim()).filter(Boolean);
122
+ };
123
+ /** An inline env table with the flag on and first, the user's pairs and comment kept. One that
124
+ * cannot be read is left alone: writing a second `env` beside it would break the whole file. */
125
+ const withFlag = (line) => {
126
+ const parts = inlineParts(line);
127
+ if (!parts)
128
+ return line;
129
+ const rest = inlinePairs(parts.inner).filter((pair) => !FLAG_KEY.test(pair));
130
+ return `env = { ${[flagPair, ...rest].join(', ')} }${parts.tail}`;
131
+ };
53
132
  /** CRLF in, CRLF out — a Windows config must not come back with mixed endings. */
54
133
  const eolOf = (config) => (config.includes('\r\n') ? '\r\n' : '\n');
55
134
  const splitLines = (config) => config.split(/\r?\n/);
@@ -109,18 +188,48 @@ const upsertHwipMcpTable = (config) => {
109
188
  const table = exports.CODEX_MCP_TABLE.split('\n');
110
189
  return rejoin([...lines.slice(0, start), ...table, ...lines.slice(start + 1)], eol);
111
190
  }
191
+ // A key defined twice makes codex refuse the whole file, so the flag goes
192
+ // into whichever env the user already keeps, and gets one only if none.
193
+ const envTable = lines.slice(bodyEnd(lines, start), entryEnd(lines, start))
194
+ .findIndex((line) => HWIP_ENV_SUBTABLE_HEADER.test(line.trim()));
195
+ const after = [...lines];
196
+ if (envTable !== -1) {
197
+ const header = bodyEnd(lines, start) + envTable;
198
+ const tableEnd = bodyEnd(lines, header);
199
+ const rows = lines.slice(header + 1, tableEnd).filter((line) => !FLAG_KEY.test(line.trim()));
200
+ after.splice(header, tableEnd - header, lines[header], flagPair, ...rows);
201
+ }
112
202
  const end = withoutTrailingPreamble(lines, start, bodyEnd(lines, start));
113
- const kept = lines.slice(start + 1, end).filter((line) => !OWNED_KEY.test(line.trim()));
203
+ const kept = lines.slice(start + 1, end)
204
+ .filter((line) => !OWNED_KEY.test(line.trim()) && !DOTTED_FLAG_KEY.test(line.trim()));
205
+ const inline = kept.findIndex((line) => INLINE_ENV.test(line.trim()));
206
+ if (inline !== -1)
207
+ kept[inline] = withFlag(kept[inline].trim());
208
+ const ours = envTable !== -1 || inline !== -1 ? []
209
+ : kept.some((line) => DOTTED_ENV.test(line.trim())) ? [`env.${flagPair}`]
210
+ : [envLine];
114
211
  return rejoin([
115
- ...lines.slice(0, start),
212
+ ...after.slice(0, start),
116
213
  '[mcp_servers.hwip]',
117
214
  commandLine,
118
215
  argsLine,
216
+ ...ours,
119
217
  ...kept,
120
- ...lines.slice(end),
218
+ ...after.slice(end),
121
219
  ], eol);
122
220
  };
123
221
  exports.upsertHwipMcpTable = upsertHwipMcpTable;
222
+ /** Whether hwip's entry turns auto names on — what verify checks to tell an
223
+ * install from before the flag existed. */
224
+ const hasHwipAutoName = (config) => {
225
+ const lines = splitLines(config);
226
+ const start = definitionIndex(lines);
227
+ if (start === -1)
228
+ return false;
229
+ const end = HWIP_INLINE_ENTRY.test(lines[start].trim()) ? start + 1 : entryEnd(lines, start);
230
+ return lines.slice(start, end).some((line) => !line.trim().startsWith('#') && FLAG_ON.test(line.trim()));
231
+ };
232
+ exports.hasHwipAutoName = hasHwipAutoName;
124
233
  /** The config with hwip's entry and its sub-tables gone, or null when it was
125
234
  * never there (so the caller can report "nothing to remove" rather than
126
235
  * rewriting an untouched file). */
@@ -4,8 +4,9 @@
4
4
  * Payload discipline (plan PLAN-13-16-18): paths never leave this machine, so
5
5
  * the output carries skill names and nothing else.
6
6
  *
7
- * An entry is the slash command itself, so it carries whatever prefix the agent
8
- * registers the skill under (`AGENT_SKILL_PREFIX`, plugin names for Claude Code).
7
+ * An entry is the invocation token itself, so it carries whatever prefix the
8
+ * agent registers the skill under (`AGENT_SKILL_PREFIX`, plugin names for Claude
9
+ * Code) — including the `$`/`@` mention sigil of agents that take no slash.
9
10
  *
10
11
  * Names come from the directory, not from the file — an ASSUMPTION, not a rule
11
12
  * the agents enforce: pi resolves a skill's name as `frontmatter.name` and only
@@ -23,7 +24,10 @@
23
24
  * existence until a divergent skill is actually measured.
24
25
  */
25
26
  export interface AgentCommandEntry {
26
- /** What the user types after `/` — see `AGENT_SKILL_PREFIX`. */
27
+ /**
28
+ * What the user types to run the skill: after a `/`, unless it starts with a
29
+ * mention sigil (`$`, `@`), which is typed as is — see `AGENT_SKILL_PREFIX`.
30
+ */
27
31
  name: string;
28
32
  }
29
33
  /** agentKind -> skill entries; agents with no skills are omitted. */
@@ -13,12 +13,24 @@ const agents_js_1 = require("./agents.js");
13
13
  * pi session was observed to abort rather than run the skill (2026-09-13).
14
14
  * Claude Code's user skills are `/<name>`; its PLUGIN skills are
15
15
  * `/<plugin>:<name>`, so those get their prefix from the install entry instead.
16
+ *
17
+ * codex and opencode do not run skills as slash commands at all. codex takes a
18
+ * `$name` mention (codex-rs `skills/src/selection.rs`; its `/skills` only opens a
19
+ * picker), and opencode 2.0 an `@name` mention (`docs/skills.mdx § Loading`; its
20
+ * `/api/command` lists no skills). Their entries carry that sigil, and a client
21
+ * inserts a name starting with `$` or `@` as typed, without the `/`.
16
22
  */
17
23
  const AGENT_SKILL_PREFIX = {
24
+ codex: '$',
25
+ opencode: '@',
18
26
  pi: 'skill:',
19
27
  };
20
- /** Agents that register skills filed under grouping folders — see `scanSkillDir`. */
21
- const AGENT_SKILL_RECURSIVE = new Set(['pi']);
28
+ /**
29
+ * Agents that register skills filed under grouping folders — see `scanSkillDir`.
30
+ * opencode walks them too: it lists `~/.claude/skills/synced/<id>/<name>`, which
31
+ * Claude Code itself does not register (`/api/skill`, 2026-10-20).
32
+ */
33
+ const AGENT_SKILL_RECURSIVE = new Set(['opencode', 'pi']);
22
34
  /** Serialized-catalog budget. Names-only measures ~2KB here; ws frames die at 128KB. */
23
35
  exports.PAYLOAD_LIMIT_BYTES = 64 * 1024;
24
36
  /**
@@ -88,28 +100,60 @@ const scanSkillDir = (dir, recursive = false, depth = 1) => {
88
100
  }
89
101
  return names;
90
102
  };
91
- /**
92
- * Plugin skills live at `<installPath>/skills/<name>/SKILL.md`. Only install paths
93
- * listed in `installed_plugins.json` are scanned — the plugin cache also holds
94
- * uninstalled marketplace entries (514 of 710 on this machine) that must not
95
- * appear.
96
- */
97
- const scanClaudePlugins = (home) => {
103
+ /** A plain JSON object, as opposed to an array, a scalar or null. */
104
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
105
+ /** The object under `key` in a JSON file, or an empty one when the file is missing, not JSON, or holds another shape there. */
106
+ const readJsonObject = (file, key) => {
98
107
  let parsed;
99
108
  try {
100
- parsed = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf-8'));
109
+ parsed = JSON.parse((0, fs_1.readFileSync)(file, 'utf-8'));
101
110
  }
102
111
  catch {
103
- return [];
112
+ return {};
104
113
  }
114
+ const value = isObject(parsed) ? parsed[key] : undefined;
115
+ return isObject(value) ? value : {};
116
+ };
117
+ /**
118
+ * Plugin keys Claude Code loads: `enabledPlugins[key] === true` in the user's
119
+ * `~/.claude/settings.json`. Installed is not enabled — a key set to `false`, or
120
+ * absent, registers nothing (`claude -p` init `slash_commands`, 2026-10-20: 71
121
+ * skills of two disabled plugins and one unlisted plugin were missing). A
122
+ * project's own settings can enable more, but the catalog is device-level.
123
+ */
124
+ const enabledClaudePlugins = (home) => {
125
+ const enabled = readJsonObject((0, path_1.join)(home, '.claude', 'settings.json'), 'enabledPlugins');
126
+ return new Set(Object.entries(enabled).filter(([, on]) => on === true).map(([key]) => key));
127
+ };
128
+ /** Command files of a plugin: `<installPath>/commands/<name>.md`. */
129
+ const scanCommandFiles = (dir) => entriesOf(dir)
130
+ .filter((entry) => entry.endsWith('.md') && !entry.startsWith('.'))
131
+ .map((entry) => entry.slice(0, -'.md'.length));
132
+ /**
133
+ * Plugin commands are `<installPath>/skills/<name>/SKILL.md` and
134
+ * `<installPath>/commands/<name>.md`, both registered as `/<plugin>:<name>`. Only
135
+ * install paths listed in `installed_plugins.json` are scanned — the plugin cache
136
+ * also holds uninstalled marketplace entries (514 of 710 on this machine) that
137
+ * must not appear — and of those only the enabled ones.
138
+ */
139
+ const scanClaudePlugins = (home) => {
140
+ const installed = readJsonObject((0, path_1.join)(home, '.claude', 'plugins', 'installed_plugins.json'), 'plugins');
141
+ const enabled = enabledClaudePlugins(home);
105
142
  const names = [];
106
- for (const [key, installs] of Object.entries(parsed.plugins ?? {})) {
143
+ for (const [key, installs] of Object.entries(installed)) {
144
+ if (!enabled.has(key) || !Array.isArray(installs))
145
+ continue;
107
146
  // Key is `<plugin>@<marketplace>`; the command prefix is the plugin half.
108
147
  const plugin = key.split('@')[0];
109
- for (const install of installs ?? []) {
110
- if (!install.installPath)
148
+ for (const install of installs) {
149
+ const installPath = isObject(install) ? install.installPath : undefined;
150
+ if (typeof installPath !== 'string' || !installPath)
111
151
  continue;
112
- names.push(...scanSkillDir((0, path_1.join)(install.installPath, 'skills')).map((name) => `${plugin}:${name}`));
152
+ const commands = [
153
+ ...scanSkillDir((0, path_1.join)(installPath, 'skills')),
154
+ ...scanCommandFiles((0, path_1.join)(installPath, 'commands')),
155
+ ];
156
+ names.push(...commands.map((name) => `${plugin}:${name}`));
113
157
  }
114
158
  }
115
159
  return names;
package/dist/installer.js CHANGED
@@ -334,7 +334,13 @@ const installGemini = () => {
334
334
  ok('MCP server added (legacy gemini CLI)');
335
335
  }
336
336
  catch {
337
- fail(`MCP add failed. Manual: ${mcp_command_js_1.GEMINI_MCP_ADD}`);
337
+ try {
338
+ (0, child_process_1.execSync)(mcp_command_js_1.GEMINI_MCP_ADD_BARE, { stdio: 'pipe' });
339
+ ok('MCP server added (legacy gemini CLI, without auto session names)');
340
+ }
341
+ catch {
342
+ fail(`MCP add failed. Manual: ${mcp_command_js_1.GEMINI_MCP_ADD}`);
343
+ }
338
344
  }
339
345
  }
340
346
  try {