diff --git a/.runseal/wrappers/cloudflare.seal b/.runseal/wrappers/cloudflare.seal index d242ea6..fa24817 100644 --- a/.runseal/wrappers/cloudflare.seal +++ b/.runseal/wrappers/cloudflare.seal @@ -49,8 +49,8 @@ load_manage_redirect_rules() { } print_manage_redirect_plan() { - pretty_sh=$(runseal @tool json pretty "$rule_sh") - pretty_ps1=$(runseal @tool json pretty "$rule_ps1") + pretty_sh=$(runseal @tool json pretty value "$rule_sh") + pretty_ps1=$(runseal @tool json pretty value "$rule_ps1") print "manage redirect plan" print "zone: $zone_name" if [ "$1" = with-zone-id ]; then @@ -107,11 +107,11 @@ case "$1" in ruleset_count=$(runseal @tool json len "$rulesets") zones_payload=$(runseal @tool cloudflare api request GET /zones --query "account.id=$account_id" --query per_page=50) zones=$(runseal @tool json get "$zones_payload" .result) - zones_pretty=$(runseal @tool json pretty "$zones") + zones_pretty=$(runseal @tool json pretty value "$zones") account=$(runseal @tool cloudflare account get --account-id "$account_id") account_name=$(runseal @tool json get "$account" .name) buckets=$(runseal @tool cloudflare account r2 bucket list --account-id "$account_id") - buckets_pretty=$(runseal @tool json pretty "$buckets") + buckets_pretty=$(runseal @tool json pretty value "$buckets") print "cloudflare check: ok" print "account id: $account_id" print "account name: $account_name" @@ -151,7 +151,7 @@ case "$1" in print "manage inspect: no manage redirect rules found" exit 0 fi - pretty=$(runseal @tool json pretty "$matched") + pretty=$(runseal @tool json pretty value "$matched") print "manage rules:" print "$pretty" ;; diff --git a/AGENTS.md b/AGENTS.md index 5990e20..4b47e32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,25 +1,68 @@ # AGENTS -## Core Principle +## 1. AGENTS.md Meta Constraints -Small CLI. Explicit profile. No hidden orchestration. +This top-level `AGENTS.md` is the repository navigation and policy layer. +- Keep this file focused on shared constraints, navigation, and recurring + operating guidance. +- Push local implementation detail downward into child `AGENTS.md` files when a + directory starts carrying its own stable rules. +- Do not duplicate large bodies of module-specific instruction here once a child + `AGENTS.md` exists. +- Treat this file as the default contract for the whole repository unless a + deeper `AGENTS.md` overrides a narrower scope. +- Keep `.task/` out of git by default. Use it only for long-running work and + update it as live task state, not as archival prose. + +Core product stance: + +- Small CLI. Explicit profile. No hidden orchestration. - Keep the Rust core thin and concrete. -- Support only `env`, `symlink`, fixed-prefix `argv`, explicit `:wrapper` command resolution, and read-only `@internal` introspection unless a new product decision explicitly expands the surface. -- Use mature CLI parsing through `clap`; do not hand-roll argument parsing. +- Support only `env`, `symlink`, fixed-prefix `argv`, explicit `:wrapper` + resolution, direct `.seal` execution, and read-only `@internal` + introspection unless a new product decision explicitly expands the surface. +- Use `clap` for CLI parsing. Do not hand-roll argument parsing. +- Preserve command lifecycle semantics: load profile, register symlinks, export + env, run command, clean up symlinks. +- Keep command namespaces explicit: `` is external, `:` is a profile + wrapper, `@` is runseal internal. + +Runtime path rules: + - Treat `RUNSEAL_HOME` as the runseal configuration root. -- Treat `RUNSEAL_PROFILE_HOME` as the profile directory, defaulting to `/profiles`. +- Treat `RUNSEAL_PROFILE_HOME` as the profile directory, defaulting to + `/profiles`. - Resolve one concrete `RUNSEAL_PROFILE_PATH` during app initialization. + +Tooling rules: + - Treat `runseal` and `flavor` as installed developer infrastructure, at the same level as `git`, `gh`, and `cargo`; this repository does not bootstrap them. -- Preserve command lifecycle semantics: load profile, register symlinks, export env, run command, cleanup symlinks. -- Keep command namespaces explicit: `` is external, `:` is profile wrapper, `@` is runseal internal. -- Treat `.runseal/wrappers/*.seal` as first-class wrappers executed directly by - runseal. `@transpile` is an isomorphic debug/export tool, not the normal - wrapper execution path. -## Directory Conventions +## 2. Directory Conventions + +Direct child directories with their own `AGENTS.md`: + +- None yet. + +Direct child directories that are likely future candidates for a child +`AGENTS.md` once their local rules become stable: + +- `app/`: Rust application code, tests, and core runtime behavior. +- `.runseal/`: repo-local wrappers and operator-facing workflow glue. +- `.github/`: CI, release automation, and workflow support scripts. +- `docs/`: durable operator or contributor documentation, if this area starts + carrying rules distinct from code. + +When a direct child directory gains its own stable constraints, add an +`AGENTS.md` there and link it from this section. + +## 3. Core File Index + +There are no child `AGENTS.md` targets yet, so this index currently points to +the repository-owned canonical files directly. - `app/src/bin/runseal.rs`: CLI entrypoint. - `app/src/core/config.rs`: app configuration and profile discovery. @@ -27,16 +70,61 @@ Small CLI. Explicit profile. No hidden orchestration. - `app/src/core/runtime.rs`: command execution lifecycle. - `app/src/core/transpile/runner.rs`: direct Seal wrapper runtime. - `app/src/core/injections/`: `env` and `symlink` implementations. -- `app/tests/`: integration tests and focused unit tests. -- `runseal.toml`: repo-local operator profile. +- `app/src/core/tool/`: built-in atomic `@tool` surface. +- `app/tests/`: integration tests and focused behavioral coverage. - `.runseal/wrappers/`: repo-local `:wrapper` entrypoints. Prefer `.seal` wrappers; platform scripts exist only while a wrapper has not migrated. -- `manage.sh` and `manage.ps1`: public install/uninstall managers. -- `.task/`: branch-bound task state, ignored by git. +- `runseal.toml`: repo-local operator profile. +- `manage.sh` and `manage.ps1`: public install and uninstall managers. + +Once child `AGENTS.md` files exist, this section should prefer links to those +local guides over repeating their detail here. + +## 4. Daily Iteration Workflow And Commands + +Normal workflow: + +1. Work on a feature branch. +2. Keep changes scoped to the current product boundary. +3. Validate locally before PR. +4. Use repo wrappers for recurring operator flows when they already encode the + intended path. + +Common validation commands: + +```bash +cargo fmt --check +cargo test --locked --workspace +flavor check +``` + +Common repo workflow commands: + +```bash +runseal :init +runseal :cloudflare +runseal :pr +runseal :release beta +``` -## Profile Discovery +Manager install/update path: -Priority order: +```bash +./manage.sh install --channel beta +``` + +Release and distribution rules: + +- Release and manager downloads use R2 metadata and artifacts as the source of + truth. +- Public install and uninstall entrypoints are `manage.sh` and `manage.ps1`. +- Release and smoke flows should reference those root files. +- Cloudflare manager redirects are exact-path rules for + `runseal.perish.uk/manage.sh` and `runseal.perish.uk/manage.ps1`, pointing to + `releases.runseal.perish.uk/manage.sh` and + `releases.runseal.perish.uk/manage.ps1`. + +Profile discovery order: 1. `--profile ` 2. From `` upward to filesystem root, at each directory: @@ -52,30 +140,42 @@ Priority order: Format priority is TOML, YAML, then JSON within each searched directory. Successful profile and wrapper paths are normalized absolute paths. -## Development Workflow +## 5. FAQ -1. Work on a feature branch. -2. Use `runseal :init`, `runseal :cloudflare`, `runseal :pr`, and - `runseal :release` for repo management. -3. Keep changes scoped to the reduced CLI surface. -4. Run: +### Why keep the CLI surface small? -```bash -cargo fmt --check -cargo test -``` +Because this repository is building explicit runtime glue, not a hidden +orchestrator. New behavior should be added only when it fits the existing +surface cleanly. + +### When should behavior become Seal syntax? + +Only when bash and PowerShell share an elegant, stable semantic shape that is +worth making first-class. + +### When should behavior become `@tool`? + +When native CLI coverage is insufficient for an atomic, reusable operation and +the result still fits the small explicit model. + +### When should logic stay outside runseal? + +When the behavior cannot be described cleanly as shared shell-shape syntax or a +small atomic tool, keep it in Python, Ruby, JavaScript, or another external +script. + +### Should `.seal` wrappers be treated as first-class runtime entrypoints? + +Yes. Treat `.runseal/wrappers/*.seal` as first-class wrappers executed directly +by runseal. `@transpile` is a debug/export tool, not the normal wrapper +execution path. -Release and manager downloads use R2 metadata and artifacts as the source of -truth. Public install/uninstall entrypoints are `manage.sh` and `manage.ps1`; -release and smoke scripts should reference those root files. +### What should never be committed? -Cloudflare manager redirects are exact-path rules for -`runseal.perish.uk/manage.sh` and `runseal.perish.uk/manage.ps1`, pointing to -`releases.runseal.perish.uk/manage.sh` and -`releases.runseal.perish.uk/manage.ps1`. +- `.task/` +- accidental broad surface expansions that were not backed by an explicit + product decision -## Commit Rules +### What is the commit style? -- Prefer small focused commits. -- Do not commit `.task/`. -- Do not reintroduce broader command surfaces without an explicit product decision. +Prefer small focused commits. diff --git a/app/src/core/tool/help/basic.rs b/app/src/core/tool/help/basic.rs index f6121a9..294a615 100644 --- a/app/src/core/tool/help/basic.rs +++ b/app/src/core/tool/help/basic.rs @@ -1,83 +1,5 @@ use super::{Entry, Section}; -pub const JSON: Entry = Entry { - key: "json", - usage: "runseal @tool json [args]", - about: None, - sections: &[Section { - title: "JSON helpers", - items: &[ - ("get ", "print one JSON value"), - ("empty ", "print true when JSON length is zero"), - ("len ", "print JSON array/object/string length"), - ("pretty ", "print formatted JSON"), - ( - "find ", - "print first object with field=value", - ), - ( - "filter ...", - "print objects with field matching values", - ), - ], - }], - examples: &[], -}; - -pub const JSON_GET: Entry = Entry { - key: "json.get", - usage: "runseal @tool json get ", - about: Some("Print one JSON value selected by the path expression."), - sections: &[Section { - title: "Arguments", - items: &[ - ("", "input JSON text"), - ("", "path expression such as `.[0].databaseId`"), - ], - }], - examples: &["runseal @tool json get '[{\"databaseId\":123}]' '.[0].databaseId'"], -}; - -pub const JSON_EMPTY: Entry = Entry { - key: "json.empty", - usage: "runseal @tool json empty ", - about: Some("Print `true` when the JSON array, object, or string length is zero."), - sections: &[], - examples: &["runseal @tool json empty '[]'"], -}; - -pub const JSON_LEN: Entry = Entry { - key: "json.len", - usage: "runseal @tool json len ", - about: Some("Print the JSON array, object, or string length as an integer."), - sections: &[], - examples: &["runseal @tool json len '[1,2,3]'"], -}; - -pub const JSON_PRETTY: Entry = Entry { - key: "json.pretty", - usage: "runseal @tool json pretty ", - about: Some("Print formatted JSON with indentation."), - sections: &[], - examples: &["runseal @tool json pretty '{\"a\":1}'"], -}; - -pub const JSON_FIND: Entry = Entry { - key: "json.find", - usage: "runseal @tool json find ", - about: Some("Print the first object in the JSON array with ` == `."), - sections: &[], - examples: &["runseal @tool json find '[{\"id\":1}]' id 1"], -}; - -pub const JSON_FILTER: Entry = Entry { - key: "json.filter", - usage: "runseal @tool json filter ...", - about: Some("Print objects in the JSON array whose `` matches any provided value."), - sections: &[], - examples: &["runseal @tool json filter '[{\"env\":\"dev\"},{\"env\":\"prod\"}]' env dev prod"], -}; - pub const STRING: Entry = Entry { key: "string", usage: "runseal @tool string [args]", diff --git a/app/src/core/tool/help/json.rs b/app/src/core/tool/help/json.rs new file mode 100644 index 0000000..f0b71b4 --- /dev/null +++ b/app/src/core/tool/help/json.rs @@ -0,0 +1,123 @@ +use super::{Entry, Section}; + +pub const JSON: Entry = Entry { + key: "json", + usage: "runseal @tool json [args]", + about: None, + sections: &[Section { + title: "JSON helpers", + items: &[ + ("get ", "print one JSON value"), + ("empty ", "print true when JSON length is zero"), + ("len ", "print JSON array/object/string length"), + ("pretty ...", "print formatted JSON"), + ( + "find ", + "print first object with field=value", + ), + ( + "filter ...", + "print objects with field matching values", + ), + ], + }], + examples: &[], +}; + +pub const JSON_GET: Entry = Entry { + key: "json.get", + usage: "runseal @tool json get ", + about: Some("Print one JSON value selected by the path expression."), + sections: &[Section { + title: "Arguments", + items: &[ + ("", "input JSON text"), + ("", "path expression such as `.[0].databaseId`"), + ], + }], + examples: &["runseal @tool json get '[{\"databaseId\":123}]' '.[0].databaseId'"], +}; + +pub const JSON_EMPTY: Entry = Entry { + key: "json.empty", + usage: "runseal @tool json empty ", + about: Some("Print `true` when the JSON array, object, or string length is zero."), + sections: &[], + examples: &["runseal @tool json empty '[]'"], +}; + +pub const JSON_LEN: Entry = Entry { + key: "json.len", + usage: "runseal @tool json len ", + about: Some("Print the JSON array, object, or string length as an integer."), + sections: &[], + examples: &["runseal @tool json len '[1,2,3]'"], +}; + +pub const JSON_PRETTY: Entry = Entry { + key: "json.pretty", + usage: "runseal @tool json pretty [args]", + about: Some("Print formatted JSON with explicit input mode selection."), + sections: &[Section { + title: "Modes", + items: &[ + ("value ", "pretty-print one JSON argument"), + ("stdin", "read JSON from stdin and print formatted output"), + ( + "file ", + "read one file and write formatted JSON", + ), + ], + }], + examples: &[ + "runseal @tool json pretty value '{\"a\":1}'", + "echo '{\"a\":1}' | runseal @tool json pretty stdin", + "runseal @tool json pretty file input.json output.json", + ], +}; + +pub const JSON_PRETTY_VALUE: Entry = Entry { + key: "json.pretty.value", + usage: "runseal @tool json pretty value ", + about: Some("Pretty-print one JSON argument with indentation."), + sections: &[], + examples: &["runseal @tool json pretty value '{\"a\":1}'"], +}; + +pub const JSON_PRETTY_STDIN: Entry = Entry { + key: "json.pretty.stdin", + usage: "runseal @tool json pretty stdin", + about: Some("Read JSON from stdin and print formatted JSON."), + sections: &[], + examples: &["echo '{\"a\":1}' | runseal @tool json pretty stdin"], +}; + +pub const JSON_PRETTY_FILE: Entry = Entry { + key: "json.pretty.file", + usage: "runseal @tool json pretty file ", + about: Some("Read one JSON file and write formatted JSON to the output path."), + sections: &[Section { + title: "Arguments", + items: &[ + ("", "source JSON file path"), + ("", "destination file path"), + ], + }], + examples: &["runseal @tool json pretty file input.json output.json"], +}; + +pub const JSON_FIND: Entry = Entry { + key: "json.find", + usage: "runseal @tool json find ", + about: Some("Print the first object in the JSON array with ` == `."), + sections: &[], + examples: &["runseal @tool json find '[{\"id\":1}]' id 1"], +}; + +pub const JSON_FILTER: Entry = Entry { + key: "json.filter", + usage: "runseal @tool json filter ...", + about: Some("Print objects in the JSON array whose `` matches any provided value."), + sections: &[], + examples: &["runseal @tool json filter '[{\"env\":\"dev\"},{\"env\":\"prod\"}]' env dev prod"], +}; diff --git a/app/src/core/tool/help/mod.rs b/app/src/core/tool/help/mod.rs index 16157b5..81c79f5 100644 --- a/app/src/core/tool/help/mod.rs +++ b/app/src/core/tool/help/mod.rs @@ -1,5 +1,6 @@ mod basic; mod cloudflare; +mod json; mod ssh; #[derive(Clone, Copy)] @@ -18,13 +19,16 @@ pub struct Section { } const ENTRIES: &[Entry] = &[ - basic::JSON, - basic::JSON_GET, - basic::JSON_EMPTY, - basic::JSON_LEN, - basic::JSON_PRETTY, - basic::JSON_FIND, - basic::JSON_FILTER, + json::JSON, + json::JSON_GET, + json::JSON_EMPTY, + json::JSON_LEN, + json::JSON_PRETTY, + json::JSON_PRETTY_VALUE, + json::JSON_PRETTY_STDIN, + json::JSON_PRETTY_FILE, + json::JSON_FIND, + json::JSON_FILTER, basic::STRING, basic::STRING_TRIM, basic::STRING_JOIN, diff --git a/app/src/core/tool/json.rs b/app/src/core/tool/json.rs index 4564ddd..05836b7 100644 --- a/app/src/core/tool/json.rs +++ b/app/src/core/tool/json.rs @@ -1,3 +1,5 @@ +use std::io::Read; + use anyhow::{Context, Result, bail}; use serde_json::Value as JsonValue; @@ -53,11 +55,53 @@ fn len(args: &[String]) -> Result> { } fn pretty(args: &[String]) -> Result> { + let [mode, rest @ ..] = args else { + bail!("usage: runseal @tool json pretty value|stdin|file ..."); + }; + match mode.as_str() { + "value" => pretty_value(rest), + "stdin" => pretty_stdin(rest), + "file" => pretty_file(rest), + _ => bail!("usage: runseal @tool json pretty value|stdin|file ..."), + } +} + +fn pretty_value(args: &[String]) -> Result> { let [json] = args else { - bail!("usage: runseal @tool json pretty "); + bail!("usage: runseal @tool json pretty value "); }; let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; - Ok(Some(serde_json::to_string_pretty(&value)?)) + Ok(Some(render_pretty(&value)?)) +} + +fn pretty_stdin(args: &[String]) -> Result> { + if !args.is_empty() { + bail!("usage: runseal @tool json pretty stdin"); + } + let mut input = String::new(); + std::io::stdin() + .read_to_string(&mut input) + .context("failed to read JSON from stdin")?; + let value: JsonValue = serde_json::from_str(&input).context("invalid JSON input")?; + Ok(Some(render_pretty(&value)?)) +} + +fn pretty_file(args: &[String]) -> Result> { + let [input_path, output_path] = args else { + bail!("usage: runseal @tool json pretty file "); + }; + let input = std::fs::read_to_string(input_path) + .with_context(|| format!("failed to read JSON file: {input_path}"))?; + let value: JsonValue = serde_json::from_str(&input).context("invalid JSON input")?; + let mut pretty = render_pretty(&value)?; + pretty.push('\n'); + std::fs::write(output_path, pretty) + .with_context(|| format!("failed to write JSON file: {output_path}"))?; + Ok(None) +} + +fn render_pretty(value: &JsonValue) -> Result { + serde_json::to_string_pretty(value).map_err(Into::into) } fn find(args: &[String]) -> Result> { diff --git a/app/tests/internal_tool.rs b/app/tests/internal_tool.rs index 3792253..dabf063 100644 --- a/app/tests/internal_tool.rs +++ b/app/tests/internal_tool.rs @@ -8,7 +8,11 @@ mod ssh; #[path = "internal_tool/string.rs"] mod string; -use std::{path::PathBuf, process::Command}; +use std::{ + io::Write, + path::PathBuf, + process::{Command, Stdio}, +}; use tempfile::TempDir; @@ -23,6 +27,10 @@ fn tool_runs_without_profile() { std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); for (args, expected) in [ + ( + vec!["@tool", "json", "pretty", "value", r#"{"a":1}"#], + "{\n \"a\": 1\n}\n", + ), ( vec![ "@tool", @@ -92,6 +100,22 @@ fn tool_help_is_progressive() { vec!["@tool", "json", "get", "--help"], "Usage: runseal @tool json get ", ), + ( + vec!["@tool", "json", "pretty", "--help"], + "Usage: runseal @tool json pretty [args]", + ), + ( + vec!["@tool", "json", "pretty", "value", "--help"], + "Usage: runseal @tool json pretty value ", + ), + ( + vec!["@tool", "json", "pretty", "stdin", "--help"], + "Usage: runseal @tool json pretty stdin", + ), + ( + vec!["@tool", "json", "pretty", "file", "--help"], + "Usage: runseal @tool json pretty file ", + ), ( vec!["@tool", "string", "--help"], "Usage: runseal @tool string [args]", @@ -370,3 +394,63 @@ fn fs_mode_touch_list() { assert!(paths[0].ends_with("a.yaml")); assert!(paths[1].ends_with("b.yaml")); } + +#[test] +fn json_pretty_stdin() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("empty"); + std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); + + let mut child = bin() + .current_dir(&cwd) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args(["@tool", "json", "pretty", "stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("runseal should start"); + + child + .stdin + .as_mut() + .expect("stdin should be piped") + .write_all(br#"{"a":1,"b":[2]}"#) + .expect("stdin write should succeed"); + + let output = child.wait_with_output().expect("runseal should finish"); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).expect("stdout should be UTF-8"), + "{\n \"a\": 1,\n \"b\": [\n 2\n ]\n}\n" + ); +} + +#[test] +fn json_pretty_file() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("empty"); + std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); + let input = cwd.join("input.json"); + let output = cwd.join("output.json"); + std::fs::write(&input, r#"{"a":1,"b":[2]}"#).expect("input file should be written"); + + let result = bin() + .current_dir(&cwd) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args([ + "@tool", + "json", + "pretty", + "file", + input.to_str().expect("input path should be UTF-8"), + output.to_str().expect("output path should be UTF-8"), + ]) + .output() + .expect("runseal should run"); + + assert!(result.status.success()); + assert_eq!( + std::fs::read_to_string(&output).expect("output file should be readable"), + "{\n \"a\": 1,\n \"b\": [\n 2\n ]\n}\n" + ); +}