diff --git a/CHANGELOG.md b/CHANGELOG.md index a58d2d1..4e143af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 2026-11-06 — v0.3.1 + +### Added + +- New `command` world in `wit/xfetch-runtime.wit` for CLI command addons. The + request is JSON and the response is plain text lines. +- New `xfetch-command-api` crate with the command request type, stdin/stdout + helpers and line formatting utilities. +- `docs/command-sdk.md` documents the command protocol, Rust core-module + example, component world and native command conventions. + +### Changed + +- All API crates bumped to `0.3.1`. +- `xfetch-guest-api` no longer declares an unused direct `serde` dependency. +- Removed the unused `parse_json` helper from `xfetch-command-api`. +- Updated the protocol reference, WebAssembly guest guide, README, ROADMAP and + getting-started guide for the command kind and 0.3.1. + ## 2026-11-03 — v0.3.0 ### Added diff --git a/Cargo.toml b/Cargo.toml index ea2af52..fe05e33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,5 +5,6 @@ members = [ "crates/extension-api", "crates/effect-api", "crates/guest-api", + "crates/command-api", ] resolver = "2" diff --git a/README.md b/README.md index c5fd3e8..6f826fd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- The current release line is 0.3.0. It keeps the JSON + The current release line is 0.3.1. It keeps the JSON protocol at version 1 and the WIT at xfetch:runtime@0.1.0; the changes are additive.

@@ -21,9 +21,10 @@
  • crates/extension-api: shared protocol crate for xfetch extensions (config providers).
  • crates/effect-api: shared protocol crate for xfetch effects (intro animations over the info lines).
  • crates/guest-api: host-call helpers for WebAssembly guests (http_request, exec, log, protocol_version).
  • -
  • wit/xfetch-runtime.wit: component-model contract (plugin, effect and extension worlds).
  • +
  • crates/command-api: shared protocol crate for CLI command addons.
  • +
  • wit/xfetch-runtime.wit: component-model contract (plugin, effect, extension and command worlds).
  • docs/: focused documentation for setup, protocol, SDK usage, wasm guests, and examples.
  • -
  • examples/: minimal plugin, effect and extension binaries that compile against the public crates.
  • +
  • crates/*/examples/: minimal plugin, effect and extension binaries that compile against the public crates.
  • Main Crate

    @@ -56,6 +57,16 @@
  • Effects are opt-in: a missing binary or invalid response simply skips the intro (no behavior change).
  • +

    Command API

    + +

    + crates/command-api (xfetch-command-api) is the + contract for CLI command addons. Commands receive arguments and return plain + text lines, so a command owns its own output format. Components use the + command world in the WIT package; core modules use the JSON + request on stdin and plain text on stdout. +

    +

    Documentation

    @@ -74,5 +86,5 @@
  • This repository owns shared contracts, not end-user config files.
  • Official plugins are expected to use the same public SDK as external plugins.
  • Protocol changes should land here first and then be adopted by the core and plugin repos.
  • -
  • Publishing order: xfetch-api-common first, then xfetch-plugin-api, xfetch-extension-api and xfetch-effect-api; xfetch-guest-api is independent.
  • +
  • Publishing order: xfetch-api-common first, then xfetch-plugin-api, xfetch-extension-api, xfetch-effect-api and xfetch-command-api; xfetch-guest-api is independent.
  • diff --git a/ROADMAP.md b/ROADMAP.md index 0f57f39..6ca7195 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,9 +8,11 @@ only when the core or the ecosystem needs them. - [x] `xfetch-plugin-api` — info providers and logo animations - [x] `xfetch-extension-api` — config providers - [x] `xfetch-effect-api` — intro effects +- [x] `xfetch-command-api` — CLI command addons - [x] Versioned protocol constants (`PROTOCOL_VERSION`, kind constants) - [x] Timeout contract: `with_timeout`/`TimedOut` as the single enforcement point (see `docs/timeouts.md`) - [x] SDK documentation: `docs/getting-started.md`, `docs/protocol.md`, - `docs/plugin-sdk.md`, `docs/extension-sdk.md`, `docs/effect-sdk.md`, `docs/examples.md` + `docs/plugin-sdk.md`, `docs/extension-sdk.md`, `docs/effect-sdk.md`, + `docs/command-sdk.md`, `docs/examples.md` - [x] Local CI (fmt, clippy, tests + Windows cross-target) via `scripts/` diff --git a/crates/command-api/Cargo.toml b/crates/command-api/Cargo.toml new file mode 100644 index 0000000..72b8dd6 --- /dev/null +++ b/crates/command-api/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "xfetch-command-api" +version = "0.3.1" +edition = "2024" +license = "MIT" +description = "Shared command protocol types and IO helpers for xfetch" +repository = "https://github.com/xfetch-cli/api" + +[dependencies] +xfetch-api-common = { version = "0.3.1", path = "../common" } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.148" diff --git a/crates/command-api/README.md b/crates/command-api/README.md new file mode 100644 index 0000000..791d46c --- /dev/null +++ b/crates/command-api/README.md @@ -0,0 +1,20 @@ +# xfetch-command-api + +Shared types and IO helpers for xfetch command addons. + +A command addon receives a JSON request on stdin and writes plain text to +stdout. The response is intentionally line-oriented rather than JSON so a +command can render its own key/value output. + +```rust +use xfetch_command_api::{read_command_args_or_default, write_command_lines}; + +fn main() { + let args = read_command_args_or_default().unwrap_or_default(); + let repo = args.first().map(String::as_str).unwrap_or("owner/repo"); + write_command_lines(&[format!("repo: {repo}")]).unwrap(); +} +``` + +Component guests use the `command` world in `wit/xfetch-runtime.wit` and return +the same plain text response from `run`. diff --git a/crates/command-api/src/lib.rs b/crates/command-api/src/lib.rs new file mode 100644 index 0000000..d2503bb --- /dev/null +++ b/crates/command-api/src/lib.rs @@ -0,0 +1,138 @@ +//! Shared command protocol types and IO helpers for xfetch command addons. +//! +//! Command addons receive a JSON request and return plain text. Native +//! commands may also use process arguments; wasm commands read the JSON +//! request from stdin (or from the component `run` request string). + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::io::{Read, Write}; + +pub use xfetch_api_common::with_timeout; + +/// Current JSON command protocol version. +pub const COMMAND_PROTOCOL_VERSION: u32 = 1; + +/// Request kind used by command addons. +pub const KIND_COMMAND: &str = "command"; + +/// One command invocation sent by the core. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandRequest { + #[serde(default = "default_version")] + pub version: u32, + #[serde(default = "default_kind")] + pub kind: String, + #[serde(default)] + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub env: BTreeMap, +} + +impl CommandRequest { + pub fn is_current(&self) -> bool { + self.version == COMMAND_PROTOCOL_VERSION && self.kind == KIND_COMMAND + } +} + +fn default_version() -> u32 { + COMMAND_PROTOCOL_VERSION +} + +fn default_kind() -> String { + KIND_COMMAND.to_string() +} + +/// Reads one command request from stdin. +pub fn read_command_request() -> Result { + let mut input = String::new(); + std::io::stdin() + .read_to_string(&mut input) + .map_err(|err| format!("failed to read command request: {err}"))?; + parse_command_request(&input) +} + +/// Parses one command request from a string. +pub fn parse_command_request(input: &str) -> Result { + let request: CommandRequest = + serde_json::from_str(input).map_err(|err| format!("invalid command request: {err}"))?; + if !request.is_current() { + return Err(format!( + "unsupported command request version/kind: {}/{}", + request.version, request.kind + )); + } + Ok(request) +} + +/// Reads the request and returns its arguments, or an empty list when stdin is +/// empty. This is convenient for commands that only need positional arguments. +pub fn read_command_args_or_default() -> Result, String> { + let mut input = String::new(); + std::io::stdin() + .read_to_string(&mut input) + .map_err(|err| format!("failed to read command request: {err}"))?; + if input.trim().is_empty() { + return Ok(Vec::new()); + } + Ok(parse_command_request(&input)?.args) +} + +/// Writes plain text to stdout. The caller is responsible for adding newlines. +pub fn write_command_text(text: &str) -> Result<(), String> { + let mut stdout = std::io::stdout(); + stdout + .write_all(text.as_bytes()) + .map_err(|err| format!("failed to write command output: {err}"))?; + stdout + .flush() + .map_err(|err| format!("failed to flush command output: {err}")) +} + +/// Formats one line per item with a trailing newline. +pub fn format_lines(lines: &[String]) -> String { + let mut text = lines.join("\n"); + if !text.is_empty() { + text.push('\n'); + } + text +} + +/// Writes one line per item, terminated by a newline. +pub fn write_command_lines(lines: &[String]) -> Result<(), String> { + write_command_text(&format_lines(lines)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_command_request() { + let request = parse_command_request( + r#"{"version":1,"kind":"command","command":"github","args":["owner/repo"]}"#, + ) + .expect("request"); + assert_eq!(request.command, "github"); + assert_eq!(request.args, vec!["owner/repo"]); + } + + #[test] + fn rejects_unknown_kinds() { + let error = parse_command_request(r#"{"version":1,"kind":"effect"}"#).unwrap_err(); + assert!(error.contains("unsupported")); + } + + #[test] + fn formats_lines_with_a_trailing_newline() { + assert_eq!( + format_lines(&["repo: x".to_string(), "author: y".to_string()]), + "repo: x\nauthor: y\n" + ); + assert_eq!(format_lines(&[]), ""); + } +} diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index f12e530..a16baa3 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xfetch-api-common" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT" description = "Shared protocol, IO, frame and timeout primitives for the xfetch addon SDKs" diff --git a/crates/common/README.md b/crates/common/README.md index 96681f1..b68f60b 100644 --- a/crates/common/README.md +++ b/crates/common/README.md @@ -9,6 +9,7 @@ Shared primitives for the xfetch addon SDK crates: This crate intentionally contains no per-kind request/response types. Plugins, effects and extensions keep their own contracts in -`xfetch-plugin-api`, `xfetch-effect-api` and `xfetch-extension-api`. +`xfetch-plugin-api`, `xfetch-effect-api`, `xfetch-extension-api` and +`xfetch-command-api`. Published first, before the per-kind crates. diff --git a/crates/effect-api/Cargo.toml b/crates/effect-api/Cargo.toml index 0e527d8..28b6e76 100644 --- a/crates/effect-api/Cargo.toml +++ b/crates/effect-api/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "xfetch-effect-api" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT" description = "Shared effect protocol types and IO helpers for xfetch" repository = "https://github.com/xfetch-cli/api" [dependencies] -xfetch-api-common = { version = "0.3.0", path = "../common" } +xfetch-api-common = { version = "0.3.1", path = "../common" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" diff --git a/crates/extension-api/Cargo.toml b/crates/extension-api/Cargo.toml index 9208dc7..6e277e7 100644 --- a/crates/extension-api/Cargo.toml +++ b/crates/extension-api/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "xfetch-extension-api" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT" description = "Shared extension protocol types and IO helpers for xfetch" repository = "https://github.com/xfetch-cli/api" [dependencies] -xfetch-api-common = { version = "0.3.0", path = "../common" } +xfetch-api-common = { version = "0.3.1", path = "../common" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" diff --git a/crates/guest-api/Cargo.toml b/crates/guest-api/Cargo.toml index 8b2bc7f..2f5253f 100644 --- a/crates/guest-api/Cargo.toml +++ b/crates/guest-api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xfetch-guest-api" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT" description = "Host-call helpers for xfetch WebAssembly guests" @@ -8,5 +8,4 @@ repository = "https://github.com/xfetch-cli/api" [dependencies] base64 = "0.23" -serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/plugin-api/Cargo.toml b/crates/plugin-api/Cargo.toml index 5c224f0..73c0a0e 100644 --- a/crates/plugin-api/Cargo.toml +++ b/crates/plugin-api/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "xfetch-plugin-api" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT" description = "Shared plugin protocol types and IO helpers for xfetch" repository = "https://github.com/xfetch-cli/api" [dependencies] -xfetch-api-common = { version = "0.3.0", path = "../common" } +xfetch-api-common = { version = "0.3.1", path = "../common" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" diff --git a/docs/README.md b/docs/README.md index 961a5f6..f30db62 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@
  • Plugin SDK: public types, entrypoints, and error handling.
  • Extension SDK: public types and the config-provider protocol.
  • Effect SDK: public types, entrypoints, and error handling.
  • +
  • Command SDK: CLI command addons and the line-oriented output contract.
  • Protocol Reference: wire formats, kinds, versioning, and JSON examples.
  • Timeouts: the with_timeout helper and runtime budgets.
  • WebAssembly Guests: building core modules and components in Rust, Python, Go and C.
  • @@ -20,11 +21,12 @@

    Current Focus

    - The public surface is centered on five crates: + The public surface is centered on six crates: xfetch-api-common (shared protocol/IO/frame/timeout primitives), xfetch-plugin-api, xfetch-extension-api, - xfetch-effect-api, and xfetch-guest-api (host calls - for WebAssembly guests). The current release line is 0.3.0. + xfetch-effect-api, xfetch-command-api, and + xfetch-guest-api (host calls for WebAssembly guests). The current + release line is 0.3.1.

    diff --git a/docs/command-sdk.md b/docs/command-sdk.md new file mode 100644 index 0000000..bd0a6f7 --- /dev/null +++ b/docs/command-sdk.md @@ -0,0 +1,96 @@ +# Command SDK + +

    + xfetch-command-api is the contract for CLI command addons. A + command addon receives a JSON request with positional arguments and returns + plain text; the core prints the text as-is unless the user asks for a layout. +

    + +

    Protocol

    + +
      +
    • Request: JSON on stdin for wasm core modules and component guests; native commands may also receive arguments as process arguments.
    • +
    • Response: plain text on stdout, one field per line.
    • +
    • Diagnostics: stderr only.
    • +
    • Exit code: 0 on success; non-zero on failure.
    • +
    + +
    {
    +  "version": 1,
    +  "kind": "command",
    +  "command": "github",
    +  "args": ["owner/repo"],
    +  "cwd": "/home/user/project",
    +  "env": {}
    +}
    + +
    repo: owner/repo
    +author: owner
    +languages: C, JavaScript, PHP
    +stars: 1234
    +
    + +

    Rust Core Module

    + +
    [dependencies]
    +serde = { version = "1", features = ["derive"] }
    +serde_json = "1"
    +xfetch-command-api = "0.3"
    +xfetch-guest-api = "0.3"
    + +
    use xfetch_command_api::{read_command_args_or_default, write_command_lines};
    +
    +fn main() {
    +    let args = read_command_args_or_default().unwrap_or_default();
    +    let repo = args.first().map(String::as_str).unwrap_or("owner/repo");
    +    write_command_lines(&[format!("repo: {repo}")]).expect("write output");
    +}
    + +

    + Wasm core modules use the same JSON request as native commands. Host calls + come from xfetch-guest-api and remain governed by the manifest + allowlist. +

    + +

    Component Guest

    + +

    + Component guests implement the command world in + wit/xfetch-runtime.wit: +

    + +
    world command {
    +    import host;
    +    export run: func(request: string) -> result<string, string>;
    +}
    + +

    + Parse the request string, use the typed host.fetch / + host.exec imports as needed, and return the plain-text response. + Returning an error string reports a command failure. +

    + +

    Native Command

    + +

    + Native command addons can read positional arguments from + std::env::args().skip(1) and write text to stdout. The core also + sends the JSON request on stdin, so a native command may use either form. +

    + +

    Output Conventions

    + +
      +
    • Prefer key: value lines for readability and future layout support.
    • +
    • Keep ANSI escapes out unless the command explicitly opts into terminal output.
    • +
    • Do not write logs to stdout; use stderr.
    • +
    • Return an empty response only when the command has nothing to show.
    • +
    + +

    Security

    + +
      +
    • Native commands run unsandboxed with the current user's privileges.
    • +
    • Wasm commands use the manifest capability policy: HTTP, exec, filesystem, environment and args are deny-by-default.
    • +
    • Command output is printed by the core; commands should not assume they own the terminal.
    • +
    diff --git a/docs/getting-started.md b/docs/getting-started.md index 897e920..ef2658f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -7,7 +7,7 @@ xfetch-effect-api (effects), and xfetch-guest-api (host calls for WebAssembly guests). The component protocol lives in wit/xfetch-runtime.wit. The current release line is - 0.3.0. + 0.3.1.

    Git Dependency

    diff --git a/docs/protocol.md b/docs/protocol.md index 50fd3cd..14bf5ad 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -11,6 +11,7 @@
  • info_provider
  • effect
  • config_provider (extension protocol)
  • +
  • command (CLI command protocol; plain-text response)
  • Logo Animation Request

    @@ -106,6 +107,35 @@ "config": { "show_colors": false, "modules": ["cpu", "memory"] } } +

    Command Request

    + +

    + Command addons receive positional arguments and return plain text lines. The + request is JSON; the response is not. +

    + +
    {
    +  "version": 1,
    +  "kind": "command",
    +  "command": "github",
    +  "args": ["owner/repo"],
    +  "cwd": "/home/user/project",
    +  "env": {}
    +}
    + +

    Command Response

    + +
    repo: owner/repo
    +author: owner
    +languages: C, JavaScript, PHP
    +stars: 1234
    +
    + +

    + The core prints the response as-is unless a layout is requested. Lines are + separated by \n; stderr is reserved for diagnostics. +

    +

    WebAssembly Guests

    diff --git a/docs/wasm-guests.md b/docs/wasm-guests.md index 4ad4682..025996f 100644 --- a/docs/wasm-guests.md +++ b/docs/wasm-guests.md @@ -1,7 +1,8 @@ # WebAssembly Guests

    - xfetch can run plugins, effects and extensions compiled to WebAssembly. This + xfetch can run plugins, effects, extensions and command addons compiled to + WebAssembly. This guide covers the authoring side; the runtime reference (manifest schema and CLI tooling) lives in the core repository at xfetch/docs/WASM.md. @@ -34,6 +35,12 @@ xfetch wasm wit.

    +

    + Command guests use the command world. The request string is JSON; + the response is plain text lines. Core modules read the JSON request from + stdin and write text to stdout. +

    +

    Rust Core Modules

    diff --git a/wit/xfetch-runtime.wit b/wit/xfetch-runtime.wit index 77241c0..5037930 100644 --- a/wit/xfetch-runtime.wit +++ b/wit/xfetch-runtime.wit @@ -6,9 +6,10 @@ /// only applies to the component model. /// /// The exported `run` function receives the same JSON request documented in -/// `api/docs/protocol.md` and must return the JSON response. The request's -/// `kind` field selects the contract (`logo_animation`, `info_provider`, -/// `effect` or `config_provider`). +/// `api/docs/protocol.md` and must return the response. The request's `kind` +/// field selects the contract (`logo_animation`, `info_provider`, `effect`, +/// `config_provider` or `command`). Command guests return plain text lines +/// instead of JSON. /// /// Host capabilities are deny-by-default: a component can only call /// `http-request` and `exec` when its manifest allowlist accepts the request. @@ -116,3 +117,13 @@ world extension { import host; export run: func(request: string) -> result; } + +/// A command guest: CLI commands that return plain text lines. +/// +/// The request string is JSON with `version`, `kind: "command"`, `command`, +/// and `args`. The response is plain text; the core prints it as-is unless a +/// layout is requested. +world command { + import host; + export run: func(request: string) -> result; +}