Skip to content
Open
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
255 changes: 253 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

38 changes: 33 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ README. For the end-to-end walkthrough, see
| `civitai app init [name] [dir] [...]` | Same scaffolder as `create` with a no-build `static` default (back-compat alias). |
| `civitai app dev-token <slug> [--env] [--spend] [--budget <n>]` | **Mint a short-lived (~4h) dev block token for `npm run dev:live`.** `--spend` must be asked for explicitly to request real-Buzz spend — without it the CLI filters `ai:write:budgeted` out of the mint request. `--env` prints a paste-ready `VITE_LIVE_BLOCK_TOKEN=<token>`. See [Local dev loop](#local-dev-loop-harness-mock-vs-live). |
| `civitai app dev-tunnel [blockId] [--port] [--tunnel-endpoint] [--idle-timeout]` | **(Pre-GA / invite-gated)** Preview your **local** dev server inside the **real** Civitai host at `civitai.com/apps/dev/<blockId>` — a prod-fidelity inner-dev-loop. Pre-flights whether the host can actually **embed** your dev server and warns (never fatally) when it cannot. See [Preview in the real host](#preview-in-the-real-host-app-dev-tunnel). |
| `civitai app validate [dir] [--strict] [--json]` | Best-effort local pre-check of `block.manifest.json`; emits non-fatal warnings (`--strict` fails on them). `--json` emits the structured result (`ok`, plus `errors`/`warnings` each with `field`/`message` — **`field` is always present and never `null`**) for scriptable parsing — still exits non-zero on failure. See [Validate fidelity](#validate-fidelity) and [The `--json` result shape](#the---json-result-shape). |
| `civitai app validate [dir] [--strict] [--json]` | Best-effort local pre-check of `block.manifest.json`; emits non-fatal warnings (`--strict` fails on them). `--json` emits the structured result (`ok`, plus `errors`/`warnings` each with `field`/`message` — **`field` is always present and never `null`**) for scriptable parsing — still exits non-zero on failure. 🔴 **BREAKING:** a `[dir]` that does not exist, or is not a directory, is now a **usage error** — exit `2` with **no JSON object** on stdout, where it used to print `{"ok": false, …}` and exit `1`. See [Validate fidelity](#validate-fidelity) and [The `--json` result shape](#the---json-result-shape). |
| `civitai app submit [dir] [--package-only] [--out f.zip] [--skip-validate]` | Validate + package the source tree + upload it with your stored token (or, with no token, write the bundle + print next steps). |
| `civitai app pull [dir] --app <slug\|appBlockId>` | **Clone (or sync) the canonical git repository behind one of your approved Apps** — the read side of git authoring. ⚠ The clone URL embeds your access token, and a fresh clone persists it into `.git/config`. See [Pull your app's repository](#pull-your-apps-repository-app-pull). |
| `civitai app listing status\|set-icon <file>\|set-cover <file>\|add-screenshot <file>\|rm-screenshot <id>\|reorder <id...>` | **Attach the store-listing media your App needs before it can be published** — an **icon and a cover are mandatory** (screenshots are optional, up to 8). `listing status` prints what is attached vs. what the publish floor still requires. See [After you submit](#after-you-submit-review--approve--deploy). |
Expand Down Expand Up @@ -902,8 +902,36 @@ omitting the key:

`ok` already accounts for `--strict`: it is `false` when there are hard errors,
and also when `--strict` is passed and there are warnings. The process exit code
matches, and the JSON still goes to **stdout** while the failure is reported on
**stderr** — so `civitai app validate --json | jq` works on a failing project.
matches, and the JSON goes to **stdout** while the failure is reported on
**stderr** — so `civitai app validate --json | jq` works on a project that fails
*validation*.

🔴 **BREAKING — a refused path now emits no object at all.** This object is
written only when validation actually produced a result. A path that does **not
exist**, or that is not a directory, is a mistake about the invocation: it writes
**nothing** to stdout and exits `2`. It used to print
`{"ok": false, "dir": "/nope", "errors": [ … ]}` and exit `1` — a fabricated
validation result, complete with a finding about a manifest nobody could have
written. A failure that produces **no validation result at all** likewise emits
no object and exits `1` — an unreadable manifest or a permissions error, and in
principle an internal schema failure, which is a directory the CLI *can* read
that still yields nothing to print.

**So branch on the exit code before parsing:**

| exit | stdout |
| --- | --- |
| `0` | the object, `"ok": true` |
| `1` | the object with `"ok": false` for a validation **verdict** — but **nothing** when validation produced no result at all (an unreadable manifest; also an internal schema failure, which a released binary should never hit) |
| `2` | **nothing** — the path does not exist, or is not a directory |

```bash
out=$(civitai app validate ./my-block --json); rc=$?
case $rc in
2) echo "bad path — check the argument"; exit 2 ;;
0|1) [ -n "$out" ] && jq -e .ok <<<"$out" || echo "no result to parse (rc=$rc)" ;;
esac
```

## Submit & auth

Expand Down Expand Up @@ -1724,8 +1752,8 @@ by this — only `echo $?` differs.
| Code | Meaning |
| --- | --- |
| `0` | Success. |
| `1` | Generic / unclassified error. A **filesystem failure** lands here — a file that exists but cannot be read, an unwritable config directory, an I/O error. It is neither a mistake about the invocation (`2`) nor a transport failure (`5`), and there is no filesystem-specific code. A resource that **exists but is not ready** lands here too, and deliberately not on `4`: `civitai app metrics <slug>` for an app whose submitted version is still in review exits `1`, because the slug is right and the app does exist — only its analytics do not exist yet, and the error names `civitai app status <slug>` as the next command. `4` stays reserved for a slug with no submissions at all, so the two remain separately actionable: fix the slug, versus wait for approval. |
| `2` | Usage error — a bad flag, a **missing required flag or argument** (e.g. `civitai app withdraw` with no publish-request id), a bad flag **value** (`--limit` out of range, a non-integer id, `--template nope`), or a request the API rejected as malformed (HTTP 400, e.g. a bad `--period`/`--sort` enum). This does not depend on where the refusal happens: a mistake the CLI catches locally and one the server rejects both exit `2`. A local image the CLI refuses before uploading anything (`civitai app listing set-icon <file>`, `civitai generate --image`) exits `2` when the file is missing, empty, a directory, over the size cap, or not a PNG/JPEG/WebP — but a file that exists and cannot be **read** (permissions, an I/O error) is a filesystem failure rather than a mistake about the invocation, and exits `1`, not `2`. That split is the rule for **every local path a flag names**, not just images: `civitai generate --input <file>` likewise exits `2` for a path that is not there or is a directory, and `1` when the file is there and the read fails. `app listing set-cover` and `app listing add-screenshot` take the same positional `<file>` and refuse it the same way. (The CLI has no `--file` image flag at all: the only `--file` is `civitai download --file`, which picks a file *inside* a model version.) |
| `1` | Generic / unclassified error. A **filesystem failure** lands here — a file that exists but cannot be read, an unwritable config directory, an I/O error. It is neither a mistake about the invocation (`2`) nor a transport failure (`5`), and there is no filesystem-specific code. A **validation verdict** lands here, and deliberately not on `2`: `civitai app validate` exits `1` when the manifest is invalid, and likewise when the directory you named is a real directory with no `block.manifest.json` at its root — you pointed at a real place, so the invocation was right and the project is wrong. (A path that does **not exist**, or that is not a directory, is the invocation being wrong, and exits `2`.) **When validation produces a result**, `civitai app validate --json` prints it in full and its `ok` field is the structured form of the same answer; a failure that produces no result at all — an unreadable manifest, say — still exits `1` with **nothing on stdout**, so branch on the exit code before parsing. A resource that **exists but is not ready** lands here too, and deliberately not on `4`: `civitai app metrics <slug>` for an app whose submitted version is still in review exits `1`, because the slug is right and the app does exist — only its analytics do not exist yet, and the error names `civitai app status <slug>` as the next command. `4` stays reserved for a slug with no submissions at all, so the two remain separately actionable: fix the slug, versus wait for approval. |
| `2` | Usage error — a bad flag, a **missing required flag or argument** (e.g. `civitai app withdraw` with no publish-request id), a bad flag **value** (`--limit` out of range, a non-integer id, `--template nope`), or a request the API rejected as malformed (HTTP 400, e.g. a bad `--period`/`--sort` enum). This does not depend on where the refusal happens: a mistake the CLI catches locally and one the server rejects both exit `2`. A local image the CLI refuses before uploading anything (`civitai app listing set-icon <file>`, `civitai generate --image`) exits `2` when the file is missing, empty, a directory, over the size cap, or not a PNG/JPEG/WebP — but a file that exists and cannot be **read** (permissions, an I/O error) is a filesystem failure rather than a mistake about the invocation, and exits `1`, not `2`. That split is not images-only and it is not flags-only — it holds for **a flag's value and a positional argument alike**, over the paths listed here: `civitai generate --input <file>` likewise exits `2` for a path that is not there or is a directory, and `1` when the file is there and the read fails. The project commands take a positional path and refuse it the same way: `civitai app validate <dir>` and `civitai app submit <dir>` exit `2` when the path does not exist **or is not a directory**, because both are mistakes about the invocation. A directory that **does** exist but holds no `block.manifest.json` is a validation verdict instead, and exits `1`. `app listing set-cover` and `app listing add-screenshot` take the same positional `<file>` and refuse it the same way. (The CLI has no `--file` image flag at all: the only `--file` is `civitai download --file`, which picks a file *inside* a model version.) **Paths outside that list are not covered, and mostly exit `1`.** `civitai app listing … --dir <missing>` exits `1` (it reports "no `block.manifest.json` found in …", the same way it does for a directory that is really there but holds no manifest), and so does `civitai app submit … --out <path under a directory that does not exist>`. Both are stated rather than promised: this is a ledger of the paths the split is published for, not a claim about every path in the CLI. A usage error emits **no JSON object**, in every mode. `civitai app validate /nope --json` therefore writes nothing to stdout and exits `2`; it used to print `{"ok": false, …}` and exit `1`, which reported a nonexistent path as a validation result. Scripts that parsed that object must branch on the exit code first. |
| `3` | Authentication/authorization — login required, token invalid/expired, or the credential lacks the needed scope (HTTP 401/403, or no token configured). **`civitai generate` refines this**: several of its failures are *not* credential problems but would otherwise land here or on `2`, so they exit `1` instead and a script never loops on `civitai login`. A **muted account or incomplete onboarding** arrives as a bare `403` that is byte-identical to a missing scope; **out of Buzz** and **generation disabled** arrive as `400` (the upstream 403 is re-thrown server-side as a tRPC `BAD_REQUEST`), which would otherwise read as "bad flags". See [Generate](#exit-codes-specific-to-generate). |
| `4` | Not found — the requested resource does not exist. Usually an HTTP 404, but not always: some lookups answer `200` with an empty result set instead (`civitai app status <slug>` for an unregistered slug, `civitai users get` for an unknown username), and those exit `4` too. The same question therefore exits the same way however the API happens to phrase the miss. |
| `5` | Network/transport failure or service unavailable — dial/timeout, or HTTP 502/503/504 after retries. This is the code to **retry** on, so a **filesystem** failure never lands here however retryable its errno looks: a permissions or I/O problem does not fix itself, and a loop that sleeps and re-runs would never terminate. Those exit `1`. |
Expand Down
9 changes: 8 additions & 1 deletion internal/cmd/app_listing.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,21 @@ func newListingClient() (*appapi.Client, error) {
return appapi.NewWithSource(cfg.BaseURL(), auth.New(cfg), ""), nil
}

// listingSlugResolveFailure is the prefix resolveListingSlug puts on a manifest
// failure. It is a named constant so a test can assert POSITIVELY that a row
// actually REACHED this function, rather than merely that it did not fail at one
// specific earlier gate — see TestUngatedPathFlagsAreNotUsageErrors, and AGENTS
// item 25 for why a denylist-of-one premise let the defect regenerate twice.
const listingSlugResolveFailure = "could not resolve the app — run this from your app directory (with block.manifest.json) or pass --slug"

// resolveListingSlug resolves the app slug from --slug or the manifest.
func resolveListingSlug(lc listingCommon) (string, error) {
if lc.slug != "" {
return lc.slug, nil
}
m, err := manifest.Load(lc.dir)
if err != nil {
return "", fmt.Errorf("could not resolve the app — run this from your app directory (with block.manifest.json) or pass --slug: %w", err)
return "", fmt.Errorf(listingSlugResolveFailure+": %w", err)
}
if m.BlockID == "" {
// The remedy is a flag, so this is a usage error (exit 2). The sibling
Expand Down
13 changes: 13 additions & 0 deletions internal/cmd/app_submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ Defaults to the current directory.`,
}
out := cmd.OutOrStdout()

// 0. Classify the path the USER named. Same gate `app validate`
// uses — one rule, one place (resolveProjectDir, project_dir.go):
// a nonexistent path or a file exits 2, a real directory with no
// manifest keeps its validation verdict and exit 1.
//
// It runs UNCONDITIONALLY, ahead of --skip-validate, because it is
// not a validation check: `--skip-validate` waives our opinion of
// the manifest, not the question of whether the directory the user
// typed exists at all.
if err := resolveProjectDir(dir); err != nil {
return err
}

// 1. Validate first — never submit a known-bad manifest.
if !skipValidate {
res, err := validate.Dir(dir)
Expand Down
16 changes: 16 additions & 0 deletions internal/cmd/app_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ Defaults to the current directory.`,
out := cmd.OutOrStdout()
errw := cmd.ErrOrStderr()

// Classify the path the USER named before validating anything.
// A path that is not there, or that is not a directory, is a
// mistake about the invocation (exit 2) — not a validation
// verdict. See resolveProjectDir (project_dir.go) for why the
// branch is here and not in internal/validate.
//
// 🔴 It runs BEFORE the --json block on purpose, and that is a
// deliberate wire break: `app validate /nope --json` used to print
// {"ok":false,"errors":[…]} and exit 1. It now prints NOTHING on
// stdout and exits 2, because a path that does not exist produced
// no validation result to report. That matches the CLI-wide
// convention that a usage error emits no JSON object.
if err := resolveProjectDir(dir); err != nil {
return err
}

res, err := validate.Dir(dir)
if err != nil {
return err
Expand Down
58 changes: 52 additions & 6 deletions internal/cmd/exitcodes_claims_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,58 @@ func exitCodeContractClaims() []contractClaim {
},
{
code: 2,
name: "the missing-vs-unreadable split covers every local path flag, not just images",
phrases: []string{"every local path a flag names", "generate --input"},
why: "stated generally because it was NOT general: --input was the counterexample, and a " +
"rule written only about images is one a future path flag can be added beside without " +
"anyone noticing it disagrees",
pinnedBy: "TestGenerateInputExitCodes (cmd/civitai) + TestReadGraphInputClassification",
name: "the missing-vs-unreadable split holds for a flag's value and a positional alike",
phrases: []string{"a flag's value and a positional argument alike", "generate --input"},
why: "the shape of the rule, not its extent: a rule written only about images is one a future " +
"path flag can be added beside without anyone noticing it disagrees, and --input was that " +
"counterexample. It said \"every local path a FLAG names\" for a release, and the two " +
"commands that broke it — `app validate <dir>` / `app submit <dir>`, issue #256 — take the " +
"path POSITIONALLY, so the sentence excluded exactly the cases that disagreed with it. " +
"🔴 The replacement then over-corrected to \"every local path the CLI is HANDED\", which is " +
"ALSO false and has a live counterexample INSIDE its own scope: `app listing --dir <missing>` " +
"exits 1, measured identical on base and on this branch. So the sentence now publishes the " +
"shape over an enumerated ledger and states the residual, rather than quantifying over " +
"paths nobody has audited",
pinnedBy: "TestGenerateInputExitCodes (cmd/civitai) + TestReadGraphInputClassification + " +
"TestProjectDirExitCodes + TestUngatedPathFlagsAreNotUsageErrors (the residual)",
},
{
code: 2,
name: "a project path that does not exist, or is not a directory, is 2",
phrases: []string{"app validate <dir>", "app submit <dir>", "or is not a directory"},
why: "issue #256: `app validate /nope` reported the missing path as \"a project root without a " +
"manifest\" and exited 1, so a script could not tell a typo'd path from an app that " +
"genuinely fails validation — the one distinction the exit-code contract exists to draw",
pinnedBy: "TestProjectDirExitCodes + TestResolveProjectDirClassification",
},
{
code: 1,
name: "a validation VERDICT is 1, and a manifest-less directory is a verdict",
phrases: []string{"validation verdict", "app validate", "no `block.manifest.json` at its root",
"app validate --json"},
why: "the counterweight to the #256 fix: it would be easy to \"tidy\" the manifest-less " +
"directory onto 2 alongside the nonexistent path. It must stay 1 — the user pointed at a " +
"real place, so the invocation was right and the project is wrong, and that is the " +
"answer `--json`'s `ok` field reports",
pinnedBy: "TestProjectDirExitCodes (the control rows)",
},
{
code: 1,
name: "`app validate --json` publishes a result only when it produced one",
phrases: []string{"When validation produces a result", "nothing on stdout",
"branch on the exit code before parsing"},
why: "the note said `--json` \"prints the full result … so a script never has to read stderr\", " +
"full stop — and that is false for the failures that produce no Result at all. Measured on " +
"this branch: `app validate <mode-000 project> --json` exits 1 with stdout EMPTY, because " +
"validate.Dir returns an error rather than a Result for a non-ENOENT stat failure and for a " +
"schema() failure. An unqualified promise here is worse than silence: it tells a script " +
"author they may parse stdout unconditionally, on the one command whose whole job is to be " +
"machine-read. 🔴 The first scoping was ALSO wrong, in the other direction: it read \"for a " +
"project directory it could READ\", which still promises an object for the schema() arm — a " +
"directory the CLI can read perfectly well that yields no Result. The condition is whether " +
"validation PRODUCED a result, which is the thing the code actually branches on, so that is " +
"what the sentence now says",
pinnedBy: "TestValidateJSONOnlyEmitsAResultItActuallyProduced (+ its readable-dir positive control)",
},
{
code: 5,
Expand Down
Loading
Loading