You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
cli: headless command tree, direct client launch and persistent serve - #50
This PR adds a headless CLI alongside the TUI. Decisions are recorded in docs/adr/0007-headless-cli.md and docs/adr/0008-persistent-bridge-fixed-port.md.
cobra now owns dispatch. A bare invocation still starts the TUI through the same code path. bridge add, list, describe and delete plus endpoint add, list and delete work headless, so a setup can live in a script, a dotfile or a unit file instead of a menu walk.
Every registered client gets a command named by its binary:
aperture codex --endpoint e1 -- --search
alias codex='aperture codex'
Aperture consumes its own flags; the first flag it doesn't know, or --, passes the rest to the client verbatim. The child's exit code becomes aperture's own, so the alias behaves like the binary. Each client package (claudecode, codex, copilot, gemini, hermes, opencode, pilike) grew a Spec method that builds a headless launch: flags win, LastLaunch fills what they leave, and the first compatible provider covers the rest. Claude Cowork refuses Spec since it's a desktop app with no headless launch.
aperture serve --bridge <name> runs one bridge on a fixed port until SIGTERM or SIGINT, for client configs that need a stable address, the ts-unplug use case. The port comes from the bridge record or --port; serve never falls back to an ephemeral port.
Flags move to GNU double-dash form (--endpoint, --bridge, --port, --config) with single-letter shorthands. The old single-dash long forms still parse, rewritten by a normalization pass before cobra sees them, with a deprecation warning on stderr and no version promised for removal. Endpoints and bridges are addressed by name now; a value that parses as a URL is read as an inline endpoint instead. --config / APERTURE_CONFIG picks an alternate settings file.
Two review passes found real gaps and both are fixed in this branch rather than follow-up. First pass: endpoint add created a bridge from a typo and now resolves through bridgeByName instead; --debug=false enabled debug; names with control characters are refused at the door; the run log had stopped opening for headless runs when the cobra refactor moved it into runTUI, now it opens for every run again; a child killed by a signal now exits 1 instead of wrapping to 255. Second pass caught the passthrough contract broken twice over: gnuForm was rewriting single-dash long flags inside a client's own tail, so a client's -config became aperture's --config and a launch read and wrote a settings file the user never named. Rewriting now stops at the client command's name. OpenCode's per-launch config file gets a unique name so two concurrent launches stop overwriting and deleting each other's.
Adds github.com/spf13/cobra and github.com/spf13/pflag to go.mod.
Risk: serve's fixed loopback listener has no auth of its own, the client's Authorization header is stripped because the Machine's tailnet identity is the ingress auth. Every process on the host can reach the user's Aperture through a running serve, and on a shared machine so can every other user, since loopback isn't per-UID. ADR 0008 accepts this as the same exposure ts-unplug already has and that serve replaces it deliberately, single-user workstations being the audience, but it's worth a second look given how easy aperture serve is to leave running. The flag surface change is large (every double-dash flag plus shorthands) and touches every client package; the deprecated single-dash forms keep old scripts working but the test coverage for that compatibility path is worth checking closely.
The working plan for the command grammar, direct client launch and
serve. Decisions live in ADRs 0007 and 0008; this file is the step list
and the watch items, so it sits in plans/ rather than docs/.
The TUI is the only interface today, so nothing about an aperture setup
composes with a shell, and the team runs ts-unplug as a second fixed-port
sidecar. 0007 takes cobra over hand-rolled dispatch and rejects Viper;
0008 puts the fixed port on the Bridge record and keeps serve a
foreground process, since a daemon is ADR 0006's rejected shape.
The command line becomes the API contract in connection-contracts.md.
The context map gains the Name and Port language rows and resolves the
bridge command ambiguity toward serve. The domain model records the
Route fixed-port invariant and the RouteTo port parameter.
ParseEndpointURL accepts any bare host, so URL-first resolution would
make every saved name unreachable and the add-time URL refusal would
reject names like e1. Names resolve first; only names containing ://
are refused. The endpoint list element is its own noun because endpoint
equality stays URL plus bridge.
The command line addresses endpoints and bridges by name (ADR 0007) and
serve binds a stored port (ADR 0008). The Name lives on a new Endpoint
Entry row rather than on the Endpoint value, because endpoint equality
is URL plus bridge and every dedup in Global relies on it. Names resolve
before URLs in EndpointFromFlags: ParseEndpointURL accepts any bare
host, so URL-first would orphan every saved name. Bridge names are now
unique at add; hand-edited duplicates still fail at resolution as
before. --config gets UseSettingsFile, a process-wide path set once
before Load.
Every command's --help carries example invocations, and a test will hold
them to the contract grammar. Recorded before the cobra step so the CLI
work is built to it.
Guy ruled the flag changes strictly additive. The single-dash long forms
keep parsing through a normalization pass before cobra, marked deprecated
in docs and source. ADR 0007 decision 2 amended in place with the date
and the reason.
The TUI was the only interface, so nothing about an aperture setup could
live in a script (ADR 0007). cobra now owns dispatch: a bare invocation
still starts the TUI through the same code path, and bridge add, list,
describe and delete plus endpoint add, list and delete work headless.
Deletion reuses CheckRemovable, WillDestroyMachine and Machines.Destroy
rather than restating their rules. Every flag has a shorthand, and the
single-dash long forms keep parsing through gnuForm, which rewrites and
warns; nothing that parsed before stops parsing. Help carries examples
because the contract makes help part of the surface, and a test walks
the tree to hold it there.
The team runs ts-unplug as a second fixed-port sidecar (ADR 0008). serve
is Machines.For plus Open plus one Route held until SIGTERM or SIGINT,
with the port from --port or the bridge record and no ephemeral fallback.
RouteTo takes the port; existing callers pass zero and keep free ports.
A signal during bring-up exits zero, because a supervisor restarting on
non-zero would flap a serve the operator asked to stop. bridgeByName
never creates: a typo must not register a device.
go test -count=3 exposed it: the --config test sets UseSettingsFile and
nothing cleared it, so later tests in the process read and wrote that
file. scratchHome now clears it, the same place the scratch home itself
is set.
The headless CLI needs a menu-free way to build a launch (ADR 0007), so
the Client interface gains Spec: flags win, LastLaunch fills what they
leave, and the first compatible provider covers the rest. buildSpec
carries what launch always did, so the menu path is byte-identical.
Codex was the reference; claudecode, copilot, gemini, hermes, opencode
and pilike followed it, each through an adversarial review. Opencode
keeps model selection inside OpenCode after review caught a synthesized
model ref the binary rejects: only --model a user typed passes through.
Claude Cowork refuses Spec, since a desktop app has no headless launch.
aperture codex --endpoint e1 -- --search replaces the menu walk, and an
alias like codex='aperture codex' works because the tail parser consumes
only aperture's flags: the first unknown flag, or --, goes to the client
verbatim (ADR 0007). The child's exit code becomes aperture's own, so
the alias behaves like the binary. The connect half reuses BeginAttempt,
Run and Commit rather than restating them. Help documents the flags by
hand because DisableFlagParsing hides them from cobra.
An adversarial review of the branch found three real gaps and two
hardening misses. endpoint add created a bridge from a typo, which a
management command must not do; it resolves with bridgeByName now, and
the launcher flags keep the creating path that makes a first run
scriptable. --debug=false enabled debug, and --debug=banana now belongs
to the client. Names with control characters are refused at the door,
since they reach tabbed stdout a script parses. The run log opens for
every run again, not only the TUI one: the original main always logged,
the cobra refactor moved it into runTUI and headless runs silently lost
their diagnostics to stderr. A serve's loopback exposure is recorded as
a consequence in ADR 0008, same trust as the ts-unplug it replaces. A
child killed by a signal exits 1 rather than wrapping to 255.
The GLM-5.3 security pass caught the passthrough contract broken twice
over: gnuForm rewrote single-dash long flags inside a client command's
tail, so a client's own -config became aperture's --config and the
launch read and wrote a settings file the user never named, and a
positional token ended flag consumption, which the contract never said.
Rewriting now stops at the client command's name, the tail takes only
GNU forms and single-letter shorthands as aperture's, and positionals
pass through without ending consumption. --version is answered before
dispatch, so a client command after it no longer swallows the flag and
launches. OpenCode's per-launch config gets a unique name, so two
concurrent launches stop overwriting and deleting each other's, and the
pilike orphan sweep leaves files inside a grace window so a sibling
launch keeps its extension. ADR 0007 and the contract record the tail
boundary.
A --config or APERTURE_CONFIG override hands a launch an endpoint and a
yolo flag with no screen to show either, so the resolved endpoint and
the yolo state go to stderr before the client starts. Additive only:
nothing is refused, and CI logs carry the source of the configuration
the run used. Approved by Guy after the security review raised it.
The flag inventory had four gaps. flagOrEnv lost its precedence test
when main_test.go died in the cobra rewrite, and --bridge= beating
APERTURE_BRIDGE is a documented contract, so it comes back against
pflag. The tail shorthands -e, -b, -c and -d had no parser cases. Bare
-v had no pre-scan case. And a deprecated single-dash flag on a
subcommand (bridge add -port) had nothing pinning it through the real
binary, the one corner of the tail boundary that could silently break.
Two suites make check does not run, because they download the latest
release and, with TS_AUTHKEY set, log a bridge into the local tailnet.
compat has the released binary write settings, state and a bridge login,
then the branch binary reads and reuses them, which is the only test
that proves a real old install upgrades cleanly. contract pins the
scripted surface byte for byte: the columns a script parses, the stderr
a user reads, the exit codes a supervisor checks. make test-tailnet runs
both, and the README documents the suites and the -update regeneration.
Driving the release through a real Aperture needed screen reading, not
row guesses: its picker shifts with whatever clients the box has.
A disk-full or close failure left the CreateTemp file behind in
~/.opencode instead of being cleaned up like the analogous writer in
pilike/extension.go.
Spec resolved the provider from LastLaunch but still passed the
caller's (possibly empty) model straight to buildSpec, so a model an
earlier headless launch recorded was never replayed on the next one.
SetActiveEndpoint looked for the new URL among existing rows to carry
its name forward, but a URL edit's new row never matches anything
already saved; only the replaced row does. The name was silently
dropped instead.
…-port test
free+1 could already be taken or overflow past 65535 when free itself
was the top of the range, failing the test before the behavior under
test ever ran.
The scanner matched any token named "version" or "v" regardless of a
leading dash, so a positional argument like `bridge add version` was
mistaken for --version; it also ignored an attached value, so
--version=false still printed the version.
The flag branch of resolve returned the caller's model unchanged, so
--provider without --model sent Codex no --model at all instead of the
provider's default, unlike the unflagged and LastLaunch paths.
…odel
Same gap as Codex: the flag branch of resolve skipped the
default-model fallback the unflagged path uses, so COPILOT_MODEL went
unset and Copilot could pick a model outside the chosen provider's set.
signal.NotifyContext's handler consumes SIGTERM on aperture's own
process; nothing forwarded it to the child, so a SIGTERM aimed at
aperture left the client running while Run kept waiting on it. runChild
now signals the child on ctx.Done and still waits for its real exit
status.
The manual client-command parser bypassed cobra's flag/environment
merging entirely, so $APERTURE_ENDPOINT, $APERTURE_BRIDGE and
$APERTURE_CONFIG were ignored whenever the matching tail flag was
absent. launchSelection now tracks whether each flag was supplied at
all, even with an empty value, so an explicit --bridge= can still mean
"no bridge" while an absent one reads the environment.
TestMain always built from source, so nothing exercised what
make install or a release actually put on disk. APERTURE_E2E_BIN
points the harness at a binary instead, and the README shows it with
make test-tailnet.
The testing story spans four places (unit style, the e2e harness, the
tailnet-tagged suites, the installed-binary override) and lived in none.
docs/testing.md collects them with working examples: a table test, a
headless launch through the stub, a PTY session, a signal test, golden
regeneration and APERTURE_E2E_BIN. The README's Test suites section
shrinks to a summary and links to it.
The tailnet-tagged suites were local-only, but only the bridge-login
half of compat actually needs a tailnet: contract needs nothing
external and the state-read half needs only GitHub. A parallel job runs
make test-tailnet on ubuntu-latest, where the login half skips unless a
TS_AUTHKEY secret exists. Same Makefile target, so a green local run
still means a green CI.
Skip pre-run config selection for manually parsed commands
internal/cli/cli.go:83
This pre-run selects APERTURE_CONFIG before the client command's manual parser runs. With APERTURE_CONFIG=other.json aperture pi --config=, the parser correctly resolves an empty override, but its nonempty guard never clears the path already selected here, so the launch still reads and writes other.json. Skip this pre-run for commands with DisableFlagParsing, leaving their configuration selection to the tail parser.
Print canonical saved name instead of raw input
internal/cli/endpoint.go:56
The output echoes the raw name rather than the validated, saved name. NameEndpoint trims whitespace before checking control characters, so a name with a leading or trailing newline/tab is accepted but those characters reappear here, allowing extra rows or columns in scripted output. Print the canonical entry from settings instead.
Preserve values of value-taking flags during argument rewriting
internal/cli/normalize.go:79
The rewrite loop treats flag values as flags. For example, aperture --config -debug bridge list rewrites the requested filename -debug to --debug and reads a different settings file; tailStart skips the value only when finding the client boundary, not here. Track value-taking flags in this loop and copy their following value verbatim without rewriting or warning.
This issue also appears on line 90 of the same file.
Validate saved model against the provider's current models
internal/clients/codex/codex.go:183
The saved model is reused without checking the freshly discovered model list. If the model was removed, or another endpoint offers the same provider ID with different models, an unflagged launch still passes the unavailable model to Codex instead of falling back. Validate LastModel against fqnModels(p), as Replay already does, and use the provider's default when stale.
Fallback when the saved model is no longer advertised
internal/clients/copilot/copilot.go:186
This restores the saved model even when the provider no longer advertises it. Removing a model, or switching to an endpoint with the same provider ID but different models, therefore produces a stale COPILOT_MODEL rather than the documented fallback. Apply the model-membership check used by Replay and select the provider's first model when the saved one is unavailable.
A compatible provider does not guarantee that its saved model still exists. After that model is removed, or an endpoint switch finds the same provider ID with different models, this branch sets HERMES_INFERENCE_MODEL to the stale selection. Check LastModel against fqnModels(p), as resolveReplay does, and fall back to defaultModel(p) when it is unavailable.
Only restore saved models still offered by the provider
internal/clients/pilike/pilike.go:242
The saved model is copied without verifying that this provider still offers it. If it disappeared, the extension contains only the current models but the child receives a reference to the missing one, so an unflagged launch cannot use the configured provider. Restore LastModel only when it appears in fqnModels(p); otherwise leave it empty so launchTarget selects the default.
Apply replacement name after selecting the edited candidate
internal/config/global.go:82
The replacement-name assignment is still overwritten on the real edit path: EditAttempt calls BeginAttempt, which appends the new URL as an unnamed candidate after the original. This loop copies the original name, then replaces head with that candidate, so a verified edit still loses its command-line handle. Apply the replacement name after selecting the candidate row, and test with both rows already present.
Setting RLIMIT_FSIZE in the test runner process breaks testlog writes
under 'go test' caching (write testlog.txt: file too large). Once
bufio.Writer hits EFBIG during the limit window, its error is sticky
and fails StopTestLog on exit even after Setrlimit restores the original
limit. Running the failure path in a child process isolates the limit
from the runner's log.
reportFailure points users to details: <run-log>, but neither main nor
the headless subcommands logged the returning error to slog, leaving
the run log empty of the failure that ended the process. Similarly,
bridge proxy/dial failures in headless serve and client launch, as well
as asynchronous TUI failures, were dropped or sent only to the UI sink.
Routing all failure paths through slog ensures aperture.log contains
the diagnostic trail across both headless and interactive modes.
…mands
root.PersistentPreRun selected APERTURE_CONFIG before client commands'
tail parser ran. When an explicit empty override (--config=) was passed,
PersistentPreRun fell back to the environment variable because flags were
not parsed by Cobra, and launch's nonempty guard never cleared it.
Skipping PersistentPreRun when DisableFlagParsing is true leaves config
selection to the tail parser.
NameEndpoint trims whitespace from names before saving them in
settings. Echoing raw args[0] in the confirmation output preserved
leading and trailing padding from input into the table. Reading the
entry from settings after saving ensures the canonical name is printed.
…checks
gnuForm and versionRequested treated any token starting with '-' as a
flag. When a value-taking flag was followed by a value that started with
'-' (e.g. '--config -debug' or '--endpoint -v'), gnuForm rewrote the value
as a flag and emitted a deprecation warning, while versionRequested
mistook the value for a version request. Tracking value-taking flags in
both scanners preserves following values verbatim.
When an unflagged headless launch restored LastModel, it checked only
that the provider was compatible, not that the saved model still existed
in the provider's advertised models. If a model was removed or an endpoint
switch served different models under the same provider ID, the launch passed
the stale model instead of falling back. Validating LastModel against
fqnModels(p) falls back to the default model across Codex, Copilot,
Hermes and Pi-like.
EditAttempt calls BeginAttempt, which appends the new candidate URL to
settings before verification. In SetActiveEndpoint, iterating through
existing endpoints matched the replacement original first, but then
overwrote head with the subsequent candidate entry, losing the copied
name. Selecting the candidate row first and then copying the replacement
name preserves the command-line handle when both rows are present.
A bridge in use by endpoints is refused by CheckRemovable, but unnamed
endpoints created by flag-based connects or older releases have no handle
to delete individually. Adding --force bypasses the guard, logs out and
destroys every machine slot from the tailnet, and cleans up the associated
endpoints from settings.
TestCompatOldBridgeLoginReusedByNewBinary registered a bridge on the real
tailnet with TS_AUTHKEY without destroying it, leaving active nodes behind.
A t.Cleanup hook now force-deletes the bridge to log out its machines.
Also fixes macOS e2e test failures:
- apertureConfigDir resolves ~/Library/Application Support/aperture on darwin
- detailsPattern in golden normalization matches paths containing spaces
- releaseBinary handles darwin .zip archive releases alongside .tar.gz
- terminal synchronization eliminates a data race between waitExit and kill
- TestLaunchPi waits for the quick select row before sending quit
Adds the --force example and explanation to aperture bridge delete's
Long and Example descriptions so the newly added flag is documented
in its help output.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR adds a headless CLI alongside the TUI. Decisions are recorded in
docs/adr/0007-headless-cli.mdanddocs/adr/0008-persistent-bridge-fixed-port.md.cobra now owns dispatch. A bare invocation still starts the TUI through the same code path.
bridge add,list,describeanddeleteplusendpoint add,listanddeletework headless, so a setup can live in a script, a dotfile or a unit file instead of a menu walk.Every registered client gets a command named by its binary:
Aperture consumes its own flags; the first flag it doesn't know, or
--, passes the rest to the client verbatim. The child's exit code becomes aperture's own, so the alias behaves like the binary. Each client package (claudecode, codex, copilot, gemini, hermes, opencode, pilike) grew aSpecmethod that builds a headless launch: flags win,LastLaunchfills what they leave, and the first compatible provider covers the rest. Claude Cowork refusesSpecsince it's a desktop app with no headless launch.aperture serve --bridge <name>runs one bridge on a fixed port until SIGTERM or SIGINT, for client configs that need a stable address, the ts-unplug use case. The port comes from the bridge record or--port; serve never falls back to an ephemeral port.Flags move to GNU double-dash form (
--endpoint,--bridge,--port,--config) with single-letter shorthands. The old single-dash long forms still parse, rewritten by a normalization pass before cobra sees them, with a deprecation warning on stderr and no version promised for removal. Endpoints and bridges are addressed by name now; a value that parses as a URL is read as an inline endpoint instead.--config/APERTURE_CONFIGpicks an alternate settings file.Two review passes found real gaps and both are fixed in this branch rather than follow-up. First pass:
endpoint addcreated a bridge from a typo and now resolves throughbridgeByNameinstead;--debug=falseenabled debug; names with control characters are refused at the door; the run log had stopped opening for headless runs when the cobra refactor moved it intorunTUI, now it opens for every run again; a child killed by a signal now exits 1 instead of wrapping to 255. Second pass caught the passthrough contract broken twice over:gnuFormwas rewriting single-dash long flags inside a client's own tail, so a client's-configbecame aperture's--configand a launch read and wrote a settings file the user never named. Rewriting now stops at the client command's name. OpenCode's per-launch config file gets a unique name so two concurrent launches stop overwriting and deleting each other's.Adds
github.com/spf13/cobraandgithub.com/spf13/pflagtogo.mod.Risk: serve's fixed loopback listener has no auth of its own, the client's
Authorizationheader is stripped because the Machine's tailnet identity is the ingress auth. Every process on the host can reach the user's Aperture through a running serve, and on a shared machine so can every other user, since loopback isn't per-UID. ADR 0008 accepts this as the same exposure ts-unplug already has and that serve replaces it deliberately, single-user workstations being the audience, but it's worth a second look given how easyaperture serveis to leave running. The flag surface change is large (every double-dash flag plus shorthands) and touches every client package; the deprecated single-dash forms keep old scripts working but the test coverage for that compatibility path is worth checking closely.