@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 +49 -18
- package/dist/agent-dirs.d.ts +15 -0
- package/dist/agent-dirs.js +76 -0
- package/dist/agents.d.ts +10 -0
- package/dist/agents.js +12 -1
- package/dist/codex-config.d.ts +8 -5
- package/dist/codex-config.js +121 -12
- package/dist/command-scan.d.ts +7 -3
- package/dist/command-scan.js +59 -15
- package/dist/installer.js +7 -1
- package/dist/listener.d.ts +33 -0
- package/dist/listener.js +72 -23
- package/dist/mcp-command.d.ts +14 -2
- package/dist/mcp-command.js +18 -5
- package/dist/pane-fit.d.ts +39 -11
- package/dist/pane-fit.js +162 -29
- package/dist/remote-agents.d.ts +64 -12
- package/dist/remote-agents.js +141 -18
- package/dist/schedule-command.d.ts +11 -1
- package/dist/schedule-command.js +113 -4
- package/dist/schedule-launch.d.ts +3 -1
- package/dist/schedule-launch.js +4 -1
- package/dist/schedule-remote.d.ts +7 -1
- package/dist/schedule-remote.js +51 -4
- package/dist/schedule-runner.js +5 -2
- package/dist/schedule.d.ts +31 -0
- package/dist/schedule.js +45 -7
- package/dist/session-registry.d.ts +12 -0
- package/dist/session-registry.js +23 -3
- package/dist/session-title.d.ts +46 -0
- package/dist/session-title.js +103 -0
- package/dist/templates.d.ts +1 -0
- package/dist/templates.js +41 -1
- package/dist/usage.js +4 -0
- package/dist/verify.d.ts +3 -0
- package/dist/verify.js +33 -8
- package/dist/wrapper.js +10 -0
- package/package.json +1 -1
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
|
|
436
|
-
|
|
437
|
-
right-aligned clocks fold in two on a phone.
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
phone's width and
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
a
|
|
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
|
-
- **
|
|
627
|
-
- **
|
|
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.
|
|
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
|
|
1179
|
-
|
|
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)();
|
package/dist/codex-config.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/** The table hwip writes into a config that has none.
|
|
2
|
-
* the Cursor/Windsurf JSON entry: codex is a terminal
|
|
3
|
-
* inherits the shell's `HWIP_API_KEY`, and an unexpanded
|
|
4
|
-
* literal would shadow the ~/.hwip/config.json fallback
|
|
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). */
|
package/dist/codex-config.js
CHANGED
|
@@ -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 `
|
|
22
|
-
* `cwd`, `startup_timeout_sec`, a comment
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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)
|
|
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
|
-
...
|
|
212
|
+
...after.slice(0, start),
|
|
116
213
|
'[mcp_servers.hwip]',
|
|
117
214
|
commandLine,
|
|
118
215
|
argsLine,
|
|
216
|
+
...ours,
|
|
119
217
|
...kept,
|
|
120
|
-
...
|
|
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). */
|
package/dist/command-scan.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
* registers the skill under (`AGENT_SKILL_PREFIX`, plugin names for Claude
|
|
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
|
-
/**
|
|
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. */
|
package/dist/command-scan.js
CHANGED
|
@@ -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
|
-
/**
|
|
21
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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)(
|
|
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(
|
|
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
|
-
|
|
148
|
+
for (const install of installs) {
|
|
149
|
+
const installPath = isObject(install) ? install.installPath : undefined;
|
|
150
|
+
if (typeof installPath !== 'string' || !installPath)
|
|
111
151
|
continue;
|
|
112
|
-
|
|
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
|
-
|
|
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 {
|