@zeph-to/hook-sdk 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,18 @@
1
1
  # @zeph-to/hook-sdk
2
2
 
3
- Push notification SDK + CLI for [Zeph](https://zeph.to). Zero dependencies — uses native `fetch`.
3
+ Push notification SDK + CLI for [Zeph](https://zeph.to), with an optional
4
+ resident listener that **drives Claude Code / Codex / Gemini sessions
5
+ from your phone** by injecting messages into named tmux sessions.
6
+
7
+ - `ZephHook` SDK — native `fetch`, no runtime deps. Send/list/dismiss pushes.
8
+ - `zeph` CLI — install Zeph plugins, send pushes, run agents under tmux,
9
+ and listen for inbound messages from your phone.
4
10
 
5
11
  ## Installation
6
12
 
7
13
  ```bash
8
- npm install @zeph-to/hook-sdk
9
- # or
14
+ npm install -g @zeph-to/hook-sdk
15
+ # or for one-off use
10
16
  npx @zeph-to/hook-sdk notify --title "Hello"
11
17
  ```
12
18
 
@@ -20,7 +26,214 @@ npx @zeph-to/hook-sdk install
20
26
  npx @zeph-to/hook-sdk install --key ak_... --hook hook_...
21
27
  ```
22
28
 
23
- Saves to `~/.zeph/config.json`. All Zeph tools (CLI, MCP server, plugin hooks) read this file.
29
+ Saves to `~/.zeph/config.json`. All Zeph tools (CLI, MCP server, plugin
30
+ hooks, listener) read this file.
31
+
32
+ To **send** notifications:
33
+
34
+ ```bash
35
+ zeph notify --title "Deploy done" --body "v2.1.0 shipped"
36
+ ```
37
+
38
+ To **drive a Claude Code / Codex / Gemini session from your phone**, see
39
+ [Remote Control](#remote-control) below.
40
+
41
+ ## Remote Control
42
+
43
+ > Send messages from your phone *into* a live Claude Code / Codex /
44
+ > Gemini session — even after a `zeph_ask` polling window has expired.
45
+
46
+ The MCP tools `zeph_ask` / `zeph_prompt` / `zeph_input` open a polling
47
+ loop on a fixed timeout (120–600 s). Once that window closes the
48
+ session becomes unaddressable from the phone, even though it's still
49
+ running. The `zeph listener` daemon fixes this by keeping a persistent
50
+ WebSocket open to Zeph and injecting matching messages into a *named*
51
+ tmux session via `tmux send-keys`.
52
+
53
+ ### Architecture
54
+
55
+ ```
56
+ [phone — "Active Agents" picker on Zeph app]
57
+ │ selects session, types message
58
+ ▼ POST /pushes/send { type: 'agent.command',
59
+ │ agentSessionName: 'zeph-myapp',
60
+ │ body: '리팩토링 마무리해줘' }
61
+ [Zeph backend]
62
+ │ WebSocket fan-out (push.new)
63
+
64
+ [zeph listener — resident daemon, started by `zeph cc` automatically]
65
+ │ tmux send-keys -l -t zeph-myapp "리팩토링 마무리해줘" + Enter
66
+
67
+ [tmux session "zeph-myapp" running claude / codex / gemini]
68
+ ```
69
+
70
+ The listener also reports its tmux session inventory back to the server
71
+ every 5 seconds, so the phone picker stays in sync — no manual
72
+ configuration needed once a session is running.
73
+
74
+ ### Setup
75
+
76
+ 1. **Install tmux.** The listener uses `send-keys`; the wrapper spawns
77
+ named sessions. `brew install tmux` on macOS, `apt install tmux` on
78
+ Debian/Ubuntu.
79
+
80
+ 2. **Add `wsUrl` to `~/.zeph/config.json`** (the WebSocket endpoint of
81
+ your Zeph backend — CDK output `WsApiUrl`):
82
+
83
+ ```json
84
+ {
85
+ "apiKey": "ak_...",
86
+ "hookId": "hook_...",
87
+ "wsUrl": "wss://<api-id>.execute-api.<region>.amazonaws.com/<stage>"
88
+ }
89
+ ```
90
+
91
+ Alternatively set `ZEPH_WS_URL` in your shell env.
92
+
93
+ 3. **Run agents through the wrapper.** That's it.
94
+
95
+ ```bash
96
+ zeph cc # claude → tmux session "zeph-<project>"
97
+ zeph codex # codex → tmux session "zeph-<project>"
98
+ zeph gemini # gemini → tmux session "zeph-<project>"
99
+ ```
100
+
101
+ The first `zeph cc` on a machine **auto-spawns a background
102
+ listener** (singleton, PID file at `~/.zeph/listener.pid`,
103
+ stdout/stderr at `~/.zeph/listener.log`). You never run
104
+ `zeph listener` by hand — every `zeph cc` checks the PID file and
105
+ skips the spawn when one is already alive, so opening a dozen
106
+ terminals doesn't create a dozen daemons. The daemon survives
107
+ between `zeph cc` invocations.
108
+
109
+ Project name resolves from `CLAUDE_PROJECT_DIR` /
110
+ `CURSOR_PROJECT_DIR` / `WINDSURF_PROJECT_DIR` if set, else the git
111
+ repo root, else the cwd basename. Any extra args after the command
112
+ pass through to the agent verbatim:
113
+
114
+ ```bash
115
+ zeph cc --resume "abc123"
116
+ zeph cc --dangerously-skip-permissions
117
+ zeph codex --model gpt-5-high "fix the failing test"
118
+ ```
119
+
120
+ **Multiple sessions in one project.** Open another terminal in the
121
+ same folder, run `zeph cc` again, and the wrapper auto-suffixes:
122
+ first session is `zeph-encl`, the next attached one becomes
123
+ `zeph-encl-2`, then `zeph-encl-3`, etc. The phone picker shows them
124
+ as `encl · Claude`, `encl · Claude #2`, `encl · Claude #3`. If
125
+ `zeph-encl` already exists but is **detached** (no one attached),
126
+ the wrapper reattaches to it instead of spawning a new one — close
127
+ the terminal, come back later, pick up where you left off.
128
+
129
+ If you're already inside a tmux session (`$TMUX` set) the wrapper
130
+ skips the outer tmux and runs the agent in the current pane — the
131
+ listener can't target an unnamed session that way, but you keep your
132
+ existing multiplexer setup.
133
+
134
+ ### Diagnostics
135
+
136
+ The auto-spawned listener writes to two files under `~/.zeph/`:
137
+
138
+ - `listener.pid` — the running daemon's PID. `cat ~/.zeph/listener.pid`
139
+ + `ps -p <pid>` to confirm it's alive.
140
+ - `listener.log` — stdout + stderr from the daemon. `tail -f` to watch.
141
+
142
+ A healthy listener log shows one line per cycle:
143
+
144
+ ```
145
+ [xx:xx:xx] reported 2 session(s): zeph-myapp, zeph-otherapp
146
+ [xx:xx:xx] ✓ server persisted 2 session(s)
147
+ ```
148
+
149
+ If you see `! server rejected listener.sessions: ...` instead, the
150
+ message points at the failure (auth, missing device record, etc.) so
151
+ you can fix the actual problem instead of guessing.
152
+
153
+ To force a restart — e.g. after upgrading `@zeph-to/hook-sdk`:
154
+
155
+ ```bash
156
+ kill $(cat ~/.zeph/listener.pid)
157
+ rm ~/.zeph/listener.pid
158
+ zeph cc # autospawns the new build
159
+ ```
160
+
161
+ To run it in the foreground (for development of the SDK itself):
162
+
163
+ ```bash
164
+ zeph listener
165
+ ```
166
+
167
+ You'll get the same logs you'd otherwise tail from `listener.log`.
168
+
169
+ ### Custom tmux sockets
170
+
171
+ The listener auto-discovers the tmux socket — it probes the default
172
+ location, walks per-user `$TMPDIR` paths (macOS `/var/folders/.../T/`),
173
+ falls back to `/tmp/tmux-<uid>/`, and finally finds running tmux servers
174
+ via `lsof` so stale socket files don't trip discovery. If your tmux
175
+ uses `tmux -L <name>` or a non-standard `-S <path>`, set the override
176
+ explicitly:
177
+
178
+ ```bash
179
+ export ZEPH_TMUX_SOCKET=/path/to/socket
180
+ ```
181
+
182
+ (The wrapper passes the env to the auto-spawned listener, so setting
183
+ it in your shell rc is enough.)
184
+
185
+ ### Wire format
186
+
187
+ The listener only acts on pushes with `type='agent.command'` carrying
188
+ the tmux session name in `agentSessionName` and the message in `body`.
189
+ Other pushes (Stop-hook auto-pushes, `zeph_ask` responses, channel
190
+ broadcasts, plain notes) are ignored. End-to-end:
191
+
192
+ ```
193
+ tmux send-keys -l -t <agentSessionName> "<body>"
194
+ tmux send-keys -t <agentSessionName> Enter
195
+ ```
196
+
197
+ If you need to send one from the command line (debugging, scripting),
198
+ build the structured push directly:
199
+
200
+ ```bash
201
+ curl -X POST "$ZEPH_BASE_URL/pushes/send" \
202
+ -H "X-API-Key: $ZEPH_API_KEY" \
203
+ -H 'Content-Type: application/json' \
204
+ -d '{
205
+ "type": "agent.command",
206
+ "targetDeviceId": "dev_listener_<sha8(hostname)>",
207
+ "agentSessionName": "zeph-myapp",
208
+ "body": "테스트 통과시키고 PR 올려줘"
209
+ }'
210
+ ```
211
+
212
+ ### Defense
213
+
214
+ The listener is a remote-code-execution surface by design (it types
215
+ into a shell-adjacent pane). The defense is layered:
216
+
217
+ 1. **Pane guard** — before injecting, the listener checks
218
+ `tmux display-message -p '#{pane_current_command}'`. If the pane is
219
+ at an interactive shell (`bash`/`zsh`/`fish`/`sh`/`dash`/`ksh`/
220
+ `tcsh`/`csh`/`pwsh`), the inject is refused. CC/Codex/Gemini exited
221
+ ≠ phone gets free shell access.
222
+ 2. **Literal injection** — `tmux send-keys -l` takes the payload as
223
+ data; tmux escape sequences inside a message can't drive other tmux
224
+ commands.
225
+ 3. **Session-name allowlist** — only `[A-Za-z0-9._-]+` is accepted as
226
+ a session target, so shell metacharacters never reach the tmux argv.
227
+ 4. **Per-session rate limit** — 30 injections/minute/session token
228
+ bucket caps a runaway/compromised sender.
229
+ 5. **Agent permission gate stays on** — your CC/Codex/Gemini permission
230
+ prompt is still in front of every destructive tool call. The phone
231
+ can *talk* but can't approve `rm -rf` for you.
232
+
233
+ The transport (WS) is currently authenticated by API key + `push:read`
234
+ scope and is **not** end-to-end encrypted in v1 — your Zeph backend
235
+ sees the message plaintext. If you self-host or trust your backend,
236
+ that's fine. If you don't, hold off until per-device E2E ships.
24
237
 
25
238
  ## CLI Usage
26
239
 
@@ -42,6 +255,15 @@ zeph dismiss --all
42
255
  # Test connection
43
256
  zeph test
44
257
 
258
+ # Run an agent in a named tmux session (so the listener can reach it)
259
+ zeph cc # claude
260
+ zeph codex # codex
261
+ zeph gemini # gemini
262
+
263
+ # Run the resident listener (foreground; background it as you like)
264
+ zeph listener
265
+ zeph listener --ws-url wss://... # override config
266
+
45
267
  # JSON output
46
268
  zeph notify --title "Hello" --json
47
269
  ```
@@ -58,6 +280,8 @@ zeph notify --title "Hello" --json
58
280
  | `list` | List recent push notifications |
59
281
  | `dismiss <id>` | Dismiss a push (or `--all`) |
60
282
  | `test` | Verify connection and API key |
283
+ | `cc` · `codex` · `gemini` | Run the agent in a `zeph-<project>` tmux session (auto-suffixed `-2`, `-3`, … on attached collisions). Auto-spawns the background listener on first invocation so the phone picker just works. Trailing args pass through to the agent (`zeph cc --resume "..."`) |
284
+ | `listener` | (Usually unnecessary — `zeph cc` autospawns it.) Resident daemon: subscribes via WebSocket, reports tmux session inventory every 5 s, injects `agent.command` pushes into the matching session. Run in the foreground for SDK development; otherwise let `zeph cc` manage it |
61
285
 
62
286
  ### Notify Options
63
287
 
@@ -70,7 +294,22 @@ zeph notify --title "Hello" --json
70
294
  | `--priority <p>` | Priority: `low`, `normal`, `high`, `urgent` |
71
295
  | `--device <id>` | Target device ID |
72
296
 
73
- The defaults are tuned for hook-driven invocations (e.g. Stop hooks calling `zeph notify --title "Task done"` without a body) — you'll see which project + branch finished without writing per-IDE wrappers. Pass `--body ""` explicitly to suppress.
297
+ The defaults are tuned for hook-driven invocations (e.g. Stop hooks
298
+ calling `zeph notify --title "Task done"` without a body) — you'll see
299
+ which project + branch finished without writing per-IDE wrappers. Pass
300
+ `--body ""` explicitly to suppress.
301
+
302
+ ### Listener Options
303
+
304
+ | Flag | Description |
305
+ |------|-------------|
306
+ | `--ws-url <url>` | WebSocket endpoint (or set `ZEPH_WS_URL` env, or `wsUrl` in `~/.zeph/config.json`) |
307
+ | `--key <api-key>` | API key (or set `ZEPH_API_KEY` env) |
308
+
309
+ The listener reconnects with exponential backoff + jitter (1 s → 30 s
310
+ cap). Heartbeat is ping every 25 s with a 10 s pong timeout. On an
311
+ authentication failure close (4001/4002/4003) the listener exits with
312
+ code 3 instead of looping forever — fix the key and restart.
74
313
 
75
314
  ### List Options
76
315
 
@@ -90,9 +329,11 @@ The defaults are tuned for hook-driven invocations (e.g. Stop hooks calling `zep
90
329
 
91
330
  ### Mute
92
331
 
93
- Mute is project-scoped (uses project directory hash). Created by Claude Code `/zeph-mute` command.
332
+ Mute is project-scoped (uses project directory hash). Created by Claude
333
+ Code `/zeph-mute` command.
94
334
 
95
- Notifications are silently skipped when a mute file exists for the current project:
335
+ Notifications are silently skipped when a mute file exists for the
336
+ current project:
96
337
 
97
338
  ```bash
98
339
  # Mute (created by /zeph-mute in Claude Code plugin)
@@ -103,7 +344,8 @@ touch /tmp/zeph-muted-$HASH
103
344
  rm /tmp/zeph-muted-$HASH
104
345
  ```
105
346
 
106
- The CLI checks `CLAUDE_PROJECT_DIR`, `CURSOR_PROJECT_DIR`, `WINDSURF_PROJECT_DIR`, and falls back to `cwd`.
347
+ The CLI checks `CLAUDE_PROJECT_DIR`, `CURSOR_PROJECT_DIR`,
348
+ `WINDSURF_PROJECT_DIR`, and falls back to `cwd`.
107
349
 
108
350
  ### Exit Codes
109
351
 
@@ -112,7 +354,8 @@ The CLI checks `CLAUDE_PROJECT_DIR`, `CURSOR_PROJECT_DIR`, `WINDSURF_PROJECT_DIR
112
354
  | 0 | Success |
113
355
  | 1 | General error |
114
356
  | 2 | Quota exceeded |
115
- | 3 | Authentication failed |
357
+ | 3 | Authentication failed (also: listener auth close 4001/4002/4003) |
358
+ | 127 | A required external binary (e.g. `tmux`, `claude`) was not found on PATH |
116
359
 
117
360
  ### Environment Variables
118
361
 
@@ -120,6 +363,9 @@ The CLI checks `CLAUDE_PROJECT_DIR`, `CURSOR_PROJECT_DIR`, `WINDSURF_PROJECT_DIR
120
363
  |----------|-------------|
121
364
  | `ZEPH_API_KEY` | API key (fallback when `--key` not provided) |
122
365
  | `ZEPH_BASE_URL` | API base URL (default: `https://api.zeph.to/v1`) |
366
+ | `ZEPH_WS_URL` | WebSocket endpoint for `zeph listener` (no default — required) |
367
+ | `ZEPH_TMUX_SOCKET` | Explicit tmux socket path for the listener (skips auto-discovery — use when your tmux runs with `-L <name>` or a custom `-S <path>`) |
368
+ | `ZEPH_SESSION_ID` | AI session ID (fallback when `--session` not provided) |
123
369
 
124
370
  ## SDK Usage
125
371
 
@@ -193,16 +439,39 @@ try {
193
439
  | Copilot CLI | Session end hook |
194
440
  | Cline | Rules file |
195
441
 
196
- ## Encryption
442
+ For remote-control via `zeph listener` the per-agent setup is the same
443
+ across CC/Codex/Gemini — the wrapper just spawns them in a named tmux
444
+ session.
197
445
 
198
- Push bodies are encrypted with AES-256-GCM. The wrapping key is derived via ECDH P-256 and synced across your own devices on first run so every device can read the same push. Toggle encryption in the Zeph app (Settings → Encryption); when disabled, the CLI sends plaintext. No configuration needed.
446
+ ## Encryption
199
447
 
200
- **Threat model honesty:** keys are persisted on the Zeph backend to enable cross-device sync, so this is *device-shared* encryption — not true end-to-end. It protects push contents from passive network observers and from a leaked database snapshot taken without the key store, but it does **not** protect against the Zeph backend itself (it has the keys it serves to your devices). A true E2E mode (per-device keypairs, server stores only public keys, no key escrow) is on the roadmap.
448
+ Push bodies are encrypted with AES-256-GCM. The wrapping key is derived
449
+ via ECDH P-256 and synced across your own devices on first run so every
450
+ device can read the same push. Toggle encryption in the Zeph app
451
+ (Settings → Encryption); when disabled, the CLI sends plaintext. No
452
+ configuration needed.
453
+
454
+ **Threat model honesty:** keys are persisted on the Zeph backend to
455
+ enable cross-device sync, so this is *device-shared* encryption — not
456
+ true end-to-end. It protects push contents from passive network
457
+ observers and from a leaked database snapshot taken without the key
458
+ store, but it does **not** protect against the Zeph backend itself (it
459
+ has the keys it serves to your devices). A true E2E mode (per-device
460
+ keypairs, server stores only public keys, no key escrow) is on the
461
+ roadmap.
462
+
463
+ The `zeph listener` ignores `isEncrypted` pushes for now — it has no
464
+ per-device key to decrypt them. Stop-hook auto-pushes and `zeph_ask`
465
+ responses are not part of the `@<session>` injection path, so this
466
+ doesn't affect normal use.
201
467
 
202
468
  ## Requirements
203
469
 
204
- - Node.js >= 18 (uses native `fetch`)
205
- - Zero runtime dependencies
470
+ - **Node.js >= 18** (uses native `fetch`).
471
+ - **tmux** required for `zeph cc` / `codex` / `gemini` and `zeph listener`.
472
+ - The `ZephHook` SDK has no runtime dependencies. The CLI depends on
473
+ `@inquirer/prompts` for the interactive `zeph install` picker and on
474
+ `ws` for the listener's WebSocket subscription.
206
475
 
207
476
  ## License
208
477
 
package/dist/cli.js CHANGED
@@ -9,6 +9,8 @@ const installer_js_1 = require("./installer.js");
9
9
  const uninstall_js_1 = require("./uninstall.js");
10
10
  const verify_js_1 = require("./verify.js");
11
11
  const check_update_js_1 = require("./check-update.js");
12
+ const wrapper_js_1 = require("./wrapper.js");
13
+ const listener_js_1 = require("./listener.js");
12
14
  const config_js_1 = require("./config.js");
13
15
  const PROJECT_DIR_VARS = ['CLAUDE_PROJECT_DIR', 'CURSOR_PROJECT_DIR', 'WINDSURF_PROJECT_DIR'];
14
16
  const detectProjectDir = () => PROJECT_DIR_VARS.reduce((found, key) => found || process.env[key], undefined) ?? process.cwd();
@@ -76,6 +78,16 @@ Commands:
76
78
  list List recent push notifications
77
79
  dismiss <id> Dismiss a push notification (or --all)
78
80
  test Send a test notification to verify setup
81
+ cc [args…] Run 'claude' in a named tmux session ('zeph-<project>')
82
+ codex [args…] Run 'codex' in a named tmux session
83
+ gemini [args…] Run 'gemini' in a named tmux session
84
+ (auto-suffixed -2/-3/… when another zeph cc is already
85
+ attached to the default name; any args after the
86
+ subcommand are forwarded verbatim, e.g.
87
+ 'zeph cc --resume')
88
+ listener Resident daemon — receives 'agent.command' pushes from
89
+ the phone picker and injects them into the matching
90
+ tmux session.
79
91
 
80
92
  Notify options:
81
93
  --title <text> Push title
@@ -97,6 +109,9 @@ Install options:
97
109
  --key <api-key> API key (non-interactive)
98
110
  --hook <hook-id> Hook ID (non-interactive)
99
111
  --base-url <url> Base URL (non-interactive)
112
+ --only <agents> Comma-separated agent ids to install for
113
+ (claude,cursor,windsurf,gemini,codex,copilot,cline,aider).
114
+ Skips the interactive picker.
100
115
 
101
116
  Uninstall options:
102
117
  --dry-run Preview what would be removed, change nothing
@@ -291,6 +306,16 @@ const handleError = (err, isJson) => {
291
306
  printError(err instanceof Error ? err.message : 'Unknown error', isJson);
292
307
  return 1;
293
308
  };
309
+ // ── Passthrough ─────────────────────────────────────────────────
310
+ /**
311
+ * Collect raw argv after the given subcommand token so flags like
312
+ * `--resume` reach the wrapped agent verbatim instead of being swallowed
313
+ * by `parseArgs`. Returns [] when the command isn't found.
314
+ */
315
+ const collectPassthrough = (argv, cmd) => {
316
+ const idx = argv.indexOf(cmd, 2);
317
+ return idx >= 0 ? argv.slice(idx + 1) : [];
318
+ };
294
319
  // ── Main ────────────────────────────────────────────────────────
295
320
  const main = async () => {
296
321
  const args = parseArgs(process.argv);
@@ -321,6 +346,14 @@ const main = async () => {
321
346
  return handleDismiss(args);
322
347
  case 'test':
323
348
  return handleTest(args);
349
+ case 'cc':
350
+ return (0, wrapper_js_1.handleAgentSession)('claude', collectPassthrough(process.argv, 'cc'));
351
+ case 'codex':
352
+ return (0, wrapper_js_1.handleAgentSession)('codex', collectPassthrough(process.argv, 'codex'));
353
+ case 'gemini':
354
+ return (0, wrapper_js_1.handleAgentSession)('gemini', collectPassthrough(process.argv, 'gemini'));
355
+ case 'listener':
356
+ return (0, listener_js_1.handleListener)(args);
324
357
  default:
325
358
  printError(`Unknown command: ${command}`, args.json === true);
326
359
  printUsage();
package/dist/config.d.ts CHANGED
@@ -4,6 +4,7 @@ export interface ZephConfig {
4
4
  apiKey?: string;
5
5
  hookId?: string;
6
6
  baseUrl?: string;
7
+ wsUrl?: string;
7
8
  deviceId?: string;
8
9
  }
9
10
  export declare const resolvedEnv: (key: string) => string | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,UAAU,QAA2B,CAAC;AACnD,eAAO,MAAM,WAAW,QAAkC,CAAC;AAE3D,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,SAGlD,CAAC;AAEF,eAAO,MAAM,UAAU,QAAO,UAM7B,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,QAAQ,UAAU,KAAG,IAG/C,CAAC;AAEF,eAAO,MAAM,OAAO,QAOhB,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,UAAU,QAA2B,CAAC;AACnD,eAAO,MAAM,WAAW,QAAkC,CAAC;AAE3D,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,SAGlD,CAAC;AAEF,eAAO,MAAM,UAAU,QAAO,UAM7B,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,QAAQ,UAAU,KAAG,IAG/C,CAAC;AAEF,eAAO,MAAM,OAAO,QAOhB,CAAC"}
@@ -1,2 +1,9 @@
1
+ import type { Agent } from './agents.js';
2
+ /**
3
+ * Resolve agents from a non-interactive `--only cursor,gemini` flag.
4
+ * Matches on agent id; unknown ids are silently dropped. Exported for
5
+ * unit testing.
6
+ */
7
+ export declare const filterAgentsByIds: (detected: Agent[], only: string) => Agent[];
1
8
  export declare const handleInstall: (args: Record<string, string | boolean>) => Promise<number>;
2
9
  //# sourceMappingURL=installer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../src/installer.ts"],"names":[],"mappings":"AA4RA,eAAO,MAAM,aAAa,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAoH1F,CAAC"}
1
+ {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../src/installer.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAwSzC;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,KAAK,EAAE,EAAE,MAAM,MAAM,KAAG,KAAK,EAKxE,CAAC;AAqBF,eAAO,MAAM,aAAa,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CA4H1F,CAAC"}
package/dist/installer.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleInstall = void 0;
3
+ exports.handleInstall = exports.filterAgentsByIds = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const fs_1 = require("fs");
6
6
  const os_1 = require("os");
@@ -251,6 +251,52 @@ const AGENT_INSTALLERS = {
251
251
  cline: installCline,
252
252
  aider: installAider,
253
253
  };
254
+ // One-line summary of what each agent's installer does — shown in the
255
+ // interactive plan before anything is written.
256
+ const AGENT_PLAN_LABELS = {
257
+ claude: 'Claude Code — install plugin',
258
+ cursor: 'Cursor — MCP + hooks + rules',
259
+ windsurf: 'Windsurf — MCP + hooks + rules',
260
+ gemini: 'Gemini CLI — MCP + hooks + rules',
261
+ codex: 'Codex CLI — hooks + rules',
262
+ copilot: 'Copilot CLI — hooks + rules',
263
+ cline: 'Cline — rules',
264
+ aider: 'Aider — conventions',
265
+ };
266
+ // ── Agent selection ──────────────────────────────────────────────
267
+ /**
268
+ * Interactive agent picker — an @inquirer/prompts checkbox (arrow keys
269
+ * to move, space to toggle, enter to confirm). Every agent starts
270
+ * checked, so a bare Enter installs for all. Returns the chosen Agent[].
271
+ *
272
+ * Dynamic import keeps @inquirer/prompts (ESM) loadable from this
273
+ * CommonJS build, and means the dependency is only touched on the
274
+ * interactive path — `notify` / `list` / scripted `install --only`
275
+ * never load it.
276
+ */
277
+ const pickAgentsInteractive = async (detected) => {
278
+ const { checkbox } = await import('@inquirer/prompts');
279
+ const picked = await checkbox({
280
+ message: 'Install Zeph for which agents? (space to toggle, enter to confirm)',
281
+ choices: detected.map((agent) => ({
282
+ name: AGENT_PLAN_LABELS[agent.id] ?? agent.name,
283
+ value: agent.id,
284
+ checked: true,
285
+ })),
286
+ loop: false,
287
+ });
288
+ return detected.filter((a) => picked.includes(a.id));
289
+ };
290
+ /**
291
+ * Resolve agents from a non-interactive `--only cursor,gemini` flag.
292
+ * Matches on agent id; unknown ids are silently dropped. Exported for
293
+ * unit testing.
294
+ */
295
+ const filterAgentsByIds = (detected, only) => {
296
+ const ids = new Set(only.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean));
297
+ return detected.filter((a) => ids.has(a.id));
298
+ };
299
+ exports.filterAgentsByIds = filterAgentsByIds;
254
300
  // ── Test Connection ──────────────────────────────────────────────
255
301
  const testConnection = async (apiKey, baseUrl) => {
256
302
  try {
@@ -291,7 +337,32 @@ const handleInstall = async (args) => {
291
337
  if (detected.length === 0) {
292
338
  console.log('\n No supported agents found. Config will still be saved.\n');
293
339
  }
294
- // 2. Collect credentials
340
+ // 2. Choose which agents to install for — asked up front so the user
341
+ // sees the choice before being walked through credential prompts.
342
+ let selected = detected;
343
+ const onlyArg = args.only?.trim();
344
+ if (detected.length > 0) {
345
+ if (onlyArg) {
346
+ // Non-interactive or scripted: --only cursor,gemini
347
+ selected = (0, exports.filterAgentsByIds)(detected, onlyArg);
348
+ console.log(`\n --only ${onlyArg} → ${selected.map((a) => a.name).join(', ') || '(no match)'}`);
349
+ }
350
+ else if (nonInteractive) {
351
+ // Scripted run with no --only: keep the all-detected default
352
+ selected = detected;
353
+ }
354
+ else {
355
+ try {
356
+ selected = await pickAgentsInteractive(detected);
357
+ }
358
+ catch {
359
+ // Ctrl-C in the picker (or no TTY) — treat as a clean cancel.
360
+ console.log('\n Cancelled.\n');
361
+ return 0;
362
+ }
363
+ }
364
+ }
365
+ // 3. Collect credentials
295
366
  const existing = (0, config_js_1.loadConfig)();
296
367
  let apiKey;
297
368
  let hookId;
@@ -321,33 +392,19 @@ const handleInstall = async (args) => {
321
392
  console.error('\n Error: API key is required.\n');
322
393
  return 1;
323
394
  }
324
- // 3. Confirmation (interactive only)
395
+ // 4. Show the resolved plan before touching anything (interactive only).
325
396
  if (!nonInteractive) {
326
397
  console.log('\n Will do:');
327
- console.log(' 1. Save config to ~/.zeph/config.json');
328
- let step = 2;
329
- for (const agent of detected) {
330
- const labels = {
331
- claude: 'Install Claude Code plugin',
332
- cursor: 'Setup Cursor (MCP + hooks + rules)',
333
- windsurf: 'Setup Windsurf (MCP + hooks + rules)',
334
- gemini: 'Setup Gemini CLI (MCP + hooks + rules)',
335
- codex: 'Setup Codex CLI (hooks + rules)',
336
- copilot: 'Setup Copilot CLI (hooks + rules)',
337
- cline: 'Setup Cline (rules)',
338
- aider: 'Setup Aider (conventions)',
339
- };
340
- console.log(` ${step}. ${labels[agent.id] ?? `Install for ${agent.name}`}`);
341
- step++;
398
+ console.log(` - Save config to ${config_js_1.CONFIG_FILE}`);
399
+ for (const agent of selected) {
400
+ console.log(` - ${AGENT_PLAN_LABELS[agent.id] ?? `Install for ${agent.name}`}`);
342
401
  }
343
- console.log(` ${step}. Test connection`);
344
- const confirm = await promptInput(' Continue? [Y/n] ');
345
- if (confirm.toLowerCase() === 'n') {
346
- console.log('\n Cancelled.\n');
347
- return 0;
402
+ if (selected.length === 0) {
403
+ console.log(' (no agents selected only the config file will be saved)');
348
404
  }
405
+ console.log(' - Test connection');
349
406
  }
350
- // 4. Save config
407
+ // 5. Save config
351
408
  console.log('');
352
409
  const config = {
353
410
  apiKey,
@@ -356,14 +413,14 @@ const handleInstall = async (args) => {
356
413
  };
357
414
  (0, config_js_1.saveConfig)(config);
358
415
  ok(`Config saved to ${config_js_1.CONFIG_FILE}`);
359
- // 5. Install per-agent
360
- for (const agent of detected) {
416
+ // 6. Install for the selected agents only
417
+ for (const agent of selected) {
361
418
  console.log(`\n Installing for ${agent.name}...`);
362
419
  const installer = AGENT_INSTALLERS[agent.id];
363
420
  if (installer)
364
421
  installer();
365
422
  }
366
- // 6. Test connection
423
+ // 7. Test connection
367
424
  console.log('\n Testing connection...');
368
425
  await testConnection(apiKey, baseUrl);
369
426
  console.log('\n Done! Restart your agents.\n');