use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::json;
use uuid::Uuid;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{body_json, method, path, query_param},
};
const JOB_ID: &str = "11111111-1111-4111-8111-111111111111";
const USER_ID: &str = "22222222-2222-4222-8222-222222222222";
const PAT_ID: &str = "33333333-3333-4333-8333-333333333333";
const CHARACTER_ID: &str = "44444444-4444-4444-8444-444444444444";
const PROJECT_ID: &str = "55555555-5555-4555-8555-555555555555";
const ASSET_ID: &str = "66666666-6666-4666-8666-666666666666";
#[test]
fn help_lists_full_command_surface() {
cmd()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("auth"))
.stdout(predicate::str::contains("gen"))
.stdout(predicate::str::contains("status"))
.stdout(predicate::str::contains("wait"))
.stdout(predicate::str::contains("assets"))
.stdout(predicate::str::contains("characters"))
.stdout(predicate::str::contains("projects"))
.stdout(predicate::str::contains("account"))
.stdout(predicate::str::contains("billing"))
.stdout(predicate::str::contains("pat"));
}
#[test]
fn gen_help_lists_modalities() {
cmd()
.args(["gen", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("image"))
.stdout(predicate::str::contains("video"))
.stdout(predicate::str::contains("audio"));
}
#[tokio::test]
async fn gen_image_writes_output_file() {
let api = MockServer::start().await;
let files = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/video.mp4"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(vec![1, 2, 3]))
.mount(&files)
.await;
Mock::given(method("POST"))
.and(path("/v1/generate/image"))
.respond_with(ResponseTemplate::new(202).set_body_json(job_json("queued", None)))
.mount(&api)
.await;
Mock::given(method("GET"))
.and(path(format!("/v1/jobs/{JOB_ID}/wait")))
.respond_with(
ResponseTemplate::new(200).set_body_json(job_json("succeeded", Some(&files.uri()))),
)
.mount(&api)
.await;
let out = tempfile::tempdir().unwrap().path().join("x.png");
run_ok(
&api,
&[
"gen",
"image",
"--prompt",
"x",
"--out",
out.to_str().unwrap(),
],
);
assert_eq!(std::fs::read(out).unwrap(), vec![1, 2, 3]);
}
#[tokio::test]
async fn json_gen_image_no_wait_returns_job_id() {
let api = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/generate/image"))
.respond_with(ResponseTemplate::new(202).set_body_json(job_json("queued", None)))
.mount(&api)
.await;
run_ok(
&api,
&["--json", "gen", "image", "--prompt", "x", "--no-wait"],
)
.stdout(predicate::str::contains("job_id"));
}
#[tokio::test]
async fn gen_video_no_wait_returns_job_id() {
let api = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/generate/video"))
.respond_with(ResponseTemplate::new(202).set_body_json(job_json("queued", None)))
.mount(&api)
.await;
run_ok(&api, &["gen", "video", "--prompt", "x", "--no-wait"])
.stdout(predicate::str::contains(JOB_ID));
}
#[tokio::test]
async fn gen_video_wait_downloads_asset() {
let api = MockServer::start().await;
let files = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/video.mp4"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(vec![9]))
.mount(&files)
.await;
Mock::given(method("POST"))
.and(path("/v1/generate/video"))
.respond_with(ResponseTemplate::new(202).set_body_json(job_json("queued", None)))
.mount(&api)
.await;
Mock::given(method("GET"))
.and(path(format!("/v1/jobs/{JOB_ID}/wait")))
.respond_with(
ResponseTemplate::new(200).set_body_json(job_json("succeeded", Some(&files.uri()))),
)
.mount(&api)
.await;
let out = tempfile::tempdir().unwrap().path().join("x.mp4");
run_ok(
&api,
&[
"gen",
"video",
"--prompt",
"x",
"--out",
out.to_str().unwrap(),
],
);
assert_eq!(std::fs::read(out).unwrap(), vec![9]);
}
#[tokio::test]
async fn gen_audio_prints_asset_url() {
let api = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/generate/audio"))
.respond_with(ResponseTemplate::new(202).set_body_json(job_json("queued", None)))
.mount(&api)
.await;
Mock::given(method("GET"))
.and(path(format!("/v1/jobs/{JOB_ID}/wait")))
.respond_with(
ResponseTemplate::new(200).set_body_json(job_json("succeeded", Some("https://files"))),
)
.mount(&api)
.await;
run_ok(&api, &["gen", "audio", "--prompt", "x"]).stdout(predicate::str::contains("video.mp4"));
}
#[tokio::test]
async fn status_fetches_job() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/v1/jobs/{JOB_ID}")))
.respond_with(ResponseTemplate::new(200).set_body_json(job_json("running", None)))
.mount(&api)
.await;
run_ok(&api, &["status", JOB_ID]).stdout(predicate::str::contains("running"));
}
#[tokio::test]
async fn wait_fetches_terminal_job() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/v1/jobs/{JOB_ID}/wait")))
.respond_with(ResponseTemplate::new(200).set_body_json(job_json("succeeded", None)))
.mount(&api)
.await;
run_ok(&api, &["wait", JOB_ID, "--timeout", "1"]).stdout(predicate::str::contains("succeeded"));
}
#[tokio::test]
async fn assets_list_outputs_asset() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/assets"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"items": [asset_json("https://files/a.png")]})),
)
.mount(&api)
.await;
run_ok(&api, &["assets", "list"]).stdout(predicate::str::contains("a.png"));
}
#[tokio::test]
async fn assets_get_downloads_asset() {
let api = MockServer::start().await;
let files = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/asset.png"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(vec![7, 7]))
.mount(&files)
.await;
Mock::given(method("GET"))
.and(path(format!("/v1/assets/{JOB_ID}")))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(asset_json(&format!("{}/asset.png", files.uri()))),
)
.mount(&api)
.await;
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("asset.bin");
run_ok(
&api,
&["assets", "get", JOB_ID, "--out", out.to_str().unwrap()],
);
assert_eq!(std::fs::read(&out).unwrap(), vec![7, 7]);
}
#[tokio::test]
async fn assets_get_prints_metadata_without_out() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("/v1/assets/{JOB_ID}")))
.respond_with(ResponseTemplate::new(200).set_body_json(asset_json("https://files/a.png")))
.mount(&api)
.await;
run_ok(&api, &["assets", "get", JOB_ID]).stdout(predicate::str::contains("a.png"));
}
#[tokio::test]
async fn assets_delete_removes_asset() {
let api = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(format!("/v1/assets/{JOB_ID}")))
.respond_with(ResponseTemplate::new(204))
.mount(&api)
.await;
run_ok(&api, &["assets", "delete", JOB_ID])
.stdout(predicate::str::contains(format!("deleted {JOB_ID}")));
}
#[tokio::test]
async fn assets_list_sends_tag_filter() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/assets"))
.and(query_param("tag", "hero"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"items": [asset_json("https://files/a.png")]})),
)
.expect(1)
.mount(&api)
.await;
run_ok(&api, &["assets", "list", "--tag", "hero"]).stdout(predicate::str::contains("a.png"));
}
#[tokio::test]
async fn assets_tag_sends_patch_body_and_prints_tags() {
let api = MockServer::start().await;
let mut asset = asset_json("https://files/a.png");
asset["tags"] = json!(["hero", "draft"]);
Mock::given(method("PATCH"))
.and(path(format!("/v1/assets/{ASSET_ID}")))
.and(body_json(json!({"tags": ["hero", "draft"]})))
.respond_with(ResponseTemplate::new(200).set_body_json(asset))
.expect(1)
.mount(&api)
.await;
run_ok(
&api,
&["assets", "tag", ASSET_ID, "--tag", "hero", "--tag", "draft"],
)
.stdout(predicate::str::contains("tags: [hero, draft]"));
}
#[tokio::test]
async fn assets_tag_clear_sends_empty_tag_set() {
let api = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path(format!("/v1/assets/{ASSET_ID}")))
.and(body_json(json!({"tags": []})))
.respond_with(ResponseTemplate::new(200).set_body_json(asset_json("https://files/a.png")))
.expect(1)
.mount(&api)
.await;
run_ok(&api, &["assets", "tag", ASSET_ID, "--clear"])
.stdout(predicate::str::contains("tags: []"));
}
#[test]
fn assets_tag_requires_tag_or_clear() {
cmd()
.args(["assets", "tag", ASSET_ID, "--api-url", "http://127.0.0.1:9"])
.assert()
.failure()
.stderr(predicate::str::contains("--tag"));
}
#[tokio::test]
async fn characters_list_outputs_characters() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/characters"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"characters": [character_json()]})),
)
.mount(&api)
.await;
run_ok(&api, &["characters", "list"])
.stdout(predicate::str::contains(CHARACTER_ID))
.stdout(predicate::str::contains("Captain Nova"))
.stdout(predicate::str::contains("1 reference"));
}
#[tokio::test]
async fn characters_create_sends_body() {
let api = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/characters"))
.and(body_json(json!({
"name": "Captain Nova",
"description": "Silver-haired astronaut",
"reference_asset_ids": [ASSET_ID]
})))
.respond_with(ResponseTemplate::new(201).set_body_json(character_json()))
.expect(1)
.mount(&api)
.await;
run_ok(
&api,
&[
"characters",
"create",
"--name",
"Captain Nova",
"--description",
"Silver-haired astronaut",
"--reference-asset-id",
ASSET_ID,
],
)
.stdout(predicate::str::contains(CHARACTER_ID));
}
#[tokio::test]
async fn characters_update_sends_only_provided_fields() {
let api = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path(format!("/v1/characters/{CHARACTER_ID}")))
.and(body_json(json!({"name": "Nova Prime"})))
.respond_with(ResponseTemplate::new(200).set_body_json(character_json()))
.expect(1)
.mount(&api)
.await;
run_ok(
&api,
&["characters", "update", CHARACTER_ID, "--name", "Nova Prime"],
)
.stdout(predicate::str::contains(CHARACTER_ID));
}
#[tokio::test]
async fn characters_delete_removes_character() {
let api = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(format!("/v1/characters/{CHARACTER_ID}")))
.respond_with(ResponseTemplate::new(204))
.mount(&api)
.await;
run_ok(&api, &["characters", "delete", CHARACTER_ID])
.stdout(predicate::str::contains(format!("deleted {CHARACTER_ID}")));
}
#[test]
fn characters_create_rejects_more_than_four_references() {
let a = "77777777-7777-4777-8777-777777777777";
cmd()
.args([
"characters",
"create",
"--name",
"x",
"--reference-asset-id",
a,
"--reference-asset-id",
a,
"--reference-asset-id",
a,
"--reference-asset-id",
a,
"--reference-asset-id",
a,
"--api-url",
"http://127.0.0.1:9",
])
.assert()
.failure()
.stderr(predicate::str::contains("at most 4"));
}
#[tokio::test]
async fn projects_list_outputs_projects() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/projects"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"projects": [project_json()]})),
)
.mount(&api)
.await;
run_ok(&api, &["projects", "list"])
.stdout(predicate::str::contains(PROJECT_ID))
.stdout(predicate::str::contains("Launch teaser"))
.stdout(predicate::str::contains("3 assets"));
}
#[tokio::test]
async fn projects_create_sends_body() {
let api = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/projects"))
.and(body_json(json!({
"name": "Launch teaser",
"description": "Spring launch assets"
})))
.respond_with(ResponseTemplate::new(201).set_body_json(project_json()))
.expect(1)
.mount(&api)
.await;
run_ok(
&api,
&[
"projects",
"create",
"--name",
"Launch teaser",
"--description",
"Spring launch assets",
],
)
.stdout(predicate::str::contains(PROJECT_ID));
}
#[tokio::test]
async fn projects_add_assets_sends_body() {
let api = MockServer::start().await;
Mock::given(method("POST"))
.and(path(format!("/v1/projects/{PROJECT_ID}/assets")))
.and(body_json(json!({"asset_ids": [ASSET_ID]})))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&api)
.await;
run_ok(
&api,
&["projects", "add-assets", PROJECT_ID, "--asset-id", ASSET_ID],
)
.stdout(predicate::str::contains("added 1 asset"));
}
#[tokio::test]
async fn projects_remove_asset_deletes_membership() {
let api = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(format!("/v1/projects/{PROJECT_ID}/assets/{ASSET_ID}")))
.respond_with(ResponseTemplate::new(204))
.mount(&api)
.await;
run_ok(&api, &["projects", "remove-asset", PROJECT_ID, ASSET_ID]).stdout(
predicate::str::contains(format!("removed {ASSET_ID} from {PROJECT_ID}")),
);
}
#[test]
fn projects_add_assets_requires_asset_id() {
cmd()
.args([
"projects",
"add-assets",
PROJECT_ID,
"--api-url",
"http://127.0.0.1:9",
])
.assert()
.failure()
.stderr(predicate::str::contains("--asset-id"));
}
#[tokio::test]
async fn account_me_outputs_email() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(user_json()))
.mount(&api)
.await;
run_ok(&api, &["account", "me"]).stdout(predicate::str::contains("ada@nolgia.ai"));
}
#[tokio::test]
async fn account_usage_combines_jobs_and_assets() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/jobs"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"items": [job_json("queued", None)], "total": 1})),
)
.mount(&api)
.await;
Mock::given(method("GET"))
.and(path("/v1/assets"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"items": []})))
.mount(&api)
.await;
run_ok(&api, &["account", "usage"]).stdout(predicate::str::contains("jobs: 1"));
}
#[tokio::test]
async fn billing_subscription_outputs_status() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/billing/subscription"))
.respond_with(ResponseTemplate::new(200).set_body_json(
json!({"tier":"pro","status":"active","current_period_end":"2026-06-13T00:00:00Z"}),
))
.mount(&api)
.await;
run_ok(&api, &["billing", "subscription"]).stdout(predicate::str::contains("active"));
}
#[tokio::test]
async fn billing_portal_outputs_url() {
let api = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/billing/portal-link"))
.respond_with(ResponseTemplate::new(200).set_body_json(
json!({"url":"https://billing.example","expires_at":"2026-06-13T00:00:00Z"}),
))
.mount(&api)
.await;
run_ok(&api, &["billing", "portal"]).stdout(predicate::str::contains("billing.example"));
}
#[tokio::test]
async fn billing_credits_shows_both_pools() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/billing/credits"))
.respond_with(ResponseTemplate::new(200).set_body_json(credit_balance_json()))
.mount(&api)
.await;
run_ok(&api, &["billing", "credits"])
.stdout(predicate::str::contains(
"subscription: 546631 (resets with plan) api top-ups: 250",
))
.stdout(predicate::str::contains("total: 546881"));
}
#[tokio::test]
async fn json_billing_credits_emits_raw_balance() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/billing/credits"))
.respond_with(ResponseTemplate::new(200).set_body_json(credit_balance_json()))
.mount(&api)
.await;
run_ok(&api, &["--json", "billing", "credits"])
.stdout(predicate::str::contains("app_subscription"))
.stdout(predicate::str::contains("shared_topup"))
.stdout(predicate::str::contains("buckets"));
}
#[tokio::test]
async fn pat_create_prints_token_once_with_warning() {
let api = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/pat"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"pat": pat_json(),
"token": "nol_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
})))
.mount(&api)
.await;
run_ok(&api, &["pat", "create", "--name", "ci-bot"])
.stdout(predicate::str::contains(
"token: nol_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
))
.stdout(predicate::str::contains("will not be shown again"));
}
#[tokio::test]
async fn pat_list_outputs_tokens() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/pat"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"items": [pat_json()]})))
.mount(&api)
.await;
run_ok(&api, &["pat", "list"])
.stdout(predicate::str::contains(PAT_ID))
.stdout(predicate::str::contains("ci-bot"))
.stdout(predicate::str::contains("nol_a1b2"))
.stdout(predicate::str::contains("never"));
}
#[tokio::test]
async fn pat_revoke_deletes_token() {
let api = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(format!("/v1/pat/{PAT_ID}")))
.respond_with(ResponseTemplate::new(204))
.mount(&api)
.await;
run_ok(&api, &["pat", "revoke", PAT_ID])
.stdout(predicate::str::contains(format!("revoked {PAT_ID}")));
}
#[test]
fn auth_help_lists_device_flow_commands() {
cmd()
.args(["auth", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("login"))
.stdout(predicate::str::contains("logout"))
.stdout(predicate::str::contains("status"))
.stdout(predicate::str::contains("whoami"));
}
#[test]
fn invalid_timeout_is_rejected() {
cmd()
.args(["wait", JOB_ID, "--timeout", "0"])
.assert()
.failure()
.stderr(predicate::str::contains("timeout"));
}
#[tokio::test]
async fn json_global_flag_is_accepted_before_command() {
let api = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(format!("/v1/assets/{JOB_ID}")))
.respond_with(ResponseTemplate::new(204))
.mount(&api)
.await;
run_ok(&api, &["--json", "assets", "delete", JOB_ID])
.stdout(predicate::str::contains("deleted"));
}
#[test]
fn image_requires_prompt() {
cmd()
.args(["gen", "image"])
.assert()
.failure()
.stderr(predicate::str::contains("prompt"));
}
#[test]
fn video_accepts_input_flag() {
cmd()
.args([
"gen",
"video",
"--prompt",
"x",
"--input",
"seed.png",
"--no-wait",
"--api-url",
"http://127.0.0.1:9",
])
.assert()
.failure();
}
#[test]
fn audio_accepts_format_flag() {
cmd()
.args([
"gen",
"audio",
"--prompt",
"x",
"--format",
"wav",
"--api-url",
"http://127.0.0.1:9",
])
.assert()
.failure();
}
#[test]
fn status_requires_uuid() {
cmd()
.args(["status", "not-a-uuid"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid value"));
}
#[test]
fn assets_list_accepts_filters() {
cmd()
.args([
"assets",
"list",
"--limit",
"1",
"--modality",
"image",
"--api-url",
"http://127.0.0.1:9",
])
.assert()
.failure();
}
#[test]
fn billing_portal_accepts_return_url() {
cmd()
.args([
"billing",
"portal",
"--return-url",
"https://nolgia.ai",
"--api-url",
"http://127.0.0.1:9",
])
.assert()
.failure();
}
#[test]
fn account_help_lists_subcommands() {
cmd()
.args(["account", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("me"))
.stdout(predicate::str::contains("usage"));
}
#[tokio::test]
async fn skill_list_shows_marketplace_catalog() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/skills"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([skill_json("public", true)])))
.mount(&api)
.await;
run_ok(&api, &["skill", "list"])
.stdout(predicate::str::contains("nolgia-cli-basics"))
.stdout(predicate::str::contains("v1.0.0"));
}
#[tokio::test]
async fn skill_list_marks_private_skills() {
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/skills"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!([skill_json("private", true)])),
)
.mount(&api)
.await;
run_ok(&api, &["skill", "list"]).stdout(predicate::str::contains("[private]"));
}
#[tokio::test]
async fn skill_install_reports_pod_delivery() {
let api = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/agent/skills/nolgia-cli-basics"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"slug": "nolgia-cli-basics", "name": "NOLGIA CLI Basics", "description": "d",
"latest_version": "1.0.0", "installed_at": "2026-06-13T00:00:00Z"
})))
.mount(&api)
.await;
run_ok(&api, &["skill", "install", "nolgia-cli-basics"]).stdout(predicate::str::contains(
"installed nolgia-cli-basics v1.0.0",
));
}
#[tokio::test]
async fn skill_sync_materializes_installed_skills() {
use base64::Engine as _;
let targz = {
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut builder = tar::Builder::new(encoder);
let body = b"---\nname: nolgia-cli-basics\n---\n";
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, "SKILL.md", &body[..])
.unwrap();
builder.into_inner().unwrap().finish().unwrap()
};
let api = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/agent/skills"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([{
"slug": "nolgia-cli-basics", "name": "NOLGIA CLI Basics", "description": "d",
"latest_version": "1.0.0", "installed_at": "2026-06-13T00:00:00Z"
}])))
.mount(&api)
.await;
Mock::given(method("GET"))
.and(path("/v1/skills/nolgia-cli-basics/content"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"slug": "nolgia-cli-basics", "version": "1.0.0", "manifest": {},
"content_base64": base64::engine::general_purpose::STANDARD.encode(&targz)
})))
.mount(&api)
.await;
let dir = tempfile::tempdir().unwrap();
run_ok(
&api,
&["skill", "sync", "--dir", dir.path().to_str().unwrap()],
)
.stdout(predicate::str::contains(
"synced nolgia-cli-basics v1.0.0",
));
assert!(dir.path().join("nolgia-cli-basics/SKILL.md").is_file());
assert!(
dir.path()
.join("nolgia-cli-basics/.nolgia-skill.json")
.is_file()
);
run_ok(
&api,
&["skill", "sync", "--dir", dir.path().to_str().unwrap()],
)
.stdout(predicate::str::contains(
"current nolgia-cli-basics v1.0.0",
));
}
#[tokio::test]
async fn skill_publish_sends_manifest_and_content() {
let pkg = tempfile::tempdir().unwrap();
std::fs::write(
pkg.path().join("skill.json"),
json!({
"slug": "nolgia-cli-basics", "name": "NOLGIA CLI Basics", "version": "1.0.0",
"description": "CLI basics", "required_env": ["NOLGIA_TOKEN"],
"min_tier": "", "visibility": "public", "credit_cost_hint": "free"
})
.to_string(),
)
.unwrap();
std::fs::write(
pkg.path().join("SKILL.md"),
"---\nname: nolgia-cli-basics\n---\n",
)
.unwrap();
let api = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/skills"))
.respond_with(ResponseTemplate::new(201).set_body_json(skill_json("public", true)))
.mount(&api)
.await;
run_ok(&api, &["skill", "publish", pkg.path().to_str().unwrap()]).stdout(
predicate::str::contains("published nolgia-cli-basics v1.0.0 (public, min_tier: free)"),
);
}
fn skill_json(visibility: &str, entitled: bool) -> serde_json::Value {
json!({
"slug": "nolgia-cli-basics", "name": "NOLGIA CLI Basics",
"description": "Drive the platform with the nolgia CLI", "required_env": ["NOLGIA_TOKEN"],
"credit_cost_hint": "free", "min_tier": "", "visibility": visibility, "entitled": entitled,
"latest_version": "1.0.0",
"created_at": "2026-06-13T00:00:00Z", "updated_at": "2026-06-13T00:00:00Z"
})
}
fn cmd() -> Command {
let mut command = Command::cargo_bin("nolgia").unwrap();
command.env_remove("NOLGIA_TOKEN");
command
}
fn run_ok(api: &MockServer, args: &[&str]) -> assert_cmd::assert::Assert {
cmd()
.arg("--api-url")
.arg(api.uri())
.args(args)
.assert()
.success()
}
fn asset_json(url: &str) -> serde_json::Value {
json!({
"id": Uuid::new_v4(), "user_id": USER_ID, "modality": "image", "model": "fal-ai/flux-pro/v1.1",
"signed_url": url, "expires_at": "2026-06-13T00:00:00Z", "created_at": "2026-06-13T00:00:00Z"
})
}
fn character_json() -> serde_json::Value {
json!({
"id": CHARACTER_ID, "user_id": USER_ID, "name": "Captain Nova",
"description": "Silver-haired astronaut",
"reference_assets": [asset_json("https://files/ref.png")],
"created_at": "2026-06-13T00:00:00Z", "updated_at": "2026-06-13T00:00:00Z"
})
}
fn project_json() -> serde_json::Value {
json!({
"id": PROJECT_ID, "user_id": USER_ID, "name": "Launch teaser",
"description": "Spring launch assets", "asset_count": 3,
"created_at": "2026-06-13T00:00:00Z", "updated_at": "2026-06-13T00:00:00Z"
})
}
fn job_json(status: &str, files_base: Option<&str>) -> serde_json::Value {
json!({
"id": JOB_ID, "user_id": USER_ID, "modality": "video", "model": "fal-ai/kling-video/v3/text-to-video",
"status": status, "asset": files_base.map(|base| asset_json(&format!("{base}/video.mp4"))),
"created_at": "2026-06-13T00:00:00Z", "updated_at": "2026-06-13T00:00:00Z"
})
}
fn credit_balance_json() -> serde_json::Value {
json!({
"user_id": USER_ID, "app_subscription": 546631, "shared_topup": 250, "total": 546881,
"available_for_app": 546881, "available_for_api": 250,
"buckets": [
{"wallet_id": Uuid::new_v4(), "type": "app_subscription", "balance": 546631, "expires_at": "2026-08-01T00:00:00Z"},
{"wallet_id": Uuid::new_v4(), "type": "shared_topup", "balance": 250, "expires_at": null}
]
})
}
fn pat_json() -> serde_json::Value {
json!({
"id": PAT_ID, "name": "ci-bot", "prefix": "nol_a1b2",
"created_at": "2026-06-13T00:00:00Z", "last_used_at": null, "revoked_at": null
})
}
fn user_json() -> serde_json::Value {
json!({"id": USER_ID, "email": "ada@nolgia.ai", "name": "Ada", "image_url": null, "created_at": "2026-06-13T00:00:00Z"})
}