[v0.1/P1] Simplify the CLI machine contract before freeze #23
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#23
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Parent: #12 (P1.4). Supersedes the actionable machine-contract scope in #3 and #9.
Why
The current contract freezes avoidable exceptions: a bounded roster is modeled as a stream, machine format does not own usage errors, exit statuses conflate typed refusal with transport failure, and identifiers/destination flags have context-dependent meanings.
Scope
sessions lista point result:{"v":1,"sessions":[...]}.error.kindadditive; unknown values must degrade to generic failure.short_idonly where roster uniqueness was established.--to-host/--to-socketand removefelis ssh.Acceptance criteria
short_iduniqueness.skills/felis, and changelog are regenerated together.CLI args focused review (#55) で、実装時に golden matrix へ含めたい追加確認が3点あった。
felis sessions spawn --format json --jsonを cold socket で実行すると、stderr は tracing の INFO 行 + JSON error の2行になる。現行の「point failure は exactly one JSON object on stderr」を破るため、machine channel と console log を分離する必要がある。--formatとcapture --sourceは customvalue_parser = fnなので clap metadata に possible values がなく、help/generated completion が値候補を列挙できない。contract を確定する際に finite vocabulary を clap にも公開したい。window retarget --session 1a -- htop、SSH carrier なしの--ssh-arg、payload なしのsendは parse 後の usage error。issue 本文の「valid machine format 認識後は同 format」をこの全経路で pin する必要がある。spawnの command separator 自体は #53 に分離した。Triage plan (2026-09-03)
Source-grounded triage against
mainat69076d42, reviewed through seven rounds of an independent reviewer (pisol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12.Claim check
Every claim in the body holds at HEAD (
69076d42); the three post-snapshot commits changed only the spawn separator and--trace-perf.sessions listis modeled as a stream.SessionOp::List { output: StreamFormat, .. }atcrates/felis-cli/src/cli_sessions.rs:129-131;cmd_listemits oneSessionObjectper item thenout.end(count, None)(cli_sessions.rs:940-952).docs/reference/cli.md:77lists it under Stream;docs/explanation/architecture/control-surfaces.md:311-313argues "listcounts sessions". The bridge already answerssessions.listas a point{"sessions":[…]}(cli.md:710-712), so the CLI is the odd framing of the same roster.eprintln!regardless of--format:reject_global_carrier(cli_sessions.rs:828-838, used frommain.rs:373-380, 463-469, 503-509),sendwith no payload (cli_sessions.rs:577-592),staged_retarget's--ssh-argwithout host and--session+-- <cmd>(cli_sessions.rs:868-881).cli.md:44-50freezes this as "always a human message on stderr".create_refusalmaps every non-capacity daemon refusal (invalid_request) to exit 2 (cli_sessions.rs:2247-2259); a daemonRefusedterminal oncapture/searchis exit 2 whatever the reason (cli_sessions.rs:2111, 2383;cli.md:574-578says an uncompilable--regexis 2);daemon statuson an old minor isunsupportedat 2 (cli_daemon.rs:61-63). Meanwhileat_capacityis 1 andnotifications subscribesplitsInvalidRequestto 1 (cli_notifications.rs:71-80). There is no single kind→code table; each site picks.error.kindis documented as closed.cli.md:107-113("a closed set a consumer branches on"),cli_output.rs:122-124, andskills/felis/SKILL.mdinherit the wording.short_idon rosterless replies.SessionRef::newusesshort_id_alone(cli_output.rs:516-525), as doTagResult,SwitchResult(from_short_id/to_short_id),RetargetResult, andNotificationObject.session_short_id(cli_output.rs:527-551, 685-705).cli.md:150-160documents that these are "not checked against anything". Roster-shortenedshort_idexists only inSessionObject::new(cli_output.rs:446-449,list/info).felis ssh.RetargetCarrierFlags { host, to_socket }(cli_sessions.rs:735-745) already mixes--hostwith--to-socketon one verb; the root--host(main.rs:308-309) means "which daemon this invocation dials".Cmd::Ssh(main.rs:145-165) is the alias, promised as "permanent — never removed within a schema major" inmain.rs:149-151,cli.md:422-429,control-surfaces.md:437-445,CHANGELOG.md:86. Pre-v0.1.0 the promise is revocable, but the docs record it as a decision, so the explanation twin must carry the reversal.Cmd::Notificationshelp reads "Stream desktop notifications from sessions as JSON lines" (main.rs:83) while the default ishuman(StreamFormatdefaultcli_output.rs:85-91;cli.md:617-620).#55's four acceptance gaps, verified:
main.rs:357initializeslogging::init(Console::Stderr, None, "info,felis=debug")before any verb; the autospawn path logsinfo!("daemon socket unreachable; auto-spawning felis-daemon")(crates/felis-client-core/src/spawn.rs:36) and a spawn failure then reachesDial::open→out.fail(2, DaemonUnreachable)as a JSON object (conn.rs:80-90). Sosessions spawn --format jsonagainst a socket whose daemon cannot be spawned writes an INFO line and then the error object on stderr, contradictingcli.md:68("exactly one typed error object").dial.rs:292(warn!) is another emitter. The--jsonin #55's example is now a clap error after #53 (tests.rs:360), so the reproduction issessions spawn --format json --socket <path>withfelis-daemonunreachable.--formatusesvalue_parser = parse_point_format/parse_stream_format(cli_output.rs:71-77, 85-91);capture --sourceusesparse_region_source(cli_sessions.rs:265, 469-480). Neither is aValueEnum, so--help,clap_complete, andclap_mangenshow no candidate list.Reporter.send --rawwith no payload is accepted.rawonly selectsKeyBytesoverPastefor the text payload (cli_sessions.rs:1192); withtext: Noneand--wait/--keythe flag is inert (cli_sessions.rs:577-580).Nothing in the issue is already fixed. One correction: the issue says "remove
felis ssh"; the closed #37/#39 comments confirm the decision (rename to--to-host, drop the alias), so this issue owns that cascade, including the reversal of the "permanent alias" record.Verdict
accept-with-changes. The scope stands; the approach should differ in three places:
felis sshis gone,--ssh-argcan carryrequires = "to_host"(the comment atcli_sessions.rs:868-871names the alias as the only reason it cannot today),--sessioncanconflicts_with = "command",textcan berequired_unless_present_any = ["wait", "key"], and--rawcanrequires = "text". That leaves exactly one post-parse usage error (reject_global_carrier, a root-flag/subcommand interaction clap cannot state), and the contract rule becomes one sentence: a refusal clap can state is a clap error (human, exit 2, no framing chosen yet); every refusal after a successful parse is emitted through the verb's class framing.ErrorKind(one table incli_output.rs) rather than a per-site literal; that is the "one terminal rule" the acceptance criterion asks for.usagekind (additive) for the surviving post-parse refusal instead of overloadinginvalid_request, which the daemon also produces.Principle check: no capability is added; the change removes ambiguity and silent no-ops. Principle 1 ("no dedicated tool does it better") and 4 ("Explicit over heuristic") pass. The
felis sshremoval does not violate the frozen-surface promise because nothing is frozen before the first tag (#12 "Contract freeze boundary").Approach
A.
sessions listbecomes a point verbcli_sessions.rs:129-131:output: PointFormat;cmd_list(940-952) buildsListResult { sessions: Vec<SessionObject> }and callsout.result. New serde type incli_output.rsbesideSessionObject;short_idstays roster-shortened against the unfiltered roster (--tagsemantics unchanged).plan()(cli_sessions.rs:549-553) usespoint_out.sessions.listalready returns{"sessions":[…]}; make it reuse the sameListResultbody viabody_valueso the two cannot drift.cli.md:76-77, 167, 559-561, 710-712;control-surfaces.md:311-313(the "listcounts sessions" argument becomes "list is a bounded roster; a stream implies an unbounded or incremental source; Revisit if the roster ever streams incrementally");ipc.md:1497, 1552;docs/how-to/reap-sessions.md:28-49anddocs/explanation/input.md:248(jq 'select(.event | not)'idioms become.sessions[]);skills/felis/SKILL.md:29, 153, 166;tests.rs:264-310matrix (movelistto the point loop); e2elist_prints_one_session_and_exits_zero,a_stream_that_never_opens_still_emits_its_error_terminal(tests/cli_sessions.rs:213, 326— the latter must move tocapture/search).B. Usage errors in the verb's framing
cli_sessions.rs):--rawrequires = "text";textrequired_unless_present_any = ["wait", "key"](delete the hand check at577-592);RetargetFlags.ssh_argrequires = "to_host";RetargetFlags.sessionconflicts_with = "command"(delete both checks instaged_retarget,868-881, keeping the function forrequire_switch_from).retarget-allflattens the same structs so it inherits them.reject_global_carrier(cli_sessions.rs:828-838) takes a&Reporterand emitsout.fail(2, ErrorKind::Usage, …). Call sites:config(main.rs:373-380, point reporter fromop'sPointFormat),window retarget(main.rs:503-509,flags.output.format),frontend(main.rs:463-469, Exempt → stays human). This needsCmd::Config/ConfigOpto expose its format beforerun; a smallfn format(&self) -> FormatonConfigOpandRetargetFlagssuffices.ErrorKind::Usage("usage") incli_output.rs:125-200; add to the token test at789-817.Cli::parse()inmain.rs:342, computecli.machine_format()(walk the subcommand for aPointFormat/StreamFormatwith a machine value) and pass a"off"fallback directive tologging::initwhen machine;RUST_LOGset explicitly still wins (felis-transport/src/logging.rs:47-49), which is the documented opt-in. Document incli.md"Machine output": machine mode emits no console log lines unlessRUST_LOGis set.tests/cli_sessions.rs): for each of {clap usage, post-parse usage onwindow retarget --format jsonwith a global carrier, daemon refusalinvalid_requestonspawn --rows 9999 --format json, transportdaemon_unreachableon a cold socket, protocol (unsupportedvia a minor-4 fixture daemon if the harness can pin one, else skipped with a note)} assert: exit code, stdout empty or one object, stderr exactly one parseable object (lines().count() == 1). For the autospawn INFO case: copyCARGO_BIN_EXE_felisinto a temp dir (sibling lookup misses,spawn.rs:48-53) and run withPATH=""sospawn_daemon_childfails immediately with NotFound; assert stderr is exactly one JSON object.C. One exit-code table
impl ErrorKind { const fn exit_code(self) -> i32 }incli_output.rs: 1 forno_match, ambiguous, timeout, no_input_owner, no_such_attachment, not_accepted, session_ended, invalid_request, at_capacity, refused; 2 forusage, malformed_request, unsupported, protocol_mismatch, daemon_unreachable, daemon_lost, protocol, canceled, input_failed, internal.Reporter::fail(kind, message)drops thecodeparameter and returnskind.exit_code(); everyout.fail(2|1, …)call (about 60 sites, list fromgrep -n "\.fail(" crates/felis-cli/src/*.rs) loses its literal.spawninvalid_request2→1 (create_refusal,cli_sessions.rs:2258);capture/searchdaemon-Refusedterminals 2→1 (2111, 2383;cli.md:574-578rewritten);daemon statusunsupportedstays 2 (the comment atcli_daemon.rs:61-62is the rationale;unsupportedis a peer-capability class). The grep-shaped exceptions (searchno match,notifications --once) and theconfig/doctorexceptions stay as documented incli.md:19-42.BridgeErrorshould use the sameErrorKind(it already does viaas_str); nothing to change beyond the new kind.D. Additive
error.kindcli.md:107-113: replace "closed set" with "a vocabulary that grows additively within an epoch; a consumer treats an unknownkindas a generic failure and reads the exit code". Same sentence inskills/felis/SKILL.mdand the bridge paragraph (cli.md:703).control-surfaces.mdrecords the decision (rejected: a closed set, because every new daemon refusal would then need avbump).E.
short_idonly with a rostershort_idfromSessionRef,TagResult,SwitchResult(from_short_id,to_short_id),RetargetResult, andsession_short_idfromNotificationObject(cli_output.rs:503-551, 670-705);short_id_alonebecomes a human-output helper only (main.rs:605,cli_notifications.rs:66). Keepshort_idonSessionObject.{"id":"<hex>","short_id":"…"}shape incli.md:169-178, 205-221, 332-355, 476;cli.md:150-160collapses to "onlylistandinfocarryshort_id";ipc.md:1489-1500;skills/felis/SKILL.md:62-71, 106, 192;cli_notifications.rs:26-28help; testsspawn_json_emits_id_object(tests/cli_sessions.rs:1516-1520),tag_json…,kill_and_evict_json…,send_wait_json…, bridge tests assertingshort_id, unit testa_rosterless_result_shows_the_floor_length_prefix(cli_output.rs:837-843).F.
--to-host/--to-socket, nofelis sshRetargetCarrierFlags.host→to_host(#[arg(long = "to-host", value_name = "user@host")],conflicts_with = "to_host"onto_socket) atcli_sessions.rs:735-745; deleteover_ssh;RetargetArgs.host→to_host(789-820);retarget_from_args(897-919).Cmd::Ssh(main.rs:145-165) and its arm (424-432);retarget()loses theverbparameter's second caller. Root--host(main.rs:301-309) keeps its name and stays root-before-command (notglobal = true); document that placement as frozen (#55's open question), with the rationale that a post-verb--hostwould collide in reading with--to-hoston the retarget verbs.cli_completions.rs): thesshpositional and--host-as-destination branches in both retarget target helpers (154-199, 285-327) and the_hostssubstitutions (365-366,402-404) move to--to-host; after #42 the target helper only handles--to-socket, so this shrinks to a rename. Tests658-705(the_ssh_alias_*) are deleted;the_ssh_alias_pushes_the_same_retarget_as_window_retarget(tests/cli_sessions.rs:1980) is deleted;zsh_overlay_routes_ssh_destination_and_host_to_hosts_completerretargets to--to-host.felis-ssh.1disappears from__mangenoutput automatically; the Nix package's install list must not reference it (checknix/for an explicit page list).cli.md:401-486(grammar block, alias paragraph, carrier bullets),cli.md:358,docs/reference/control-surfaces.mdretarget row,control-surfaces.md:437-450(replace the "permanent alias" record with: rejectedfelis ssh— one destination spelled three ways; Revisit if a launcher-shaped remote verb is ever earned),spec.md:36(REQ-006b "aliasedfelis ssh <dest>"),session-lifecycle.md:712,scrollback.md:522,keybindings.md:152,docs/how-to/attach-over-ssh.md:94-137,README.md:39,skills/felis/SKILL.md:356-384, 528,crates/felis-protocolcomments mentioning the alias (messages.rs,felis.proto— comments only, no wire change, so nojust protoregeneration beyond comment sync),CHANGELOG.md(one "Removed" entry; the old entries at 86, 891 stay as history).G. Notification help
main.rs:83: "Stream desktop notifications from sessions (human lines by default,--format jsonlfor objects)". Alsocli_notifications.rs:23-34already says the right thing.H. Finite vocabularies in clap
parse_point_format/parse_stream_formatwith twoValueEnums (PointFormatValue { Human, Json },StreamFormatValue { Human, Jsonl }) converting intoFormat; replaceparse_region_sourcewith aValueEnumSourceArgmapping ontoRegionSource. The clap error then lists possible values, which satisfiescli.md:80-83"naming the value the verb does take"; the unit test atcli_output.rs:723-736changes to assert the ValueEnum's variants.completionsand__mangenpick the values up automatically; add a test that the generated zsh script contains(human json)/(human jsonl)and the four sources.I. Classification table and generated surfaces
cli.md:60-78gains the terminal rule per class in one column (Point: one object per channel; Stream: exactly one terminal on stdout; Exempt: none) and the sentence "a refusal clap can state is human; a refusal after a successful parse is in the class framing".#35(post-v0.1) stays the process rule for future verbs.just check(help snapshots are not stored — consider adding atests.rstest that assertsCli::command().render_long_help()contains nofelis sshand the--to-hostspelling),felis completions {fish,zsh,bash}(covered by unit tests),__mangen(a test that the page set equals the visible subcommand set),skills/felis,CHANGELOG.md. The config JSON schema is untouched (no config key moves).Dependencies
--jsonexample in #55 gap 1 must be restated as above).RetargetResult/SwitchResultshape; decide its fields before E removesshort_idfrom them, or land #24 first and let this issue apply theshort_idrule to its final shape.create_refusal); C's kind→code table should be applied on top of #20's shape, not before it, or the mapping moves twice. Tracker order (#20 in step 5, #23 in step 7) still holds.main.rs:347-351(--versionexclusivity); independent but same file — sequence to avoid conflicts.Risk/effort
L. Wide but mechanical: ~60
failsites, two overlay scripts, and a doc cascade across 15+ files. Main risks: (1) the exit-code retargeting ofcapture/search/spawnbreaks the existing e2e assertions and any external consumer of the pre-release surface (acceptable pre-v0.1.0, but every changed code must land inCHANGELOG.md); (2) the log-suppression in machine mode hides a real diagnostic when autospawn fails — mitigated by keepingRUST_LOGas the override and putting the spawn failure detail intoerror.message(conn.rs:76-78already renders the chain); (3) the zsh overlay's substitution anchors (cli_completions.rs:512-535) silently no-op if value names change — the tripwire test at536-549must be extended for--to-host's value name.Labels
Keep priority/P1, release/v0.1.0. It sits inside #12's freeze boundary ("CLI command/option meanings, exit-status classes, and machine JSON epoch 1"); none of it can be done after the tag without a
vbump.