@fnclaude/cli 1.1.1 → 2.0.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/bin/fnc.js +34 -79
- package/package.json +6 -9
- package/share/fnclaude/templates/handoff.template.md +11 -0
- package/src/argv/classify.ts +48 -0
- package/src/argv/expand.ts +51 -0
- package/src/argv/intake.ts +52 -0
- package/src/argv/magic.ts +103 -0
- package/src/argv/parse.ts +213 -0
- package/src/argv/preserve-args.ts +333 -0
- package/src/argv/sentinel.ts +41 -0
- package/src/argv/short-flags.ts +152 -0
- package/src/config/load.ts +116 -0
- package/src/handoff/awaiter.ts +140 -0
- package/src/handoff/clean-env.ts +45 -0
- package/src/handoff/kill-and-exec.ts +110 -0
- package/src/handoff/spawn-launcher.ts +185 -0
- package/src/handoff/summary-file.ts +86 -0
- package/src/handoff/trigger.ts +90 -0
- package/src/help-version.ts +151 -0
- package/src/launch/compose-env.ts +34 -0
- package/src/launch/cross-cwd-parse.ts +69 -0
- package/src/launch/cross-cwd-relaunch.ts +95 -0
- package/src/launch/find-claude.ts +52 -0
- package/src/launch/live-permission-reader.ts +133 -0
- package/src/launch/ring-buffer.ts +92 -0
- package/src/main.ts +580 -437
- package/src/mcp/dispatch.ts +240 -0
- package/src/mcp/handlers/clipboard-backends.ts +176 -0
- package/src/mcp/handlers/clipboard.ts +62 -0
- package/src/mcp/handlers/restart.ts +156 -0
- package/src/mcp/handlers/spawn.ts +219 -0
- package/src/mcp/handlers/switch.ts +272 -0
- package/src/mcp/inject-config.ts +59 -0
- package/src/mcp/jsonrpc-server.ts +154 -0
- package/src/mcp/listener.ts +141 -0
- package/src/mcp/parent-dispatch.ts +154 -0
- package/src/mcp/socket-path.ts +48 -0
- package/src/mcp/wire.ts +181 -0
- package/src/name/auto-name.ts +162 -0
- package/src/name/llm-prompt.ts +14 -0
- package/src/name/sanitize.ts +57 -0
- package/src/name/sdk-llm.ts +42 -0
- package/src/noop/seed.ts +63 -0
- package/src/noop/template-source.ts +62 -0
- package/src/path/ensure-cwd.ts +95 -0
- package/src/path/resolve.ts +58 -0
- package/src/prompts/dir.ts +61 -0
- package/src/prompts/load.ts +100 -0
- package/src/prompts/select.ts +43 -0
- package/src/repo/clone-exec.ts +37 -0
- package/src/repo/clone.ts +45 -0
- package/src/repo/gh-runner.ts +68 -0
- package/src/repo/host-aliases.ts +58 -0
- package/src/repo/owner-lookup.ts +71 -0
- package/src/repo/ref.ts +146 -0
- package/src/repo/repo-settings.ts +99 -0
- package/src/repo/resolve-input.ts +179 -0
- package/src/repo/template.ts +92 -0
- package/src/warnings/buffer.ts +39 -0
- package/src/worktree/auto-tmux.ts +45 -0
- package/src/worktree/git-list.ts +73 -0
- package/src/worktree/intercept.ts +150 -0
- package/bin/preflight.js +0 -66
- package/prompts/agent-pitfall.md +0 -1
- package/prompts/noop-router.md +0 -186
- package/prompts/project-switch.md +0 -64
- package/prompts/restart.md +0 -50
- package/prompts/spawn.md +0 -62
- package/src/argParser.ts +0 -367
- package/src/args/preserve.ts +0 -338
- package/src/args.ts +0 -239
- package/src/argv.ts +0 -219
- package/src/autoname.ts +0 -273
- package/src/clipboard.ts +0 -149
- package/src/config.ts +0 -369
- package/src/errors.ts +0 -13
- package/src/handoff.ts +0 -108
- package/src/help.ts +0 -139
- package/src/hostAliases.ts +0 -139
- package/src/index.ts +0 -120
- package/src/mcp/client.ts +0 -645
- package/src/mcp/protocol.ts +0 -445
- package/src/mcp/socketListener.ts +0 -540
- package/src/noop.ts +0 -106
- package/src/passthrough.ts +0 -36
- package/src/paths.ts +0 -55
- package/src/prompts.ts +0 -279
- package/src/pty/unix.ts +0 -429
- package/src/pty/windows.ts +0 -125
- package/src/pty.ts +0 -380
- package/src/repoRef.ts +0 -158
- package/src/repoSettings.ts +0 -144
- package/src/resolver.ts +0 -519
- package/src/sanitize.ts +0 -120
- package/src/sessionState.ts +0 -220
- package/src/silentRelaunch.ts +0 -178
- package/src/spawn.ts +0 -163
- package/src/template.ts +0 -44
- package/src/warnings.ts +0 -34
- package/src/worktree.ts +0 -201
package/bin/fnc.js
CHANGED
|
@@ -1,90 +1,45 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// @fnclaude/cli entry point — owns the Node→Bun preflight and re-execs
|
|
3
|
-
// itself under Bun when needed.
|
|
4
2
|
//
|
|
5
|
-
//
|
|
6
|
-
// `
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// umbrella package (`fnclaude`), which meant standalone installs of
|
|
11
|
-
// `@fnclaude/cli` skipped it and silently degraded (and, on the Bun
|
|
12
|
-
// side, hit the `--`-stripping bug). Owning the preflight here is the
|
|
13
|
-
// single source of truth.
|
|
3
|
+
// Node→Bun preflight. Bun 1.3.14 still strips the literal `--` sentinel from
|
|
4
|
+
// `process.argv` (see docs/decisions.md), which would corrupt `fnc -- <prompt>`
|
|
5
|
+
// invocations. Running this shim under Node first preserves the unstripped
|
|
6
|
+
// argv long enough to stuff it into FNC_ARGS_JSON, then re-execs under Bun
|
|
7
|
+
// where main.ts reads back from the env var instead of process.argv.
|
|
14
8
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// both workflows are first-class without a separate dev shim.
|
|
19
|
-
import { spawnSync } from 'node:child_process';
|
|
20
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
-
import { dirname, join, resolve } from 'node:path';
|
|
22
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
23
|
-
import { decide, defaultLookupBun } from './preflight.js';
|
|
9
|
+
// When this file is invoked directly under Bun (e.g. `bun bin/fnc.js`, or
|
|
10
|
+
// via the `#!/usr/bin/env bun` future state), `typeof Bun !== 'undefined'`
|
|
11
|
+
// short-circuits the preflight and we jump straight to main.
|
|
24
12
|
|
|
25
|
-
|
|
26
|
-
const here = dirname(selfPath);
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
27
14
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const pkg = JSON.parse(
|
|
34
|
-
readFileSync(join(here, '..', 'package.json'), 'utf8'),
|
|
35
|
-
);
|
|
36
|
-
process.stdout.write(`fnclaude ${pkg.version}\n`);
|
|
37
|
-
process.exit(0);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const decision = decide({
|
|
41
|
-
hasBun: typeof globalThis.Bun !== 'undefined',
|
|
42
|
-
lookupBun: defaultLookupBun,
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
if (decision.kind === 'error') {
|
|
46
|
-
process.stderr.write(`${decision.message}\n`);
|
|
47
|
-
process.exit(1);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (decision.kind === 'reexec') {
|
|
51
|
-
// Re-launch ourselves under Bun. `spawnSync` with stdio:'inherit'
|
|
52
|
-
// forwards streams transparently; we propagate the child's exit code
|
|
53
|
-
// so the OS / parent process sees the same status it would have seen
|
|
54
|
-
// had Bun been the launcher all along.
|
|
55
|
-
//
|
|
56
|
-
// Argv-via-env: Bun strips the first `--` from a script's argv,
|
|
57
|
-
// regardless of where it appears (script invocation, `bun --`, `bun
|
|
58
|
-
// run`, shebang). Confirmed empirically. So passing the user's args
|
|
59
|
-
// as Bun-script argv would silently mangle `fnc -- "prompt"` into
|
|
60
|
-
// `fnc "prompt"` — the cli then treats the prompt as a cwd
|
|
61
|
-
// positional, the resolver fires, and 8 GitHub orgs 404 in series
|
|
62
|
-
// before the user gets a misleading "could not resolve" error. We
|
|
63
|
-
// sidestep by serialising the user's args into FNC_ARGS_JSON; main()
|
|
64
|
-
// reads from there when present (and deletes the env var to avoid
|
|
65
|
-
// leaking to its own children).
|
|
66
|
-
const r = spawnSync(decision.bun, [selfPath], {
|
|
15
|
+
if (typeof Bun === 'undefined') {
|
|
16
|
+
const { spawnSync } = await import('node:child_process');
|
|
17
|
+
const self = fileURLToPath(import.meta.url);
|
|
18
|
+
const argvJson = JSON.stringify(process.argv.slice(2));
|
|
19
|
+
const result = spawnSync('bun', [self], {
|
|
67
20
|
stdio: 'inherit',
|
|
68
|
-
env: {
|
|
69
|
-
...process.env,
|
|
70
|
-
FNC_ARGS_JSON: JSON.stringify(argv),
|
|
71
|
-
},
|
|
21
|
+
env: { ...process.env, FNC_ARGS_JSON: argvJson },
|
|
72
22
|
});
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
23
|
+
if (result.error) {
|
|
24
|
+
const err = result.error;
|
|
25
|
+
const isMissingBun = /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT';
|
|
26
|
+
if (isMissingBun) {
|
|
27
|
+
process.stderr.write(
|
|
28
|
+
'fnc: Bun runtime not found on PATH.\n' +
|
|
29
|
+
' fnclaude requires Bun (Node alone is not supported).\n' +
|
|
30
|
+
' Install: https://bun.sh — `curl -fsSL https://bun.sh/install | bash`\n',
|
|
31
|
+
);
|
|
32
|
+
} else {
|
|
33
|
+
process.stderr.write(`fnc: failed to re-exec under bun (${err.message})\n`);
|
|
34
|
+
}
|
|
35
|
+
process.exit(127);
|
|
36
|
+
}
|
|
37
|
+
if (result.signal) {
|
|
38
|
+
process.kill(process.pid, result.signal);
|
|
39
|
+
// Unreachable on Unix; defensive return for Windows where kill-self doesn't terminate.
|
|
78
40
|
process.exit(1);
|
|
79
41
|
}
|
|
80
|
-
process.exit(
|
|
42
|
+
process.exit(result.status ?? 1);
|
|
81
43
|
}
|
|
82
44
|
|
|
83
|
-
|
|
84
|
-
// dispatch.
|
|
85
|
-
const distMain = resolve(here, '..', 'dist', 'main.js');
|
|
86
|
-
const srcMain = resolve(here, '..', 'src', 'main.ts');
|
|
87
|
-
const target = existsSync(distMain) ? distMain : srcMain;
|
|
88
|
-
const { main } = await import(pathToFileURL(target).href);
|
|
89
|
-
|
|
90
|
-
main();
|
|
45
|
+
await import('../src/main.ts');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fnclaude/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "fnclaude CLI implementation (TypeScript rewrite, in progress)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -9,28 +9,25 @@
|
|
|
9
9
|
"directory": "packages/cli"
|
|
10
10
|
},
|
|
11
11
|
"type": "module",
|
|
12
|
-
"main": "./
|
|
13
|
-
"types": "./dist/index.d.ts",
|
|
12
|
+
"main": "./src/main.ts",
|
|
14
13
|
"bin": {
|
|
15
14
|
"fnc": "./bin/fnc.js"
|
|
16
15
|
},
|
|
17
16
|
"files": [
|
|
18
17
|
"bin",
|
|
19
|
-
"
|
|
20
|
-
"prompts",
|
|
18
|
+
"share",
|
|
21
19
|
"src"
|
|
22
20
|
],
|
|
23
21
|
"scripts": {
|
|
24
|
-
"lint": "echo 'lint stub (cli)' && exit 0",
|
|
22
|
+
"lint": "echo 'lint stub (cli, rewrite window)' && exit 0",
|
|
25
23
|
"test": "bun test",
|
|
26
|
-
"build": "
|
|
24
|
+
"build": "echo 'no build step (bun runs ts directly)' && exit 0"
|
|
27
25
|
},
|
|
28
26
|
"publishConfig": {
|
|
29
27
|
"access": "public",
|
|
30
28
|
"provenance": true
|
|
31
29
|
},
|
|
32
30
|
"dependencies": {
|
|
33
|
-
"@anthropic-ai/sdk": "^0.
|
|
34
|
-
"node-pty": "1.2.0-beta.13"
|
|
31
|
+
"@anthropic-ai/sdk": "^0.99.0"
|
|
35
32
|
}
|
|
36
33
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!-- BURN AFTER READING -->
|
|
2
|
+
|
|
3
|
+
This file is a scratch handoff buffer claude writes when one noop session
|
|
4
|
+
hands the conversation off to another. The receiving session reads it,
|
|
5
|
+
acts on it, and is free to overwrite or delete it.
|
|
6
|
+
|
|
7
|
+
Treat anything below this line as the previous session's outgoing brief.
|
|
8
|
+
If the file is otherwise empty, no prior session has written one yet —
|
|
9
|
+
that's the normal first-launch state.
|
|
10
|
+
|
|
11
|
+
---
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token classification. Pure functions over a single argv token.
|
|
3
|
+
*
|
|
4
|
+
* The Go canonical does this inline inside its argv loop; here it's
|
|
5
|
+
* lifted out as a pure function so the magic-positional state machine
|
|
6
|
+
* (§2.3) and short-flag expander (§4.5) can share the same classifier
|
|
7
|
+
* without restating the alphabets.
|
|
8
|
+
*
|
|
9
|
+
* Case-sensitive on the magic words — `Opus`, `OPUS`, etc. are positionals.
|
|
10
|
+
* Matches Go canonical, which does exact string comparison against
|
|
11
|
+
* lowercase literals.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const MODELS = ['opus', 'sonnet', 'haiku'] as const;
|
|
15
|
+
export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'auto'] as const;
|
|
16
|
+
|
|
17
|
+
export type Model = (typeof MODELS)[number];
|
|
18
|
+
export type Effort = (typeof EFFORTS)[number];
|
|
19
|
+
export type CanonicalSubcommand = 'resume' | 'continue' | 'fork';
|
|
20
|
+
|
|
21
|
+
export const SUBCOMMAND_ALIASES: Readonly<Record<string, CanonicalSubcommand>> = Object.freeze({
|
|
22
|
+
resume: 'resume',
|
|
23
|
+
res: 'resume',
|
|
24
|
+
continue: 'continue',
|
|
25
|
+
con: 'continue',
|
|
26
|
+
fork: 'fork',
|
|
27
|
+
fk: 'fork',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const MODEL_SET = new Set<string>(MODELS);
|
|
31
|
+
const EFFORT_SET = new Set<string>(EFFORTS);
|
|
32
|
+
|
|
33
|
+
export type TokenKind = 'flag' | 'model' | 'effort' | 'subcommand' | 'positional';
|
|
34
|
+
|
|
35
|
+
export function classifyToken(tok: string): TokenKind {
|
|
36
|
+
// Flag-shape check is first because tokens starting with `-` shouldn't
|
|
37
|
+
// ever match a magic word alphabet (none start with `-`), and a leading
|
|
38
|
+
// `-` is the cheapest possible discriminator.
|
|
39
|
+
if (tok.startsWith('-')) return 'flag';
|
|
40
|
+
if (MODEL_SET.has(tok)) return 'model';
|
|
41
|
+
if (EFFORT_SET.has(tok)) return 'effort';
|
|
42
|
+
if (tok in SUBCOMMAND_ALIASES) return 'subcommand';
|
|
43
|
+
return 'positional';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function canonicalSubcommand(tok: string): CanonicalSubcommand | null {
|
|
47
|
+
return SUBCOMMAND_ALIASES[tok] ?? null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expand magic-captured aliases into the passthrough flag list claude
|
|
3
|
+
* eventually receives. The parser captures `model`/`effort`/`subcommand`
|
|
4
|
+
* into structured fields; this module emits the corresponding
|
|
5
|
+
* `--model`/`--effort`/`--resume`/`--fork-session` flags and prepends
|
|
6
|
+
* them to the original passthrough.
|
|
7
|
+
*
|
|
8
|
+
* Order: magic flags first (so explicit user-supplied flags appear later
|
|
9
|
+
* and win via claude's last-occurrence semantics). Within the magic
|
|
10
|
+
* block: model → effort → subcommand.
|
|
11
|
+
*
|
|
12
|
+
* §4.3 (effort-without-model → opus) is already handled in the parser
|
|
13
|
+
* by setting `model = 'opus'` when a bare effort is captured at the
|
|
14
|
+
* first magic slot; this module just emits whatever the parser captured.
|
|
15
|
+
*
|
|
16
|
+
* Subcommand mapping (§4.4 / design.md §1):
|
|
17
|
+
* resume → --resume
|
|
18
|
+
* continue → --continue
|
|
19
|
+
* fork → --resume --fork-session
|
|
20
|
+
*
|
|
21
|
+
* Mirrors Go canonical `buildClaudeArgs` (`src/main.go` near the magic
|
|
22
|
+
* + subcommand merge point).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { CanonicalSubcommand } from './classify.ts';
|
|
26
|
+
import type { ParsedArgsOk } from './parse.ts';
|
|
27
|
+
|
|
28
|
+
const SUBCOMMAND_FLAGS: Record<CanonicalSubcommand, readonly string[]> = {
|
|
29
|
+
resume: ['--resume'],
|
|
30
|
+
continue: ['--continue'],
|
|
31
|
+
fork: ['--resume', '--fork-session'],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function expandAliases(parsed: ParsedArgsOk): string[] {
|
|
35
|
+
const out: string[] = [];
|
|
36
|
+
|
|
37
|
+
if (parsed.model !== null) {
|
|
38
|
+
out.push('--model', parsed.model);
|
|
39
|
+
}
|
|
40
|
+
if (parsed.effort !== null) {
|
|
41
|
+
out.push('--effort', parsed.effort);
|
|
42
|
+
}
|
|
43
|
+
if (parsed.subcommand !== null) {
|
|
44
|
+
for (const f of SUBCOMMAND_FLAGS[parsed.subcommand]) out.push(f);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Append the original passthrough verbatim.
|
|
48
|
+
for (const tok of parsed.passthrough) out.push(tok);
|
|
49
|
+
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argv intake. Reads the user's command-line arguments, working around
|
|
3
|
+
* bun's argv-stripping behavior via the Node-shebang preflight indirection.
|
|
4
|
+
*
|
|
5
|
+
* When invoked under Node first, `bin/fnc.js`'s preflight stuffs the raw
|
|
6
|
+
* argv into `FNC_ARGS_JSON` and re-execs under Bun. By the time main.ts
|
|
7
|
+
* runs, `process.argv` may have lost its `--` sentinel (bun 1.3.14 still
|
|
8
|
+
* strips it; see `docs/decisions.md`), so we prefer the env var.
|
|
9
|
+
*
|
|
10
|
+
* Direct `bun fnc.js` invocations (no preflight) still work — there's
|
|
11
|
+
* just no way to recover `--` in that path.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const ENV_KEY = 'FNC_ARGS_JSON';
|
|
15
|
+
|
|
16
|
+
function warnStderr(msg: string): void {
|
|
17
|
+
try {
|
|
18
|
+
Bun.write(Bun.stderr, `fnc: ${msg}\n`);
|
|
19
|
+
} catch {
|
|
20
|
+
// Stderr write failure is non-fatal — the fallback still applies.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function tryParseArgsJson(raw: string): readonly string[] | null {
|
|
25
|
+
let parsed: unknown;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(raw);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
warnStderr(`${ENV_KEY} is not valid JSON (${(err as Error).message}); falling back to process.argv`);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (!Array.isArray(parsed)) {
|
|
33
|
+
warnStderr(`${ENV_KEY} did not parse to an array; falling back to process.argv`);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
for (const el of parsed) {
|
|
37
|
+
if (typeof el !== 'string') {
|
|
38
|
+
warnStderr(`${ENV_KEY} contains a non-string element; falling back to process.argv`);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return parsed as readonly string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readArgv(): readonly string[] {
|
|
46
|
+
const raw = process.env[ENV_KEY];
|
|
47
|
+
if (raw !== undefined) {
|
|
48
|
+
const parsed = tryParseArgsJson(raw);
|
|
49
|
+
if (parsed !== null) return parsed;
|
|
50
|
+
}
|
|
51
|
+
return process.argv.slice(2);
|
|
52
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magic-positional state machine.
|
|
3
|
+
*
|
|
4
|
+
* Walks the prefix of argv consuming model/effort/subcommand tokens per
|
|
5
|
+
* the rules in docs/design.md §1. Subcommands are order-independent and
|
|
6
|
+
* do not advance the model/effort state; model+effort move through a
|
|
7
|
+
* three-state machine:
|
|
8
|
+
*
|
|
9
|
+
* state 0 → next token may be model OR effort (effort implies opus,
|
|
10
|
+
* rewrite extension)
|
|
11
|
+
* state 1 → next token may be effort (model already consumed)
|
|
12
|
+
* state 2 → magic scanning is done
|
|
13
|
+
*
|
|
14
|
+
* Magic also ends as soon as a flag (token starting with `-`) is seen.
|
|
15
|
+
*
|
|
16
|
+
* Only one subcommand per invocation; the second is an error.
|
|
17
|
+
*
|
|
18
|
+
* The fn returns `consumed` (the count of leading tokens absorbed by the
|
|
19
|
+
* scan) rather than slicing — the caller still owns the original argv
|
|
20
|
+
* for downstream phases that need indices into it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
canonicalSubcommand,
|
|
25
|
+
classifyToken,
|
|
26
|
+
type CanonicalSubcommand,
|
|
27
|
+
type Effort,
|
|
28
|
+
type Model,
|
|
29
|
+
} from './classify.ts';
|
|
30
|
+
|
|
31
|
+
export interface MagicResultOk {
|
|
32
|
+
ok: true;
|
|
33
|
+
model: Model | null;
|
|
34
|
+
effort: Effort | null;
|
|
35
|
+
subcommand: CanonicalSubcommand | null;
|
|
36
|
+
consumed: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MagicResultErr {
|
|
40
|
+
ok: false;
|
|
41
|
+
error: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type MagicResult = MagicResultOk | MagicResultErr;
|
|
45
|
+
|
|
46
|
+
const ERR_DUPLICATE_SUBCOMMAND = 'fnc: only one of resume/continue/fork may be used per invocation';
|
|
47
|
+
|
|
48
|
+
export function scanMagic(args: readonly string[]): MagicResult {
|
|
49
|
+
let model: Model | null = null;
|
|
50
|
+
let effort: Effort | null = null;
|
|
51
|
+
let subcommand: CanonicalSubcommand | null = null;
|
|
52
|
+
let state: 0 | 1 | 2 = 0;
|
|
53
|
+
let i = 0;
|
|
54
|
+
|
|
55
|
+
while (i < args.length) {
|
|
56
|
+
const tok = args[i]!;
|
|
57
|
+
const kind = classifyToken(tok);
|
|
58
|
+
|
|
59
|
+
if (kind === 'flag') break;
|
|
60
|
+
|
|
61
|
+
if (kind === 'subcommand') {
|
|
62
|
+
if (subcommand !== null) {
|
|
63
|
+
return { ok: false, error: ERR_DUPLICATE_SUBCOMMAND };
|
|
64
|
+
}
|
|
65
|
+
subcommand = canonicalSubcommand(tok);
|
|
66
|
+
i++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (state === 0) {
|
|
71
|
+
if (kind === 'model') {
|
|
72
|
+
model = tok as Model;
|
|
73
|
+
state = 1;
|
|
74
|
+
i++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (kind === 'effort') {
|
|
78
|
+
// Rewrite extension: effort-only at pos 1 implies opus.
|
|
79
|
+
effort = tok as Effort;
|
|
80
|
+
model = 'opus';
|
|
81
|
+
state = 2;
|
|
82
|
+
i++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (state === 1) {
|
|
89
|
+
if (kind === 'effort') {
|
|
90
|
+
effort = tok as Effort;
|
|
91
|
+
state = 2;
|
|
92
|
+
i++;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// state === 2 — magic is done, leave the rest to downstream phases.
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { ok: true, model, effort, subcommand, consumed: i };
|
|
103
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full argv parser. Consumes the user's raw argv (post-intake) and produces
|
|
3
|
+
* a structured Args record for the launcher and MCP pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors Go canonical `parseArgs` (`fnclaude@fnrhombus/src/main.go:130–343`):
|
|
6
|
+
* one left-to-right walk with a sticky `inFlags` boundary. Magic positionals
|
|
7
|
+
* (model/effort/subcommand) consume the leading prefix; then a maximum of
|
|
8
|
+
* two non-flag positionals fill firstPath and worktreeArg; once the first
|
|
9
|
+
* flag-shape token is seen, all subsequent tokens are flag territory.
|
|
10
|
+
*
|
|
11
|
+
* fnclaude-eaten flags (NOT forwarded to claude):
|
|
12
|
+
* --no-tmux → noTmux = true
|
|
13
|
+
* -A | --also <dir> → push to extraDirs
|
|
14
|
+
* -A=<dir> | --also=<dir>
|
|
15
|
+
* -w | --worktree <name> → worktreeSet, worktreeArg
|
|
16
|
+
* -w=<name> | --worktree=<name>
|
|
17
|
+
* bare -w or --worktree → worktreeSet, worktreeArg = ''
|
|
18
|
+
*
|
|
19
|
+
* Subcommand tokens (resume/res/continue/con/fork/fk) are recognized at
|
|
20
|
+
* any positional slot, are order-independent with magic, and do not
|
|
21
|
+
* advance the magic state. At most one per invocation.
|
|
22
|
+
*
|
|
23
|
+
* Short-flag clusters (-BVC, -BVCM plan) are NOT expanded here — they
|
|
24
|
+
* land in passthrough verbatim and §4.5 transforms them later. Unknown
|
|
25
|
+
* long flags also pass through unchanged.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
canonicalSubcommand,
|
|
30
|
+
classifyToken,
|
|
31
|
+
type CanonicalSubcommand,
|
|
32
|
+
type Effort,
|
|
33
|
+
type Model,
|
|
34
|
+
} from './classify.ts';
|
|
35
|
+
|
|
36
|
+
export interface ParsedArgsOk {
|
|
37
|
+
ok: true;
|
|
38
|
+
model: Model | null;
|
|
39
|
+
effort: Effort | null;
|
|
40
|
+
subcommand: CanonicalSubcommand | null;
|
|
41
|
+
firstPath: string | null;
|
|
42
|
+
worktreeSet: boolean;
|
|
43
|
+
worktreeArg: string;
|
|
44
|
+
extraDirs: string[];
|
|
45
|
+
noTmux: boolean;
|
|
46
|
+
passthrough: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ParsedArgsErr {
|
|
50
|
+
ok: false;
|
|
51
|
+
error: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ParsedArgs = ParsedArgsOk | ParsedArgsErr;
|
|
55
|
+
|
|
56
|
+
const ERR_DUPLICATE_SUBCOMMAND = 'fnc: only one of resume/continue/fork may be used per invocation';
|
|
57
|
+
const ERR_TOO_MANY_POSITIONALS = (got: string): string =>
|
|
58
|
+
`fnc: too many positional arguments (got ${JSON.stringify(got)}; max is 2 — cwd and worktree-name)`;
|
|
59
|
+
|
|
60
|
+
export function parseArgs(args: readonly string[]): ParsedArgs {
|
|
61
|
+
let model: Model | null = null;
|
|
62
|
+
let effort: Effort | null = null;
|
|
63
|
+
let subcommand: CanonicalSubcommand | null = null;
|
|
64
|
+
let firstPath: string | null = null;
|
|
65
|
+
let worktreeSet = false;
|
|
66
|
+
let worktreeArg = '';
|
|
67
|
+
const extraDirs: string[] = [];
|
|
68
|
+
let noTmux = false;
|
|
69
|
+
const passthrough: string[] = [];
|
|
70
|
+
|
|
71
|
+
let magicState: 0 | 1 | 2 = 0;
|
|
72
|
+
let inFlags = false;
|
|
73
|
+
|
|
74
|
+
let i = 0;
|
|
75
|
+
while (i < args.length) {
|
|
76
|
+
const tok = args[i]!;
|
|
77
|
+
|
|
78
|
+
// ── Positional territory (before the first flag-shape token) ────────────
|
|
79
|
+
if (!inFlags && !tok.startsWith('-')) {
|
|
80
|
+
const kind = classifyToken(tok);
|
|
81
|
+
|
|
82
|
+
// Subcommand: any positional slot, doesn't advance magic state.
|
|
83
|
+
if (kind === 'subcommand') {
|
|
84
|
+
if (subcommand !== null) {
|
|
85
|
+
return { ok: false, error: ERR_DUPLICATE_SUBCOMMAND };
|
|
86
|
+
}
|
|
87
|
+
subcommand = canonicalSubcommand(tok);
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Magic state 0 → check model OR effort (effort implies opus).
|
|
93
|
+
if (magicState === 0) {
|
|
94
|
+
if (kind === 'model') {
|
|
95
|
+
model = tok as Model;
|
|
96
|
+
magicState = 1;
|
|
97
|
+
i++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (kind === 'effort') {
|
|
101
|
+
effort = tok as Effort;
|
|
102
|
+
model = 'opus';
|
|
103
|
+
magicState = 2;
|
|
104
|
+
i++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Not magic — fall through to positional slot assignment, but advance
|
|
108
|
+
// magicState so position 2's effort check doesn't re-fire.
|
|
109
|
+
magicState = 2;
|
|
110
|
+
} else if (magicState === 1) {
|
|
111
|
+
// Magic state 1 → check effort (model was matched at state 0).
|
|
112
|
+
if (kind === 'effort') {
|
|
113
|
+
effort = tok as Effort;
|
|
114
|
+
magicState = 2;
|
|
115
|
+
i++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
magicState = 2;
|
|
119
|
+
}
|
|
120
|
+
// magicState === 2 from here on.
|
|
121
|
+
|
|
122
|
+
// Positional slot assignment.
|
|
123
|
+
if (firstPath === null) {
|
|
124
|
+
firstPath = tok;
|
|
125
|
+
} else if (!worktreeSet) {
|
|
126
|
+
worktreeSet = true;
|
|
127
|
+
worktreeArg = tok;
|
|
128
|
+
} else {
|
|
129
|
+
return { ok: false, error: ERR_TOO_MANY_POSITIONALS(tok) };
|
|
130
|
+
}
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Flag territory (sticky) ─────────────────────────────────────────────
|
|
136
|
+
inFlags = true;
|
|
137
|
+
|
|
138
|
+
// fnclaude-eaten: --no-tmux
|
|
139
|
+
if (tok === '--no-tmux') {
|
|
140
|
+
noTmux = true;
|
|
141
|
+
i++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// fnclaude-eaten: -A / --also (extra dirs)
|
|
146
|
+
if (tok === '-A' || tok === '--also') {
|
|
147
|
+
const next = args[i + 1];
|
|
148
|
+
if (next === undefined || next.startsWith('-')) {
|
|
149
|
+
const which = next === undefined ? tok : `${tok} ${next}`;
|
|
150
|
+
return { ok: false, error: `fnc: ${which} requires a directory argument` };
|
|
151
|
+
}
|
|
152
|
+
extraDirs.push(next);
|
|
153
|
+
i += 2;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (tok.startsWith('-A=')) {
|
|
157
|
+
const val = tok.slice(3);
|
|
158
|
+
if (val === '') return { ok: false, error: 'fnc: -A= requires a directory argument' };
|
|
159
|
+
extraDirs.push(val);
|
|
160
|
+
i++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (tok.startsWith('--also=')) {
|
|
164
|
+
const val = tok.slice(7);
|
|
165
|
+
if (val === '') return { ok: false, error: 'fnc: --also= requires a directory argument' };
|
|
166
|
+
extraDirs.push(val);
|
|
167
|
+
i++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// fnclaude-eaten: -w / --worktree (worktree flag)
|
|
172
|
+
if (tok === '-w' || tok === '--worktree') {
|
|
173
|
+
worktreeSet = true;
|
|
174
|
+
const next = args[i + 1];
|
|
175
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
176
|
+
worktreeArg = next;
|
|
177
|
+
i += 2;
|
|
178
|
+
} else {
|
|
179
|
+
i++;
|
|
180
|
+
}
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (tok.startsWith('-w=')) {
|
|
184
|
+
worktreeSet = true;
|
|
185
|
+
worktreeArg = tok.slice(3);
|
|
186
|
+
i++;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (tok.startsWith('--worktree=')) {
|
|
190
|
+
worktreeSet = true;
|
|
191
|
+
worktreeArg = tok.slice('--worktree='.length);
|
|
192
|
+
i++;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Everything else (including `--` and following prompt body) → passthrough
|
|
197
|
+
passthrough.push(tok);
|
|
198
|
+
i++;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
model,
|
|
204
|
+
effort,
|
|
205
|
+
subcommand,
|
|
206
|
+
firstPath,
|
|
207
|
+
worktreeSet,
|
|
208
|
+
worktreeArg,
|
|
209
|
+
extraDirs,
|
|
210
|
+
noTmux,
|
|
211
|
+
passthrough,
|
|
212
|
+
};
|
|
213
|
+
}
|