feat(cli): add hydrate project create/archive/delete/rename verbs - #98
Conversation
`hydrate projects` could only list; authoring a graph on a fresh project
meant leaving the tool for `curl` to create one, and there was no way to
clean up afterward at all. Adds the mutating counterpart:
hydrate project create <name>
hydrate project archive <name>
hydrate project delete <name>
hydrate project rename <old> <new>
`projects` (plural) stays the listing verb, unchanged — a shipped verb
becoming the second-class spelling of itself was judged a worse trade
than the small asymmetry with the new singular `project` group.
Every verb addresses its target by exact name, never an id (ids stay an
internal wire detail). `GET /v1/projects` excludes archived projects, so
an already-archived project's name cannot be resolved by these verbs
today; that limitation is surfaced as a clear, specific error rather than
a confusing bare 404 or a silent no-op.
`delete` is irreversible and, per `stage discard`'s precedent, has no
confirmation prompt (this CLI is driven non-interactively) — it prints
what is about to go, by name, before making the call. It also requires
an API key minted with the `project:delete` scope, which is deliberately
separate from `graph:write`: every key issued before this scope existed
cannot delete. A bare 403 there would be unusable, so the CLI recognizes
that specific case (403 on this one route) and reports plainly that the
key needs re-minting with the scope, rather than a generic service error.
Regenerates the vendored `wire/` client from an updated `openapi.json`
carrying the new `DELETE` and `PATCH /v1/projects/{id}` routes.
… trip, safer rename grammar, precise 403 handling
Addresses fan-out review findings on the `hydrate project` verbs:
- Re-vendor `openapi.json` from the app's `feat/v1-project-patch` branch,
which shipped `include_archived` on `GET /v1/projects` specifically for
name-addressed callers like this CLI, plus a real archive/restore
semantic on `PATCH .../archived`. Regenerate `wire/`. Keep the more
precise `ProjectPatchConflict`/`ProjectPatchUnprocessable` 409/422
envelopes from the original hand-authored spec, which the app's own
auto-generated copy doesn't declare.
- `find_by_name` now resolves against both active and archived projects
(`Client::list_projects_including_archived`), preferring an exact
active match when a name collides with an archived project (archiving
never reserved the name). The "already-archived projects are
unreachable by name" limitation this PR previously documented in four
places no longer exists and all four have been corrected.
- Add `hydrate project restore <name>`, the other half of the archive
round trip. Before this, `archive()` only ever sent `archived: true` —
there was no code path that could reverse it, regardless of what the
docs claimed.
- `hydrate project rename` now takes `<name> --to <new-name>` instead of
two bare positionals. The old grammar let an agent transpose the two
names into a rename in the wrong direction with nothing to catch it;
every other multi-value edit in this CLI addresses its target
positionally and puts the new value behind a named flag.
- `translate_delete_error` no longer treats every 403 on the delete route
as a missing `project:delete` scope. That route can also 403 from a
whitelist-scoped key's per-key project allowlist
(`project_not_in_key_whitelist`, a structured body), and a key can hold
`project:delete` while still hitting that gate for an unrelated reason.
Only a 403 whose body carries no structured code (the scope gate's bare
`{"detail": "forbidden"}`) is now reinterpreted; a structured 403 passes
through as itself.
- `error::parse_detail`'s `code`-field fallback (added for the project
routes) also changes `error.kind` for every other `/v1` route using the
same shared envelope, including the 404s `cmd::walk` remaps — that was
already true before this commit but framed too narrowly. Documented
plainly, and pinned with a regression test showing `walk.rs`'s remap
depends on `status`, not `kind`, so it is unaffected.
- Dropped the mid-sentence "ARCHIVED" caps-for-emphasis from error text.
|
Pushed a follow-up commit (952eff8) closing the fan-out review findings: Archive round trip now real, end to end
403 handling is now body-aware, not status-only
Also: dropped the mid-sentence "ARCHIVED" caps-for-emphasis from error text. All mutation-tested (broke it, grepped to confirm, confirmed the test failed, reverted): the active/archived preference in |
Both branches added CliError variants, kind() arms, Display arms and tests at the same points; kept both sides in each case.
Summary
Implements P3 of
20-project-lifecycle.md: the CLI's mutatingprojectverb group, so a graph-authoring session (create a project, author,
clean up) is reproducible entirely from the CLI.
hydrate project create <name>hydrate project archive <name>— non-destructive, reversiblehydrate project delete <name>— permanent; requiresproject:deletehydrate project rename <old> <new>hydrate projects(plural) is unchanged — it stays the listing verb.Design decisions
resolved against
GET /v1/projects. No id is ever surfaced by theseverbs. Matching is exact (no fuzzy/substring), per the plan.
GET /v1/projectsexcludes archivedprojects, so an already-archived project's name cannot be resolved by
archive/rename/deletetoday — there is no archived-inclusivelisting route to check against. Rather than a bare 404 or a silent
no-op,
find_by_namereports this plainly: it names the possibilitythat the project is archived and points at the web app as the current
way to manage it. Documented in the guide, README, and the function's
own doc comment.
deleteposture mirrorsstage discard: no confirmation prompt(this CLI is driven non-interactively), but it prints what is about to
be destroyed, by name, before the irreversible call.
project:deletescope 403.DELETE /v1/projects/{id}requiresa scope that's deliberately separate from
graph:write, and no keyminted before it existed has it. A bare 403 there is unusable, so
cmd::project::translate_delete_errorrecognizes 403 on specificallythis route (documented as fragile/route-inferred, since every
/v1scope gate returns the same fixed
{"detail": "forbidden"}body withnothing to key off) and reports that the key needs re-minting with the
scope, via a new
CliError::MissingScopevariant.POST /v1/projectsalready existed; this PR addsthe vendored
openapi.jsonentries forDELETEandPATCH /v1/projects/{id}(coded against the settled-but-unmerged appcontracts) and regenerates
wire/from them — no hand edits.error::parse_detail: it only read theerrorkey for the machine-readable kind, but the project routes' envelope
uses
code(name_taken,not_found, ...). Both are checked now.Test plan
cargo test— 459 unit tests + all integration suites green(
tests/scoped_request.rshas one pre-existing, unrelated flakealso reproducible on unmodified
main; not touched here)cargo clippy --workspace --all-targets -- -D warningscleancargo fmt -p hydrate -- --checkclean./scripts/regen-wire.shis idempotent against the committedwire/implementation, confirmed the mutation applied via grep, confirmed
the test failed, reverted) — covered: exact-name matching (mutated
to substring match), the 403→
MissingScopetranslation (mutated to404), a render string, positional-arg field order in
clap, andthe
error/codefallback inparse_detail--ignoredlive-integration coverage(
live_project_create_rename_archive_delete) mirroring theexisting
tests/runtime.rspatternhydrate guide,README.md, andtests/help.rsupdated🤖 Generated with Claude Code