Skip to content

feat(cli): add hydrate project create/archive/delete/rename verbs - #98

Merged
rennehan merged 3 commits into
mainfrom
feat/project-verbs
Aug 2, 2026
Merged

feat(cli): add hydrate project create/archive/delete/rename verbs#98
rennehan merged 3 commits into
mainfrom
feat/project-verbs

Conversation

@rennehan

@rennehan rennehan commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements P3 of 20-project-lifecycle.md: the CLI's mutating project
verb 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, reversible
  • hydrate project delete <name> — permanent; requires project:delete
  • hydrate project rename <old> <new>

hydrate projects (plural) is unchanged — it stays the listing verb.

Design decisions

  • Name-only addressing. All four verbs take an exact project name,
    resolved against GET /v1/projects. No id is ever surfaced by these
    verbs. Matching is exact (no fuzzy/substring), per the plan.
  • Archived-project addressing. GET /v1/projects excludes archived
    projects, so an already-archived project's name cannot be resolved by
    archive/rename/delete today — there is no archived-inclusive
    listing route to check against. Rather than a bare 404 or a silent
    no-op, find_by_name reports this plainly: it names the possibility
    that 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.
  • delete posture mirrors stage 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.
  • The project:delete scope 403. DELETE /v1/projects/{id} requires
    a scope that's deliberately separate from graph:write, and no key
    minted before it existed has it. A bare 403 there is unusable, so
    cmd::project::translate_delete_error recognizes 403 on specifically
    this route (documented as fragile/route-inferred, since every /v1
    scope gate returns the same fixed {"detail": "forbidden"} body with
    nothing to key off) and reports that the key needs re-minting with the
    scope, via a new CliError::MissingScope variant.
  • Wire contract. POST /v1/projects already existed; this PR adds
    the vendored openapi.json entries for DELETE and
    PATCH /v1/projects/{id} (coded against the settled-but-unmerged app
    contracts) and regenerates wire/ from them — no hand edits.
  • Fixed a latent bug in error::parse_detail: it only read the error
    key 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.rs has one pre-existing, unrelated flake
    also reproducible on unmodified main; not touched here)
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt -p hydrate -- --check clean
  • ./scripts/regen-wire.sh is idempotent against the committed wire/
  • TDD throughout; every new test mutation-tested by hand (broken the
    implementation, confirmed the mutation applied via grep, confirmed
    the test failed, reverted) — covered: exact-name matching (mutated
    to substring match), the 403→MissingScope translation (mutated to
    404), a render string, positional-arg field order in clap, and
    the error/code fallback in parse_detail
  • Added --ignored live-integration coverage
    (live_project_create_rename_archive_delete) mirroring the
    existing tests/runtime.rs pattern
  • hydrate guide, README.md, and tests/help.rs updated

🤖 Generated with Claude Code

`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.
@rennehan rennehan added the enhancement New feature or request label Aug 2, 2026
@rennehan rennehan self-assigned this Aug 2, 2026
… 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.
@rennehan

rennehan commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit (952eff8) closing the fan-out review findings:

Archive round trip now real, end to end

  • Re-vendored 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) and regenerated wire/. Kept the more precise ProjectPatchConflict/ProjectPatchUnprocessable 409/422 envelopes from my original spec — the app's own auto-generated copy doesn't declare those statuses on the PATCH route decorator, so re-vendoring wholesale would have silently regressed spec accuracy.
  • find_by_name resolves against both active and archived projects now, preferring an exact active match on a name collision (archiving never reserved the name — a fresh active project can legitimately reuse an archived one's name).
  • Added hydrate project restore <name>. Previously archive() only ever sent archived: true; there was no code path that could reverse it. All four places that documented "archived projects are unreachable by name" as a limitation have been corrected — that was true when written and is no longer true.

rename grammar

  • Changed to hydrate project rename <name> --to <new-name> (was two bare positionals). The old grammar let an agent transpose the two names into a rename in the wrong direction with no syntax error to catch it.

403 handling is now body-aware, not status-only

  • DELETE /v1/projects/{id} can 403 for two structurally different reasons: the project:delete scope gate (bare {"detail": "forbidden"}, no code) and a whitelist-scoped key's per-key project allowlist (project_not_in_key_whitelist, a structured body). A key can hold project:delete and still hit the second one for an unrelated reason. translate_delete_error now only reinterprets the bare/untyped shape as MissingScope; a structured 403 passes through as itself.

error.kind contract note

  • parse_detail's code-field fallback (needed 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, just framed too narrowly before. Now documented plainly in error.rs, and pinned with a regression test in walk.rs showing its remap depends on status, not kind, so it's unaffected.

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 find_by_name, the structured-vs-bare 403 guard, the render_archive restore-verb naming, the rename grammar (both the --to flag itself and the second-bare-positional rejection), and the parse_detail code fallback (including the new walk.rs regression test). cargo test/clippy -D warnings/fmt --check all clean; ./scripts/regen-wire.sh idempotent against the committed wire/.

Both branches added CliError variants, kind() arms, Display arms and
tests at the same points; kept both sides in each case.
@rennehan
rennehan merged commit 05ef473 into main Aug 2, 2026
3 checks passed
@rennehan
rennehan deleted the feat/project-verbs branch August 2, 2026 01:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant