The openai4s command lives here: daemon lifecycle (serve, status, stop, url), one-shot local task execution (run), first-run model configuration (init), scientific environment creation (setup) and the transactional env generations beside it, the support surfaces (doctor, diagnostics, verify-package), the workflow benchmark, read-only session sharing (share) with the public relay that fronts it, batch-job submission (cluster), team-mode account administration (user), and the optional Jupyter adapter commands. -V/--version answers with the package version.
The CLI composes; it does not orchestrate. openai4s run builds the local outer loop out of ../agent/, and a persistent kernel only starts if a turn actually routes a code cell. openai4s serve hands off to the HTTP/WebSocket server. The setup and status commands run outside any agent turn.
One argparse tree covers three kinds of command, and the difference decides what a failure means. run, init, setup, env, doctor, diagnostics, verify-package, benchmark, user and the Jupyter commands do their work in this process — user writes accounts through the store directly, because the operator who needs to bootstrap or disable one is often the operator whose daemon is down. serve becomes the daemon. share and cluster are REST clients of a daemon that must already be running, so they fail on a missing daemon or a missing credential before they ever reach the feature; cluster is daemon-facing deliberately, since a submission has to reach the reconciler that will act on it, and a second process writing workload rows behind the daemon's back is how two reconcilers end up disagreeing about one job.
| File | Responsibility |
|---|---|
__init__.py |
Re-exports main, so the package itself is the CLI entry point. |
main.py |
One argparse tree plus its handlers. In-process: run, init, setup, env (plan/apply/list/rollback/recover), doctor, diagnostics, verify-package, benchmark, user (add/list/disable/reset-password, direct against the store; --password-stdin or a generated password printed exactly once, and disabling an account also clears its stored LLM keys), and jupyter describe/export/install. Daemon-facing: serve (with --detached to background it on Linux/macOS, including WSL2), status, stop, url, the eight share subcommands, and cluster (submit/list/cancel/logs/profiles against the daemon's orchestration routes); relay serve / relay gen-token run the public share relay on a VPS rather than on this machine. It also owns the daemon pidfile and statefile, and drives conda when building environments — setup --profile standard for the everyday Python and R pair, full for all four, or --only <name> for one. An existing environment is left alone unless --update says otherwise, an update never prunes what you installed yourself, and it names the prefix discovery actually found rather than the spec's name: — conda resolves a bare name against its own root, so the alternative is a second environment built somewhere the agent never runs, reported as success. |
run executes in-process and follows the same Engine action and completion rules as the local Agent facade. run --auto is Auto Mode: boundary actions go to the Guardian instead of failing closed, and the result is reviewed before the run reports a terminal — CI needs to tell "ran and was verified" from "ran and nobody checked". The review reads what the run recorded (--auto records its cells: code, output, errors, plus the native tool ledger); evidence that is missing is reported as an omission, never as verified. That covers the root run's own cells only: a delegated child's cells live under the child's frame and are neither collected nor declared, the same as on the Web path. It is not full access; the Guardian's active surface is a read-only allowlist bound to a verified action digest. run --mode reusable_pipeline|codebase_change completes only with test evidence backed by a Host-authorized host.bash receipt; a headless run has nobody to approve that shell command, so --allow-test-command CMD (repeatable) pre-authorizes that exact command string for the run, and without it (or a standing rule / unattended policy) the run refuses before its first model call. The receipt is the kernel worker's own report of that string's exit status, run in the Cell's environment (PATH, cwd, in-process state): it catches a test that was never run, only printed, or failed, and it is not proof against a Cell that deliberately fakes the runner.run exits 0 only when the run completed (stop_reason submitted). A run that ended any other way — max_turns, no_progress, cancelled, or a stop reason the CLI has never heard of — exits 3, and --json still prints the full result so the reason stays readable. 2 stays a refusal (a usage error such as an empty task, empty_task, or an invalid --allow-test-command, invalid_allow_test_command; an explicit code mode whose test command nothing can authorize, code_mode_test_runner_unauthorized; the standard environment not ready; a database newer than this build, future_schema; an upgrade of an older database that failed and was rolled back, migration_failed, whose error line names the kept backup — --json prints the error and its code on stdout, and the two database refusals keep their error: line on stderr) and 1 an unhandled error with a traceback. With --auto the status follows stop_reason alone; the review verdict is auto_mode.terminal. The table is in openai4s run --help. It used to be 0 for every terminal, so a wrapper checking $? read a looping or capped run as success while the Action Ledger recorded it as failed.run opens is closed by that run: done for a completion, cancelled for a cancel or Ctrl-C, failed for everything else including an exception, with the provider token totals on the row. A run refused before its first model call after the frame already exists (an explicit code mode whose test runner nothing can authorize) closes it failed too. Before, it stayed processing with NULL tokens forever, which is what a daemon on the same data dir then reported for it. Delegated child frames are still owned by the delegation runner.serve must keep taking its bind address from Config, and the default must stay on loopback — do not hardcode a bind. Exposing the daemon beyond this machine is a job for a trusted reverse proxy or an SSH tunnel. The access token is required by default now, on loopback as well: the gateway mints one into an owner-only file under the data dir, keeps it across restarts, and demands it everywhere except /health and /api/v1/auth/status — a client cannot be told it needs a token by a response it is not allowed to read. There is no opt-out: OPENAI4S_REQUIRE_TOKEN=0 was honoured on loopback for exactly one minor release (D1) and is ignored from 0.3.0, so the token file always exists once the daemon has started. That token is a thin last line, not the reason it is safe to expose the port.serve and url print carries ?token=…, or the SPA answers 401 before it can offer any way in. Every use that is not being handed to a person asks for the same URL without the token: a credential does not belong in a log line or a window title. status is one of those uses — a health check whose output lands in CI and support logs — so it prints the plain origin and names openai4s url as the way to get the sign-in URL; it used to print the full token.OPENAI4S_TOKEN when the daemon runs under another account. The request path is joined from contract.API_ROOT, and passing an already-prefixed path is an error rather than a 404 — hard-coding /api/ is how every share subcommand came to receive the daemon's own "the API is versioned" 404, with not one of them ever reaching a route.serve binds before it prints its banner, so a port collision fails the start instead of looking like a crash after a successful one. stop only claims what it verified: it polls for the exit, and a daemon still shutting down when --timeout (default 30s) runs out is exit 2 with the pidfile left in place — clearing state early is how a live daemon on a bound port got called "not running" by status and crashed into by the next serve; stop --force escalates to SIGKILL after the same poll. Past the 5s SIGTERM grace it prints a shutting down… line to stderr and keeps polling: a daemon tearing down a first-kernel bootstrap or several live kernels takes 8-9s, and giving up at 5s turned a shutdown that finished by itself into a false failure.setup edits an environment in place; env treats one as a transaction — a generation is built from a spec staged under the apply lock, verified by starting the interpreter (a file at the path is not an environment), and only then pointed at. env recover reports what a restart should know, including an apply still holding the lock.doctor answers 0 ok / 1 degraded / 2 failed and needs no daemon, because the situation that motivates running it is usually one where the daemon will not start; it also falls back to a plain Config rather than raising when the data directory is the thing that is broken. benchmark fails on zero workflows instead of reporting a clean run over nothing.