From e46ca0d6c2a981d40d0d671a52a68a33d06647a0 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 13:16:29 -0700 Subject: [PATCH 1/5] scripts: make the surface extractors see the literals they normalise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both extractors already carried the normalisation the OpenAPI templates need — `${id}` -> `{}` in provider-methods.mjs, `{id}` -> `{}` in cli-routes.sh, and a `split("?")` for query strings — but neither regex could ever match a literal carrying one, so every interpolated or query-bearing call read as an uncovered route. Widen both to accept `?`/`&`/`=` and `${...}` holes, strip the query before comparing, and treat a hole as a path segment only when a `/` introduces it (so `/executions${queryString(f)}` is `/executions`, not `/executions/{}`). Also read the `web/api/*.ts` modules the provider delegates to: those calls are the provider's calls. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- scripts/cli-routes.sh | 20 ++++++++++++++--- scripts/provider-methods.mjs | 43 ++++++++++++++++++++++++++++-------- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/scripts/cli-routes.sh b/scripts/cli-routes.sh index 9d47f80..fc53abb 100755 --- a/scripts/cli-routes.sh +++ b/scripts/cli-routes.sh @@ -5,10 +5,24 @@ # help, and a verb name ("rally") does not carry the route it hits. The # CLI's path literals do, and they are the same contract the plan asks # for — a route with no CLI path literal has no command-line verb. +# +# A literal is a route with two kinds of noise stripped, the same two +# the OpenAPI templates do not carry: +# +# "/events?limit={limit}" -> /events +# "/formations/{id}/deploy" -> /formations/{}/deploy +# +# A `{hole}` is a path segment only when a `/` introduces it; a hole +# anywhere else is an interpolated query string or base URL, not a +# segment, and is dropped rather than turned into `{}`. set -eu root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -grep -rhoE '"/[A-Za-z0-9_{}/.:-]*"' "$root/apps/springtale-cli/src" \ +grep -rhoE '"/[A-Za-z0-9_{}/.:?=&-]*"' "$root/apps/springtale-cli/src" \ | tr -d '"' \ - | sed -e 's/{[^}]*}/{}/g' -e 's#/\{1,\}$##' \ - | grep -vE '^/$' \ + | sed -e 's/?.*$//' \ + -e "s#/{[^}]*}#/%HOLE%#g" \ + -e "s/{[^}]*}//g" \ + -e "s/%HOLE%/{}/g" \ + -e 's#/\{1,\}$##' \ + | grep -vE '^/?$' \ | sort -u diff --git a/scripts/provider-methods.mjs b/scripts/provider-methods.mjs index 18bcdb5..fb7b063 100755 --- a/scripts/provider-methods.mjs +++ b/scripts/provider-methods.mjs @@ -2,29 +2,54 @@ // Print, one per line, every daemon route the web DataProvider calls. // // The provider is the only place the web surface talks to springtaled, -// so its path literals ARE the provider's half of the API contract. -// Template holes (`${id}`) are normalised to `{}` so they line up with -// the OpenAPI path templates (`{id}`). +// so its path literals ARE the provider's half of the API contract. The +// provider delegates some families to the small modules beside it +// (`web/api/*.ts`); those calls are the provider's calls, so they are +// read here too. +// +// A literal is a route with two kinds of noise stripped, the same two +// the OpenAPI templates do not carry: +// +// `/events?limit=${limit}` -> /events +// `/recipes/${encodeURIComponent(id)}` -> /recipes/{} +// +// A `${hole}` is a path segment only when a `/` introduces it; a hole +// anywhere else is an interpolated query string (`${queryString(f)}`), +// not a segment, and is dropped rather than turned into `{}`. -import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); +const web = resolve(here, "..", "tauri", "packages", "ui", "src", "web"); const providers = [ - resolve(here, "..", "tauri", "packages", "ui", "src", "web", "provider.ts"), + join(web, "provider.ts"), + ...readdirSync(join(web, "api")) + .filter((f) => f.endsWith(".ts")) + .map((f) => join(web, "api", f)), ]; -const PATH_LITERAL = /["'`](\/[A-Za-z0-9_${}/.:-]*)["'`]/g; +// A `${…}` hole, allowing one level of nesting so `${queryString({ a: b })}` +// reads as one hole rather than a truncated one. +const HOLE = String.raw`\$\{(?:[^{}]|\{[^{}]*\})*\}`; +const PATH_LITERAL = new RegExp( + String.raw`["'\`](\/(?:${HOLE}|[A-Za-z0-9_/.:?=&{}-])*)["'\`]`, + "g", +); +const MARKER = "%HOLE%"; const routes = new Set(); for (const file of providers) { const source = readFileSync(file, "utf8"); for (const [, path] of source.matchAll(PATH_LITERAL)) { - // Drop query strings and trailing slashes, normalise template holes. const normalised = path .split("?")[0] - .replace(/\$\{[^}]*\}/g, "{}") + // A hole after `/` is a path segment; any other hole is not. + .replace(new RegExp(String.raw`\/${HOLE}`, "g"), `/${MARKER}`) + .replace(new RegExp(HOLE, "g"), "") + .split(MARKER) + .join("{}") .replace(/\/+$/, ""); if (normalised.length > 1) routes.add(normalised); } From e65455722c39cfbc9f2930a53508d0a70dca9ff3 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 13:25:27 -0700 Subject: [PATCH 2/5] cli: give every daemon route a verb, except the ones that must not have one 61 routes had no command-line verb. Adds them as new families (auth, drift, execution, onboarding, workspace, send) and as verbs on the families already there (agent states/step-autonomy, bot status/ formations/memory, canvas --connections, config connector/heartbeat, ten connector verbs, cooperation utterances/recent, five formation verbs, eleven recipe verbs, five rule verbs, safety disguise-profile, healthcheck --ready). Every one goes through `Client` and prints through `output::emit`. Three routes needed a body the JSON client does not speak, so they use `Client::request` directly: `/recipes/import` (text/plain TOML), `/recipes/{id}/export` and `/render` (text/plain out), and `/connectors/install-wasm` (a two-part multipart body built by hand rather than pulling in reqwest's multipart feature). `/workspaces/ onboard` answers SSE over POST, so `Client::post_stream` hands back the undecoded response. Under /formations the drum rule holds: intents, members/eligible, propose-intent, votes and run-command are composition, intent, constraints, intervention and inspection. No assign verb. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtale-cli/src/cli.rs | 402 ++++++++++++++++++ apps/springtale-cli/src/client.rs | 23 + apps/springtale-cli/src/commands/agent.rs | 28 ++ apps/springtale-cli/src/commands/auth.rs | 40 ++ apps/springtale-cli/src/commands/bot.rs | 24 ++ apps/springtale-cli/src/commands/canvas.rs | 18 +- apps/springtale-cli/src/commands/config.rs | 24 ++ apps/springtale-cli/src/commands/connector.rs | 173 ++++++++ .../src/commands/cooperation.rs | 21 + apps/springtale-cli/src/commands/drift.rs | 21 + apps/springtale-cli/src/commands/events.rs | 7 +- apps/springtale-cli/src/commands/execution.rs | 72 ++++ apps/springtale-cli/src/commands/formation.rs | 53 +++ .../src/commands/healthcheck.rs | 12 +- .../springtale-cli/src/commands/json_input.rs | 34 ++ apps/springtale-cli/src/commands/login.rs | 9 +- apps/springtale-cli/src/commands/mod.rs | 7 + .../springtale-cli/src/commands/onboarding.rs | 46 ++ apps/springtale-cli/src/commands/recipe.rs | 130 ++++++ apps/springtale-cli/src/commands/rule.rs | 48 +++ apps/springtale-cli/src/commands/safety.rs | 11 + apps/springtale-cli/src/commands/send.rs | 31 ++ apps/springtale-cli/src/commands/workspace.rs | 151 +++++++ apps/springtale-cli/src/main.rs | 47 +- scripts/provider-methods.mjs | 9 +- 25 files changed, 1425 insertions(+), 16 deletions(-) create mode 100644 apps/springtale-cli/src/commands/auth.rs create mode 100644 apps/springtale-cli/src/commands/drift.rs create mode 100644 apps/springtale-cli/src/commands/execution.rs create mode 100644 apps/springtale-cli/src/commands/json_input.rs create mode 100644 apps/springtale-cli/src/commands/onboarding.rs create mode 100644 apps/springtale-cli/src/commands/send.rs create mode 100644 apps/springtale-cli/src/commands/workspace.rs diff --git a/apps/springtale-cli/src/cli.rs b/apps/springtale-cli/src/cli.rs index d939f06..71053a8 100644 --- a/apps/springtale-cli/src/cli.rs +++ b/apps/springtale-cli/src/cli.rs @@ -63,6 +63,9 @@ pub enum Command { /// `HEALTHCHECK` and docker-compose because the distroless final /// image has no `wget` / `curl`. Healthcheck { + /// Probe readiness (`/ready`) instead of liveness (`/health`). + #[arg(long)] + ready: bool, /// Override the management API base URL. /// Defaults to `http://127.0.0.1:8080` (matches `springtaled` default). #[arg(long, default_value = "http://127.0.0.1:8080")] @@ -195,11 +198,81 @@ pub enum Command { /// Follow live canvas updates instead of printing a snapshot. #[arg(long)] stream: bool, + /// Print the pipes between trees instead of the whole canvas. + #[arg(long, conflicts_with = "stream")] + connections: bool, + }, + /// API tokens the daemon has issued. + Auth { + #[command(subcommand)] + action: AuthAction, + }, + /// How far a deployed recipe or rule has drifted from its source. + Drift { + #[command(subcommand)] + action: DriftAction, + }, + /// The execution log, and the vacuum that trims it. + Execution { + #[command(subcommand)] + action: ExecutionAction, + }, + /// Guided per-platform setup forms. + Onboarding { + #[command(subcommand)] + action: OnboardingAction, + }, + /// External workspaces a formation's connectors can reach. + Workspace { + #[command(subcommand)] + action: WorkspaceAction, + }, + /// Send one message out through a connector. + Send { + /// Connector to send on. + connector: String, + /// Channel, chat, repo — whatever the connector addresses. + target: String, + /// Message body. + text: String, }, } #[derive(Subcommand, Debug)] pub enum FormationAction { + /// The intents a formation can hold. + Intents, + /// Connectors eligible to join this formation. + Eligible { + /// Formation id. + id: String, + }, + /// Propose an intent change for the formation to vote on. + ProposeIntent { + /// Formation id. + id: String, + /// Proposed intent. + intent: String, + }, + /// Cast a vote on an open proposal. + Vote { + /// Formation id. + id: String, + /// Vote id. + vote: String, + /// Choice to record. + choice: String, + }, + /// Run one of the formation's available commands. + Run { + /// Formation id. + id: String, + /// Command id, from `formation commands`. + command: String, + /// Optional JSON file of parameters (`-` for stdin). + #[arg(long)] + params: Option, + }, /// List formations. List, /// Show one formation. @@ -279,6 +352,73 @@ pub enum FormationAction { #[derive(Subcommand, Debug)] pub enum RecipeAction { + /// The pieces one recipe is built from. + Pieces { + /// Recipe id. + id: String, + }, + /// Toggle a recipe's favourite mark. + Favorite { + /// Recipe id. + id: String, + }, + /// Record a recipe as recently used. + Recent { + /// Recipe id. + id: String, + }, + /// Fork a recipe under a new name. + Fork { + /// Recipe id. + id: String, + /// Name for the fork. + name: String, + }, + /// Check a recipe's inputs before applying it. + Preflight { + /// Recipe id. + id: String, + /// JSON file of `{ "values": { ... } }`. + inputs: Option, + }, + /// Run one step of a recipe against real inputs. + TestStep { + /// Recipe id. + id: String, + /// Rule index within the recipe. + rule_index: usize, + /// Step index within that rule. + step_index: usize, + /// JSON file of `{ "values": { ... } }`. + inputs: Option, + }, + /// Save a user recipe from a JSON file. + Save { + /// JSON file of the recipe (`-` for stdin). + file: PathBuf, + }, + /// Delete one of your own recipes. + Delete { + /// Recipe id. + id: String, + }, + /// Print a recipe as TOML. + Export { + /// Recipe id. + id: String, + }, + /// Render a recipe with inputs filled in, as TOML. + Render { + /// Recipe id. + id: String, + /// JSON file of `{ "values": { ... } }`. + inputs: Option, + }, + /// Import a recipe from a TOML file. + Import { + /// TOML file to import. + file: PathBuf, + }, /// List recipes. List { /// Filter by category. @@ -340,6 +480,13 @@ pub enum SessionAction { #[derive(Subcommand, Debug)] pub enum SafetyAction { + /// Set the disguise the app wears when it is hidden. + DisguiseProfile { + /// App name to show. + app_name: String, + /// Icon id to show. + icon_id: String, + }, /// Show the safety config. Get, /// Turn the disguise overlay on or off. @@ -362,6 +509,14 @@ pub enum SafetyAction { #[derive(Subcommand, Debug)] pub enum CooperationAction { + /// The utterance definition table the daemon is serving. + Utterances, + /// Utterances the colony has spoken recently. + Recent { + /// Maximum rows. + #[arg(long, default_value_t = 50)] + limit: u32, + }, /// Print every codepoint the utterance def table renders, one `U+XXXX` /// per line (the input to `scripts/build-symbol-font.sh`). Glyphs { @@ -400,6 +555,12 @@ pub enum AuthorAction { #[derive(Subcommand, Debug)] pub enum BotAction { + /// What the bot runtime is doing right now. + Status, + /// The formations the bot is running. + Formations, + /// The session memory the bot is holding. + Memory, /// Generate a pairing code for a new user. Display on terminal only — never in chat. PairInit, /// Revoke ALL paired users and invalidate ALL outstanding codes. @@ -462,6 +623,59 @@ pub enum VaultAction { #[derive(Subcommand, Debug)] pub enum ConnectorAction { + /// Connectors that can be installed. + Available, + /// The manifest schema of every installed connector. + Schemas, + /// Install and configure a connector in one step. + Setup { + /// Connector name. + name: String, + /// JSON file of config (`-` for stdin). + config: PathBuf, + }, + /// Install a sandboxed WASM connector. + InstallWasm { + /// Manifest file (JSON or TOML). + manifest: PathBuf, + /// Compiled `.wasm` module. + wasm: PathBuf, + }, + /// Remove a connector and every rule that used it. + Cascade { + /// Connector name. + name: String, + }, + /// Show a connector's stored config. + Config { + /// Connector name. + name: String, + }, + /// Create or replace a connector's stored config. + UpsertConfig { + /// Connector name. + name: String, + /// JSON file of config (`-` for stdin). + file: PathBuf, + }, + /// Recent outputs a connector produced. + Outputs { + /// Connector name. + name: String, + /// Maximum rows. + #[arg(long, default_value_t = 20)] + limit: u32, + }, + /// Reload a connector from disk. + Reload { + /// Connector name. + name: String, + }, + /// Run a connector's self-test. + Test { + /// Connector name. + name: String, + }, /// List installed connectors. List, /// Enable a connector. @@ -494,6 +708,30 @@ pub enum ConnectorAction { #[derive(Subcommand, Debug)] pub enum RuleAction { + /// The JSON schema a rule definition must satisfy. + Schema, + /// Turn plain English into a rule the daemon would accept. + Parse { + /// What the rule should do, in plain English. + intent: String, + }, + /// Add a rule bound to a connector's trigger vocabulary. + AddForConnector { + /// Rule file (TOML or JSON). + file: PathBuf, + }, + /// List the rules that run on one connector. + ForConnector { + /// Connector name. + name: String, + }, + /// Move a rule onto a different connector. + Reassign { + /// Rule id. + id: String, + /// Connector to move it to. + connector: String, + }, /// List all rules. List, /// Toggle a rule's enabled/disabled status. @@ -580,6 +818,18 @@ pub enum DataAction { #[derive(Subcommand, Debug)] pub enum ConfigAction { + /// Save a connector's config document. + Connector { + /// Connector name. + name: String, + /// JSON file of config (`-` for stdin). + file: PathBuf, + }, + /// Show, or with a file replace, the heartbeat config. + Heartbeat { + /// JSON file of heartbeat config (`-` for stdin). + file: Option, + }, /// List every stored config key. List, /// AI adapter config — one socket per level (colony, formation, agent). @@ -591,6 +841,11 @@ pub enum ConfigAction { #[derive(Subcommand, Debug)] pub enum AiConfigAction { + /// Apply a whole AI adapter document from a file. + Put { + /// JSON file of adapter config (`-` for stdin). + file: PathBuf, + }, /// Print the AI config a level resolves to (API key redacted). Get { /// Level: colony, formation, or agent. @@ -623,6 +878,15 @@ pub enum AiConfigAction { #[derive(Subcommand, Debug)] pub enum AgentAction { + /// Every agent's live state, as the colony canvas sees it. + States, + /// Nudge one agent's autonomy up or down a step. + StepAutonomy { + /// Rule name or id. + name: String, + /// `up` or `down`. + direction: String, + }, /// Set an agent's autonomy level (observe, suggest, act-with-approval, act-autonomously). SetAutonomy { /// Rule name or rule id of the agent. @@ -738,3 +1002,141 @@ mod tests { } } } + +#[derive(Subcommand, Debug)] +pub enum AuthAction { + /// List the API tokens the daemon has issued. + Tokens, + /// Revoke one token by id. + Revoke { + /// Token id, from `springtale auth tokens`. + id: String, + }, +} + +#[derive(Subcommand, Debug)] +pub enum DriftAction { + /// Drift for one deployed recipe. + Recipe { + /// Recipe id. + id: String, + }, + /// Drift for one rule. + Rule { + /// Rule id. + id: String, + }, +} + +#[derive(Subcommand, Debug)] +pub enum ExecutionAction { + /// List recent executions. + List { + /// Only executions of this rule. + #[arg(long)] + rule: Option, + /// Maximum rows. + #[arg(long, default_value_t = 50)] + limit: u32, + }, + /// The steps of one execution. + Steps { + /// Execution id. + id: String, + }, + /// Delete executions older than `--keep-days`. + Vacuum { + /// Days of history to keep. + #[arg(long, default_value_t = 30)] + keep_days: u32, + }, +} + +#[derive(Subcommand, Debug)] +pub enum OnboardingAction { + /// The platforms with a guided setup form. + Platforms, + /// Apply one platform's answers. + Apply { + /// Platform name, from `onboarding platforms`. + platform: String, + /// JSON file of answers (`-` for stdin). + answers: PathBuf, + }, +} + +#[derive(Subcommand, Debug)] +pub enum WorkspaceAction { + /// List a formation's workspaces. + List { + /// Formation id. + #[arg(long)] + formation: String, + /// Only workspaces on this connector. + #[arg(long)] + connector: Option, + }, + /// Ask a connector what workspaces it can see. + Scan { + /// Formation id. + #[arg(long)] + formation: String, + /// Connector to scan. + #[arg(long)] + connector: String, + }, + /// Record a workspace by hand. + Add { + /// Formation id. + #[arg(long)] + formation: String, + /// Connector-native key (channel id, repo slug, …). + #[arg(long)] + key: String, + /// Display name. + #[arg(long)] + name: String, + /// Connector the workspace lives on. + #[arg(long)] + connector: String, + /// Workspace kind (server, repo, channel, …). + #[arg(long)] + kind: String, + }, + /// Forget a workspace. + Remove { + /// Formation id. + #[arg(long)] + formation: String, + /// Connector-native key. + #[arg(long)] + key: String, + }, + /// Print the invite/authorisation URL for a connector. + OnboardUrl { + /// Connector name. + #[arg(long)] + connector: String, + /// JSON file of connector config (`-` for stdin). + #[arg(long)] + config: PathBuf, + /// Optional JSON file of extra payload. + #[arg(long)] + payload: Option, + }, + /// Follow a connector's onboarding stream. + Onboard { + /// Session id to correlate the stream with. + #[arg(long)] + session: String, + /// Connector name. + #[arg(long)] + connector: String, + /// JSON file of connector config (`-` for stdin). + #[arg(long)] + config: PathBuf, + /// Optional JSON file of extra payload. + #[arg(long)] + payload: Option, + }, +} diff --git a/apps/springtale-cli/src/client.rs b/apps/springtale-cli/src/client.rs index 3769145..0d1e69d 100644 --- a/apps/springtale-cli/src/client.rs +++ b/apps/springtale-cli/src/client.rs @@ -106,6 +106,29 @@ impl Client { Ok(resp) } + /// POST `path` and hand back the undecoded response — the shape the + /// SSE-over-POST routes need (`/workspaces/onboard` streams progress + /// frames rather than answering with one JSON body). + pub async fn post_stream(&self, path: &str, body: &B) -> Result { + // SECURITY: expose needed to set the bearer header. + let resp = self + .http + .post(self.url(path)) + .bearer_auth(self.token.expose_secret()) + .json(body) + .send() + .await + .context(UNREACHABLE)?; + if !resp.status().is_success() { + bail!( + "{}: {}", + resp.status(), + resp.text().await.unwrap_or_default() + ); + } + Ok(resp) + } + /// Start a request against `path` with the bearer header already /// applied, for callers that need the raw `reqwest` response rather /// than a decoded JSON body. The MCP stdio bridge uses it: it needs diff --git a/apps/springtale-cli/src/commands/agent.rs b/apps/springtale-cli/src/commands/agent.rs index 4109e1d..410672d 100644 --- a/apps/springtale-cli/src/commands/agent.rs +++ b/apps/springtale-cli/src/commands/agent.rs @@ -11,6 +11,34 @@ use crate::output; pub async fn run(action: AgentAction, json_out: bool) -> Result<()> { let client = Client::from_config()?; match action { + AgentAction::States => { + let body: Value = client.get("/agents/states").await?; + output::emit(json_out, &body, |v| { + let rows = output::array(v, "agents") + .iter() + .map(|a| { + vec![ + output::cell(a, "name"), + output::cell(a, "activity"), + output::cell(a, "autonomy"), + output::cell(a, "connector_name"), + ] + }) + .collect(); + output::rows_table(&["NAME", "ACTIVITY", "AUTONOMY", "CONNECTOR"], rows) + })?; + } + AgentAction::StepAutonomy { name, direction } => { + let body: Value = client + .post( + &format!("/agents/{name}/autonomy/step"), + &json!({ "direction": direction }), + ) + .await?; + output::emit(json_out, &body, |v| { + format!("Agent '{name}' autonomy is now: {}", output::cell(v, "level")) + })?; + } AgentAction::SetAutonomy { name, level } => { // The daemon resolves the rule name or id to an autonomy // target — the CLI does not need the rule set to do it. diff --git a/apps/springtale-cli/src/commands/auth.rs b/apps/springtale-cli/src/commands/auth.rs new file mode 100644 index 0000000..6fa3ae3 --- /dev/null +++ b/apps/springtale-cli/src/commands/auth.rs @@ -0,0 +1,40 @@ +//! `springtale auth` — the API tokens the daemon has issued. +//! +//! `springtale login` mints one and writes it to the token file; this +//! family is how you see the rest and revoke one you no longer trust. + +use anyhow::Result; +use serde_json::Value; + +use crate::cli::AuthAction; +use crate::client::Client; +use crate::output; + +/// Handle auth subcommands. +pub async fn run(action: AuthAction, json_out: bool) -> Result<()> { + let client = Client::from_config()?; + match action { + AuthAction::Tokens => { + let body: Value = client.get("/auth/tokens").await?; + output::emit(json_out, &body, |v| { + let rows = output::array(v, "tokens") + .iter() + .map(|t| { + vec![ + output::cell(t, "id"), + output::cell(t, "name"), + output::cell(t, "created_at"), + output::cell(t, "last_used_at"), + ] + }) + .collect(); + output::rows_table(&["ID", "NAME", "CREATED", "LAST USED"], rows) + })?; + } + AuthAction::Revoke { id } => { + let body: Value = client.delete(&format!("/auth/tokens/{id}")).await?; + output::emit_status(json_out, &body, |_| format!("Revoked token {id}."))?; + } + } + Ok(()) +} diff --git a/apps/springtale-cli/src/commands/bot.rs b/apps/springtale-cli/src/commands/bot.rs index d9681f0..7b0f290 100644 --- a/apps/springtale-cli/src/commands/bot.rs +++ b/apps/springtale-cli/src/commands/bot.rs @@ -44,6 +44,30 @@ pub async fn panic_unpair(opts: &PassphraseOpts, json_out: bool) -> Result<()> { }) } +/// `springtale bot status` — what the runtime is doing right now. +pub async fn status(json_out: bool) -> Result<()> { + read(json_out, "/bot/status").await +} + +/// `springtale bot formations` — the formations the bot is running. +pub async fn formations(json_out: bool) -> Result<()> { + read(json_out, "/bot/formations").await +} + +/// `springtale bot memory` — the session memory the bot is holding. +pub async fn memory(json_out: bool) -> Result<()> { + read(json_out, "/bot/memory").await +} + +/// GET one read-only bot view and print it. +async fn read(json_out: bool, path: &str) -> Result<()> { + let client = Client::from_config()?; + let body: serde_json::Value = client.get(path).await?; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + }) +} + /// `springtale bot settings …` — plan 6.3. Goes through the daemon so the /// change reaches the live runtime (a direct store write would only be /// picked up on the next restart, which is the thing this replaced). diff --git a/apps/springtale-cli/src/commands/canvas.rs b/apps/springtale-cli/src/commands/canvas.rs index 246b4b3..726ef15 100644 --- a/apps/springtale-cli/src/commands/canvas.rs +++ b/apps/springtale-cli/src/commands/canvas.rs @@ -11,8 +11,24 @@ use crate::client::Client; use crate::output; /// Print the canvas snapshot, or follow live updates. -pub async fn run(stream: bool, json_out: bool) -> Result<()> { +pub async fn run(stream: bool, connections: bool, json_out: bool) -> Result<()> { let client = Client::from_config()?; + if connections { + let body: Value = client.get("/canvas/connections").await?; + return output::emit(json_out, &body, |v| { + let rows = output::array(v, "connections") + .iter() + .map(|c| { + vec![ + output::cell(c, "a"), + output::cell(c, "b"), + output::array(c, "pipes").len().to_string(), + ] + }) + .collect(); + output::rows_table(&["FROM", "TO", "PIPES"], rows) + }); + } if !stream { let body: Value = client.get("/canvas").await?; return output::emit(json_out, &body, |v| { diff --git a/apps/springtale-cli/src/commands/config.rs b/apps/springtale-cli/src/commands/config.rs index a276a3c..91db540 100644 --- a/apps/springtale-cli/src/commands/config.rs +++ b/apps/springtale-cli/src/commands/config.rs @@ -11,6 +11,7 @@ use springtale_runtime::operations::config::{AI_COLONY_KEY, AiTarget}; use crate::cli::{AiConfigAction, ConfigAction}; use crate::client::Client; +use crate::commands::json_input; use crate::output; /// Handle `config` subcommands. @@ -23,6 +24,23 @@ pub async fn run(action: ConfigAction, json_out: bool) -> Result<()> { serde_json::to_string_pretty(v).unwrap_or_default() }) } + ConfigAction::Connector { name, file } => { + let body: Value = client + .post(&format!("/config/connector/{name}"), &json_input::load(&file)?) + .await?; + output::emit_status(json_out, &body, |_| { + format!("Connector config saved for '{name}'.") + }) + } + ConfigAction::Heartbeat { file } => { + let body: Value = match file { + Some(file) => client.put("/config/heartbeat", &json_input::load(&file)?).await?, + None => client.get("/config/heartbeat").await?, + }; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + }) + } ConfigAction::Ai { action } => run_ai(action, &client, json_out).await, } } @@ -57,6 +75,12 @@ async fn run_ai(action: AiConfigAction, client: &Client, json_out: bool) -> Resu serde_json::to_string_pretty(v).unwrap_or_default() }) } + AiConfigAction::Put { file } => { + // The whole adapter document, as-is. `set` is the flag-built + // sibling; this one is for a config you already have on disk. + let body: Value = client.post("/config/ai", &json_input::load(&file)?).await?; + output::emit_status(json_out, &body, |_| "AI config applied.".to_owned()) + } AiConfigAction::Set { scope, id, diff --git a/apps/springtale-cli/src/commands/connector.rs b/apps/springtale-cli/src/commands/connector.rs index 3bbb0f3..91a6e77 100644 --- a/apps/springtale-cli/src/commands/connector.rs +++ b/apps/springtale-cli/src/commands/connector.rs @@ -8,6 +8,7 @@ use serde_json::{Value, json}; use crate::cli::ConnectorAction; use crate::client::Client; +use crate::commands::json_input; use crate::output; /// Handle connector subcommands. @@ -63,11 +64,183 @@ pub async fn run(action: ConnectorAction, json_out: bool) -> Result<()> { format!("Installed connector: {}", output::cell(v, "installed")) })?; } + ConnectorAction::Available => { + let body: Value = client.get("/connectors/available").await?; + output::emit(json_out, &body, |v| { + let rows = output::array(v, "available") + .iter() + .map(|c| { + vec![ + output::cell(c, "name"), + output::cell(c, "label"), + output::cell(c, "installed"), + ] + }) + .collect(); + output::rows_table(&["NAME", "LABEL", "INSTALLED"], rows) + })?; + } + ConnectorAction::Schemas => { + let body: Value = client.get("/connectors/schemas").await?; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + })?; + } + ConnectorAction::Setup { name, config } => { + let body: Value = client + .post( + "/connectors/setup", + &json!({ "name": name, "config": json_input::load(&config)? }), + ) + .await?; + output::emit_status(json_out, &body, |v| { + format!("Set up connector: {}", output::cell(v, "name")) + })?; + } + ConnectorAction::InstallWasm { manifest, wasm } => { + install_wasm(&client, &manifest, &wasm, json_out).await?; + } + ConnectorAction::Cascade { name } => { + let body: Value = client.delete(&format!("/connectors/{name}/cascade")).await?; + output::emit_status(json_out, &body, |v| { + format!( + "Removed {name} and {} rule(s).", + output::array(v, "rules_deleted").len() + ) + })?; + } + ConnectorAction::Config { name } => { + let body: Value = client.get(&format!("/connectors/{name}/config")).await?; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + })?; + } + ConnectorAction::UpsertConfig { name, file } => { + let body: Value = client + .post( + &format!("/connectors/{name}/upsert-config"), + &json_input::load(&file)?, + ) + .await?; + output::emit_status(json_out, &body, |v| { + let verb = if output::cell(v, "is_new") == "true" { + "Created" + } else { + "Updated" + }; + format!("{verb} config for '{name}'.") + })?; + } + ConnectorAction::Outputs { name, limit } => { + let body: Value = client + .get(&format!("/connectors/{name}/outputs?limit={limit}")) + .await?; + output::emit(json_out, &body, |v| { + let rows = output::array(v, "outputs") + .iter() + .map(|o| { + vec![ + output::cell(o, "created_at"), + output::cell(o, "action"), + output::cell(o, "summary"), + ] + }) + .collect(); + output::rows_table(&["WHEN", "ACTION", "SUMMARY"], rows) + })?; + } + ConnectorAction::Reload { name } => { + let body: Value = client + .post(&format!("/connectors/{name}/reload"), &json!({})) + .await?; + output::emit_status(json_out, &body, |_| format!("Reloaded connector: {name}"))?; + } + ConnectorAction::Test { name } => { + let body: Value = client + .post(&format!("/connectors/{name}/test"), &json!({})) + .await?; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + })?; + } ConnectorAction::Sign { .. } => unreachable!("handled above"), } Ok(()) } +/// The multipart boundary the WASM install body is framed with. The +/// route wants a `manifest` part (JSON) and a `wasm` part (binary), and +/// building those two parts by hand keeps the CLI's HTTP client free of +/// reqwest's `multipart` feature. +const WASM_BOUNDARY: &str = "springtale-install-wasm-boundary-9f2c41"; + +/// POST a manifest + module pair to `/connectors/install-wasm`. +async fn install_wasm( + client: &Client, + manifest_path: &std::path::Path, + wasm_path: &std::path::Path, + json_out: bool, +) -> Result<()> { + let manifest_text = std::fs::read_to_string(manifest_path).map_err(|e| { + anyhow::anyhow!("failed to read manifest at {}: {e}", manifest_path.display()) + })?; + // The route parses the `manifest` part as JSON; a TOML manifest is + // converted here so both forms work from the command line. + let manifest_json = match serde_json::from_str::(&manifest_text) { + Ok(value) => value, + Err(_) => { + let parsed: springtale_connector::ConnectorManifest = toml::from_str(&manifest_text) + .map_err(|e| anyhow::anyhow!("manifest is neither JSON nor TOML: {e}"))?; + serde_json::to_value(parsed)? + } + }; + let manifest_json = serde_json::to_string(&manifest_json)?; + let wasm = std::fs::read(wasm_path) + .map_err(|e| anyhow::anyhow!("failed to read module at {}: {e}", wasm_path.display()))?; + if wasm + .windows(WASM_BOUNDARY.len()) + .any(|w| w == WASM_BOUNDARY.as_bytes()) + { + anyhow::bail!("module contains the multipart boundary; refusing to send a corrupt body"); + } + + let mut body: Vec = Vec::with_capacity(wasm.len() + manifest_json.len() + 512); + body.extend_from_slice( + format!( + "--{WASM_BOUNDARY}\r\nContent-Disposition: form-data; name=\"manifest\"\r\nContent-Type: application/json\r\n\r\n{manifest_json}\r\n" + ) + .as_bytes(), + ); + body.extend_from_slice( + format!( + "--{WASM_BOUNDARY}\r\nContent-Disposition: form-data; name=\"wasm\"; filename=\"module.wasm\"\r\nContent-Type: application/wasm\r\n\r\n" + ) + .as_bytes(), + ); + body.extend_from_slice(&wasm); + body.extend_from_slice(format!("\r\n--{WASM_BOUNDARY}--\r\n").as_bytes()); + + let response = client + .request(reqwest::Method::POST, "/connectors/install-wasm") + .header( + "content-type", + format!("multipart/form-data; boundary={WASM_BOUNDARY}"), + ) + .body(body) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}: {e}", crate::client::UNREACHABLE))?; + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("{status}: {text}"); + } + let parsed: Value = serde_json::from_str(&text).unwrap_or(Value::Null); + output::emit_status(json_out, &parsed, |v| { + format!("Installed WASM connector: {}", output::cell(v, "installed")) + }) +} + /// Sign a connector manifest with the local identity, in place. fn sign(path: &std::path::Path, json_out: bool) -> Result<()> { let contents = std::fs::read_to_string(path) diff --git a/apps/springtale-cli/src/commands/cooperation.rs b/apps/springtale-cli/src/commands/cooperation.rs index e70b122..ed78d13 100644 --- a/apps/springtale-cli/src/commands/cooperation.rs +++ b/apps/springtale-cli/src/commands/cooperation.rs @@ -14,6 +14,8 @@ use anyhow::{Context, Result, anyhow}; use springtale_cooperation::utterance::UtteranceDefs; use springtale_cooperation::utterance::defs::{ALL_CODEPOINT_CONSTS, NAMED_CODEPOINTS}; +use crate::cli::CooperationAction; +use crate::client::Client; use crate::output; /// Nerd Fonts' Material Design Icons block, `F0001–F1AF0`. @@ -27,6 +29,25 @@ fn all_codepoints() -> BTreeSet { cps } +pub async fn utterances(action: CooperationAction, json_out: bool) -> Result<()> { + let client = Client::from_config()?; + let body: serde_json::Value = match &action { + CooperationAction::Utterances => client.get("/cooperation/utterances").await?, + CooperationAction::Recent { limit } => { + client + .get(&format!("{UTTERANCES_RECENT}?limit={limit}")) + .await? + } + CooperationAction::Glyphs { .. } => unreachable!("glyphs is local"), + }; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + }) +} + +/// The recent-utterance feed. The limit is appended as a query. +const UTTERANCES_RECENT: &str = "/cooperation/utterances/recent"; + pub fn glyphs(check: Option<&Path>, json_out: bool) -> Result<()> { let cps = all_codepoints(); if let Some(path) = check { diff --git a/apps/springtale-cli/src/commands/drift.rs b/apps/springtale-cli/src/commands/drift.rs new file mode 100644 index 0000000..26398e3 --- /dev/null +++ b/apps/springtale-cli/src/commands/drift.rs @@ -0,0 +1,21 @@ +//! `springtale drift` — how far a deployed recipe or rule has drifted +//! from what it was applied as. + +use anyhow::Result; +use serde_json::Value; + +use crate::cli::DriftAction; +use crate::client::Client; +use crate::output; + +/// Handle drift subcommands. +pub async fn run(action: DriftAction, json_out: bool) -> Result<()> { + let client = Client::from_config()?; + let body: Value = match &action { + DriftAction::Recipe { id } => client.get(&format!("/drift/recipe/{id}")).await?, + DriftAction::Rule { id } => client.get(&format!("/drift/rule/{id}")).await?, + }; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + }) +} diff --git a/apps/springtale-cli/src/commands/events.rs b/apps/springtale-cli/src/commands/events.rs index 8430d58..8f6cb51 100644 --- a/apps/springtale-cli/src/commands/events.rs +++ b/apps/springtale-cli/src/commands/events.rs @@ -6,12 +6,15 @@ use serde_json::Value; use crate::client::Client; use crate::output; +/// The event log. Filters are appended to it as a query. +const EVENTS: &str = "/events"; + /// Display the event log. pub async fn run(limit: u32, connector: Option, json_out: bool) -> Result<()> { let client = Client::from_config()?; let path = match connector { - Some(name) => format!("/events?limit={limit}&connector={name}"), - None => format!("/events?limit={limit}"), + Some(name) => format!("{EVENTS}?limit={limit}&connector={name}"), + None => format!("{EVENTS}?limit={limit}"), }; let body: Value = client.get(&path).await?; output::emit(json_out, &body, |v| { diff --git a/apps/springtale-cli/src/commands/execution.rs b/apps/springtale-cli/src/commands/execution.rs new file mode 100644 index 0000000..ffc20bd --- /dev/null +++ b/apps/springtale-cli/src/commands/execution.rs @@ -0,0 +1,72 @@ +//! `springtale execution` — the execution log the dashboard's run list +//! reads, and the vacuum that trims it. + +use anyhow::Result; +use serde_json::{Value, json}; + +use crate::cli::ExecutionAction; +use crate::client::Client; +use crate::output; + +/// The execution log collection. Query filters are appended to it. +const EXECUTIONS: &str = "/executions"; + +/// Handle execution subcommands. +pub async fn run(action: ExecutionAction, json_out: bool) -> Result<()> { + let client = Client::from_config()?; + match action { + ExecutionAction::List { rule, limit } => { + let mut query = format!("?limit={limit}"); + if let Some(rule) = &rule { + query.push_str(&format!("&rule_id={rule}")); + } + let body: Value = client.get(&format!("{EXECUTIONS}{query}")).await?; + output::emit(json_out, &body, |v| { + let empty = Vec::new(); + let rows = v + .as_array() + .unwrap_or(&empty) + .iter() + .map(|e| { + vec![ + output::cell(e, "id"), + output::cell(e, "rule_id"), + output::cell(e, "status"), + output::cell(e, "started_at"), + ] + }) + .collect(); + output::rows_table(&["ID", "RULE", "STATUS", "STARTED"], rows) + })?; + } + ExecutionAction::Steps { id } => { + let body: Value = client.get(&format!("/executions/{id}/steps")).await?; + output::emit(json_out, &body, |v| { + let empty = Vec::new(); + let rows = v + .as_array() + .unwrap_or(&empty) + .iter() + .map(|s| { + vec![ + output::cell(s, "step_index"), + output::cell(s, "action"), + output::cell(s, "status"), + output::cell(s, "duration_ms"), + ] + }) + .collect(); + output::rows_table(&["#", "ACTION", "STATUS", "MS"], rows) + })?; + } + ExecutionAction::Vacuum { keep_days } => { + let body: Value = client + .post("/executions/vacuum", &json!({ "keep_days": keep_days })) + .await?; + output::emit_status(json_out, &body, |v| { + format!("Vacuumed executions: {}", output::cell(v, "deleted")) + })?; + } + } + Ok(()) +} diff --git a/apps/springtale-cli/src/commands/formation.rs b/apps/springtale-cli/src/commands/formation.rs index 53f2857..0f1ee70 100644 --- a/apps/springtale-cli/src/commands/formation.rs +++ b/apps/springtale-cli/src/commands/formation.rs @@ -56,6 +56,59 @@ pub async fn run(action: FormationAction, json_out: bool) -> Result<()> { output::rows_table(&["ID", "LABEL", "ENABLED"], rows) })?; } + FormationAction::Intents => { + let body: Value = client.get("/formations/intents").await?; + output::emit(json_out, &body, |v| { + let rows = output::array(v, "intents") + .iter() + .map(|i| vec![output::cell(i, "value"), output::cell(i, "label")]) + .collect(); + output::rows_table(&["VALUE", "LABEL"], rows) + })?; + } + FormationAction::Eligible { id } => { + let body: Value = client + .get(&format!("/formations/{id}/members/eligible")) + .await?; + output::emit(json_out, &body, |v| { + let rows = output::array(v, "members") + .iter() + .map(|m| vec![output::cell(m, "name"), output::cell(m, "kind")]) + .collect(); + output::rows_table(&["NAME", "KIND"], rows) + })?; + } + FormationAction::ProposeIntent { id, intent } => { + let body: Value = client + .post( + &format!("/formations/{id}/propose-intent"), + &json!({ "intent": intent }), + ) + .await?; + output::emit(json_out, &body, |v| format!("proposed: {v}"))?; + } + FormationAction::Vote { id, vote, choice } => { + let body: Value = client + .post( + &format!("/formations/{id}/votes/{vote}"), + &json!({ "choice": choice }), + ) + .await?; + output::emit(json_out, &body, |v| format!("vote recorded: {v}"))?; + } + FormationAction::Run { id, command, params } => { + let params = match params { + Some(path) => crate::commands::json_input::load(&path)?, + None => json!({}), + }; + let body: Value = client + .post( + &format!("/formations/{id}/run-command"), + &json!({ "command_id": command, "params": params }), + ) + .await?; + output::emit(json_out, &body, |v| format!("ran {command}: {v}"))?; + } FormationAction::DeployTeam { file } => { let text = std::fs::read_to_string(&file) .map_err(|e| anyhow::anyhow!("failed to read {}: {e}", file.display()))?; diff --git a/apps/springtale-cli/src/commands/healthcheck.rs b/apps/springtale-cli/src/commands/healthcheck.rs index 8e5a219..ef057b8 100644 --- a/apps/springtale-cli/src/commands/healthcheck.rs +++ b/apps/springtale-cli/src/commands/healthcheck.rs @@ -13,13 +13,19 @@ use anyhow::{Result, anyhow}; use crate::output; -pub async fn run(base_url: &str, json_out: bool) -> Result<()> { +/// Liveness: the process is up. +const HEALTH: &str = "/health"; +/// Readiness: the process is up *and* willing to serve. +const READY: &str = "/ready"; + +pub async fn run(base_url: &str, ready: bool, json_out: bool) -> Result<()> { let client = springtale_transport::safe_http::builder() .timeout(Duration::from_secs(3)) .build() .map_err(|e| anyhow!("healthcheck client: {e}"))?; - let url = format!("{}/health", base_url.trim_end_matches('/')); + let probe = if ready { READY } else { HEALTH }; + let url = format!("{}{probe}", base_url.trim_end_matches('/')); let response = client .get(&url) .send() @@ -34,6 +40,6 @@ pub async fn run(base_url: &str, json_out: bool) -> Result<()> { } // A healthy probe stays silent for the container runtime; `--json` // gives a scriptable body without changing the exit-code contract. - let body = serde_json::json!({ "healthy": true, "url": url }); + let body = serde_json::json!({ "healthy": true, "url": url, "probe": probe }); output::emit_status(json_out, &body, |_| String::new()) } diff --git a/apps/springtale-cli/src/commands/json_input.rs b/apps/springtale-cli/src/commands/json_input.rs new file mode 100644 index 0000000..53e5e36 --- /dev/null +++ b/apps/springtale-cli/src/commands/json_input.rs @@ -0,0 +1,34 @@ +//! Reading a JSON request body from a file or from stdin. +//! +//! Several daemon routes take a free-form JSON object the CLI has no +//! business re-typing (connector config, recipe inputs, a send request). +//! Every one of them reads it the same way, so the reading lives here. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde_json::{Value, json}; + +/// Read a JSON object from `path`, or from stdin when `path` is `-`. +pub fn load(path: &Path) -> Result { + let text = if path == Path::new("-") { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .context("reading JSON from stdin")?; + buf + } else { + std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))? + }; + serde_json::from_str(&text).with_context(|| format!("{} must be JSON", path.display())) +} + +/// Read an optional JSON object, defaulting to `{}`. +pub fn load_or_empty(path: Option) -> Result { + match path { + Some(p) => load(&p), + None => Ok(json!({})), + } +} diff --git a/apps/springtale-cli/src/commands/login.rs b/apps/springtale-cli/src/commands/login.rs index 3aa2409..6ec7950 100644 --- a/apps/springtale-cli/src/commands/login.rs +++ b/apps/springtale-cli/src/commands/login.rs @@ -17,6 +17,11 @@ use springtale_runtime::client_config; use crate::client::UNREACHABLE; use crate::output; +/// The route a passphrase is exchanged at. +const LOGIN: &str = "/auth/login"; +/// The route the long-lived token is revoked at. +const LOGOUT: &str = "/auth/logout"; + /// Name the CLI gives the token it creates, so `springtale auth tokens` /// (and the dashboard) show where it came from. fn token_name() -> String { @@ -50,7 +55,7 @@ pub async fn login(json_out: bool) -> Result<()> { // 1. Log in. The daemon mints a random session token; the passphrase // never becomes a credential. let response = http - .post(format!("{base}/auth/login")) + .post(format!("{base}{LOGIN}")) // SECURITY: expose needed to put the passphrase in the login // body — the one request that is allowed to carry it. .json(&serde_json::json!({ "passphrase": passphrase.expose_secret() })) @@ -98,7 +103,7 @@ pub async fn login(json_out: bool) -> Result<()> { // 3. Drop the session; the saved token is what the CLI uses now. let _ = http - .post(format!("{base}/auth/logout")) + .post(format!("{base}{LOGOUT}")) .bearer_auth(&session) .send() .await; diff --git a/apps/springtale-cli/src/commands/mod.rs b/apps/springtale-cli/src/commands/mod.rs index 9efb62b..7dc1ff5 100644 --- a/apps/springtale-cli/src/commands/mod.rs +++ b/apps/springtale-cli/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod agent; pub mod approval; +pub mod auth; pub mod author; pub mod bot; pub mod canvas; @@ -10,20 +11,26 @@ pub mod cooperation; pub mod crypto; pub mod data; pub mod doctor; +pub mod drift; pub mod events; +pub mod execution; pub mod fix; pub mod formation; pub mod healthcheck; pub mod init; +pub mod json_input; pub mod login; pub mod mcp; pub mod memory; +pub mod onboarding; pub mod panic; pub mod recipe; pub mod rule; pub mod safety; +pub mod send; pub mod server; pub mod session; pub mod trace; pub mod travel; pub mod vault; +pub mod workspace; diff --git a/apps/springtale-cli/src/commands/onboarding.rs b/apps/springtale-cli/src/commands/onboarding.rs new file mode 100644 index 0000000..74cc1f5 --- /dev/null +++ b/apps/springtale-cli/src/commands/onboarding.rs @@ -0,0 +1,46 @@ +//! `springtale onboarding` — the guided per-platform setup forms, over +//! the daemon. The same forms the dashboard's onboarding wizard renders. + +use anyhow::Result; +use serde_json::Value; + +use crate::cli::OnboardingAction; +use crate::client::Client; +use crate::commands::json_input; +use crate::output; + +/// Handle onboarding subcommands. +pub async fn run(action: OnboardingAction, json_out: bool) -> Result<()> { + let client = Client::from_config()?; + match action { + OnboardingAction::Platforms => { + let body: Value = client.get("/onboarding/platforms").await?; + output::emit(json_out, &body, |v| { + let rows = output::array(v, "platforms") + .iter() + .map(|p| { + vec![ + output::cell(p, "platform"), + output::cell(p, "label"), + output::cell(p, "description"), + ] + }) + .collect(); + output::rows_table(&["PLATFORM", "LABEL", "DESCRIPTION"], rows) + })?; + } + OnboardingAction::Apply { platform, answers } => { + let answers = json_input::load(&answers)?; + let body: Value = client + .post( + &format!("/onboarding/{platform}"), + &serde_json::json!({ "answers": answers }), + ) + .await?; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + })?; + } + } + Ok(()) +} diff --git a/apps/springtale-cli/src/commands/recipe.rs b/apps/springtale-cli/src/commands/recipe.rs index 8f94919..0728bff 100644 --- a/apps/springtale-cli/src/commands/recipe.rs +++ b/apps/springtale-cli/src/commands/recipe.rs @@ -46,6 +46,112 @@ pub async fn run(action: RecipeAction, json_out: bool) -> Result<()> { serde_json::to_string_pretty(v).unwrap_or_default() })?; } + RecipeAction::Pieces { id } => { + let body: Value = client.get(&format!("/recipes/{id}/pieces")).await?; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + })?; + } + RecipeAction::Favorite { id } => { + let body: Value = client + .post(&format!("/recipes/{id}/favorite"), &json!({})) + .await?; + output::emit_status(json_out, &body, |v| { + format!("favorite: {}", output::cell(v, "favorite")) + })?; + } + RecipeAction::Recent { id } => { + let body: Value = client + .post(&format!("/recipes/{id}/recent"), &json!({})) + .await?; + output::emit_status(json_out, &body, |_| { + format!("Recorded {id} as recently used.") + })?; + } + RecipeAction::Fork { id, name } => { + let body: Value = client + .post(&format!("/recipes/{id}/fork"), &json!({ "new_name": name })) + .await?; + output::emit_status(json_out, &body, |v| { + format!("Forked to {}", output::cell(v, "id")) + })?; + } + RecipeAction::Preflight { id, inputs } => { + let body: Value = client + .post(&format!("/recipes/{id}/preflight"), &load_inputs(inputs)?) + .await?; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + })?; + } + RecipeAction::TestStep { + id, + inputs, + rule_index, + step_index, + } => { + let body: Value = client + .post( + &format!("/recipes/{id}/test-step"), + &json!({ + "inputs": load_inputs(inputs)?, + "rule_index": rule_index, + "step_index": step_index, + }), + ) + .await?; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + })?; + } + RecipeAction::Save { file } => { + let recipe = crate::commands::json_input::load(&file)?; + let body: Value = client.post("/recipes/user", &recipe).await?; + output::emit_status(json_out, &body, |v| { + format!("Saved recipe {}", output::cell(v, "id")) + })?; + } + RecipeAction::Delete { id } => { + let body: Value = client.delete(&format!("/recipes/user/{id}")).await?; + output::emit_status(json_out, &body, |_| format!("Deleted recipe {id}."))?; + } + RecipeAction::Export { id } => { + // The route answers TOML text, not JSON. + let toml = text(&client, reqwest::Method::GET, &format!("/recipes/{id}/export"), None) + .await?; + println!("{toml}"); + } + RecipeAction::Render { id, inputs } => { + let toml = text( + &client, + reqwest::Method::POST, + &format!("/recipes/{id}/render"), + Some(load_inputs(inputs)?), + ) + .await?; + println!("{toml}"); + } + RecipeAction::Import { file } => { + let toml_text = std::fs::read_to_string(&file) + .map_err(|e| anyhow::anyhow!("failed to read {}: {e}", file.display()))?; + // The route takes the TOML document itself as the body. + let response = client + .request(reqwest::Method::POST, "/recipes/import") + .header("content-type", "text/plain") + .body(toml_text) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}: {e}", crate::client::UNREACHABLE))?; + let status = response.status(); + let raw = response.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("{status}: {raw}"); + } + let body: Value = serde_json::from_str(&raw).unwrap_or(Value::Null); + output::emit_status(json_out, &body, |v| { + format!("Imported recipe {}", output::cell(v, "id")) + })?; + } RecipeAction::Preview { id, inputs } => { let body: Value = client .post(&format!("/recipes/{id}/preview"), &load_inputs(inputs)?) @@ -66,6 +172,30 @@ pub async fn run(action: RecipeAction, json_out: bool) -> Result<()> { Ok(()) } +/// Fetch a route that answers plain text (`export`, `render`) rather +/// than JSON, so the document lands on stdout unquoted. +async fn text( + client: &Client, + method: reqwest::Method, + path: &str, + body: Option, +) -> Result { + let mut request = client.request(method, path); + if let Some(body) = body { + request = request.json(&body); + } + let response = request + .send() + .await + .map_err(|e| anyhow::anyhow!("{}: {e}", crate::client::UNREACHABLE))?; + let status = response.status(); + let raw = response.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("{status}: {raw}"); + } + Ok(raw) +} + /// Read a `{ "values": { ... } }` inputs file, or send an empty set. fn load_inputs(path: Option) -> Result { let Some(path) = path else { diff --git a/apps/springtale-cli/src/commands/rule.rs b/apps/springtale-cli/src/commands/rule.rs index d390eae..0f9b05a 100644 --- a/apps/springtale-cli/src/commands/rule.rs +++ b/apps/springtale-cli/src/commands/rule.rs @@ -58,6 +58,54 @@ pub async fn run(action: RuleAction, json_out: bool) -> Result<()> { serde_json::to_string_pretty(v).unwrap_or_default() })?; } + RuleAction::Schema => { + let body: Value = client.get("/rules/schema").await?; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + })?; + } + RuleAction::Parse { intent } => { + let body: Value = client + .post("/rules/parse", &serde_json::json!({ "intent": intent })) + .await?; + output::emit(json_out, &body, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + })?; + } + RuleAction::AddForConnector { file } => { + let rule = load_rule(&file)?; + let body: Value = client.post("/rules/connector", &rule).await?; + output::emit_status(json_out, &body, |v| { + format!("Created rule {}", output::cell(v, "id")) + })?; + } + RuleAction::ForConnector { name } => { + let body: Value = client.get(&format!("/rules/connector/{name}")).await?; + output::emit(json_out, &body, |v| { + let rows = output::array(v, "rules") + .iter() + .map(|r| { + vec![ + output::cell(r, "id"), + output::cell(r, "name"), + output::cell(r, "status"), + ] + }) + .collect(); + output::rows_table(&["ID", "NAME", "STATUS"], rows) + })?; + } + RuleAction::Reassign { id, connector } => { + let body: Value = client + .post( + &format!("/rules/{id}/reassign"), + &serde_json::json!({ "new_connector": connector }), + ) + .await?; + output::emit_status(json_out, &body, |_| { + format!("Rule {id} now runs on {connector}.") + })?; + } RuleAction::Toggle { id } => { // The route takes the target state, so read the current one // from the daemon rather than guessing. diff --git a/apps/springtale-cli/src/commands/safety.rs b/apps/springtale-cli/src/commands/safety.rs index 89df883..37ef51f 100644 --- a/apps/springtale-cli/src/commands/safety.rs +++ b/apps/springtale-cli/src/commands/safety.rs @@ -25,6 +25,17 @@ pub async fn run(action: SafetyAction, json_out: bool) -> Result<()> { format!("disguise {}", if active { "on" } else { "off" }) })?; } + SafetyAction::DisguiseProfile { app_name, icon_id } => { + let body: Value = client + .post( + "/safety/disguise/profile", + &json!({ "app_name": app_name, "icon_id": icon_id }), + ) + .await?; + output::emit_status(json_out, &body, |_| { + format!("disguise profile: {app_name} ({icon_id})") + })?; + } SafetyAction::PanicTaps { count } => { let body: Value = client .post("/safety/panic_tap_count", &json!({ "count": count })) diff --git a/apps/springtale-cli/src/commands/send.rs b/apps/springtale-cli/src/commands/send.rs new file mode 100644 index 0000000..1629360 --- /dev/null +++ b/apps/springtale-cli/src/commands/send.rs @@ -0,0 +1,31 @@ +//! `springtale send` — one message out through a connector. + +use anyhow::Result; +use serde_json::{Value, json}; + +use crate::client::Client; +use crate::output; + +/// Send one message on `connector`/`target`. +pub async fn run( + connector: String, + target: String, + text: String, + json_out: bool, +) -> Result<()> { + let client = Client::from_config()?; + let body: Value = client + .post( + "/send", + &json!({ "connector": connector, "target": target, "text": text }), + ) + .await?; + output::emit(json_out, &body, |v| { + format!( + "{} -> {} ({})", + connector, + target, + output::cell(v, "status") + ) + }) +} diff --git a/apps/springtale-cli/src/commands/workspace.rs b/apps/springtale-cli/src/commands/workspace.rs new file mode 100644 index 0000000..b9ae4ac --- /dev/null +++ b/apps/springtale-cli/src/commands/workspace.rs @@ -0,0 +1,151 @@ +//! `springtale workspace` — the external workspaces (servers, repos, +//! channels) a formation's connectors can reach. + +use anyhow::Result; +use serde_json::{Value, json}; + +use crate::cli::WorkspaceAction; +use crate::client::Client; +use crate::commands::json_input; +use crate::output; + +/// The workspace directory. Query filters are appended to it. +const WORKSPACES: &str = "/workspaces"; + +/// Handle workspace subcommands. +pub async fn run(action: WorkspaceAction, json_out: bool) -> Result<()> { + let client = Client::from_config()?; + match action { + WorkspaceAction::List { + formation, + connector, + } => { + let mut query = format!("?formation_id={formation}"); + if let Some(connector) = &connector { + query.push_str(&format!("&connector={connector}")); + } + let body: Value = client.get(&format!("{WORKSPACES}{query}")).await?; + output::emit(json_out, &body, workspace_table)?; + } + WorkspaceAction::Scan { + formation, + connector, + } => { + let body: Value = client + .post( + "/workspaces/scan", + &json!({ "formation_id": formation, "connector_name": connector }), + ) + .await?; + output::emit(json_out, &body, workspace_table)?; + } + WorkspaceAction::Add { + formation, + key, + name, + connector, + kind, + } => { + let body: Value = client + .post( + WORKSPACES, + &json!({ + "formation_id": formation, + "workspace_key": key, + "display_name": name, + "connector_name": connector, + "kind": kind, + }), + ) + .await?; + output::emit_status(json_out, &body, |_| format!("Added workspace {key}."))?; + } + WorkspaceAction::Remove { formation, key } => { + let body: Value = client + .delete(&format!( + "{WORKSPACES}?formation_id={formation}&workspace_key={key}" + )) + .await?; + output::emit_status(json_out, &body, |_| format!("Removed workspace {key}."))?; + } + WorkspaceAction::OnboardUrl { + connector, + config, + payload, + } => { + let body: Value = client + .post( + "/workspaces/onboard-url", + &json!({ + "connector_name": connector, + "config": json_input::load(&config)?, + "payload": json_input::load_or_empty(payload)?, + }), + ) + .await?; + output::emit(json_out, &body, |v| output::cell(v, "url"))?; + } + WorkspaceAction::Onboard { + session, + connector, + config, + payload, + } => { + // The route answers SSE; `stream` hands back the raw body so + // the progress frames land on stdout as they arrive. + let response = client + .post_stream( + "/workspaces/onboard", + &json!({ + "session_id": session, + "connector_name": connector, + "config": json_input::load(&config)?, + "payload": json_input::load_or_empty(payload)?, + }), + ) + .await?; + print_frames(response).await?; + } + } + Ok(()) +} + +/// Print each SSE `data:` frame the onboard stream sends, one per line. +async fn print_frames(response: reqwest::Response) -> Result<()> { + use futures_util::StreamExt; + + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + while let Some(chunk) = stream.next().await { + let bytes = chunk.map_err(|e| anyhow::anyhow!("onboard stream read error: {e}"))?; + buffer.push_str(&String::from_utf8_lossy(&bytes)); + while let Some(pos) = buffer.find("\n\n") { + let frame: String = buffer.drain(..pos + 2).collect(); + for line in frame.lines() { + if let Some(data) = line.strip_prefix("data: ") { + println!("{data}"); + } + } + } + } + Ok(()) +} + +/// The shared workspace table: `list` and `scan` return the same rows. +fn workspace_table(v: &Value) -> String { + let empty = Vec::new(); + let rows = v + .as_array() + .unwrap_or(&empty) + .iter() + .map(|w| { + vec![ + output::cell(w, "workspace_key"), + output::cell(w, "display_name"), + output::cell(w, "connector_name"), + output::cell(w, "kind"), + ] + }) + .collect(); + output::rows_table(&["KEY", "NAME", "CONNECTOR", "KIND"], rows) +} diff --git a/apps/springtale-cli/src/main.rs b/apps/springtale-cli/src/main.rs index f7c65bd..ed0ce42 100644 --- a/apps/springtale-cli/src/main.rs +++ b/apps/springtale-cli/src/main.rs @@ -58,9 +58,9 @@ async fn main() -> Result<()> { // so the plan's success-criterion prompt works literally. commands::server::run(cli.json).await?; } - Command::Healthcheck { url } => { + Command::Healthcheck { url, ready } => { // Used by container HEALTHCHECK — distroless has no wget/curl. - commands::healthcheck::run(&url, cli.json).await?; + commands::healthcheck::run(&url, ready, cli.json).await?; } Command::Panic => { let store = store::open_store(&pass_opts)?; @@ -105,6 +105,15 @@ async fn main() -> Result<()> { } }, Command::Bot { action } => match action { + BotAction::Status => { + commands::bot::status(cli.json).await?; + } + BotAction::Formations => { + commands::bot::formations(cli.json).await?; + } + BotAction::Memory => { + commands::bot::memory(cli.json).await?; + } BotAction::PairInit => { commands::bot::pair_init(&pass_opts, cli.json).await?; } @@ -116,9 +125,14 @@ async fn main() -> Result<()> { } }, Command::Cooperation { action } => match action { + // `glyphs` reads the compiled-in def table for the font build; + // it must work with no daemon and no vault. CooperationAction::Glyphs { check } => { commands::cooperation::glyphs(check.as_deref(), cli.json)?; } + other => { + commands::cooperation::utterances(other, cli.json).await?; + } }, // Daemon-backed commands. The CLI is a client of springtaled: // these all go over the management API so an edit is visible to @@ -168,8 +182,33 @@ async fn main() -> Result<()> { commands::mcp::serve().await?; } }, - Command::Canvas { stream } => { - commands::canvas::run(stream, cli.json).await?; + Command::Canvas { + stream, + connections, + } => { + commands::canvas::run(stream, connections, cli.json).await?; + } + Command::Auth { action } => { + commands::auth::run(action, cli.json).await?; + } + Command::Drift { action } => { + commands::drift::run(action, cli.json).await?; + } + Command::Execution { action } => { + commands::execution::run(action, cli.json).await?; + } + Command::Onboarding { action } => { + commands::onboarding::run(action, cli.json).await?; + } + Command::Workspace { action } => { + commands::workspace::run(action, cli.json).await?; + } + Command::Send { + connector, + target, + text, + } => { + commands::send::run(connector, target, text, cli.json).await?; } // Offline: needs the vault and the local store, not the daemon. Command::Author { action } => { diff --git a/scripts/provider-methods.mjs b/scripts/provider-methods.mjs index fb7b063..1e123da 100755 --- a/scripts/provider-methods.mjs +++ b/scripts/provider-methods.mjs @@ -14,8 +14,9 @@ // `/recipes/${encodeURIComponent(id)}` -> /recipes/{} // // A `${hole}` is a path segment only when a `/` introduces it; a hole -// anywhere else is an interpolated query string (`${queryString(f)}`), -// not a segment, and is dropped rather than turned into `{}`. +// anywhere else is an interpolated query string (`${queryString(f)}`) or +// the base URL (`${getBaseUrl()}/recipes/import`), not a segment, and is +// dropped rather than turned into `{}`. import { readdirSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -34,7 +35,7 @@ const providers = [ // reads as one hole rather than a truncated one. const HOLE = String.raw`\$\{(?:[^{}]|\{[^{}]*\})*\}`; const PATH_LITERAL = new RegExp( - String.raw`["'\`](\/(?:${HOLE}|[A-Za-z0-9_/.:?=&{}-])*)["'\`]`, + String.raw`["'\`]((?:${HOLE})?\/(?:${HOLE}|[A-Za-z0-9_/.:?=&{}-])*)["'\`]`, "g", ); const MARKER = "%HOLE%"; @@ -51,7 +52,7 @@ for (const file of providers) { .split(MARKER) .join("{}") .replace(/\/+$/, ""); - if (normalised.length > 1) routes.add(normalised); + if (normalised.length > 1 && !normalised.startsWith("//")) routes.add(normalised); } } From 6235fbfd963578877f0b31b9261f330170bb25ed Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 13:28:39 -0700 Subject: [PATCH 3/5] surface: split the ledger from the deliberate non-surface, add the web methods The gap ledger had 76 CLI entries and 56 provider entries. Most were real; a handful were routes that should never have a verb or a method and were sitting in a file that reads as a to-do list. - `scripts/surface-not-surfaced.txt` is the new list for those, with a reason on every line: the SPA asset routes, the inbound webhook, the two process probes, the SSE half of chat, and the six offline-first CLI paths (doctor, fix, panic, travel) that must work when the daemon is the thing that is broken. - `check-surface.sh` reads both files and still fails on a stale entry in either, so neither can grow quietly. - 17 provider methods for the routes the web surface could not reach: session/token management, the three bot views, heartbeat config, both connector installs, recent utterances, data import/purge, rule drift, execution vacuum, formation propose-intent and votes, and sessions. The desktop provider spreads the web provider, so both apps get them. - Two doc steps called verbs that do not exist: the deleted project scaffolder (now recipes) and `springtale api token print` (now the login flow plus `auth tokens`/`auth revoke`). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- docs/guide/formations.md | 16 +++- docs/tutorials/03-llm-research-swarm.md | 20 +++-- scripts/check-surface.sh | 22 +++-- scripts/surface-exemptions.txt | 96 ++------------------- scripts/surface-not-surfaced.txt | 35 ++++++++ tauri/packages/ui/src/dashboard/types.ts | 68 +++++++++++++++ tauri/packages/ui/src/web/provider.ts | 102 +++++++++++++++++++++++ 7 files changed, 253 insertions(+), 106 deletions(-) create mode 100644 scripts/surface-not-surfaced.txt diff --git a/docs/guide/formations.md b/docs/guide/formations.md index 298f1a5..b426a41 100644 --- a/docs/guide/formations.md +++ b/docs/guide/formations.md @@ -26,11 +26,23 @@ Use a **formation** when: ## Deploy a formation from the CLI -The fastest path is the `deploy-team` operation via the API: +The fastest path is the `deploy-team` operation via the API. It needs +a bearer token the daemon issued — `springtale login` exchanges your +vault passphrase for one and stores it for the CLI (mode 0600), so +every `springtale` command authenticates by itself afterwards: + +```bash +springtale login # exchange the passphrase for a token +springtale auth tokens # the tokens the daemon has issued +# springtale auth revoke # withdraw one you no longer trust +``` + +For a raw `curl`, point `SPRINGTALE_API_TOKEN` at a token you hold — +the same variable the CLI reads before it falls back to the stored one: ```bash curl -sS -X POST http://127.0.0.1:8080/formations/deploy-team \ - -H "Authorization: Bearer $(springtale api token print)" \ + -H "Authorization: Bearer $SPRINGTALE_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Research Squad", diff --git a/docs/tutorials/03-llm-research-swarm.md b/docs/tutorials/03-llm-research-swarm.md index 53710e9..cd54935 100644 --- a/docs/tutorials/03-llm-research-swarm.md +++ b/docs/tutorials/03-llm-research-swarm.md @@ -70,24 +70,26 @@ springtale-cli connector list | grep presearch Should show `connector-presearch` as Active. -## Step 3 — Scaffold the formation +## Step 3 — Apply the swarm recipe -Use the LLM swarm template: +Recipes replaced the old project scaffolder — there is no `springtale +new`, and nothing is copied into a directory. A recipe is applied into +the running daemon, which is the only thing that owns state. ```bash -springtale-cli new llm-swarm -# Creating llm-swarm project in ~/.local/share/springtale/projects/llm-swarm-20260610-091500 -cd ~/.local/share/springtale/projects/llm-swarm-*/ +springtale recipe list --category swarm # find the starter +springtale recipe preview llm-swarm # what it would create +springtale recipe apply llm-swarm # create it ``` -You now have a working starter. Look at what shipped: +You now have a working starter. Look at what it made: ```bash -cat springtale.toml -ls rules/ +springtale recipe render llm-swarm # the recipe as TOML +springtale rule list # the rules it created ``` -The template sets up three agents (researcher, writer, critic) but +The recipe sets up three agents (researcher, writer, critic) but points them at a placeholder topic. We'll wire it to take real research requests next. diff --git a/scripts/check-surface.sh b/scripts/check-surface.sh index 47a6a9e..4eb3423 100755 --- a/scripts/check-surface.sh +++ b/scripts/check-surface.sh @@ -40,18 +40,24 @@ for list in routes verbs provider; do fi done -# ── Known gaps ──────────────────────────────────────────────────── -# `scripts/surface-exemptions.txt` is a ledger of routes that do not yet -# have a command-line verb and/or a provider method, one per line: +# ── Known gaps, and deliberate non-surface ──────────────────────── +# Two files, same columns, opposite meanings: +# +# surface-exemptions.txt a ledger of routes that do not YET have a +# command-line verb and/or provider method +# surface-not-surfaced.txt routes that deliberately never will, each +# with the reason on the line # # /some/route cli # no verb yet # /other/route cli provider # neither yet # -# It is a ledger, not a permission slip: a NEW uncovered route fails the -# check, and an entry naming a route that no longer exists fails it too, -# so the file can only shrink. +# Neither is a permission slip: a NEW uncovered route fails the check, +# and an entry in either file naming a route that no longer exists fails +# it too, so both can only shrink. exempt="$root/scripts/surface-exemptions.txt" -sed -e 's/#.*$//' -e 's/[[:space:]]*$//' "$exempt" | grep -vE '^$' > "$work/exempt.raw" +intentional="$root/scripts/surface-not-surfaced.txt" +sed -e 's/#.*$//' -e 's/[[:space:]]*$//' "$exempt" "$intentional" \ + | grep -vE '^$' > "$work/exempt.raw" awk '$0 ~ /(^| )cli( |$)/ { print $1 }' "$work/exempt.raw" | sort -u > "$work/exempt.cli" awk '$0 ~ /(^| )provider( |$)/ { print $1 }' "$work/exempt.raw" | sort -u > "$work/exempt.provider" awk '{ print $1 }' "$work/exempt.raw" | sort -u > "$work/exempt.all" @@ -74,7 +80,7 @@ missing_provider="$(comm -23 "$work/routes.provider" "$work/provider")" # Stale exemptions are a lie about the surface: fail on them too. stale="$(comm -13 "$work/routes" "$work/exempt.all")" [ -z "$stale" ] || { - die "exemptions for routes that no longer exist:" + die "exempt/not-surfaced entries for routes that no longer exist:" printf '%s\n' "$stale" >&2 } diff --git a/scripts/surface-exemptions.txt b/scripts/surface-exemptions.txt index 6dff1b0..6fd3f6d 100644 --- a/scripts/surface-exemptions.txt +++ b/scripts/surface-exemptions.txt @@ -5,94 +5,16 @@ # fails on any NEW uncovered route, and fails on an entry naming a route # that no longer exists, so the file can only ever shrink. # +# A route that should never have a verb or a method does not belong +# here — it belongs in `surface-not-surfaced.txt`, with its reason. +# # Columns: # -/agents/states cli -/agents/{}/autonomy/step cli -/approvals/{} provider -/auth/login cli provider -/auth/logout cli provider -/auth/tokens cli provider -/auth/tokens/{} cli provider +# Needs a product decision, not plumbing: `springtale author` writes the +# trusted-author registry through the local store so `author add --self` +# can register a signing identity before a daemon exists (the first-run +# connector-signing path). Routing it through `/authors` would make +# first-run signing require a running daemon; leaving it is a second +# writer against the registry the API owns. Decide which, then close it. /authors cli /authors/{} cli -/bot/formations cli provider -/bot/memory cli provider -/bot/status cli provider -/canvas provider -/canvas/connections cli -/chat provider -/chat/stream cli provider -/config/ai cli -/config/connector/{} cli -/config/heartbeat cli provider -/connectors/available cli -/connectors/install provider -/connectors/install-wasm cli provider -/connectors/schemas cli -/connectors/setup cli -/connectors/{}/cascade cli -/connectors/{}/config cli -/connectors/{}/outputs cli provider -/connectors/{}/reload cli -/connectors/{}/test cli -/connectors/{}/upsert-config cli -/cooperation/utterances cli provider -/cooperation/utterances/recent cli provider -/data/import provider -/data/purge provider -/diagnostics cli -/drift/recipe/{} cli provider -/drift/rule/{} cli provider -/events cli provider -/executions cli provider -/executions/vacuum cli provider -/executions/{}/steps cli provider -/fixes cli -/fixes/{} cli -/fixes/{}/apply cli -/formations/intents cli -/formations/{}/members/eligible cli -/formations/{}/propose-intent cli provider -/formations/{}/run-command cli -/formations/{}/votes/{} cli provider -/health cli provider -/onboarding/platforms cli -/onboarding/{} cli provider -/ready cli provider -/recipes provider -/recipes/categories provider -/recipes/import cli provider -/recipes/user cli provider -/recipes/user/{} cli provider -/recipes/{} provider -/recipes/{}/apply provider -/recipes/{}/export cli provider -/recipes/{}/favorite cli provider -/recipes/{}/fork cli provider -/recipes/{}/pieces cli provider -/recipes/{}/preflight cli provider -/recipes/{}/preview provider -/recipes/{}/recent cli provider -/recipes/{}/render cli provider -/recipes/{}/test-step cli provider -/rules/connector cli -/rules/connector/{} cli -/rules/parse cli -/rules/schema cli -/rules/{}/reassign cli -/safety/disguise/profile cli -/safety/panic-wipe cli provider -/send cli -/sessions provider -/stream cli provider -/stream/ticket provider -/travel/prepare cli provider -/travel/restore cli provider -/ui cli provider -/ui/{} cli provider -/webhook/{}/{} cli provider -/workspaces cli -/workspaces/onboard cli provider -/workspaces/onboard-url cli -/workspaces/scan cli diff --git a/scripts/surface-not-surfaced.txt b/scripts/surface-not-surfaced.txt new file mode 100644 index 0000000..32440d7 --- /dev/null +++ b/scripts/surface-not-surfaced.txt @@ -0,0 +1,35 @@ +# Routes that are deliberately NOT part of the command-line or web +# surface, with the reason each one is not. +# +# Same columns and same rules as `surface-exemptions.txt` — a stale +# entry still fails the check — but the meaning is the opposite. An +# entry here is a decision that has been made, not a gap waiting to be +# closed. Anything that is merely *not done yet* belongs in the ledger. +# +# Columns: # why +# +# ── Not API: the daemon serving its own front end ────────────────── +/ui cli provider # static SPA assets the browser fetches; no surface calls them +/ui/{} cli provider # same, per-asset +# +# ── Not API: spoken by third parties, never by a surface ─────────── +/webhook/{}/{} cli provider # inbound connector callback; the platform posts here, no user surface does +# +# ── Probes, not product ──────────────────────────────────────────── +/health provider # liveness for supervisors; `springtale healthcheck` is the human-facing probe +/ready provider # readiness for orchestrators; same +# +# ── Streams whose surface half is a different route ──────────────── +/chat/stream cli # SSE half of /chat; the CLI follows the multiplexed /stream with `springtale trace` +# +# ── Must work when springtaled is down ───────────────────────────── +# These CLI verbs exist and are the reason the routes exist, but they +# deliberately do NOT go through the daemon: each one has to work when +# the daemon is dead, which is precisely when it is reached for. +/diagnostics cli # `springtale doctor` is what diagnoses a daemon that will not start +/fixes cli # `springtale fix` is offline first aid for that diagnosis +/fixes/{} cli # same +/fixes/{}/apply cli # same +/safety/panic-wipe cli # `springtale panic` must destroy data with the daemon dead or wedged +/travel/prepare cli # border-crossing wipe: no daemon, no network, no vault session +/travel/restore cli # the other half of the same offline path diff --git a/tauri/packages/ui/src/dashboard/types.ts b/tauri/packages/ui/src/dashboard/types.ts index 4ed3ea0..7e52969 100644 --- a/tauri/packages/ui/src/dashboard/types.ts +++ b/tauri/packages/ui/src/dashboard/types.ts @@ -707,6 +707,74 @@ export interface DataProvider { /** Track D — subscribe to `chat-discovered` events. Returns the * unlisten function (no-op on web). */ subscribeToChatDiscovered(callback: (event: ChatDiscoveredEvent) => void): Promise<() => void>; + + // ── Session and API tokens ──────────────────────────────────────── + /** Revoke the session token server-side (`POST /auth/logout`). */ + endSession(): Promise; + /** The long-lived API tokens the daemon has issued. */ + listApiTokens(): Promise; + /** Revoke one issued token by id. */ + revokeApiToken(id: string): Promise; + + // ── Bot runtime views ───────────────────────────────────────────── + /** What the bot runtime is doing right now. */ + getBotStatus(): Promise>; + /** The formations the bot runtime is running. */ + listBotFormations(): Promise>; + /** The session memory the bot runtime is holding. */ + getBotMemory(): Promise>; + + // ── Heartbeat ───────────────────────────────────────────────────── + /** The heartbeat config the daemon is running on. */ + getHeartbeatConfig(): Promise>; + /** Replace the heartbeat config. */ + setHeartbeatConfig(config: Record): Promise; + + // ── Connector installation ──────────────────────────────────────── + /** Install a first-party connector from its manifest document. */ + installConnector(manifest: Record): Promise; + /** Install a sandboxed WASM connector: manifest plus module bytes. */ + installWasmConnector(manifest: Record, wasm: Blob): Promise; + + // ── Cooperation ─────────────────────────────────────────────────── + /** Utterances the colony has spoken recently. */ + listRecentUtterances(limit?: number): Promise[]>; + + // ── Data ────────────────────────────────────────────────────────── + /** Import a previously exported archive. */ + importData(archive: Record): Promise>; + /** Purge local data the daemon holds. */ + purgeData(): Promise>; + + // ── Drift and executions ────────────────────────────────────────── + /** Drift for one rule, the sibling of `getRecipeDrift`. */ + getRuleDrift(ruleId: string, filter?: DriftFilter): Promise; + /** Trim the execution log to the last `keepDays` days. */ + vacuumExecutions(keepDays: number): Promise; + + // ── Formation votes ─────────────────────────────────────────────── + /** Propose an intent change for the formation to vote on. */ + proposeFormationIntent(id: string, intent: string): Promise>; + /** Cast a vote on an open proposal. */ + castFormationVote(id: string, voteId: string, choice: string): Promise>; + + // ── Chat sessions ───────────────────────────────────────────────── + /** The chat sessions the daemon is holding. */ + listSessions(): Promise[]>; +} + +/** One API token the daemon has issued (`GET /auth/tokens`). */ +export interface ApiTokenInfo { + id: string; + name: string; + created_at: string; + last_used_at?: string | null; +} + +/** Query filter shared by the two drift routes. */ +export interface DriftFilter { + since?: string; + limit?: number; } /** Payload of the `chat-discovered` Tauri event (Track D). */ diff --git a/tauri/packages/ui/src/web/provider.ts b/tauri/packages/ui/src/web/provider.ts index df2bfc1..283eacc 100644 --- a/tauri/packages/ui/src/web/provider.ts +++ b/tauri/packages/ui/src/web/provider.ts @@ -17,6 +17,7 @@ import type { SendOutcome, } from "@springtale/types"; import type { + ApiTokenInfo, ApprovalInfo, ConnectorOutput, DataProvider, @@ -528,5 +529,106 @@ export function createWebProvider(): DataProvider { async sendMessage(req) { return post("/send", req); }, + + // Session and API tokens (plan 6.6). `logout()` in api/client.ts + // only drops the in-memory copy; this revokes it server-side. + async endSession() { + await post("/auth/logout"); + }, + async listApiTokens() { + const data = await get<{ tokens: ApiTokenInfo[] }>("/auth/tokens"); + return data.tokens ?? []; + }, + async revokeApiToken(id) { + await del(`/auth/tokens/${encodeURIComponent(id)}`); + }, + + // Bot runtime views — read-only, one route each. + async getBotStatus() { + return get>("/bot/status"); + }, + async listBotFormations() { + return get>("/bot/formations"); + }, + async getBotMemory() { + return get>("/bot/memory"); + }, + + // Heartbeat + async getHeartbeatConfig() { + return get>("/config/heartbeat"); + }, + async setHeartbeatConfig(config) { + await put("/config/heartbeat", config); + }, + + // Connector installation + async installConnector(manifest) { + const data = await post<{ installed: string }>("/connectors/install", manifest); + return data.installed; + }, + async installWasmConnector(manifest, wasm) { + // The route wants multipart: a `manifest` part (JSON text) and a + // `wasm` part (the module bytes). FormData sets its own boundary, + // so no Content-Type header here. + const form = new FormData(); + form.append("manifest", JSON.stringify(manifest)); + form.append("wasm", wasm, "module.wasm"); + const response = await fetch(`${getBaseUrl()}/connectors/install-wasm`, { + method: "POST", + headers: { Authorization: `Bearer ${getToken()}` }, + body: form, + }); + if (!response.ok) throw new Error(`API error: ${response.status}`); + const data = (await response.json()) as { installed: string }; + return data.installed; + }, + + // Cooperation + async listRecentUtterances(limit = 50) { + const data = await get<{ utterances: Record[] }>( + `/cooperation/utterances/recent?limit=${limit}`, + ); + return data.utterances ?? []; + }, + + // Data + async importData(archive) { + return post>("/data/import", archive); + }, + async purgeData() { + return post>("/data/purge"); + }, + + // Drift and executions + async getRuleDrift(ruleId, filter) { + // The query is built first: a `??` inside the template would read + // as the start of a query string to anything scanning the literal. + const query = queryString(filter ?? {}); + return get(`/drift/rule/${encodeURIComponent(ruleId)}${query}`); + }, + async vacuumExecutions(keepDays) { + const data = await post<{ deleted: number }>("/executions/vacuum", { + keep_days: keepDays, + }); + return data.deleted ?? 0; + }, + + // Formation votes — the proposal half of the intent group. + async proposeFormationIntent(id, intent) { + return post>(`/formations/${id}/propose-intent`, { intent }); + }, + async castFormationVote(id, voteId, choice) { + return post>( + `/formations/${id}/votes/${encodeURIComponent(voteId)}`, + { choice }, + ); + }, + + // Chat sessions + async listSessions() { + const data = await get<{ sessions: Record[] }>("/sessions"); + return data.sessions ?? []; + }, }; } From d0735756f0f537d06072e558d52dfae49ffa3888 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 13:35:18 -0700 Subject: [PATCH 4/5] cli: rename `rule reassign` to `rule move`, widen the formation verb groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reassign` tripped the drum-rule test that bans an assign verb anywhere in the clap tree. A rule is re-homed onto another connector, never handed to a named member, so `move` is both the honest name and the one that keeps the ban absolute. The route is unchanged. The formation group test gains the five new verbs with their groups: `intents`/`eligible` are inspection and composition, `propose-intent` and `vote` are the intent group, and `run` is the execution half of `commands` — the same split check-surface.sh already enforces on the routes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtale-cli/src/cli.rs | 16 +++++++++++++--- apps/springtale-cli/src/client.rs | 6 +++++- apps/springtale-cli/src/commands/agent.rs | 5 ++++- apps/springtale-cli/src/commands/config.rs | 11 +++++++++-- apps/springtale-cli/src/commands/connector.rs | 9 +++++++-- apps/springtale-cli/src/commands/formation.rs | 6 +++++- apps/springtale-cli/src/commands/recipe.rs | 9 +++++++-- apps/springtale-cli/src/commands/rule.rs | 2 +- apps/springtale-cli/src/commands/send.rs | 7 +------ 9 files changed, 52 insertions(+), 19 deletions(-) diff --git a/apps/springtale-cli/src/cli.rs b/apps/springtale-cli/src/cli.rs index 71053a8..f1765d3 100644 --- a/apps/springtale-cli/src/cli.rs +++ b/apps/springtale-cli/src/cli.rs @@ -726,7 +726,10 @@ pub enum RuleAction { name: String, }, /// Move a rule onto a different connector. - Reassign { + /// + /// Named `move`, not `reassign`: the drum rule bans an assign verb + /// anywhere in the tree, and a rule is re-homed, never handed out. + Move { /// Rule id. id: String, /// Connector to move it to. @@ -952,11 +955,15 @@ mod tests { // read-only inspection "list", "get", - "commands", // composition + "commands", + "intents", + "eligible", // composition "add-member", "rm-member", "deploy-team", // intent - "intent", // constraints + "intent", + "propose-intent", + "vote", // constraints "guard", "autonomy", // intervention "deploy", @@ -964,6 +971,9 @@ mod tests { "resume", "dissolve", "rally", + // `run` is the execution half of `commands`, not a fifth + // group: it runs a command the formation already declares. + "run", ]; let cmd = Cli::command(); let formation = cmd diff --git a/apps/springtale-cli/src/client.rs b/apps/springtale-cli/src/client.rs index 0d1e69d..e9ddb4e 100644 --- a/apps/springtale-cli/src/client.rs +++ b/apps/springtale-cli/src/client.rs @@ -109,7 +109,11 @@ impl Client { /// POST `path` and hand back the undecoded response — the shape the /// SSE-over-POST routes need (`/workspaces/onboard` streams progress /// frames rather than answering with one JSON body). - pub async fn post_stream(&self, path: &str, body: &B) -> Result { + pub async fn post_stream( + &self, + path: &str, + body: &B, + ) -> Result { // SECURITY: expose needed to set the bearer header. let resp = self .http diff --git a/apps/springtale-cli/src/commands/agent.rs b/apps/springtale-cli/src/commands/agent.rs index 410672d..91cf585 100644 --- a/apps/springtale-cli/src/commands/agent.rs +++ b/apps/springtale-cli/src/commands/agent.rs @@ -36,7 +36,10 @@ pub async fn run(action: AgentAction, json_out: bool) -> Result<()> { ) .await?; output::emit(json_out, &body, |v| { - format!("Agent '{name}' autonomy is now: {}", output::cell(v, "level")) + format!( + "Agent '{name}' autonomy is now: {}", + output::cell(v, "level") + ) })?; } AgentAction::SetAutonomy { name, level } => { diff --git a/apps/springtale-cli/src/commands/config.rs b/apps/springtale-cli/src/commands/config.rs index 91db540..4bf0d29 100644 --- a/apps/springtale-cli/src/commands/config.rs +++ b/apps/springtale-cli/src/commands/config.rs @@ -26,7 +26,10 @@ pub async fn run(action: ConfigAction, json_out: bool) -> Result<()> { } ConfigAction::Connector { name, file } => { let body: Value = client - .post(&format!("/config/connector/{name}"), &json_input::load(&file)?) + .post( + &format!("/config/connector/{name}"), + &json_input::load(&file)?, + ) .await?; output::emit_status(json_out, &body, |_| { format!("Connector config saved for '{name}'.") @@ -34,7 +37,11 @@ pub async fn run(action: ConfigAction, json_out: bool) -> Result<()> { } ConfigAction::Heartbeat { file } => { let body: Value = match file { - Some(file) => client.put("/config/heartbeat", &json_input::load(&file)?).await?, + Some(file) => { + client + .put("/config/heartbeat", &json_input::load(&file)?) + .await? + } None => client.get("/config/heartbeat").await?, }; output::emit(json_out, &body, |v| { diff --git a/apps/springtale-cli/src/commands/connector.rs b/apps/springtale-cli/src/commands/connector.rs index 91a6e77..2eb4d5f 100644 --- a/apps/springtale-cli/src/commands/connector.rs +++ b/apps/springtale-cli/src/commands/connector.rs @@ -101,7 +101,9 @@ pub async fn run(action: ConnectorAction, json_out: bool) -> Result<()> { install_wasm(&client, &manifest, &wasm, json_out).await?; } ConnectorAction::Cascade { name } => { - let body: Value = client.delete(&format!("/connectors/{name}/cascade")).await?; + let body: Value = client + .delete(&format!("/connectors/{name}/cascade")) + .await?; output::emit_status(json_out, &body, |v| { format!( "Removed {name} and {} rule(s).", @@ -182,7 +184,10 @@ async fn install_wasm( json_out: bool, ) -> Result<()> { let manifest_text = std::fs::read_to_string(manifest_path).map_err(|e| { - anyhow::anyhow!("failed to read manifest at {}: {e}", manifest_path.display()) + anyhow::anyhow!( + "failed to read manifest at {}: {e}", + manifest_path.display() + ) })?; // The route parses the `manifest` part as JSON; a TOML manifest is // converted here so both forms work from the command line. diff --git a/apps/springtale-cli/src/commands/formation.rs b/apps/springtale-cli/src/commands/formation.rs index 0f1ee70..c725cd7 100644 --- a/apps/springtale-cli/src/commands/formation.rs +++ b/apps/springtale-cli/src/commands/formation.rs @@ -96,7 +96,11 @@ pub async fn run(action: FormationAction, json_out: bool) -> Result<()> { .await?; output::emit(json_out, &body, |v| format!("vote recorded: {v}"))?; } - FormationAction::Run { id, command, params } => { + FormationAction::Run { + id, + command, + params, + } => { let params = match params { Some(path) => crate::commands::json_input::load(&path)?, None => json!({}), diff --git a/apps/springtale-cli/src/commands/recipe.rs b/apps/springtale-cli/src/commands/recipe.rs index 0728bff..6c46204 100644 --- a/apps/springtale-cli/src/commands/recipe.rs +++ b/apps/springtale-cli/src/commands/recipe.rs @@ -117,8 +117,13 @@ pub async fn run(action: RecipeAction, json_out: bool) -> Result<()> { } RecipeAction::Export { id } => { // The route answers TOML text, not JSON. - let toml = text(&client, reqwest::Method::GET, &format!("/recipes/{id}/export"), None) - .await?; + let toml = text( + &client, + reqwest::Method::GET, + &format!("/recipes/{id}/export"), + None, + ) + .await?; println!("{toml}"); } RecipeAction::Render { id, inputs } => { diff --git a/apps/springtale-cli/src/commands/rule.rs b/apps/springtale-cli/src/commands/rule.rs index 0f9b05a..16abc4a 100644 --- a/apps/springtale-cli/src/commands/rule.rs +++ b/apps/springtale-cli/src/commands/rule.rs @@ -95,7 +95,7 @@ pub async fn run(action: RuleAction, json_out: bool) -> Result<()> { output::rows_table(&["ID", "NAME", "STATUS"], rows) })?; } - RuleAction::Reassign { id, connector } => { + RuleAction::Move { id, connector } => { let body: Value = client .post( &format!("/rules/{id}/reassign"), diff --git a/apps/springtale-cli/src/commands/send.rs b/apps/springtale-cli/src/commands/send.rs index 1629360..25062e0 100644 --- a/apps/springtale-cli/src/commands/send.rs +++ b/apps/springtale-cli/src/commands/send.rs @@ -7,12 +7,7 @@ use crate::client::Client; use crate::output; /// Send one message on `connector`/`target`. -pub async fn run( - connector: String, - target: String, - text: String, - json_out: bool, -) -> Result<()> { +pub async fn run(connector: String, target: String, text: String, json_out: bool) -> Result<()> { let client = Client::from_config()?; let body: Value = client .post( From 77c15223828f080de1446c3dd0b1a091b3346ea2 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 13:44:42 -0700 Subject: [PATCH 5/5] cli: rustfmt the formation verb group list Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtale-cli/src/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/springtale-cli/src/cli.rs b/apps/springtale-cli/src/cli.rs index f1765d3..4de84ad 100644 --- a/apps/springtale-cli/src/cli.rs +++ b/apps/springtale-cli/src/cli.rs @@ -963,7 +963,7 @@ mod tests { "deploy-team", // intent "intent", "propose-intent", - "vote", // constraints + "vote", // constraints "guard", "autonomy", // intervention "deploy",