Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/readme-source.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": 1,
"sha256": "fae15d1ecd7216d25d89f53ecf6615482599c17421c8e27d28de3aece372a783",
"sha256": "34bc204446a8b81cad69a60f855cb0d8899c5dab8dbd6c186b54bfdeaad44b5b",
"history": {
"forbidden_commits": [
"302ba24725a33285cd71d5c84e3f65c812cd7e25"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dependency-policy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- Generated from private documentation source. Do not edit directly. Source SHA256: 25e5dcd05c5286f92e1f5d8fb8eaa30b9c9e818f7b9ec4eb0ebfc37efea1b9e1 -->
<!-- Generated from private documentation source. Do not edit directly. Source SHA256: fdc0a03bc9002222edf92db7eed99ad2d3c4f5fe62721885ba4392d60b8e7d3b -->

# spritesmith

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 7 additions & 2 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Value>(&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 {
Expand Down
91 changes: 78 additions & 13 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>
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=<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=<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<client::Request> {
fn parse(args: &[String]) -> Result<(client::Request, bool)> {
let mut machine = false;
let mut request = client::Request {
model: "haiku".into(),
operation: String::new(),
Expand All @@ -35,6 +36,11 @@ fn parse(args: &[String]) -> Result<client::Request> {
if !arg.starts_with('-') || arg == "-" {
break;
}
if arg == "--json" {
machine = true;
index += 1;
continue;
}
let (name, value) = arg
.trim_start_matches('-')
.split_once('=')
Expand Down Expand Up @@ -90,26 +96,66 @@ fn parse(args: &[String]) -> Result<client::Request> {
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() {
Expand All @@ -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);
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ pub fn path() -> Option<PathBuf> {
.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") {
Expand Down
35 changes: 34 additions & 1 deletion tests/client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading