diff --git a/.github/readme-source.json b/.github/readme-source.json index 52ef877..2828333 100644 --- a/.github/readme-source.json +++ b/.github/readme-source.json @@ -1,6 +1,6 @@ { "version": 1, - "sha256": "fae15d1ecd7216d25d89f53ecf6615482599c17421c8e27d28de3aece372a783", + "sha256": "34bc204446a8b81cad69a60f855cb0d8899c5dab8dbd6c186b54bfdeaad44b5b", "history": { "forbidden_commits": [ "302ba24725a33285cd71d5c84e3f65c812cd7e25" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d47d56..0d26ecc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: merge-multiple: true path: .artifacts/native-cli - name: Set up development tools - uses: nicodes/tools/setup@f412061a7a3f0914db9e09d1159e69504aee8706 + uses: nicodes/tools/setup@a1c0321f3b4917a7e99eca42e241e46670f6e1e1 env: GH_TOKEN: ${{ github.token }} timeout-minutes: 5 diff --git a/.github/workflows/dependency-policy.yml b/.github/workflows/dependency-policy.yml index 72e5f83..ee3394f 100644 --- a/.github/workflows/dependency-policy.yml +++ b/.github/workflows/dependency-policy.yml @@ -48,7 +48,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} timeout-minutes: 7 - uses: nicodes/tools/dependencies@f412061a7a3f0914db9e09d1159e69504aee8706 + uses: nicodes/tools/dependencies@a1c0321f3b4917a7e99eca42e241e46670f6e1e1 with: apply: 'true' output: ${{ runner.temp }}/dependency-report.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00d1f99..1100aec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: repository: nicodes/tools - ref: 091101094fedf5ce2e67c339c1c9c842d6a662dc + ref: a1c0321f3b4917a7e99eca42e241e46670f6e1e1 path: _cli_tools persist-credentials: false - name: Determine release version @@ -76,7 +76,7 @@ jobs: merge-multiple: true path: .artifacts/native-cli - name: Set up development tools - uses: nicodes/tools/setup@f412061a7a3f0914db9e09d1159e69504aee8706 + uses: nicodes/tools/setup@a1c0321f3b4917a7e99eca42e241e46670f6e1e1 env: GH_TOKEN: ${{ github.token }} timeout-minutes: 5 @@ -103,7 +103,7 @@ jobs: publish: name: Publish verified archives needs: [identity, accept] - uses: nicodes/tools/.github/workflows/cli-publish.yml@f412061a7a3f0914db9e09d1159e69504aee8706 + uses: nicodes/tools/.github/workflows/cli-publish.yml@a1c0321f3b4917a7e99eca42e241e46670f6e1e1 permissions: contents: write with: diff --git a/README.md b/README.md index df921c5..accc40a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + # spritesmith @@ -62,3 +62,13 @@ mise run check Native build and test commands retain source and output checks, with receipts in `.artifacts/verification`. Existing product release rules and artifacts remain owned by this repository. End users install the product’s published distribution. The client is implemented in Rust. To build it from source with the pinned Rust 1.94.0 toolchain, run `cargo build --release --locked`; the executable is `target/release/spritesmith` (`spritesmith.exe` on Windows). Local checks validate the native archive. GitHub Actions tests and packages all five published platforms before accepting a release. + +## Contributor task interface + +Contributor tool versions and task entry points live in the native Mise configuration. Use `mise run lint` for source checks, `mise run test` for the applicable behavioral tests, and `mise run check` for the complete supported gate. `mise run clean` removes declared generated outputs while preserving source, credentials and retained data. This package has no separate Mise development service; use its native entry points directly. + +## Machine results and configuration + +Use `spritesmith --json prompt [flags] PROMPT` or `spritesmith prompt --json [flags] PROMPT` for one JSON result. Successful results identify the saved image path, format and actual operation ID. Retain that ID for safe retries. Version and help also accept the leading `--json` flag. Diagnostics use stderr; invalid syntax exits 2 and a failed operation exits 1. + +`SPRITESMITH_CONFIG_DIR` overrides the OS configuration directory and contains the `token` file. Without it, the platform's existing application configuration path is used. `SPRITESMITH_API_TOKEN` takes precedence over the file. No token appears in command arguments or machine results. diff --git a/mise.toml b/mise.toml index e62ebae..7ca4828 100644 --- a/mise.toml +++ b/mise.toml @@ -32,7 +32,7 @@ depends = ["build"] run = ["cargo audit --file Cargo.lock --db .artifacts/advisory-db"] [tasks."clean"] -run = ["python3 -c 'import shutil; [shutil.rmtree(p,ignore_errors=True) for p in ['\"'\"'dist'\"'\"', '\"'\"'.artifacts'\"'\"']]'"] +run = "python3 \"$TOOLS_ROOT/helpers/clean.py\" --path dist --path target --path .artifacts/build --path .artifacts/rust-target --path .artifacts/advisory-db" [tasks."integration"] run = "python3 \"$TOOLS_ROOT/helpers/verification.py\" test --name integration --artifact dist --environment VERSION --environment COMMIT --revision-environment COMMIT -- mise run _integration" diff --git a/src/client.rs b/src/client.rs index 4938155..fee6686 100644 --- a/src/client.rs +++ b/src/client.rs @@ -296,9 +296,14 @@ pub fn process(request: &Request, credential: &Credential) -> Result<()> { response.take(65537).read_to_string(&mut data)?; let message = serde_json::from_str::(&data) .ok() - .and_then(|v| v["error"].as_str().map(str::to_owned)) + .and_then(|v| { + v["error"]["message"] + .as_str() + .or_else(|| v["error"].as_str()) + .map(str::to_owned) + }) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| data.trim().to_owned()); + .unwrap_or_default(); return Err(if message.is_empty() { format!("API returned {status}") } else { diff --git a/src/main.rs b/src/main.rs index a88a957..aee2b0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,12 +5,13 @@ type Result = std::result::Result const FLAGS: &[&str] = &["model", "operation-id", "api", "out", "kind", "format"]; fn help() -> String { format!( - "usage: spritesmith COMMAND [flags]\n\nCommands:\n prompt PROMPT make a sprite from a text prompt through the API\n version print version and source revision\n\nFlags (before PROMPT):\n --kind vector SVG animation creation (default)\n --format png|svg download format, default png\n --model haiku|opus reasoning model, default haiku\n --operation-id ID retain this ID for safe retries\n --api URL API base, default http://127.0.0.1:8080\n --out FILE output path, default spritesmith.png (or spritesmith.svg)\n\nAuthentication:\n Create a Clerk API key for your spritesmith account. An explicitly configured local development API needs none.\n export SPRITESMITH_API_TOKEN=\n # or: install -D -m 600 /dev/null {0} && $EDITOR {0}\n SPRITESMITH_API_TOKEN wins over the file. There is no --token flag because arguments are visible in process lists and shell history.\n Tokens are sent only over https or to a loopback address.\n\nOutput:\n Each run downloads one PNG or SVG. An existing file is never overwritten.\n Use the web app to attach visual references to prompts.\n", + "usage: spritesmith COMMAND [flags]\n\nCommands:\n prompt PROMPT make a sprite from a text prompt through the API\n version print version and source revision\n\nFlags (before PROMPT):\n --json print one structured result; diagnostics use stderr\n --kind vector SVG animation creation (default)\n --format png|svg download format, default png\n --model haiku|opus reasoning model, default haiku\n --operation-id ID retain this ID for safe retries\n --api URL API base, default http://127.0.0.1:8080\n --out FILE output path, default spritesmith.png (or spritesmith.svg)\n\nAuthentication:\n Create a Clerk API key for your spritesmith account. An explicitly configured local development API needs none.\n export SPRITESMITH_API_TOKEN=\n # or: install -D -m 600 /dev/null {0} && $EDITOR {0}\n SPRITESMITH_CONFIG_DIR overrides the OS config directory.\n SPRITESMITH_API_TOKEN wins over the file. There is no --token flag because arguments are visible in process lists and shell history.\n Tokens are sent only over https or to a loopback address.\n\nOutput:\n Each run downloads one PNG or SVG. An existing file is never overwritten.\n Use the web app to attach visual references to prompts.\n", token::hint() ) } -fn parse(args: &[String]) -> Result { +fn parse(args: &[String]) -> Result<(client::Request, bool)> { + let mut machine = false; let mut request = client::Request { model: "haiku".into(), operation: String::new(), @@ -35,6 +36,11 @@ fn parse(args: &[String]) -> Result { if !arg.starts_with('-') || arg == "-" { break; } + if arg == "--json" { + machine = true; + index += 1; + continue; + } let (name, value) = arg .trim_start_matches('-') .split_once('=') @@ -90,26 +96,66 @@ fn parse(args: &[String]) -> Result { if request.format.is_empty() { request.format = "png".into(); } - Ok(request) + Ok((request, machine)) } +fn machine_error(code: &str, error: &dyn std::fmt::Display) { + println!( + "{}", + serde_json::json!({"v":1,"ok":false,"error":{"code":code,"message":error.to_string()}}) + ); +} fn main() { - let args: Vec<_> = std::env::args().skip(1).collect(); + let mut args: Vec<_> = std::env::args().skip(1).collect(); + let global_machine = args.first().is_some_and(|arg| arg == "--json"); + if global_machine { + args.remove(0); + } + let machine = global_machine + || args + .iter() + .take_while(|arg| arg.as_str() != "--") + .any(|arg| arg == "--json"); match args.first().map(String::as_str) { - Some("--version" | "version") => println!( - "spritesmith {} ({})", - env!("SPRITESMITH_VERSION"), - env!("SPRITESMITH_REVISION") - ), - Some("help" | "--help" | "-h") => print!("{}", help()), + Some("--version" | "version") => { + if machine { + println!( + "{}", + serde_json::json!({"v":1,"ok":true,"result":{"version":env!("SPRITESMITH_VERSION"),"commit":env!("SPRITESMITH_REVISION")}}) + ); + } else { + println!( + "spritesmith {} ({})", + env!("SPRITESMITH_VERSION"), + env!("SPRITESMITH_REVISION") + ); + } + } + Some("help" | "--help" | "-h") => { + if machine { + println!( + "{}", + serde_json::json!({"v":1,"ok":true,"result":{"help":help()}}) + ); + } else { + print!("{}", help()); + } + } Some("prompt") => { - let request = match parse(&args[1..]) { + let (mut request, machine) = match parse(&args[1..]) { Ok(value) => value, Err(error) => { eprintln!("{error}"); + if machine { + machine_error("invalid_usage", &error); + } std::process::exit(2); } }; + let machine = global_machine || machine; + if request.operation.is_empty() { + request.operation = uuid::Uuid::new_v4().simple().to_string(); + } let result = (|| { let credential = token::load()?; if !credential.warning.is_empty() { @@ -118,15 +164,34 @@ fn main() { client::process(&request, &credential) })(); match result { - Ok(()) => println!("{}", request.out), + Ok(()) => { + if machine { + println!( + "{}", + serde_json::json!({"v":1,"ok":true,"result":{"path":request.out,"format":request.format,"operation_id":request.operation}}) + ); + } else { + println!("{}", request.out); + } + } Err(error) => { eprintln!("{error}"); + if machine { + machine_error("operation_failed", &error); + } std::process::exit(1); } } } _ => { - eprint!("{}", help()); + if machine { + machine_error( + "invalid_usage", + &"expected spritesmith prompt, version or help", + ); + } else { + eprint!("{}", help()); + } std::process::exit(2); } } diff --git a/src/token.rs b/src/token.rs index 329ddbb..702214a 100644 --- a/src/token.rs +++ b/src/token.rs @@ -19,6 +19,9 @@ pub fn path() -> Option { .filter(|v| !v.is_empty()) .map(PathBuf::from) }; + if let Some(directory) = env("SPRITESMITH_CONFIG_DIR") { + return Some(directory.join("token")); + } let root = if cfg!(target_os = "windows") { env("APPDATA") } else if cfg!(target_os = "macos") { diff --git a/tests/client_test.py b/tests/client_test.py index 5d95402..7ebbeeb 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -54,7 +54,7 @@ def setUp(self): self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.env = dict(os.environ, SPRITESMITH_API_TOKEN='', SPRITESMITH_API_BASE='http://127.0.0.1:1', - HOME=str(self.root), XDG_CONFIG_HOME=str(self.root/'config'), APPDATA=str(self.root/'config')) + SPRITESMITH_CONFIG_DIR='', HOME=str(self.root), XDG_CONFIG_HOME=str(self.root/'config'), APPDATA=str(self.root/'config')) def run_cli(self, *args, **env): return subprocess.run([str(BINARY), *args], cwd=self.root, env=self.env|env, capture_output=True, text=True, timeout=10) @@ -78,6 +78,39 @@ def test_prompt_fields_auth_output_and_model(self): fields={part.get_param('name',header='content-disposition'):part.get_payload(decode=True).decode() for part in message.walk() if part.get_param('name',header='content-disposition')} self.assertEqual(fields,dict(kind='vector',workflow='agent',subject='character',agent_model=expected,prompt='a duck')) + def test_json_has_one_result_and_retains_the_actual_operation_identity(self): + with api(lambda h,_:reply(h)) as (url,calls): + result=self.prompt(url,'--json','--out','machine.png') + self.assertEqual(result.returncode,0,result.stderr) + value=json.loads(result.stdout) + self.assertEqual(value['result']['path'],'machine.png') + self.assertEqual(value['result']['operation_id'],calls[0][2]['Idempotency-Key']) + self.assertEqual((self.root/'machine.png').read_bytes(),b'synthetic-image') + self.assertNotIn('\x1b',result.stdout) + def test_json_failure_is_not_replayed_or_written_as_an_image(self): + body=json.dumps({'error':{'code':'operation_conflict','message':'Retain the saved operation','request_id':'fixture-id'}}).encode() + with api(lambda h,_:reply(h,409,body,'application/json')) as (url,calls): + result=self.prompt(url,'--json') + self.assertEqual(result.returncode,1) + self.assertFalse(json.loads(result.stdout)['ok']) + self.assertIn('Retain the saved operation',json.loads(result.stdout)['error']['message']) + self.assertEqual(len(calls),1) + self.assertFalse((self.root/'spritesmith.png').exists()) + self.assertTrue(json.loads(self.run_cli('--json','version').stdout)['ok']) + invalid=self.run_cli('prompt','--json','--unknown') + self.assertEqual(invalid.returncode,2) + self.assertEqual(json.loads(invalid.stdout)['error']['code'],'invalid_usage') + def test_explicit_config_directory_and_environment_token_precedence(self): + config=self.root/'explicit-config';config.mkdir() + (config/'token').write_text('file-fixture-token') + with api(lambda h,_:reply(h)) as (url,calls): + result=self.prompt(url,'--out','file.png',SPRITESMITH_CONFIG_DIR=str(config)) + self.assertEqual(result.returncode,0,result.stderr) + self.assertEqual(calls[0][2]['Authorization'],'Bearer file-fixture-token') + result=self.prompt(url,'--out','environment.png',SPRITESMITH_CONFIG_DIR=str(config),SPRITESMITH_API_TOKEN='env-fixture-token') + self.assertEqual(result.returncode,0,result.stderr) + self.assertEqual(calls[1][2]['Authorization'],'Bearer env-fixture-token') + def test_no_token_has_no_authorization_header(self): with api(lambda h,_:reply(h)) as (url,calls): result=self.prompt(url)