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
110 changes: 109 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ EXISTING app is missing the item-11 handshake (20 is the reachability repair to
`field` every `--json` consumer groups on; and item 24 covers the ONE
transport-vs-filesystem predicate now shared by the CLI-wide exit-code
classifier (which every command's published exit code funnels through) and
`pkg/civitai`'s read-GET retry loop.
`pkg/civitai`'s read-GET retry loop; and item 25 covers blockId derivation —
the one identity this CLI mints that can NEVER be renamed, and the residuals the
refusal knowingly ships with.
The durable fix for the mirroring is a server-side
`civitai app validate` endpoint that calls the real `BlockManifestValidator` —
until that exists, vendoring is on purpose.
Expand Down Expand Up @@ -1865,6 +1867,112 @@ neither one's.
full type resolution because `golang.org/x/tools/go/packages` would be a
new dependency, which is an "ask first" below.

25. **The blockId derivation REFUSES rather than transliterates, and the
exemption that makes refusing safe is "LOWERCASES INTO ASCII" — never a
character allowlist.** `scaffold.Slugify` used to lowercase the name and
replace every run of non-`[a-z0-9]` with a hyphen, which silently DROPPED
content: `"Café App"` minted the blockId `caf-app` and `"ÜberApp Ω"` minted
`berapp` (measured), at exit 0, for an identity that **cannot be renamed
afterwards** — it is the hostname the app is served at and the argument every
later command takes. It now refuses and names the offending characters, with
`--slug` as the escape hatch (#259).
- 🔴 **THE EXEMPTION IS THE LOAD-BEARING PART, AND IT IS A PREDICATE ON THE
LOWERED RUNE, NOT A LIST.** `isLossyBase` asks `unicode.ToLower(r) <
utf8.RuneSelf` and, above that, whether the lowered rune is a
space/punct/symbol. Because lowercasing is what derivation does first, the
exemption re-decides NOTHING: every ASCII derivation this CLI has ever
produced is byte-identical, including the two dead ends whose existing
messages are good (`"123 Numbers"`, `"!!!"`). **Do not "improve" it into an
allowlist of permitted characters.** An allowlist is a second, hand-
maintained mirror of the slug alphabet that re-opens every one of those
derivations to a typo, and it buys nothing the boundary does not already
give. The `<` was a surviving mutant (only U+0080 changes hands, a C1
control nobody types) and is now pinned from both sides precisely because
an unpinned boundary is a claim nothing holds.
- 🔴 **IT NARROWS #259; IT DOES NOT CLOSE IT — and the first write-up said
otherwise.** An audit measured three classes of input still producing the
exact `#259` shape after the refusal shipped. State the residuals with the
claim, or the next reader inherits a closure that was never delivered:
- **(a) INVALID UTF-8 — now CLOSED.** `app create $'caf\xe9 app'` derived
`caf-app` at rc 0, because `for _, r := range` yields U+FFFD per bad byte
and U+FFFD is a Symbol, i.e. the SEPARATOR branch. The written
`block.manifest.json` also held the raw `0xE9` and was not valid UTF-8,
and `validate.ManifestOnly` accepted it. `Slugify` now gates on
`utf8.ValidString` BEFORE anything ranges over the string, and
`runAppScaffold` refuses an invalid-UTF-8 DISPLAY name too — that second
guard is not redundant, because `--slug` bypasses derivation entirely and
the name still reaches the manifest verbatim. `internal/validate` still
has no UTF-8 check of its own; that gap is real and is not this item's.
- **(b) THE TWO RUNES ABOVE ASCII THAT LOWER INTO ASCII — a deliberate,
ENUMERATED exception.** All of Unicode was walked: exactly two exist,
`İ` U+0130 → `i` and `K` U+212A KELVIN SIGN → `k`. So `"İstanbul App"`
derives `istanbul-app` at rc 0. That is the nicest transliteration
available and it costs nothing, so it STAYS — but it is why any absolute
claim that every non-ASCII letter is refused is false, and why the
exemption is spelled "lowers into ASCII" rather than "is ASCII".
`TestSlugifyLowersIntoAsciiIsADocumentedException` re-walks Unicode and
fails if a future Go table grows a third.
- **(c) SYMBOLS, EMOJI AND NON-ASCII PUNCTUATION fold to a hyphen**, so
`"Rocket 🚀 App"` derives `rocket-app` at rc 0. Census over printable
non-ASCII runes: **8,580** take the separator branch, **140,321** the
refuse branch. This is the asymmetry working as designed for `—` and `»`,
and arguably not what an author means by an emoji — but an emoji has no
lossless ASCII form either, so refusing would trade a silent drop for a
dead end. **The product decision is made and the behaviour stays:
civitai/cli#272.** Do not re-argue it in a doc comment; point at the
issue.
- 🔴 **A COMBINING MARK IS REPORTED WITH ITS BASE, because the same VISIBLE
name arrives as two different byte sequences.** macOS paths and some paste
routes deliver NFD, so `"Café App"` arrives as `e` + U+0301 and the first
message read `"́" cannot appear in a blockId` — an accent rendered over
nothing, while the NFC form of the same name said `"é"`. `LossyChars`
clusters base+marks, so both forms now report `"é"`; a mark with no base at
all is shown on a dotted circle (U+25CC). This changed only the RENDERING —
a mark is non-ASCII and is neither space, punct nor symbol, so it was
already refused, and the set of refused names is unchanged.
- 🔴 **CLASSIFY ON THE LOWERED RUNE, REPORT THE ORIGINAL. The first version
did both on the lowered one and quoted characters the user never typed.**
Measured: `"ẞE App"` reported `"ß"` — a rune ABSENT from the input, because
U+1E9E lowers to U+00DF — and `"ABC"` reported `"a", "b", "c"`. Someone
searching their own name for the quoted character finds nothing, which is
worse than a generic message. The two axes are independent and the code
keeps them apart; `TestSlugifyReportsTheCharacterTheAuthorTyped` pins it
with rows whose lowered form is provably not in the input.
- 🔴 **`--slug` SUPPRESSES THE NAME FIELD, NOT THE PROMPT — and getting that
wrong is a silent capability loss, not a cosmetic one.** `--slug` was first
wired into the `stdinIsTTY` guard on the reasoning that it "supplies the
one thing the prompt exists to collect". `runScaffoldForm` collects a name
AND a TEMPLATE, so `civitai app create --slug my-app` on a TTY silently
took page-money with no template choice — a question the user was asked
before the flag existed. The mutant deleting `slugFlag == ""` from that
guard survived with **zero** failures, because nothing covered the
suppression at all. Whenever a flag is made to skip a prompt, enumerate
what ELSE that prompt collects.
- 🔴 **THE ECHOED URL IS FUTURE TENSE, and that is not style.** The scaffold
echoes the blockId always (it was a DEAD PARAMETER of
`printScaffoldResult` — no line of output named the app's permanent id, and
with `--dir` the only copy was inside `block.manifest.json`). But the first
version printed the bare `https://<blockId>.civit.ai/` as the "permanent
public id" at scaffold time — a URL **guaranteed to 404 at that exact
moment**, since the subdomain is only programmed on approval + deploy (the
README says so, and `app status` already says "Not live yet — … only serves
after the app is approved and deployed"). That is the same false-promise
class as the "validates clean" claim two lines up in the SAME output block.
Keep the two surfaces on the same words.
- 🔴 **A `mustNotProduce` ROW THAT CANNOT RUN IS NOT COVERAGE, AND A GREEN
SUITE CANNOT TELL YOU.** The refusal test claimed each row "pins the exact
pre-fix output it must not produce (`berapp`, `caf-del-mar`) rather than
merely 'an error came back'". The block sat AFTER a `t.Fatalf` that had
already aborted the subtest whenever `err == nil`, so its `e == nil`
condition never held. Measured: deleting the whole block left
`internal/scaffold` green while a positive control reddened 1 — the harness
could go red; the assertion simply never executed. It now lives INSIDE the
`err == nil` branch, and each row's expected pre-fix string is verified
against `legacySlugify`, a copy of the pre-refusal derivation, so a row
cannot name an output the old code never emitted. **When a test's headline
claim is "it pins the exact old value", check that the line can be
reached.**

**When you change a validation rule, keep all four vendored mirrors in sync with
the server — `schema/`, the ported Go checks in `internal/validate/` (including
the slot registry), the Vite dotenv resolution behind the dev-tunnel parent-origin
Expand Down
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ contract, and **packages/submits** it for review.
- [Quickstart: build an App Block](#quickstart-build-an-app-block)
- [SDK packages](#sdk-packages)
- [Command reference](#command-reference)
- [The blockId](#the-blockid)
- [Templates](#templates)
- [The host handshake (`BLOCK_READY`)](#the-host-handshake-block_ready)
- [Local dev loop (harness: mock vs live)](#local-dev-loop-harness-mock-vs-live)
Expand Down Expand Up @@ -273,7 +274,7 @@ README. For the end-to-end walkthrough, see
| `civitai buzz [--json]` | Show your spendable Buzz balance (**blue / green / yellow**, plus a **total**). Needs the BuzzRead scope — a full-scope personal API key or `civitai login --scopes generate`; a **default** OAuth login token can't read it, and gets a clear message naming both fixes. `--json` emits `{blue,green,yellow,total}` (scriptable — handy for before/after diffing a `dev:live` spend). |
| `civitai app list [--kind <k>] [--category <c>] [--sort <s>] [--limit <n>] [--cursor <c>] [--json]` | **Discover published Apps in the store** (`GET /api/v1/apps`) — filter-based discovery, not free-text search. **Needs a credential** (`civitai login` or `CIVITAI_TOKEN`): the endpoint keys the visible catalog off your identity, so this is *not* one of the anonymous reads. Cursor-paged. See [Browse the App store](#browse-the-app-store). |
| `civitai app view <slug> [--json]` | **Show one published App's store detail** (`GET /api/v1/apps/{slug}`) — description, category, rating, gallery, live/external target. **Needs a credential**, same as `app list`. Reads the *public store catalog*, which is a different resource from your own deploy — a not-found here says nothing about `<slug>.civit.ai`. See [Browse the App store](#browse-the-app-store). |
| `civitai app create [name] [dir] [--template static\|page-vite\|page-money] [--dir <path>] [--name <display>]` | **The friendly happy path.** Scaffold a ready-to-build App, defaulting to the batteries-included `page-money` SDK template (default dir `./<slug>`). |
| `civitai app create [name] [dir] [--template static\|page-vite\|page-money] [--dir <path>] [--name <display>] [--slug <slug>]` | **The friendly happy path.** Scaffold a ready-to-build App, defaulting to the batteries-included `page-money` SDK template (default dir `./<slug>`). `--slug` sets the **blockId** explicitly instead of deriving it from the name — needed when derivation refuses the name (see [The blockId](#the-blockid)). |
| `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). |
Expand All @@ -294,6 +295,40 @@ README. For the end-to-end walkthrough, see
Run `civitai help`, `civitai app --help`, or `civitai <command> --help` for the
full details and examples.

### The blockId

The **blockId** is your app's permanent public identity: the hostname it will be
served at once approved (`https://<blockId>.civit.ai/`) and the argument every
later command takes (`app status`, `app metrics`, `app listing`, `app dev-token`,
`app dev-tunnel`). **It cannot be renamed afterwards.** `app create` / `app init`
echo the one they chose, so it is on screen before you commit anything.

By default it is derived from the name: `"My Cool Block"` → `my-cool-block`.
Pass **`--slug <slug>`** to choose it yourself — it bypasses derivation entirely,
so name, blockId and directory are three fully independent axes.

> **Breaking change.** Derivation used to lowercase the name and replace every
> run of non-`[a-z0-9]` with a hyphen, which silently **dropped characters**:
> `civitai app create "Café App"` minted the blockId **`caf-app`**, and
> `"ÜberApp"` minted **`berapp`** — a different permanent public id than the
> author typed, with no warning and exit 0. Derivation now **refuses** and names
> the offending characters, exiting **2** and asking for `--slug`. **If you have
> a script passing a non-ASCII name, it must now pass `--slug <slug>`.** The old
> output was wrong, so the break is the point — but it is a break.

What derivation refuses is **letters, digits and marks** above ASCII that the
slug alphabet cannot carry. Three things still derive rather than refuse, and
they are deliberate:

| input | blockId | why |
| --- | --- | --- |
| `"Rocket 🚀 App"` | `rocket-app` | Symbols, emoji and non-ASCII punctuation are **separators** — that is what makes `"Widget — Pro"` → `widget-pro` right. An emoji has no lossless ASCII form either, so refusing would only trade a silent drop for a dead end. Tracked as [#272](https://github.com/civitai/cli/issues/272). |
| `"İstanbul App"` | `istanbul-app` | Exactly two runes above ASCII lowercase **into** ASCII — `İ` (U+0130) and `K` (U+212A). Lowercasing is what decides whether a character survives, so these transliterate for free. |
| `"My Cool___Block"` | `my-cool-block` | ASCII is exempt by construction — every derivation that worked before still produces the byte-identical blockId. |

A name that is **not valid UTF-8** is refused outright (it used to lose the bad
bytes from the blockId *and* write them into `block.manifest.json`).

### Templates

- **`static`** — a no-build page app (`index.html` + a tiny `app.js`,
Expand Down
24 changes: 19 additions & 5 deletions internal/cmd/app_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func newAppCreateCmd() *cobra.Command {
var fromSlug string
var dirFlag string
var nameFlag string
var slugFlag string
var noInput bool

cmd := &cobra.Command{
Expand All @@ -25,8 +26,9 @@ This is the friendly happy path: a thin superset of "civitai app init" that
defaults to the rich page-money template — a Vite + React + TypeScript full-page
app wired to the published App SDK (estimate -> consent -> submit -> poll ->
Buzz spend), with a mock-host dev harness and a unit test. The scaffold is
immediately runnable (npm install && npm run dev:harness), test-green, and
validates clean.
immediately runnable (npm install && npm run dev:harness) and test-green.
"civitai app validate" passes once you have run "npm install" — until then it
correctly reports the package-lock.json the platform build installs from.

The default scaffold ships a runnable txt2img money path AND a Comfy on Civitai
(customComfy) sample that runs a server-registered recipe (invite-only beta) —
Expand All @@ -50,6 +52,14 @@ Templates (override with --template):
The display name can be free-form ("My Cool Block"); it is slugified for the
blockId. A slug-shaped name is used verbatim.

The blockId is your app's PERMANENT public identity — the hostname your app will
be served at once it is approved, and the argument every later command takes — so
derivation refuses rather than guesses when the name carries LETTERS a blockId
cannot hold ("Café Del Mar", "ÜberApp", any non-Latin name). Punctuation, symbols
and emoji still fold to a hyphen, as they always have ("Rocket 🚀 App" ->
rocket-app). Pass --slug <slug> to choose the blockId yourself; it bypasses
derivation entirely.

By default the project is created in ./<slug>. Override the output directory with
a positional [dir] or --dir <path>; override the display name independently with
--name (so name, slug, and directory can all differ).
Expand All @@ -67,19 +77,23 @@ the AI Services scopes: ` + spendCredentialRoutes + `.`,
civitai app create my-block --template static

# Custom output directory (slug stays my-block; created in ./apps/foo).
civitai app create my-block --dir ./apps/foo`,
civitai app create my-block --dir ./apps/foo

# A name derivation cannot slugify: choose the blockId yourself.
civitai app create "Café Del Mar" --slug cafe-del-mar`,
Args: cobra.MaximumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return runAppScaffold(cmd, args, templateFlag, fromSlug, dirFlag, nameFlag, noInput)
return runAppScaffold(cmd, args, templateFlag, fromSlug, dirFlag, nameFlag, slugFlag, noInput)
},
}

// create defaults to the batteries-included page-money template; every
// other flag matches init exactly.
cmd.Flags().StringVarP(&templateFlag, "template", "t", string(scaffold.PageMoney), "project template: static | page-vite | page-money")
cmd.Flags().StringVar(&fromSlug, "from", "", "fork from an existing published app slug (not yet wired)")
cmd.Flags().StringVar(&fromSlug, "from", "", fromFlagUsage)
cmd.Flags().StringVar(&dirFlag, "dir", "", "output directory (default ./<slug>)")
cmd.Flags().StringVar(&nameFlag, "name", "", "display name (default derived from the name argument)")
cmd.Flags().StringVar(&slugFlag, "slug", "", slugFlagUsage)
cmd.Flags().BoolVarP(&noInput, "yes", "y", false, "non-interactive: never prompt (use flags/defaults; fail if a name is missing)")
return cmd
}
9 changes: 6 additions & 3 deletions internal/cmd/app_create_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,18 @@ func TestAppCreateRefusesNonEmptyDir(t *testing.T) {
}
}

// TestAppCreateFromIsNotWired pins the `--from` refusal for `app create`; the
// message contract itself (no internal TODO, an actionable next command) is
// asserted for BOTH commands in TestScaffoldFromErrorShipsNoEngineeringNote.
func TestAppCreateFromIsNotWired(t *testing.T) {
tmp := t.TempDir()
dest := filepath.Join(tmp, "out")
_, errOut, err := run(t, "app", "create", "my-block", dest, "--from", "some-slug")
if err == nil {
t.Fatal("expected --from to error (not yet wired)")
t.Fatal("expected --from to error (not available yet)")
}
if !strings.Contains(err.Error()+errOut, "not yet wired") {
t.Errorf("--from should report it is not wired: err=%v stderr=%s", err, errOut)
if !strings.Contains(err.Error()+errOut, "--from is not available yet") {
t.Errorf("--from should report it is unavailable: err=%v stderr=%s", err, errOut)
}
}

Expand Down
Loading
Loading