@fnclaude/cli 0.7.2 → 0.7.4
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 +215 -27
- package/package.json +1 -1
- package/src/argParser.ts +19 -32
- package/src/args/preserve.ts +2 -2
- package/src/args.ts +200 -23
- package/src/argv.ts +18 -19
- package/src/config.ts +85 -52
- package/src/help.ts +1 -1
- package/src/hostAliases.ts +40 -15
- package/src/index.ts +11 -5
- package/src/main.ts +209 -88
- package/src/mcp/client.ts +12 -9
- package/src/mcp/protocol.ts +66 -26
- package/src/mcp/socketListener.ts +35 -11
- package/src/passthrough.ts +36 -0
- package/src/paths.ts +31 -0
- package/src/prompts.ts +5 -12
- package/src/pty/unix.ts +250 -107
- package/src/pty.ts +20 -13
- package/src/repoSettings.ts +35 -11
- package/src/sessionState.ts +125 -1
- package/src/silentRelaunch.ts +3 -3
- package/src/spawn.ts +2 -28
- package/src/warnings.ts +15 -31
- package/src/worktree.ts +57 -43
package/README.md
CHANGED
|
@@ -1,50 +1,238 @@
|
|
|
1
1
|
# @fnclaude/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`claude`, with the rough edges filed off.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
```sh
|
|
6
|
+
fnc opus max ~/src/myproject -- "refactor the auth module"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
No `--model claude-opus-4-5`, no `--effort max`, no `--print` gymnastics. `@fnclaude/cli` (the `fnc` binary) sits in front of `claude` and translates short, readable invocations into the full-form flags `claude` expects. Magic positional words for model and effort, capital-letter short flags for everything claude makes you spell out, and a config file for the auto-features you want on every launch. Bun-runtime; install via `bun add -g fnclaude` (or `npm i -g fnclaude`).
|
|
10
|
+
|
|
11
|
+
## Platform support
|
|
12
|
+
|
|
13
|
+
Supported on **Linux** and **macOS**. The codebase has a Windows fallback path (`spawn + process.exit` instead of `process.execve`) but it has never been exercised — treat it as untested. If you run on Windows, expect breakage.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
6
16
|
|
|
7
|
-
```
|
|
8
|
-
|
|
9
|
-
|
|
17
|
+
```sh
|
|
18
|
+
bun add -g fnclaude
|
|
19
|
+
# or
|
|
20
|
+
npm install -g fnclaude
|
|
10
21
|
```
|
|
11
22
|
|
|
23
|
+
Requires the **Bun runtime** for terminal session management. Node.js is supported for installation, but session execution runs via Bun.
|
|
24
|
+
|
|
12
25
|
## Requirements
|
|
13
26
|
|
|
14
|
-
|
|
27
|
+
- [Bun](https://bun.sh/) >= 1.0
|
|
28
|
+
- [claude](https://github.com/anthropics/claude-code) CLI installed and on your `$PATH`
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
# Launch in the current directory
|
|
34
|
+
fnc .
|
|
35
|
+
|
|
36
|
+
# Specific model in a specific project
|
|
37
|
+
fnc sonnet ~/src/myproject
|
|
38
|
+
|
|
39
|
+
# High-effort opus session
|
|
40
|
+
fnc opus high ~/src/myproject
|
|
41
|
+
|
|
42
|
+
# Attach a shared tools directory
|
|
43
|
+
fnc ~/src/myproject -A ~/src/shared-tools
|
|
44
|
+
|
|
45
|
+
# Pass a prompt inline
|
|
46
|
+
fnc . -- "refactor the auth module"
|
|
47
|
+
|
|
48
|
+
# Collapse multiple short flags
|
|
49
|
+
fnc -BVC .
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
### Magic positional words
|
|
55
|
+
|
|
56
|
+
The first two positional slots can be a model alias and an effort level. `fnc` intercepts them before `claude` ever sees the args.
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
fnc opus max ~/src/proj # --model claude-opus-4-5 --effort max
|
|
60
|
+
fnc sonnet ~/src/proj # --model claude-sonnet-4-5
|
|
61
|
+
fnc haiku low ~/src/proj # --model claude-haiku-4-5 --effort low
|
|
62
|
+
fnc ~/src/proj # no model flag — claude picks the default
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Supported model aliases: `opus`, `sonnet`, `haiku`.
|
|
66
|
+
Supported effort levels: `low`, `medium`, `high`, `xhigh`, `max`.
|
|
67
|
+
|
|
68
|
+
A directory that happens to be named `opus`? Prefix it: `fnc ./opus`.
|
|
69
|
+
|
|
70
|
+
### Capital-letter short flags
|
|
71
|
+
|
|
72
|
+
`claude`'s long options are the right thing to pass and a chore to type. `fnc` maps each one to a capital-letter short flag that collapses with standard POSIX rules.
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
fnc -BVC ~/src/proj # --brief --verbose --chrome
|
|
76
|
+
fnc -T ~/src/proj # --tmux
|
|
77
|
+
fnc -D ~/src/proj # --dangerously-skip-permissions
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Full mapping in the [flag reference](#short-translations-fnc--claude) below.
|
|
81
|
+
|
|
82
|
+
### Prompt after `--`
|
|
15
83
|
|
|
16
|
-
|
|
84
|
+
Pass a prompt inline without `--print` or redirection — just drop a `--` and write the prompt. When `--name`/`-n` isn't already set, `fnc` generates a 1–3-word session label from the prompt via Haiku (see [Auto-name from prompt](#auto-name-from-prompt) below).
|
|
17
85
|
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
|
|
86
|
+
```sh
|
|
87
|
+
fnc sonnet src/ -- "add integration tests for the payments module"
|
|
88
|
+
```
|
|
21
89
|
|
|
22
|
-
|
|
23
|
-
fnc opus max ~/my-project
|
|
90
|
+
### Multi-directory MCP injection
|
|
24
91
|
|
|
25
|
-
|
|
26
|
-
fnc ~/my-project my-feature-branch
|
|
92
|
+
Need claude to see a second project's MCP config and settings? Pass it as an extra positional or with `-A`/`--also`. `fnc` injects `--add-dir`, `--mcp-config`, and `--settings` for each extra dir automatically.
|
|
27
93
|
|
|
28
|
-
|
|
29
|
-
fnc
|
|
94
|
+
```sh
|
|
95
|
+
fnc src/ -A tools/ -A shared/
|
|
96
|
+
# or equivalently:
|
|
97
|
+
fnc src/ tools/ shared/
|
|
30
98
|
```
|
|
31
99
|
|
|
32
|
-
|
|
100
|
+
Each extra dir gets `--mcp-config <dir>/.mcp.json` (if the file exists) and `--settings <dir>/.claude/settings.json` (if it exists). The primary dir is launched in; extra dirs are attached.
|
|
101
|
+
|
|
102
|
+
### Auto-features you configure once
|
|
103
|
+
|
|
104
|
+
Tired of typing `--tmux` every launch? Set it once in config and forget it.
|
|
33
105
|
|
|
34
|
-
```
|
|
35
|
-
|
|
106
|
+
```toml
|
|
107
|
+
# ~/.config/fnclaude/config.toml
|
|
108
|
+
[auto]
|
|
109
|
+
tmux = "worktree" # inject --tmux when you're creating a worktree via -w
|
|
36
110
|
```
|
|
37
111
|
|
|
38
|
-
|
|
112
|
+
Off by default. The per-invocation override `--no-tmux` lets you escape for a single run without touching config.
|
|
113
|
+
|
|
114
|
+
`auto.tmux = "worktree"` is the only valid non-default value: it injects `--tmux` only when you're explicitly creating a new worktree via `-w` (which claude requires for `--tmux` anyway). `fnc` never spawns worktrees on its own — that's always a user-initiated action.
|
|
115
|
+
|
|
116
|
+
> **What about `--dangerously-skip-permissions` and `--ide`?** Use claude's own settings: `permissions.defaultMode` and `skipDangerousModePermissionPrompt` in `~/.claude/settings.json`, plus `autoConnectIde` in `~/.claude.json`. The CLI flags themselves (`-D`, `--dangerously-skip-permissions`, `-I`, `--ide`) still pass through verbatim for per-invocation use.
|
|
117
|
+
|
|
118
|
+
### Auto-name from prompt
|
|
119
|
+
|
|
120
|
+
When you pass a prompt after `--` (and aren't resuming an existing session), `fnc` generates a short hyphenated session label via Haiku and injects it as `--name`. The call has a 3-second timeout; on timeout, missing API key, or any error, it falls back to a heuristic that strips stop-words and takes the first three meaningful tokens.
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
fnc . -- "refactor the auth module"
|
|
124
|
+
# → --name refactor-auth-module
|
|
125
|
+
```
|
|
39
126
|
|
|
40
|
-
|
|
127
|
+
Skipped for `-p`/`--print`, `-r`/`--resume`, `-c`/`--continue`, and `--from-pr` — those don't create new named sessions. Requires `ANTHROPIC_API_KEY`; suppress the missing-key warning with `FNCLAUDE_QUIET_MISSING_API_KEY=1` (or the config equivalent) if you'd rather just rely on the heuristic.
|
|
41
128
|
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
129
|
+
### Cross-cwd `--resume`
|
|
130
|
+
|
|
131
|
+
`claude --resume` normally exits with a "this conversation is from a different directory" message when you pick a session from elsewhere via the picker. `fnc` scans the last 4 KB of claude's output, catches that message after exit, and transparently re-execs a fresh `fnc` in the destination directory. The picker just works across all your projects — no flicker, no manual `cd`.
|
|
132
|
+
|
|
133
|
+
Linux and macOS only; on Windows `fnc` falls back to a plain exec (no PTY, no detection).
|
|
134
|
+
|
|
135
|
+
### Worktree intercept
|
|
136
|
+
|
|
137
|
+
`fnc -w <name>` looks up `<name>` against the existing git worktrees of the project repo. If it matches, `fnc` swaps its cwd to that worktree and drops the `-w` flag — no new worktree is created, no duplicate. If it doesn't match, the flag passes through and the name doubles as the session `--name`.
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
fnc -w feature-branch # cds to the feature-branch worktree if it exists
|
|
141
|
+
fnc -w new-thing # passes -w new-thing through; sets --name new-thing
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Auto-handoff from noop sessions
|
|
145
|
+
|
|
146
|
+
When `fnc` is launched with no positional path, it drops into a "noop" session whose system prompt makes claude act as a router: classify the user's prompt into general-Q&A, read-shaped, or action-on-a-project. Action requests get written as a handoff file to a temp path and the relaunch command fires automatically (or with a confirmation, depending on `auto.handoff`).
|
|
147
|
+
|
|
148
|
+
`auto.handoff` controls how the noop router proposes the relaunch:
|
|
149
|
+
|
|
150
|
+
- `"never"` — paste-flow only. claude renders the relaunch command and copies it to the clipboard.
|
|
151
|
+
- `"ask"` (default) — claude asks "Want me to switch you over now?" before doing anything.
|
|
152
|
+
- `"<N>"` (seconds, e.g. `"5"`) — countdown auto-switch. `"0"` means instant.
|
|
153
|
+
|
|
154
|
+
## Reference
|
|
155
|
+
|
|
156
|
+
### Argument grammar
|
|
157
|
+
|
|
158
|
+
The first two positional arguments may be "magic" shorthands:
|
|
159
|
+
|
|
160
|
+
- **Position 1**: if the value is exactly `opus`, `sonnet`, or `haiku`, it is translated to `--model <alias>` and consumed. Otherwise treated as a path.
|
|
161
|
+
- **Position 2**: only checked when position 1 was a model alias. If the value is exactly `low`, `medium`, `high`, `xhigh`, or `max`, it is translated to `--effort <level>` and consumed. Otherwise treated as a path.
|
|
162
|
+
- **Position 3+**: never magic.
|
|
163
|
+
|
|
164
|
+
To pass a literal directory named `opus`, prefix with `./`: `fnc ./opus`
|
|
165
|
+
|
|
166
|
+
After magic slots resolve, the first remaining positional is the directory to launch `claude` in. Subsequent positionals are "extra dirs" that each receive `--add-dir`, `--mcp-config`, and `--settings` injection (files must exist for the latter two).
|
|
167
|
+
|
|
168
|
+
Use `--` to separate `fnc` args from the prompt string:
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
fnc sonnet src/ -- "do the thing"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Flag reference
|
|
175
|
+
|
|
176
|
+
#### fnc-owned flags
|
|
177
|
+
|
|
178
|
+
| Flag | Long | Description |
|
|
179
|
+
|---|---|---|
|
|
180
|
+
| | `--no-tmux` | Suppress auto-`--tmux` for this invocation |
|
|
181
|
+
| `-A <dir>` | `--also <dir>` | Add an extra dir (repeatable) |
|
|
182
|
+
| `-h` | `--help` | Print the flag reference and exit |
|
|
183
|
+
| `-v` | `--version` | Print `fnc`'s version and exit |
|
|
184
|
+
|
|
185
|
+
#### Short translations (fnc → claude)
|
|
186
|
+
|
|
187
|
+
| Short | Long | Value |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `-A` | `--also` | required (fnc-owned) |
|
|
190
|
+
| `-B` | `--brief` | none |
|
|
191
|
+
| `-C` | `--chrome` | none |
|
|
192
|
+
| `-D` | `--dangerously-skip-permissions` | none |
|
|
193
|
+
| `-F` | `--fork-session` | none |
|
|
194
|
+
| `-G` | `--agent` | required |
|
|
195
|
+
| `-I` | `--ide` | none |
|
|
196
|
+
| `-M` | `--permission-mode` | required |
|
|
197
|
+
| `-P` | `--from-pr` | optional |
|
|
198
|
+
| `-R` | `--remote-control` | optional |
|
|
199
|
+
| `-T` | `--tmux` | optional |
|
|
200
|
+
| `-V` | `--verbose` | none |
|
|
201
|
+
| `-W` | `--allowedTools` | required |
|
|
202
|
+
|
|
203
|
+
Short flags follow standard POSIX collapsing: `-BVC` expands to `-B -V -C`. Only the last flag in a collapsed group may take a value. All other flags pass through to `claude` verbatim.
|
|
204
|
+
|
|
205
|
+
### Config file
|
|
206
|
+
|
|
207
|
+
Location: `$XDG_CONFIG_HOME/fnclaude/config.toml` (fallback `~/.config/fnclaude/config.toml`). A missing file is not an error — all defaults apply.
|
|
208
|
+
|
|
209
|
+
Precedence: **CLI flag > env var > config file > built-in default**
|
|
210
|
+
|
|
211
|
+
```toml
|
|
212
|
+
[name]
|
|
213
|
+
model = "claude-haiku-4-5" # model for auto-generated session names
|
|
214
|
+
timeout = "3s" # timeout for the name-generation API call
|
|
215
|
+
quiet_missing_api_key = false
|
|
216
|
+
|
|
217
|
+
[auto]
|
|
218
|
+
tmux = "never" # "never" | "worktree"
|
|
219
|
+
handoff = "ask" # "never" | "ask" | non-negative integer seconds
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
#### Env var mapping
|
|
223
|
+
|
|
224
|
+
| Config key | Env var |
|
|
225
|
+
|---|---|
|
|
226
|
+
| `name.model` | `FNCLAUDE_NAME_MODEL` |
|
|
227
|
+
| `name.timeout` | `FNCLAUDE_NAME_TIMEOUT` |
|
|
228
|
+
| `name.quiet_missing_api_key` | `FNCLAUDE_QUIET_MISSING_API_KEY` |
|
|
229
|
+
| `auto.tmux` | `FNCLAUDE_TMUX` |
|
|
230
|
+
| `auto.handoff` | `FNCLAUDE_HANDOFF` |
|
|
231
|
+
|
|
232
|
+
`ANTHROPIC_API_KEY` is read (standard) for the auto-name LLM call.
|
|
47
233
|
|
|
48
234
|
## Status
|
|
49
235
|
|
|
50
|
-
The CLI is
|
|
236
|
+
The CLI is a recent TypeScript rewrite of the original Go binary. It is actively maintained and published to npm under `@latest`. See the [main fnclaude repository](https://github.com/fnrhombus/fnclaude) for release notes and version history.
|
|
237
|
+
|
|
238
|
+
File bugs and feature requests on [GitHub Issues](https://github.com/fnrhombus/fnclaude/issues).
|
package/package.json
CHANGED
package/src/argParser.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { join as pathJoin } from 'node:path';
|
|
19
|
-
import type
|
|
19
|
+
import { brandParsed, type ParsedArgs } from './args.js';
|
|
20
20
|
|
|
21
21
|
// ── Magic-word vocabularies ────────────────────────────────────────────────
|
|
22
22
|
|
|
@@ -163,11 +163,15 @@ export function parseShortFlag(arg: string, rest: readonly string[]): ParseShort
|
|
|
163
163
|
* parseArgs is the canonical argv parser. `home` is the user's home dir
|
|
164
164
|
* (typically `os.homedir()`); it's used to derive the noop fallback path.
|
|
165
165
|
*
|
|
166
|
+
* Returns a `ParsedArgs` — the first stage of the immutable argv pipeline.
|
|
167
|
+
* Notably absent: `worktreeMatched`. That value only becomes meaningful
|
|
168
|
+
* after the intercept stage and is part of `InterceptedArgs` instead.
|
|
169
|
+
*
|
|
166
170
|
* Throws Error on invalid input (too many positionals, missing values,
|
|
167
171
|
* collapsed-group misuse, two subcommands, etc.). The Go original returns
|
|
168
172
|
* `(Args, error)`; TS uses throw for the natural shape.
|
|
169
173
|
*/
|
|
170
|
-
export function parseArgs(argv: readonly string[], home: string):
|
|
174
|
+
export function parseArgs(argv: readonly string[], home: string): ParsedArgs {
|
|
171
175
|
let firstPath = '';
|
|
172
176
|
const extraDirs: string[] = [];
|
|
173
177
|
const passthrough: string[] = [];
|
|
@@ -339,7 +343,7 @@ export function parseArgs(argv: readonly string[], home: string): Args {
|
|
|
339
343
|
const cwd = firstPathSet ? firstPath : defaultNoopDir(home);
|
|
340
344
|
const usedNoopFallback = !firstPathSet;
|
|
341
345
|
|
|
342
|
-
return {
|
|
346
|
+
return brandParsed({
|
|
343
347
|
cwd,
|
|
344
348
|
extraDirs,
|
|
345
349
|
passthrough: finalPassthrough,
|
|
@@ -347,34 +351,17 @@ export function parseArgs(argv: readonly string[], home: string): Args {
|
|
|
347
351
|
worktreeSet,
|
|
348
352
|
worktreeArg,
|
|
349
353
|
usedNoopFallback,
|
|
350
|
-
|
|
351
|
-
};
|
|
354
|
+
});
|
|
352
355
|
}
|
|
353
356
|
|
|
354
|
-
// ── Passthrough inspection helpers
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* True when the exact token appears, or any `token=<anything>` form.
|
|
367
|
-
*/
|
|
368
|
-
export function tokenInPassthrough(passthrough: readonly string[], long: string): boolean {
|
|
369
|
-
const prefix = `${long}=`;
|
|
370
|
-
return passthrough.some((t) => t === long || t.startsWith(prefix));
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* True when --name or -n (bare or =value) appears anywhere in passthrough.
|
|
375
|
-
*/
|
|
376
|
-
export function nameInPassthrough(passthrough: readonly string[]): boolean {
|
|
377
|
-
return passthrough.some(
|
|
378
|
-
(t) => t === '--name' || t === '-n' || t.startsWith('--name=') || t.startsWith('-n='),
|
|
379
|
-
);
|
|
380
|
-
}
|
|
357
|
+
// ── Passthrough inspection helpers ─────────────────────────────────────────
|
|
358
|
+
//
|
|
359
|
+
// The canonical implementations live in passthrough.ts. Re-exported here
|
|
360
|
+
// for back-compat with callers that grew up importing them from argParser.
|
|
361
|
+
// New code should import from passthrough.ts directly.
|
|
362
|
+
|
|
363
|
+
export {
|
|
364
|
+
nameInPassthrough,
|
|
365
|
+
settingSourcesInPassthrough,
|
|
366
|
+
tokenInPassthrough,
|
|
367
|
+
} from './passthrough.js';
|
package/src/args/preserve.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* port mirrors every case 1:1.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import type {
|
|
19
|
+
import type { RequestOverrides } from '../mcp/protocol.js';
|
|
20
20
|
|
|
21
21
|
// ── Magic-word vocabularies (must match argParser.ts) ──────────────────────
|
|
22
22
|
|
|
@@ -219,7 +219,7 @@ export const transferDenyBareOK: ReadonlySet<string> = new Set([
|
|
|
219
219
|
*/
|
|
220
220
|
export function applyOverrides(
|
|
221
221
|
preserved: readonly string[],
|
|
222
|
-
req:
|
|
222
|
+
req: RequestOverrides,
|
|
223
223
|
): string[] {
|
|
224
224
|
let out = preserved.slice();
|
|
225
225
|
|
package/src/args.ts
CHANGED
|
@@ -1,62 +1,239 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Stage-typed argv pipeline.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* fnclaude's argv flows through five stages — parse, resolve, intercept,
|
|
5
|
+
* auto-name, sanitize — before being handed to buildArgv. Earlier the
|
|
6
|
+
* intermediate state was a single mutable `Args` bag passed through every
|
|
7
|
+
* step, each free to rewrite any field. The pipeline's *ordering* was a
|
|
8
|
+
* runtime invariant only: nothing stopped `buildArgv` from being called
|
|
9
|
+
* before `applyWorktreeIntercept`, and `worktreeMatched` started life as a
|
|
10
|
+
* placeholder boolean on the parsed shape, even though it has no meaningful
|
|
11
|
+
* value until intercept has run.
|
|
12
|
+
*
|
|
13
|
+
* The new shape uses distinct types per stage. Each function takes its
|
|
14
|
+
* predecessor's output type as input and returns the next stage's type.
|
|
15
|
+
* The shapes are structurally readonly and brand-discriminated, so:
|
|
16
|
+
*
|
|
17
|
+
* - `parseArgs` returns `ParsedArgs`, which has NO `worktreeMatched`
|
|
18
|
+
* field at all — the invariant "the intercept hasn't run yet" is
|
|
19
|
+
* encoded in the type, not a sentinel value.
|
|
20
|
+
* - `applyWorktreeIntercept` returns `InterceptedArgs`, which has
|
|
21
|
+
* `worktreeMatched` materialized. Passing a `ParsedArgs` to `buildArgv`
|
|
22
|
+
* is a compile error because `InterceptedArgs` is what `buildArgv`
|
|
23
|
+
* accepts.
|
|
24
|
+
* - Stages are immutable; each function returns a new value. The
|
|
25
|
+
* pipeline composes by value, not by aliased reference.
|
|
26
|
+
*
|
|
27
|
+
* The brand fields (`__stage`) only exist in the type system — they're
|
|
28
|
+
* never assigned at runtime. The brand functions (`brandParsed` etc.) are
|
|
29
|
+
* named casts that document the boundary at which the stage transition
|
|
30
|
+
* happens.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// ── Base shape ─────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fields every stage carries. All readonly: stage transitions produce new
|
|
37
|
+
* objects, never mutate.
|
|
7
38
|
*/
|
|
8
|
-
export interface
|
|
39
|
+
export interface BaseArgs {
|
|
9
40
|
/**
|
|
10
41
|
* CWD is the directory claude will be launched in (first positional, or
|
|
11
|
-
* the noop fallback when no positionals are given).
|
|
42
|
+
* the noop fallback when no positionals are given). The interpretation
|
|
43
|
+
* narrows as stages progress — `ParsedArgs.cwd` is whatever the user
|
|
44
|
+
* typed; later stages have it resolved to an absolute path and/or
|
|
45
|
+
* swapped to a matched-worktree path.
|
|
12
46
|
*/
|
|
13
|
-
cwd: string;
|
|
47
|
+
readonly cwd: string;
|
|
14
48
|
|
|
15
49
|
/**
|
|
16
50
|
* ExtraDirs collects all -A / --also values in order. Positional 2 is the
|
|
17
51
|
* worktree slot; -A is the only way to supply extra dirs.
|
|
18
52
|
*/
|
|
19
|
-
extraDirs: string[];
|
|
53
|
+
readonly extraDirs: readonly string[];
|
|
20
54
|
|
|
21
55
|
/**
|
|
22
56
|
* Passthrough is everything else, preserved in order, to be forwarded to
|
|
23
57
|
* claude verbatim. Short flags are already translated to their long
|
|
24
|
-
* forms.
|
|
58
|
+
* forms. Later stages may *extend* this slice (e.g. the intercept pushes
|
|
59
|
+
* `--worktree <name>`, auto-name prepends `--name <name>`).
|
|
25
60
|
*/
|
|
26
|
-
passthrough: string[];
|
|
61
|
+
readonly passthrough: readonly string[];
|
|
27
62
|
|
|
28
63
|
/**
|
|
29
64
|
* NoTmux is true when the user passed --no-tmux (eaten by fnclaude; not
|
|
30
65
|
* forwarded to claude).
|
|
31
66
|
*/
|
|
32
|
-
noTmux: boolean;
|
|
67
|
+
readonly noTmux: boolean;
|
|
33
68
|
|
|
34
69
|
/**
|
|
35
70
|
* WorktreeSet is true when the user passed -w / --worktree, OR supplied
|
|
36
|
-
* a 2nd positional after magic + subcommand consumption
|
|
71
|
+
* a 2nd positional after magic + subcommand consumption, OR the Resolve
|
|
72
|
+
* step picked up a `+workspace` suffix from a repo reference.
|
|
37
73
|
*/
|
|
38
|
-
worktreeSet: boolean;
|
|
74
|
+
readonly worktreeSet: boolean;
|
|
39
75
|
|
|
40
76
|
/**
|
|
41
77
|
* WorktreeArg is the name/value given with -w / --worktree (or the 2nd
|
|
42
|
-
* positional), or "" if the flag was bare.
|
|
78
|
+
* positional / +workspace suffix), or "" if the flag was bare.
|
|
43
79
|
*/
|
|
44
|
-
worktreeArg: string;
|
|
80
|
+
readonly worktreeArg: string;
|
|
45
81
|
|
|
46
82
|
/**
|
|
47
83
|
* UsedNoopFallback is true when CWD was filled by the noop fallback (no
|
|
48
84
|
* positional path given). Caller uses this to gate seed-noop behavior —
|
|
49
85
|
* explicit paths don't get auto-seeded.
|
|
50
86
|
*/
|
|
51
|
-
usedNoopFallback: boolean;
|
|
87
|
+
readonly usedNoopFallback: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Brand machinery ────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `Branded<T, K>` tags `T` with a phantom string literal `K` so two
|
|
94
|
+
* otherwise-structurally-identical types become assignment-incompatible.
|
|
95
|
+
* The `__stage` property exists only in the type — runtime values never
|
|
96
|
+
* carry it.
|
|
97
|
+
*/
|
|
98
|
+
type Branded<T, K extends string> = T & { readonly __stage: K };
|
|
99
|
+
|
|
100
|
+
// ── Stage 1: parsed ────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Output of `parseArgs`. The argv has been split into structural fields,
|
|
104
|
+
* but no I/O has run yet — `cwd` is still the user-typed string, the
|
|
105
|
+
* worktree intercept hasn't queried git, and no autoname has been generated.
|
|
106
|
+
*
|
|
107
|
+
* Has NO `worktreeMatched` field. That value only becomes meaningful after
|
|
108
|
+
* the intercept stage, and encoding its absence in the type means a stale
|
|
109
|
+
* `worktreeMatched: false` can't accidentally be read by a downstream step.
|
|
110
|
+
*/
|
|
111
|
+
export type ParsedArgs = Branded<BaseArgs, 'parsed'>;
|
|
112
|
+
|
|
113
|
+
// ── Stage 2: resolved ──────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Output of the Resolve / tilde-expand step. `cwd` is an absolute path
|
|
117
|
+
* (when a path or repo ref was resolved) or the noop fallback. The Resolve
|
|
118
|
+
* step may also have promoted a `+workspace` suffix into `worktreeSet` +
|
|
119
|
+
* `worktreeArg`.
|
|
120
|
+
*
|
|
121
|
+
* Still no `worktreeMatched` — that's the next stage.
|
|
122
|
+
*/
|
|
123
|
+
export type ResolvedArgs = Branded<BaseArgs, 'resolved'>;
|
|
124
|
+
|
|
125
|
+
// ── Stage 3: intercepted ───────────────────────────────────────────────────
|
|
52
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Output of `applyWorktreeIntercept`. `worktreeMatched` is now meaningful:
|
|
129
|
+
* true iff an existing worktree of the project repo matched
|
|
130
|
+
* `worktreeArg` (and `cwd` was swapped to that worktree's path). Downstream
|
|
131
|
+
* consumers (`buildArgv`'s auto-tmux gate, primarily) treat matched=true
|
|
132
|
+
* as "no new worktree being created this run" and avoid injecting flags
|
|
133
|
+
* that only make sense when claude is about to spin up a fresh worktree.
|
|
134
|
+
*
|
|
135
|
+
* `passthrough` may have been extended with `--worktree`, `--worktree <name>`,
|
|
136
|
+
* or `--name <name>` depending on whether the intercept matched.
|
|
137
|
+
*/
|
|
138
|
+
export interface InterceptedFields {
|
|
53
139
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* swapped to that worktree). Downstream consumers (buildArgv's auto-tmux
|
|
57
|
-
* gate, primarily) treat matched=true as "no new worktree being created
|
|
58
|
-
* this run" and avoid injecting flags that only make sense when claude
|
|
59
|
-
* is about to spin up a fresh worktree.
|
|
140
|
+
* True iff -w / --worktree was resolved against an existing worktree of
|
|
141
|
+
* the project repo (and cwd was swapped to that worktree).
|
|
60
142
|
*/
|
|
61
|
-
worktreeMatched: boolean;
|
|
143
|
+
readonly worktreeMatched: boolean;
|
|
62
144
|
}
|
|
145
|
+
|
|
146
|
+
export type InterceptedArgs = Branded<BaseArgs & InterceptedFields, 'intercepted'>;
|
|
147
|
+
|
|
148
|
+
// ── Brand constructors ─────────────────────────────────────────────────────
|
|
149
|
+
//
|
|
150
|
+
// Each brand function is a named cast — it doesn't validate anything,
|
|
151
|
+
// it just documents *where* the stage transition happens in the pipeline.
|
|
152
|
+
// The asserted shape carries all the invariants the stage promises.
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Stamp a `BaseArgs`-shaped value as `ParsedArgs`. Only `parseArgs` should
|
|
156
|
+
* call this.
|
|
157
|
+
*/
|
|
158
|
+
export function brandParsed(a: BaseArgs): ParsedArgs {
|
|
159
|
+
return a as ParsedArgs;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Stamp a `BaseArgs`-shaped value as `ResolvedArgs`. Called after the
|
|
164
|
+
* Resolve / tilde-expand step (or to short-circuit when the input is
|
|
165
|
+
* already absolute and doesn't need resolution).
|
|
166
|
+
*/
|
|
167
|
+
export function brandResolved(a: BaseArgs): ResolvedArgs {
|
|
168
|
+
return a as ResolvedArgs;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Stamp a `BaseArgs & InterceptedFields`-shaped value as `InterceptedArgs`.
|
|
173
|
+
* Only `applyWorktreeIntercept` should call this.
|
|
174
|
+
*/
|
|
175
|
+
export function brandIntercepted(a: BaseArgs & InterceptedFields): InterceptedArgs {
|
|
176
|
+
return a as InterceptedArgs;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Stage transitions (replace one or more fields, restamp the brand) ──────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Return a new `ResolvedArgs` with the given field overrides. Used by the
|
|
183
|
+
* Resolve step to swap `cwd` (and possibly `worktreeSet` / `worktreeArg`)
|
|
184
|
+
* without mutating the parsed value.
|
|
185
|
+
*/
|
|
186
|
+
export function withResolved(
|
|
187
|
+
a: ParsedArgs | ResolvedArgs,
|
|
188
|
+
overrides: Partial<BaseArgs>,
|
|
189
|
+
): ResolvedArgs {
|
|
190
|
+
return brandResolved({ ...(a as BaseArgs), ...overrides });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Return a new `InterceptedArgs` from a `ResolvedArgs` plus the
|
|
195
|
+
* intercept's outputs. `InterceptedFields` (currently just `worktreeMatched`)
|
|
196
|
+
* is mandatory in the overrides so the brand can't be applied without it.
|
|
197
|
+
*/
|
|
198
|
+
export function withIntercepted(
|
|
199
|
+
a: ResolvedArgs,
|
|
200
|
+
overrides: Partial<BaseArgs> & InterceptedFields,
|
|
201
|
+
): InterceptedArgs {
|
|
202
|
+
return brandIntercepted({ ...(a as BaseArgs), ...overrides });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Return a new `InterceptedArgs` with `passthrough` (or other fields)
|
|
207
|
+
* replaced. Used by the auto-name and sanitize steps — both operate on the
|
|
208
|
+
* passthrough slice and produce a new slice; the rest of the args carries
|
|
209
|
+
* through unchanged.
|
|
210
|
+
*
|
|
211
|
+
* Returns `InterceptedArgs` (not a separate "named" / "sanitized" type)
|
|
212
|
+
* because no new invariants are established at those steps — only the
|
|
213
|
+
* passthrough slice changes shape, and that's already an in-stage edit
|
|
214
|
+
* the intercept itself does.
|
|
215
|
+
*/
|
|
216
|
+
export function withPassthroughUpdate(
|
|
217
|
+
a: InterceptedArgs,
|
|
218
|
+
overrides: Partial<BaseArgs>,
|
|
219
|
+
): InterceptedArgs {
|
|
220
|
+
// Carry worktreeMatched through; only overrides win.
|
|
221
|
+
return brandIntercepted({
|
|
222
|
+
...(a as BaseArgs & InterceptedFields),
|
|
223
|
+
...overrides,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Back-compat re-export ──────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `Args` was the single mutable bag the pipeline threaded through before
|
|
231
|
+
* the stage-typed refactor. Retained as an alias for `InterceptedArgs` so
|
|
232
|
+
* external consumers (the published `index.ts` surface, test helpers in
|
|
233
|
+
* downstream tooling) keep working while the refactor lands. New code
|
|
234
|
+
* should use the stage-specific types directly.
|
|
235
|
+
*
|
|
236
|
+
* @deprecated Use `ParsedArgs` / `ResolvedArgs` / `InterceptedArgs` per
|
|
237
|
+
* the position in the pipeline.
|
|
238
|
+
*/
|
|
239
|
+
export type Args = InterceptedArgs;
|