SFEP-0009
CLI Modularization
- Status
- Superseded
- Type
- tooling
- Created
- Updated
- Author
- agent:compiler-architect
- Tracking
- #351
Superseded by SFEP-0027 (
0027-cli-rss-modularization.md), 2026-06-26. This proposal predates the runtime migration (#822); its inventory (cli_main.sfn= 1,534 lines) andtracking: "#345"pointer are stale — the CLI epic is #351, andcli_main.sfnhas since grown to 3,067 lines as build/ link orchestration bled in. SFEP-0027 re-anchors the epic on per-worker peak RSS (the live CI--jobsblocker) and sequences RSS-relief first. Retained for historical context.
CLI Modularization Epic
Status: Draft Date: 2026-05-06 Owner: Core team (compiler-architect)
1. Motivation
Sailfin’s CLI today is split between three places:
- The
sfn/clicapsule (capsules/sfn/cli/) — a single 766-linemod.sfncontaining types, builders, parser, accessors, help formatting, and ANSI helpers. Its tests redefine the API as private_command/_with_arghelpers because cross-capsule import in the test runner has been intermittent. Marketed as a library, but no production caller imports it. - The compiler CLI (
compiler/src/cli_*.sfn) — ~4,500 lines of hand-rolled argv-walking, per-subcommandif strings_equal(args[i], "--flag")chains, open-coded help strings, ad-hoc error printing, and a long pile of filesystem / shell helpers (cli_commands_utils.sfn) that have nothing to do with the CLI. - Cross-cutting bleed in
compiler/src/main.sfn, which imports_env_flag/_legacy_flag_filefromcli_commands_utilsto parse env flags inside a non-CLI module.
Pain this epic removes:
- DRY. Every subcommand reinvents flag parsing. Adding
--jsontobuild,run,emit, andchecktoday means four manual edits. - Testability.
sfn/cli’srun()callsprocess.exit()on--help,--version, and parse errors. There is no path for a test or library consumer to drive parsing, observe errors, and recover. - Agent adoption. The MCP server (
tools/mcp-server/) and any agentic compile-check-fix loop want a structured command tree they can introspect. The current CLI exposes none. - Capsule positioning.
sfn/cliis the single most reusable library Sailfin can ship in 1.0. If the compiler itself doesn’t consume it, third-party users have no reference and no incentive to trust it. Result<T, E>migration. Once typed errors land (Phase 1 of runtime sequencing), parse errors should flow throughResult. A monolithicmod.sfnis harder to retrofit than a modularized parser.- Roadmap traction. Structured
--help --json, machine-readable diagnostics, and shell completion all read off the same command tree. The epic’s deliverable is the tree.
Now, because (a) the capsule is small enough that the split is one mechanical refactor, (b) the compiler CLI is the single largest non-pipeline module set in the tree, and (c) every 1.0 milestone — runtime rewrite, effect-as-gate, MCP server — benefits from a unified CLI surface.
2. Goals & non-goals
Goals:
- Split
sfn/cliinto focused internal modules (parser / help / style / print / types) behind a stable public re-export inmod.sfn. - Expand the public API to cover real CLI needs: typed flag values, env bindings, required/exclusive flag groups, variadic positionals, non-exiting parse, NO_COLOR/TTY-aware styling, structured help tree, canonical exit-code constants.
- Migrate the compiler’s CLI to import
sfn/clifrom the capsule. Onecompiler/src/cli/directory with one file per subcommand replacescli_main.sfn+cli_commands.sfn. - Move filesystem / shell / crypto helpers out of
cli_commands_utils.sfnto the appropriate stdlib capsule (sfn/fs,sfn/path,sfn/crypto) or to a tightly scopedcompiler/src/cli/util.sfnfor genuinely compiler-specific helpers. - Flip the capsule tests to import the public surface instead of redefining it.
Non-goals:
- Do not block on
Result<T, E>or the?operator. Use the existing “result struct withokflag” pattern that the rest of the compiler already uses (seeparser_state.sfn,effect_checker.sfn). Re-shape toResultpost-Phase-1 in a follow-up. - Do not change the effect taxonomy. The capsule keeps
["io"]as its only required effect. Pure parsing functions stay effect-free. - Do not invent a DSL. No macros, no custom syntax, no
deriveattributes. Builder functions only. Boring syntax wins. - Do not ship shell completion or
--help --jsonas part of the core migration. Both ride on the structured command tree this epic produces, but each is its own follow-up issue. - Do not add new keywords.
sfn/cliis and will remain a library. - Do not break the seed. Every phase must self-host with the current released seed binary; the migration uses additive features only until the next seed is cut.
3. Current-state inventory
The table below is the source of truth for “what lives where today” and “where it lands after the migration.” Each row maps to one or more issues in section 7.
3.1 capsules/sfn/cli/
| Today | Lines | Lands in |
|---|---|---|
src/mod.sfn — types Arg/Flag/Command/Matches |
31–62 | src/types.sfn (re-exported by mod.sfn) |
src/mod.sfn — builders arg, arg_optional, flag*, command, with_* |
64–215 | src/builder.sfn |
src/mod.sfn — run() (exiting parse) |
219–373 | src/parser.sfn (kept as run(); parse() added alongside) |
src/mod.sfn — accessors get, get_or, has_flag, has, subcommand, positionals |
377–466 | src/matches.sfn |
src/mod.sfn — help formatting _format_help, _flag_label, width helpers |
470–615 | src/help.sfn |
src/mod.sfn — ANSI styling bold, dim, color helpers, _esc, _ansi_wrap |
624–678 | src/style.sfn |
src/mod.sfn — print_error/warn/success/hint |
682–700 | src/print.sfn |
src/mod.sfn — internal helpers _starts_with, _pad, _find_flag_*, _resolve_active, _help_hint |
704–765 | src/parser.sfn (private) |
tests/cli_test.sfn — redefines API privately |
all 1032 | rewritten to import from mod.sfn |
3.2 compiler/src/cli_main.sfn (1,534 lines)
| Today | Lines | Lands in |
|---|---|---|
_usage() — hand-concatenated multi-line string |
72–108 | deleted; auto-generated from Command tree by sfn/cli |
_ends_with, _path_join, _dirname, _path_basename, _path_strip_ext |
110–322 | sfn/path (or compiler/src/cli/util.sfn if kept compiler-local) |
_write_llvm_text, _print_cache_summary, _emit_capsule_artifact_sidecar, _emit_build_report |
136–250 | unchanged location, but invoked from compiler/src/cli/commands/build.sfn |
_runtime_bundle_exists, _runtime_prelude_path |
257–289 | compiler/src/cli/runtime_root.sfn |
Internal --runtime-root / --binary-dir parsing (driver-injected) |
892–916 | compiler/src/cli/context.sfn (CliContext struct) |
is_version_flag / is_emit_flag / is_build / … dispatch chain |
957–988, 1523 | replaced by sfn/cli Command tree dispatch |
Per-subcommand argv walking (base_index, consumed_flag loops) for emit, build, run |
1000–1521 | each subcommand owns flag definitions in compiler/src/cli/commands/<name>.sfn |
sailfin_cli_main(argv) entry signature |
876–1531 | thin orchestrator in compiler/src/cli/main.sfn |
native_cli_main(argv) C-shim entry |
1532–1534 | preserved in compiler/src/cli/main.sfn |
3.3 compiler/src/cli_commands.sfn (1,959 lines)
One handle_<name>_command per subcommand. Each one re-implements argv
walking and prints its own help. Migration moves each to
compiler/src/cli/commands/<name>.sfn:
| Function | Subcommand | Lands in |
|---|---|---|
handle_test_command |
sfn test |
compiler/src/cli/commands/test.sfn |
handle_config_command |
sfn config |
compiler/src/cli/commands/config.sfn |
handle_publish_command |
sfn publish |
compiler/src/cli/commands/publish.sfn |
handle_package_command |
sfn package |
compiler/src/cli/commands/package.sfn |
handle_add_command |
sfn add |
compiler/src/cli/commands/add.sfn |
handle_init_command |
sfn init |
compiler/src/cli/commands/init.sfn |
handle_login_command |
sfn login |
compiler/src/cli/commands/login.sfn |
handle_guillermo_command |
sfn guillermo |
compiler/src/cli/commands/guillermo.sfn |
handle_fmt_command |
sfn fmt |
compiler/src/cli/commands/fmt.sfn |
_test_*, _package_*, _clang_link_test_cmd_with_deps |
(private) | stay near the subcommand that uses them |
_config_*, _default_registry_url_cmd, _resolve_registry_url_cmd |
(registry policy) | compiler/src/cli/registry.sfn (compiler-specific config policy) |
3.4 compiler/src/cli_commands_utils.sfn (507 lines)
Most of this file does not belong in any cli_* module. The migration
breaks it up:
| Function group | Today | Target |
|---|---|---|
_ends_with_cmd, _dirname_cmd, _path_join_cmd, _has_prefix_cmd, _string_contains_cmd |
path/string utils | sfn/path + sfn/strings |
_toml_trim_cmd, _byte_at_cmd, _word_matches_cmd, _is_ident_char_cmd |
token utils | compiler/src/string_utils.sfn (already exists; consolidate) |
_shell_read_cmd, _curl_post_json_cmd, _curl_download_cmd, _shell_single_quote_arg |
shell + http | sfn/http (curl wrappers); compiler/src/cli/shell.sfn (compiler-local) |
_get_env_cmd, _get_home_cmd, _env_flag, _legacy_flag_file |
env policy | sfn/os + a thin compiler/src/cli/env.sfn for the legacy flag-file shim |
_ensure_dir_cmd, _ensure_dir_recursive_cmd, _write_text_cmd, _collect_test_files_cmd, _collect_sfn_files_cmd |
fs | sfn/fs |
_sha256_of_file_cmd |
crypto | sfn/crypto |
_extract_sfnpkg_cmd |
sfnpkg archive format | compiler/src/cli/sfnpkg.sfn (compiler-local; sfnpkg is a tooling concern, not stdlib) |
_is_stdlib_capsule_cmd, _has_slash_cmd, _resolve_capsule_name_cmd, _display_capsule_name_cmd, _sanitize_filename_cmd, _is_safe_capsule_segment_cmd, _is_safe_capsule_version_cmd |
capsule-name validation | compiler/src/capsule_artifact.sfn (already hosts is_safe_scope_name; consolidate) |
_string_list_contains_cmd |
array util | compiler/src/array_utils.sfn (or sfn/strings) |
3.5 compiler/src/cli_check.sfn (476 lines)
Already isolated — leave the body intact; rename to
compiler/src/cli/commands/check.sfn and update its imports during the
Phase 3 migration.
3.6 compiler/src/main.sfn cross-bleed
main.sfn:5 imports _env_flag, _legacy_flag_file from
cli_commands_utils. These are env-policy helpers — they belong in
compiler/src/cli/env.sfn (re-exported), or, better, sfn/os. The
import chain reverses post-migration: cli imports from os, not
the other way around. main.sfn either imports sfn/os directly or
keeps a tiny compiler/src/env_flags.sfn that re-exports the two
functions for non-CLI callers.
4. Target architecture
4.1 Capsule layout — capsules/sfn/cli/
capsules/sfn/cli/ capsule.toml # version bump 0.1.1 -> 0.2.0 src/ mod.sfn # re-exports public surface only types.sfn # Arg, Flag, Command, Matches, ParseError, ParseResult, ExitCode builder.sfn # arg, flag, flag_int, flag_choice, command, with_* parser.sfn # parse() (non-exiting), run() (exits, unchanged) matches.sfn # get, get_or, has_flag, has, subcommand, positionals, # get_int, get_float, get_path, get_choice, count_flag help.sfn # render_help_text(), render_help_tree() (structured) style.sfn # bold, dim, color helpers + NO_COLOR/TTY gate print.sfn # print_error/warn/success/hint/diag/status exit_codes.sfn # EXIT_OK, EXIT_BUILD_FAIL, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_INTERNAL tests/ cli_test.sfn # imports from "sfn/cli" — no inlined redefinitions parser_test.sfn # focused parser coverage typed_flags_test.sfn # int/float/path/choice/repeat/count style_test.sfn # NO_COLOR + TTY gating help_tree_test.sfn # structured help tree shape (for --help --json)Public surface (src/mod.sfn re-exports only these — anything not listed
is internal):
// TypesArg, Flag, Command, Matches, ParseError, ParseResult, ExitCode, FlagKind,HelpNode, Style
// Argument buildersarg, arg_optional, arg_variadic
// Flag buildersflag, flag_short, flag_value, flag_value_short,flag_int, flag_float, flag_path, flag_choice, flag_repeat, flag_count,flag_required, flag_env
// Group constraintsflag_group_exclusive, flag_group_required_one
// Command builderscommand, with_version, with_arg, with_flag, with_subcommand,with_about, with_long_about
// Parsingparse, // -> ParseResult (no exit, no print)run, // -> Matches ![io] (existing semantics; calls process.exit())
// Matches accessorsget, get_or, has_flag, has, subcommand, positionals,get_int, get_float, get_path, get_choice, count_flag, repeated
// Helphelp, render_help_text, render_help_tree, print_help
// Stylebold, dim, underline, red, green, yellow, blue, magenta, cyan, gray,style_default, style_disabled, style_for_stream, no_color_active
// Printprint_error, print_warn, print_success, print_hint, print_diag, print_status
// Exit codesEXIT_OK, EXIT_BUILD_FAIL, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_INTERNALNotes on the new types:
ParseError— struct{ kind: string; message: string; hint: string; exit_code: number }withkind∈{ "unknown_flag", "missing_value", "missing_required_arg", "missing_required_flag", "unknown_subcommand", "type_error", "exclusive_violation", "help_requested", "version_requested" }.missing_required_argcovers positionals;missing_required_flagcovers flags marked viaflag_required(added in Issue 1.8).help_requestedandversion_requestedare not errors per se but reuse the result channel to keepparse()non-exiting; the caller decides whether to print and exit.ParseResult— sentinel-flag struct{ ok: boolean; matches: Matches; error: ParseError }. Switches toResult<Matches, ParseError>once the language ships it (Phase 1 of runtime sequencing).FlagKind— enum-via-string{ "bool", "string", "int", "float", "path", "choice", "repeat", "count" }. Keeps the parser type-erased while stdlib lacks discriminated unions.HelpNode— recursive struct mirroring theCommandtree but flattened for renderers (text + JSON). Letsrender_help_textand a futurerender_help_jsonshare the structure.Style— struct{ ansi_enabled: boolean; force: boolean }so callers don’t sprinkle raw ANSI.style_for_stream(stream_name)returns the rightStylefor stdout vs stderr (TTY check + NO_COLOR).
4.2 Compiler CLI layout — compiler/src/cli/
compiler/src/cli/ main.sfn # sailfin_cli_main + native_cli_main; build the # Command tree, call sfn/cli parse(), dispatch. context.sfn # CliContext { runtime_root, binary_dir, # registry_url, trace_argv }. # Parses --runtime-root / --binary-dir prefix. registry.sfn # registry URL resolution + config-file policy. runtime_root.sfn # _runtime_bundle_exists, _runtime_prelude_path. shell.sfn # _shell_read, _shell_single_quote_arg # (until sfn/process exists). env.sfn # _env_flag, _legacy_flag_file (compiler-local # legacy flag-file shim). sfnpkg.sfn # extract_sfnpkg (compiler-only archive format). exit_codes.sfn # re-exports sfn/cli's constants. commands/ build.sfn # build_command(): Command + run(matches, ctx) run.sfn emit.sfn version.sfn check.sfn # body of today's cli_check.sfn fmt.sfn test.sfn init.sfn add.sfn publish.sfn package.sfn login.sfn config.sfn guillermo.sfnEach commands/<name>.sfn exports two functions:
fn command_def() -> Command { ... } // builds the sfn/cli commandfn run(matches: Matches, ctx: CliContext) -> number ![io, ...]main.sfn composes the root Command by calling each
command_def(), calls parse(root, argv), prints help/version and
exits via the canonical exit-code constants when the result asks for
it, and otherwise dispatches to the matching run().
4.3 Dependency direction
compiler/src/main.sfn | v compiler/src/cli/main.sfn | +------------+------------+ v v v sfn/cli compiler/src/cli/ compiler/src/cli/ (capsule) commands/*.sfn context.sfn, registry.sfn, ... | +-> sfn/fs, sfn/path, sfn/os, sfn/crypto (stdlib) +-> compiler/src/* (compile pipeline modules)Strict rules:
sfn/clidepends on nothing from the compiler tree. It is a reusable library; it must build and ship standalone.compiler/src/cli/depends onsfn/cliand on the rest of the compiler. It is a thin orchestrator.- No reverse imports. No compiler module imports from
compiler/src/cli/. Exception:main.sfnimportssailfin_cli_mainand the_env_flagshim; the latter moves tocompiler/src/env_flags.sfnduring Phase 0.5 so the CLI tree owns no cross-cutting exports.
5. Migration phases
Each phase produces a buildable, self-hosting compiler that passes
make check. No phase depends on a feature that hasn’t shipped.
Phase 0 — Capsule internal split (no API change)
Split capsules/sfn/cli/src/mod.sfn into the internal modules above.
mod.sfn becomes pure re-exports. Public surface, semantics, and
behaviour are byte-identical. Tests still inline-redefine the API in
this phase (the test rewrite is Phase 0.5).
Validation: make compile && make check passes; the diff in capsule
behaviour is zero.
Phase 0.5 — Capsule test rewrite
Rewrite capsules/sfn/cli/tests/cli_test.sfn to import directly from
mod.sfn. Confirms the cross-capsule import path works for non-compiler
consumers (the test runner is the canary).
If cross-capsule import in tests is genuinely broken (the prompt
hints it might be), this phase is split into two: (a) fix the test
runner’s capsule resolution, (b) flip the imports. The fix lives in
compiler/src/cli/commands/test.sfn (or cli_check.sfn precursor) and
in the resolver’s test path; investigation issue is sized S.
Phase 1 — Capsule API expansion
Each item is its own issue, sized XS or S. None of these change the compiler — they expand the capsule surface so Phase 2 can consume it.
parse()non-exiting variant returningParseResult.EXIT_*constants inexit_codes.sfn.- NO_COLOR + TTY-aware styling:
Style,style_for_stream,no_color_active. print_diagandprint_status.flag_int,flag_float,flag_path+ matchingget_*accessors.flag_choice(["a", "b"])+get_choice.flag_repeat(-v -v -vcollects values) +repeatedaccessor.flag_count(counted boolean) +count_flag.flag_required+ missing-required-flag diagnostic.flag_env(f, "VAR_NAME")— decorate an existing flag to fall back to the env var when the flag is absent on the command line.flag_group_exclusive,flag_group_required_one.arg_variadic—<files>...positional collection.--key=valuesyntax +--end-of-options sentinel.HelpNode+render_help_tree(powers future--help --json).- Multi-level subcommand parsing (the parser today only walks one
level deep;
with_subcommandstacks further nesting silently).
Phase 2 — Compiler CLI skeleton + first migration
Introduce compiler/src/cli/ with:
main.sfncontaining a newsailfin_cli_main_v2(argv)that builds the root Command viacommand_def()calls, parses withsfn/cli, and dispatches. Initially onlyversionis wired up; every other subcommand falls through to the legacysailfin_cli_main(which is renamed tosailfin_cli_main_legacyand kept verbatim).context.sfnwithCliContextparsed from the driver-injected--runtime-root/--binary-dirprefix.commands/version.sfnas the proof-of-concept migration.- A top-level dispatcher in
compiler/src/cli_main.sfnthat callssailfin_cli_main_v2first, falls through to the legacy path on unrecognised subcommands.
compiler/capsule.toml gains sfn/cli = "0.2.0" in [dependencies].
build.sh already routes sfn/* deps through capsules/<scope>/<name>/src/,
so no build-script changes are needed.
Validation: make check passes; sfn version round-trips through the
new path; every other subcommand still works via the legacy path.
Phase 3 — Subcommand migration (one issue per command)
Migrate one subcommand at a time. Order: simplest first so the pattern solidifies before the heavy ones. Each issue is S or M.
init— minimal flags, no positionals.login— single optional positional.guillermo— no flags.fmt— boolean flags + variadic paths.add— boolean flags + one positional.config— multi-mode subcommand-of-subcommand.check— flag + variadic paths; reuse existingcli_check.sfnbody.test— single optional positional.publish— single optional positional.package— many flags including value flags.emit— order-independent flag parsing; tests for argv ordering.build— full build orchestration; cache flags; capsule mode.run— wraps build + exec.
After each issue: make check green; the migrated subcommand goes
through sfn/cli; legacy fallback in cli_main.sfn no longer reaches
that branch (dead-code annotated, removed in Phase 4).
Phase 4 — Legacy removal
Delete compiler/src/cli_main.sfn, compiler/src/cli_commands.sfn,
compiler/src/cli_commands_utils.sfn. Move surviving helpers per the
inventory table (section 3.4). Update compiler/src/main.sfn to
import _env_flag / _legacy_flag_file from
compiler/src/env_flags.sfn (the new home), or from sfn/os if the
env-policy helpers shipped there during Phase 1.
This phase is one issue, sized M. It is gated on Phase 3 completing.
Touches every compiler file that imports from the deleted modules —
the inventory table tells us which ones, but the issue’s Files Affected list will be regenerated at pickup time.
Phase 5 — Optional follow-ups
These are not part of the core epic but ride on the structured tree:
--help --jsonflag — emitsrender_help_treeoutput as JSON.- Shell completion generation (
sfn completion bash|zsh|fish). print_diagintegration with the rendered-diagnostic format used incompiler/src/diagnostics_render.sfn(unify the two paths).Result<Matches, ParseError>migration once?ships.
6. Files affected (full picture)
Grouped by phase. Every file gets exactly one home post-migration.
Phase 0 — capsule split:
- Read/rewrite:
capsules/sfn/cli/src/mod.sfn - New:
capsules/sfn/cli/src/{types,builder,parser,matches,help,style,print,exit_codes}.sfn
Phase 0.5 — capsule test rewrite:
- Rewrite:
capsules/sfn/cli/tests/cli_test.sfn - Possibly:
compiler/src/runtime_capsule_resolver.sfn,compiler/src/capsule_resolver.sfn(only if cross-capsule import in tests needs a fix)
Phase 1 — API expansion: capsule files only (one PR per item):
capsules/sfn/cli/src/parser.sfn,matches.sfn,builder.sfn,help.sfn,style.sfn,print.sfn,exit_codes.sfn,types.sfn- New tests under
capsules/sfn/cli/tests/
Phase 2 — compiler CLI skeleton:
compiler/capsule.toml(addsfn/clidep)- New:
compiler/src/cli/main.sfn,context.sfn,runtime_root.sfn,commands/version.sfn - Edit:
compiler/src/cli_main.sfn(top-level dispatcher; renamessailfin_cli_main->sailfin_cli_main_legacy)
Phase 3 — per-subcommand:
- New:
compiler/src/cli/commands/<name>.sfn(one per phase-3 issue) - Edit:
compiler/src/cli_commands.sfn(drop the migrated handler);compiler/src/cli_main.sfn(drop the migrated dispatch arm);compiler/src/cli/main.sfn(add the new command_def)
Phase 4 — legacy removal:
- Delete:
compiler/src/cli_main.sfn,compiler/src/cli_commands.sfn,compiler/src/cli_commands_utils.sfn,compiler/src/cli_check.sfn - New:
compiler/src/cli/registry.sfn,shell.sfn,env.sfn,sfnpkg.sfn,util.sfn,env_flags.sfn(compiler-local re-export for_env_flag/_legacy_flag_file) - Edit:
compiler/src/main.sfn(re-point_env_flagimport),compiler/src/native_driver.c(re-point entry symbol ifnative_cli_mainmoves)
7. Workstream issues
Every issue below uses the contract from
.github/ISSUE_TEMPLATE/claude-task.md. Sizes: XS (<1h), S (1-3h), M
(3-6h). All non-bug issues cite this epic as their workstream.
Phase 0
Issue 0.1 — Split sfn/cli mod.sfn into internal modules
- Goal: Split
capsules/sfn/cli/src/mod.sfnintotypes,builder,parser,matches,help,style,print,exit_codes.mod.sfnre-exports the existing public surface only. - Scope. In: file split, re-exports, no semantic changes. Out: any new API, any compiler-side change, any test rewrite.
- Acceptance:
mod.sfncontains onlyimport+exportstatements.- Public function/struct names match today’s surface byte-for-byte.
make checkpasses.sfn fmt --check capsules/sfn/cli/src/is clean.
- Files:
capsules/sfn/cli/src/mod.sfn(rewrite);types.sfn,builder.sfn,parser.sfn,matches.sfn,help.sfn,style.sfn,print.sfn,exit_codes.sfn(new). - Verification:
make check. - Size: S. Type: refactor. Depends on: none.
Issue 0.2 — Bump sfn/cli to 0.2.0 (pre-API-expansion marker)
- Goal: Bump
capsules/sfn/cli/capsule.tomlfrom0.1.1to0.2.0. No code changes. - Acceptance: version updated;
make checkpasses. - Size: XS. Type: refactor. Depends on: 0.1.
Phase 0.5
Issue 0.5.1 — Investigate cross-capsule test import
- Goal: Determine whether the bootstrap test runner can resolve
import { ... } from "sfn/cli"from a test file atcapsules/sfn/cli/tests/cli_test.sfn. If not, identify the resolver gap. - Scope. In: read-only investigation; one-paragraph writeup appended to this epic. Out: any code change.
- Acceptance: writeup posted as a comment on the issue; follow-up issue opened if a fix is needed.
- Size: XS. Type: refactor (investigation). Depends on: 0.1.
Issue 0.5.2 — Rewrite sfn/cli tests to import public API
- Goal: Replace the inlined
_command/_with_arg/etc. helpers incapsules/sfn/cli/tests/cli_test.sfnwith imports frommod.sfn. - Scope. In: test file rewrite. Out: any non-test change.
- Acceptance: all tests pass;
_arg/_flag/etc. private helpers removed; coverage is the same set of cases. - Size: S. Type: refactor. Depends on: 0.5.1 (and any
fix issue it spawns), and Issue 1.1 (non-exiting
parse()is required so the parser-error tests and the_help_hint-driven tests can be re-expressed against the public surface without triggeringprocess.exit()mid-test). The original ordering (0.5.2 before 1.1) assumed cross-capsule imports would be the load-bearing blocker; #508/PR #523 closed that, and the actual load-bearing blocker turned out to be coverage of error paths thatrun()services viaprocess.exit(). Swapping the dependency keeps “no coverage regression” honest.
Phase 1 — capsule API expansion
Issue 1.1 — Add parse() non-exiting variant
- Goal: Add
fn parse(cmd: Command, argv: string[]) -> ParseResultalongside the existingrun().parse()does not callprocess.exit()and does not print. Existingrun()callsparse()internally and exits per the current rules. - Scope. In:
parser.sfn,types.sfn(ParseResult,ParseError),mod.sfnre-exports, new tests. Out: any compiler change; any rename ofrun(). - Acceptance:
parse()returnsParseResult { ok, matches, error }.error.kindcovers all currentprocess.exit(2)paths plushelp_requested,version_requested.run()semantics unchanged (delegates internally).- New
tests/parser_test.sfncovers eacherror.kind.
- Files:
capsules/sfn/cli/src/{parser,types,mod}.sfn,capsules/sfn/cli/tests/parser_test.sfn(new). - Size: M. Type: feature. Depends on: 0.5.1. (Originally
listed as depending on 0.5.2; the dependency was inverted after
the cross-capsule import gap closed in #508 and the actual blocker
for 0.5.2 was found to be the absence of a non-exiting parse
variant.
parse()itself is additive and can ship against the existing inlined-helper test file — 0.5.2 rewrites that file afterwards.)
Issue 1.2 — Exit code constants
- Goal: Define
EXIT_OK = 0,EXIT_BUILD_FAIL = 1,EXIT_USAGE = 2,EXIT_NOT_FOUND = 3,EXIT_INTERNAL = 70inexit_codes.sfn. Use them in the existingrun()body. Codes follow the loose tooling convention (Rust/Cargo):1is the generic “operation failed” code,2is reserved for usage errors,3distinguishes “input not found” so scripts/CI can branch on missing-file vs. compile-failure without parsing stderr.70matches sysexits.hEX_SOFTWAREfor internal panics. - Acceptance: constants exported;
run()uses them; tests pass. - Size: XS. Type: feature. Depends on: 0.1.
Issue 1.3 — NO_COLOR / TTY-aware Style
- Goal: Add
Stylestruct,style_for_stream(stream: string),no_color_active() -> boolean ![io]. Existing color helpers return plain text when ANSI is disabled. RespectsNO_COLORenv var (per theno-color.orgconvention) and--color=auto|always|nevercallers. - Acceptance:
style_for_stream("stderr")returns disabled style whenNO_COLORis set; newtests/style_test.sfncovers both env states. - Files:
capsules/sfn/cli/src/{style,types,mod}.sfn,capsules/sfn/cli/tests/style_test.sfn(new). - Size: S. Type: feature. Depends on: 0.1.
Issue 1.4 — print_diag and print_status
- Goal: Add
print_diag(severity, message, hint)andprint_status(step, total, message). Severities:error,warn,info,hint. - Acceptance: functions exported; tests cover each severity;
output matches the rendered-diagnostic prefix in
diagnostics_render.sfn. - Size: S. Type: feature. Depends on: 1.3.
Issue 1.5 — Typed value flags (int, float, path)
- Goal: Add
flag_int,flag_float,flag_pathbuilders +get_int,get_float,get_pathaccessors. Parse failures emittype_errorwith span pointing at the offending argv index. - Acceptance: typed flags round-trip; bad input produces
structured
ParseError; tests cover overflow/underflow/empty. - Size: S. Type: feature. Depends on: 1.1.
Issue 1.6 — Choice flags
- Goal:
flag_choice(name, choices: string[])validates the value against the list;get_choicereturns the matched value. - Size: XS. Type: feature. Depends on: 1.1.
Issue 1.7 — Repeating and counted flags
- Goal:
flag_repeatcollects multiple values (--include a --include b);flag_countcounts boolean occurrences (-vvv-> 3). New accessorsrepeated,count_flag. - Size: S. Type: feature. Depends on: 1.1.
Issue 1.8 — Required flags + missing diagnostic
- Goal:
flag_required(f)marker; parser emitsmissing_required_flagif absent. - Size: XS. Type: feature. Depends on: 1.1.
Issue 1.9 — Env-bound flags
- Goal:
flag_env(f, "VAR_NAME"); falls back to env var when flag absent. Help text shows[env: VAR_NAME]. - Size: S. Type: feature. Depends on: 1.8.
Issue 1.10 — Mutually exclusive and required-one groups
- Goal:
flag_group_exclusive(["a", "b"])andflag_group_required_one(["a", "b"]); parser validates. - Size: S. Type: feature. Depends on: 1.1.
Issue 1.11 — Variadic positionals
- Goal:
arg_variadic(name, description)collects all remaining positionals.positionals(m)returns them. - Size: XS. Type: feature. Depends on: 1.1.
Issue 1.12 — --key=value and -- sentinel
- Goal: Parser accepts
--out=FILEand stops flag parsing after--. - Size: S. Type: feature. Depends on: 1.1.
Issue 1.13 — Structured help tree
- Goal:
HelpNodestruct +render_help_tree(cmd: Command) -> HelpNodeandrender_help_text(node: HelpNode) -> string(the current_format_helpbecomesrender_help_text+ a small adapter on top of the tree). - Acceptance: existing help output is byte-identical; new
test covers tree shape (powers future
--help --json). - Size: M. Type: feature. Depends on: 0.1.
Issue 1.14 — Multi-level subcommands
- Goal: Parser walks
with_subcommand(with_subcommand(...))trees of arbitrary depth. - Acceptance:
sfn config get key(two-level) parses. - Size: S. Type: feature. Depends on: 1.1.
Phase 2 — compiler CLI skeleton
Issue 2.1 — Add sfn/cli as a compiler dependency
- Goal: Add
sfn/cli = "0.2.0"tocompiler/capsule.toml’s[dependencies]. Confirmbuild.shresolves and stages the capsule. - Acceptance:
make clean-build && make compilesucceeds; the build log showscapsules/sfn/cli/src/mod.sfnbeing compiled. - Size: XS. Type: refactor. Depends on: Phase 1 complete (or at minimum 1.1, 1.2, 1.13 — the items Phase 2 uses).
Issue 2.2 — CliContext + driver-prefix parsing
- Goal: New
compiler/src/cli/context.sfnwithCliContext { runtime_root, binary_dir, registry_url, trace_argv }. Move--runtime-root/--binary-dirconsumption out ofsailfin_cli_mainintoparse_driver_prefix(argv) -> CliContext. - Acceptance:
CliContextpopulated identically to today’s inline parsing; legacysailfin_cli_mainbody usesCliContext. - Size: S. Type: refactor. Depends on: 2.1.
Issue 2.3 — Skeleton for compiler/src/cli/main.sfn (version only)
- Goal: New
compiler/src/cli/main.sfnexportingsailfin_cli_main_v2(argv) -> number ![io, clock]. Builds a Command viasfn/cli, registers onlyversion, dispatches viaparse(). Falls through to legacy on every other subcommand. Existingcompiler/src/cli_main.sfn:sailfin_cli_mainbecomes the legacy fallback called from v2. - Acceptance:
sfn versionruns through v2;sfn build,sfn run, etc. unchanged;make checkgreen. - Size: M. Type: feature. Depends on: 2.2.
Issue 2.4 — Migrate the version subcommand
- Goal:
compiler/src/cli/commands/version.sfnexportscommand_def()andrun(matches, ctx). Removes theversionarm fromcli_main.sfn’s legacy dispatch. - Acceptance:
sfn versionandsfn --versionboth work; tests incompiler/tests/integration/cover both forms. - Size: S. Type: refactor. Depends on: 2.3.
Phase 3 — per-subcommand migration
Each issue follows the pattern:
Goal: migrate
sfn <name>tosfn/cli. Move the body ofhandle_<name>_commandintocompiler/src/cli/commands/<name>.sfn; express its flags viasfn/clibuilders; exportcommand_def()andrun(matches, ctx). Drop the legacy arm fromcli_main.sfnand thehandle_<name>_commandfromcli_commands.sfn.Acceptance: subcommand works identically (same flags, same exit codes, same output); existing integration tests pass; legacy code path is dead (
grep handle_<name>_command compiler/src/returns empty).
| Issue | Subcommand | Size | Depends on |
|---|---|---|---|
| 3.1 | init |
S | 2.4 |
| 3.2 | login |
S | 2.4 |
| 3.3 | guillermo |
XS | 2.4 |
| 3.4 | fmt |
S | 1.11 (variadic), 2.4 |
| 3.5 | add |
S | 2.4 |
| 3.6 | config |
M | 1.14 (multi-level), 2.4 |
| 3.7 | check |
S | 1.11, 2.4 |
| 3.8 | test |
S | 2.4 |
| 3.9 | publish |
S | 2.4 |
| 3.10 | package |
M | 1.5 (typed flags), 2.4 |
| 3.11 | emit |
M | 1.12 (--key=value), 2.4 |
| 3.12 | build |
M | 1.5, 1.9 (env-bound), 2.4 |
| 3.13 | run |
M | 3.12 |
Phase 4 — legacy removal
Issue 4.1 — Move env-flag helpers out of cli_commands_utils
- Goal: Move
_env_flagand_legacy_flag_filetocompiler/src/env_flags.sfn. Updatecompiler/src/main.sfn,compiler/src/cli/main.sfn, and any other consumer to import from the new location. - Acceptance: no compiler module imports from
cli_commands_utils.sfnfor env helpers;make checkgreen. - Size: S. Type: refactor. Depends on: 3.13.
Issue 4.2 — Move filesystem/shell/crypto helpers to stdlib
- Goal: Move per the inventory table in section 3.4. Functions
with a stdlib home (
sfn/fs,sfn/path,sfn/crypto,sfn/http,sfn/os) move there; consumers re-import. Functions that are genuinely compiler-specific (sfnpkg extraction, registry policy) move tocompiler/src/cli/{sfnpkg,registry}.sfn. - Acceptance:
cli_commands_utils.sfnhas no remaining users;make checkgreen. - Size: M. Type: refactor. Depends on: 4.1.
Issue 4.3 — Delete legacy CLI modules
- Goal: Delete
cli_main.sfn,cli_commands.sfn,cli_commands_utils.sfn,cli_check.sfn. Updatenative_driver.cifnative_cli_main’s symbol moved. Update any remaining imports. - Acceptance:
find compiler/src -name "cli_*.sfn"returns empty (post-rename);make checkgreen; full test suite green. - Size: S. Type: refactor. Depends on: 4.2.
Phase 5 — optional follow-ups (not part of core epic)
Issue 5.1 — --help --json flag
- Goal: Top-level
--help --jsonemitsHelpNodeas JSON. - Size: S. Type: feature. Depends on: 1.13, 4.3.
Issue 5.2 — Shell completion generation
- Goal:
sfn completion bash|zsh|fishemits a completion script derived from theCommandtree. - Size: M. Type: feature. Depends on: 4.3.
Issue 5.3 — Unify print_diag with diagnostics_render
- Goal:
print_diagcalls intocompiler/src/diagnostics_render.sfnfor compiler diagnostics, so CLI errors and compile errors render identically. - Size: S. Type: refactor. Depends on: 1.4, 4.3.
Issue 5.4 — Result<Matches, ParseError> migration
- Goal: Replace
ParseResultsentinel withResult<Matches, ParseError>onceResult<T, E>and?ship. - Size: S. Type: refactor. Depends on: Phase 1 of
runtime sequencing (
Result<T, E>shipping in the language).
8. Risks & mitigations
R1 — Self-host break when adding sfn/cli as a compiler dep
Severity: high. The compiler currently imports nothing from
capsules/sfn/*. The prior build script (scripts/build.sh:400-562,
since retired in Stage E PR7 / #383; the equivalent driver path
lives in capsule_resolver.sfn) historically did route sfn/* deps
through capsules/<scope>/<name>/src/ slugs, but that path has only
ever been exercised for runtime-side deps, not self-hosted compiler
imports.
Detect: make clean-build && make compile immediately after
Issue 2.1 lands. The first failure mode shows up as a missing-symbol
error at link time (sfn__cli__mod__parse not found).
Mitigate:
- Make 2.1 a standalone PR with no behaviour change. If it breaks, revert is a one-file edit.
- Validate Phase 1 capsule changes do not depend on compiler-tree modules (capsule must build standalone).
- If the build script needs a fix, fix it once, in 2.1, before any
compiler code starts importing from
sfn/cli.
R2 — process.exit semantics conflict
Severity: medium. sfn/cli’s current run() calls
process.exit() directly. sailfin_cli_main returns a number exit
code. Mixing the two paths means the migrated subcommands return
codes, while help/version flows still exit out from under the caller
— different semantics for --version vs version.
Detect: integration test that asserts the C driver sees the
returned exit code in both paths.
Mitigate: Phase 1 Issue 1.1 introduces parse() which never
exits. Phase 2’s sailfin_cli_main_v2 calls parse(), prints
help/version itself, and returns a code via EXIT_* constants. The
exiting run() stays in sfn/cli for non-compiler consumers but
the compiler never calls it.
R3 — Capsule test runner cannot import from mod.sfn
Severity: medium. The current capsule tests inline the API
because of (suspected) test-runner import gaps. If true, Phase 0.5
needs a resolver fix before tests can flip.
Detect: Issue 0.5.1 is explicitly an investigation; outcome
gates 0.5.2. If broken, file a separate priority:high issue
against compiler/src/runtime_capsule_resolver.sfn /
capsule_resolver.sfn and block 0.5.2 on it.
Mitigate: the capsule split (Phase 0) is independent of test
imports — it can ship while 0.5.2 stays blocked. Worst case, capsule
tests stay inlined for a release while we fix the resolver.
R4 — Performance regression on every CLI invocation
Severity: low. The parser runs on every sfn invocation. Going
from a hand-rolled if-chain to a Command-tree walk adds allocations
and copies (every with_* builder copies the Command struct). On a
tree of ~13 subcommands with average ~5 flags each, that is ~65
copies on every invocation.
Detect: time sfn version and sfn --version before and after
Phase 2 lands. Threshold: regress no more than 30 ms wall on a
warm filesystem.
Mitigate: the Command struct is small (six fields); copies are
cheap. If the bench regresses past threshold, switch the builders to
a single-allocation pattern (mut a stack-local Command, return at
the end) — additive change, no API impact.
R5 — Stale legacy fallback path during Phase 3 migration
Severity: medium. While Phase 3 is in flight, two CLIs run side
by side. A user reporting a flag bug in sfn build could be hitting
either path; reproductions are ambiguous.
Detect: every Phase-3 PR description explicitly states which
path the bug fix touches; integration tests assert behaviour against
the v2 path once a subcommand has been migrated.
Mitigate:
- Add a one-line stderr trace on legacy fallback (
SAILFIN_TRACE_CLIgated):cli: legacy fallback for <subcmd>. Removed in Phase 4. - Phase 3 is sequenced (one issue per command) so the legacy path shrinks monotonically.
R6 — Effect-surface drift
Severity: low. Adding env-bound flags (flag_env) requires io
to read env vars. The capsule’s declared effect (io) covers it,
but the parser itself currently has no ![io] annotation — it’s
pure. We must keep the pure parsing path pure and isolate ![io]
to the flag_env resolution step.
Detect: compiler/src/effect_checker.sfn flags any leak;
make test-integration covers the effect annotations.
Mitigate: parse() remains effect-free; parse_with_env() is a
separate variant marked ![io] that calls into parse() after
populating env values. Or: the parser takes a pre-resolved
{ name -> value } env map that the caller fills in (![io] at the
call site, pure inside the parser).
R7 — Builders allocate on every with_* call
Severity: low (correctness), medium (perf if hit). The current
with_arg/with_flag/with_subcommand build new arrays via a
manual loop { push }, allocating O(n) on every call. Building a
13-subcommand root tree is O(n^2). Acceptable today but worth
flagging.
Mitigate: post-Phase-2, if make bench flags startup time, add
a mutable-builder variant. Out of scope for this epic.
R8 — Drift between _format_help and render_help_tree
Severity: low. Issue 1.13 introduces a structured tree and
makes the existing text renderer go through it. Risk: text output
changes byte-for-byte even though the intent was to refactor.
Detect: golden-file test in tests/help_tree_test.sfn snapshots
the rendered text against today’s output.
Mitigate: the issue’s acceptance criterion is byte-identical
output; the test enforces it. Any formatting cleanup is a separate
issue post-1.13.
9. Open questions
These need human input before any of these phases get groomed into
claude-ready issues.
- Cross-capsule import in tests. Is the test runner’s capsule
resolver currently functional for
sfn/clitest imports? If not, is fixing it a Phase 0.5.1 prerequisite, or do we ship Phase 0 with the inlined test alias intact and fix it later? --color=auto|always|never. Standard CLI convention. Do we want this as a top-level Sailfin flag, or per-subcommand? Phase 1 currently scopes it to env (NO_COLOR) only.configsubcommand shape. Todaysfn config <get|set|unset|list> [key] [value]is parsed by hand. Migrating to multi-level subcommands (Issue 1.14) is the natural fit. Confirm before sizing 3.6.emitflag ordering. Todaysfn emit --timing -o foo llvm path.sfnparses positional-after-flags. Thesfn/cliparser as currently designed parses positionals after all flags too, so this should be a free win — but the current implementation has subtleties around--module-name SLUG llvm path.sfnthat need a targeted test (Issue 3.11).sfn/cliversioning. Bump to0.2.0(additive, but signals breaking semantics forrun()which now returns instead of exits) or0.1.2(claim API stability)? The plan above proposes0.2.0because Phase 1 adds many new builders.- Effect annotation on
parse(). Pure or![io]? If we wantflag_envto live insideparse(), the parser becomes![io]. The plan above proposes keepingparse()pure and addingparse_with_env()for env resolution — but a single function may be simpler. Confirm. - Should
compiler/src/cli/util.sfnexist? Some path/string helpers do not have a clear stdlib home today. Ship them incompiler/src/cli/util.sfnas a holding pen, or push them intosfn/patheven thoughsfn/pathmay not yet have a home for them?
10. Out-of-scope follow-ups
These are not part of the epic but are unblocked by it:
- LSP server CLI surface. A future
sfn lspsubcommand reads the sameCommandtree to advertise capabilities. No new design needed — once Phase 4 is done,sfn lspis a new entry undercompiler/src/cli/commands/. - MCP server tool registration. The MCP server in
tools/mcp-server/can introspectrender_help_treeto discover subcommands and their flags automatically, eliminating the hard-coded tool registry. Issue: post-5.1. sfn vetsecurity audit subcommand. Capsule-aware effect audit; reusesflag_choicefor severity levels. Standalone issue post-Phase 4.--help --jsonschema versioning. Once 5.1 ships, the JSON shape becomes a public contract. Add aschema_versionfield from day one.- Subcommand aliases.
sfn b->sfn build. Builder addition, one issue, post-Phase 4. - Cargo-style global
--manifest-path. Letssfn buildrun from anywhere given a path to acapsule.toml. Post-Phase 4.