Skip to content

feat: add product commands and a gh-style api command - #24

Closed
alukach wants to merge 26 commits into
mainfrom
feat/product-commands
Closed

alukach wants to merge 26 commits into
mainfrom
feat/product-commands

Conversation

@alukach

@alukach alukach commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Part of #21, stacked on the login-session PR. Builds on the product endpoints in source-cooperative/source.coop#651.

source-coop product list | view | create | edit | delete over /api/v1/products. As #21 asks, the CLI checks nothing itself and shows the API's errors field by field.

  • Auth: requests carry the login session's access token, or SOURCE_TOKEN if set. Without either they run signed out, which is enough to read public products, and a 401 says to run source-coop login.
  • Reading: list follows paging up to -L (default 30). Reads take --json, and view --web opens the product page.
  • Input: create and edit take fields as flags, from a JSON object with -F/--from-file (- for stdin), or both, with flags winning.
  • Prompts: when stdin and stderr are terminals (and SOURCE_PROMPT_DISABLED is unset), the CLI asks for whatever is still missing:
    • create: a title defaulting to one made from the ID, a data connection from those the account can use, and only the visibilities that connection allows.
    • Descriptions: typed on one line, or written in $VISUAL/$EDITOR by answering e, as in gh.
    • edit with no flags: asks which fields to change, and starts each from its current value.
    • delete: asks whether to keep the data, then for the product name typed back. Without a terminal it needs --yes.
  • Asking again: when the API rejects fields a person can answer (product ID, title, description, visibility, data connection), the CLI shows the API's message and asks for just those fields, keeping every other answer, then resends. Any other rejection is an error, as before.
  • Output: list prints a header and aligned columns on a terminal, and tab-separated rows without a header when piped. create and edit print the product URL on stdout and their message on stderr.
  • Errors: a request that gets no response says why (DNS, TLS, refused connection), not just which URL.

source-coop api PATH sends any /api/v1 request as the signed-in user, for whatever the other commands don't cover yet:

  • gh-style flags: -X for the method, -f key=value for a string, -F key=value for a JSON boolean, null or number, and --input FILE.
  • Fields go in the query string for GET and a JSON body otherwise, and the method defaults to POST when fields are given.
  • It prints the response and exits non-zero on an error status.

Follow-ups, not in this PR:

  • --jq.
  • Generating argument help and types from the OpenAPI document.
  • An endpoint listing the data connections an account may use for a product. The picker currently filters the legacy /data-connections route itself, and doesn't know about connections that require an account flag.

Deploying: needs the login-session PR, and source.coop#651 deployed. See the runbook in {{CLI_LOGIN_PR}}, steps 4 and 6.

Testing

  • cargo fmt --check, cargo clippy --all-targets -- -D warnings and cargo test (58 passed) all pass.
  • Product and api tests run against wiremock, using the request shapes from #651's routes. They include asking again for just the rejected field, a rejection of another field staying an error, the field picker, the piped table format, and api's query and body handling.
  • The binary, run against a local stand-in API:
    • product list and api sent the session's refreshed access token.
    • api exited 1 on a 404.
    • An unreachable host reported "Connection refused".
  • Not run against a live deployment: #651 isn't deployed yet.

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_017poyHYEyxWEJct9X8Q71qF

@alukach alukach changed the title … feat: add product commands and a gh-style api command Oct 9, 2026
@alukach
alukach added this pull request to stack #25 October 9, 2026 19:20
Base automatically changed from feat/login-session to main October 9, 2026 19:38
@alukach
alukach force-pushed the feat/product-commands branch from 0102eef to bbe48fe Compare October 9, 2026 19:38
alukach added a commit to source-cooperative/source.coop that referenced this pull request Oct 13, 2026
Part of #588. The CLI half is source-cooperative/source-coop-cli#23.

The API now takes an Ory access token as a bearer, alongside the
assertions the data proxy signs, so the CLI can call `/api/v1` as the
person who logged in. `getApiSession` routes a bearer by its unverified
`iss`: Ory's tokens go to the new `authenticateWithOryAccessToken`, and
the proxy's go to the existing verifier. Neither falls back to the other
or to the session cookie.

The Ory verifier checks the token against Ory's published JWKS, with the
issuer (with or without a trailing slash), RS256, expiry, a 30-second
tolerance, and the request's origin as audience. An ID token, which Ory
issues to the client ID, never passes, and which clients may mint a
token for the API is decided in Ory by the audiences each client is
allowed. The subject is looked up only as a person's Ory identity. Both
verifiers build the session in one place, `sessionForAccount`. The
issuer is `NEXT_PUBLIC_ORY_SDK_URL`, which is each Ory project's issuer
domain.

**Deploying:**

The CLI's Ory clients need the `jwt` access-token strategy and this
site's origin as an allowed audience. Until a client has both, the API
answers that environment's Ory tokens with 401. Nothing else breaks:
only newer CLI builds send Ory tokens, so merging first is safe.

| | Staging client (`a79c9537…`) | Production client (`197e20e7…`) |
|---|---|---|
| Allowed audience | `https://staging.source.coop` ✅ |
`https://source.coop` ✅ |
| `access_token_strategy: jwt` | ✅ set 2026-10-13 | ✅ set 2026-10-13 |

**Changing a client** (done for both; kept for reference and rollback):
`ory patch oauth2-client` doesn't exist, and `ory update oauth2-client`
replaces the whole client. So export the client, change one field, and
send the whole thing back:

```bash
P=d567800d-b705-49d1-8a28-6f92f3a4c053 C=197e20e7-d52d-4d1d-9e54-4b73a342034b
ory get oauth2-client $C --project $P --format json > prod-cli-client.before.json
jq '.access_token_strategy = "jwt"' prod-cli-client.before.json > prod-cli-client.jwt.json
ory update oauth2-client $C --project $P --file prod-cli-client.jwt.json
ory get oauth2-client $C --project $P --format json | jq .access_token_strategy   # "jwt"
```

Rollback: `ory update oauth2-client $C --project $P --file
prod-cli-client.before.json`.

- **No Vercel changes.** Each deployment's `NEXT_PUBLIC_ORY_SDK_URL` is
already the issuer in its tokens: `https://auth.staging.source.coop` on
staging and previews (checked against a real staging token),
`https://auth.source.coop` on production. A local API can't accept CLI
tokens at all, since no CLI client allows a localhost audience.
- Merging deploys staging. Check with a staging CLI build: `source-coop
login`, then `source-coop api whoami --api-url
https://staging.source.coop` should return your account, and `creds`
with `aws s3 ls` should still work.
- The next release deploys production. Repeat the checks against
`https://source.coop`. The release ships everything on `main`: if #651
is in it, data.source.coop#253 must be deployed first.
- Rollback is a revert: only Ory-token calls are affected.

**Docs:**

- **ADR-005:** amended in source-cooperative/data.source.coop#254. It
said the API authenticates proxy-signed tokens; it now records the
second signer.
- **OpenAPI:** the `bearerAuth` description names `source-coop login`
and `source-coop auth token`.
- **`public/llms.txt`:** checked. Its line ("a token from the CLI's
login or a service account") still holds.
- **docs.source.coop:** checked. No page describes calling the API with
a token yet.

## Testing

- `npm run type-check` passes.
- `npx jest src/app/api src/lib`: 535 passed across 53 suites, including
new suites for the Ory verifier and for `getApiSession`'s routing.
- `next lint` on the changed files reports nothing.
- Against real Ory (staging, 2026-10-13): after the CLI client was
switched to `jwt`, a `source-coop login` token was an RS256 JWT with
`iss` `https://auth.staging.source.coop` and `aud`
`["https://staging.source.coop"]`, and empty `ext`. That matches what
the verifier checks. The API call itself (`source-coop api whoami`
against staging) waits for this to deploy, since a preview's origin
isn't an allowed audience.

## Related

- CLI login session, which sends these tokens:
source-cooperative/source-coop-cli#23
- CLI product commands: source-cooperative/source-coop-cli#24
- ADR-005 amendment: source-cooperative/data.source.coop#254
- Deployment runbook: in source-cooperative/source-coop-cli#23

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_017poyHYEyxWEJct9X8Q71qF

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
alukach and others added 3 commits October 13, 2026 15:41
Adds `source-coop product`, the first gh-style command group from #21, over the `/api/v1/products` endpoints from source-cooperative/source.coop#651. A small API client sends the request and turns the API's `{"error": {code, message, field_errors}}` body into a message; the CLI does no validation of its own. Requests carry the login session's access token (or `SOURCE_TOKEN`, if set), and run signed out without one, which is enough to read public products.

`create` and `edit` take fields from flags, from a JSON file (`--from-file`), or both. When stdin and stderr are terminals, whatever is still missing is prompted for with defaults: a title from the product ID, the data connections the account can use, and the visibilities the chosen one allows. `edit` with no flags prompts from the product's current values and sends only what changed. `delete` asks whether to keep the data and for the name to be typed back; without a terminal it needs `--yes`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017poyHYEyxWEJct9X8Q71qF
When the API rejects a field and someone is at the terminal, the CLI shows why and asks for just that field again, keeping every other answer, then resends; the API stays the only judge. A rejection naming a field nobody can be asked for is an error, as before.

Descriptions can be written in `$VISUAL`/`$EDITOR`, gh's way: Enter keeps, `e` opens the editor, anything else is the text. `edit` with no flags first asks which fields to change.

`list` prints a header and aligned columns on a terminal, and tab-separated rows when piped. `create` and `edit` print the product URL on stdout and their message on stderr. A request that gets no response now says why (DNS, TLS, refused connection), not just which URL.

`source-coop api PATH` sends any `/api/v1` request as the signed-in user, with gh-style `-X`, `-f`, `-F` and `--input`, prints the response, and exits non-zero on an error status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017poyHYEyxWEJct9X8Q71qF
Rebased onto per-environment caching, which keys the session by proxy
host; `product` and `api` now take --proxy-url / SOURCE_PROXY_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alukach
alukach force-pushed the feat/product-commands branch from bbe48fe to dfcfaae Compare October 13, 2026 22:41
@claude

claude Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Claude finished @alukach's task in 22s —— View job


✅ No blocking issues — safe to merge.

I read src/api.rs, src/api_cmd.rs and the diffs to cache.rs, session.rs and Cargo.toml, and looked at how main.rs wires the token and URL. I did not read product.rs, prompt.rs, picker.rs or draft.rs in depth. I did not build or run the tests.

  • Token handling: Client::resolve rejects any URL whose origin isn't the API's, so the bearer token can't be pointed at another host through api PATH.
  • Cache files: cache::write_file now creates files as 0600 before writing, so a refresh token is never briefly world-readable.

Minor, not blocking:

  • src/api_cmd.rs:53: api -X GET --input f.json puts -F/-f fields in the query string, but the --input body is still sent on the GET. Either drop it for GET or reject the combination.
  • src/api_cmd.rs:44: -X is uppercased, so a lowercase method works, but a custom method like PROPFIND is accepted silently. Harmless.
  • .env.staging and main.rs:85,101 use SOURCE_API_URL. AGENTS.md says environments come from --proxy-url / SOURCE_PROXY_URL via cache::env. Check that the login session and cached credentials are keyed per environment when only SOURCE_API_URL differs.

Simplify (ponytail)

  • Cargo.toml: dialoguer is pulled in with only the editor feature, while picker.rs already hand-rolls a console-based picker and text prompt. Consider whether dialoguer can go entirely, using console plus $EDITOR through std::process::Command.
  • src/product.rs (2849 lines) and src/prompt.rs: the Prompter trait, Step/Next loop and Draft machinery are a lot of abstraction. AGENTS.md asks for most of it, so this is advisory only.
  • src/api.rs: Client::get and Client::request overlap with Client::send. The request generics could collapse if get is the only caller.

💰 Estimated review cost: $0.16 · 0m22s · 6 turns

alukach and others added 4 commits October 13, 2026 15:54
product list takes --cursor and, on a terminal, prints the cursor for
the next page. It no longer truncates to -L, since a search page can
hold more than asked for and the cursor resumes after the whole page.

product view shows one labelled row per field (visibility, status, data
mode, featured, account, dates, URL) with colour on a terminal, honouring
NO_COLOR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a terminal, without --json, `product list` streams each page into
$SOURCE_PAGER, $PAGER or `less -FRX` as it is fetched. The pipe's
backpressure holds off fetching until the user scrolls, so without an
explicit -L the list goes on to the end; quitting the pager ends the
command quietly. Column widths come from the first page. An empty pager
or `cat`, --no-pager, or a pager that won't start prints directly as
before. Piped and --json output are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
product list now shows each product's fields as view does, with a rule
between products, streamed through the pager like the table. --format
table keeps the aligned table (tab-separated when piped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tags live under metadata.tags; empty entries (some records hold [""])
are skipped. data_mode is the legacy field visibility replaced, so it
no longer gets a row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… drafts

create and edit ask GET /whoami before their first question, so a missing
or refused token stops them before anyone types a form.

If sending the answers still fails, they're saved as a draft, and the
next run of the same command offers them back (flags still win) or takes
them with --from-file. Drafts live where the OS keeps app state, not its
cache: XDG_STATE_HOME on Linux, Application Support on macOS,
LocalAppData on Windows. src/draft.rs is resource-agnostic: a command
calls resume() before prompting and finish() with the send's result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit that referenced this pull request Oct 13, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach and others added 11 commits October 13, 2026 16:46
* feat: product browse, a picker over the product list

browse lists products in a type-to-filter picker (dialoguer's
FuzzySelect), with "Load more…" while the listing has pages left. A
picked product is shown as view shows it, with Back, Open in browser,
Edit and Delete, which run the same code as those commands; a failed
action is reported and the menu offered again.

list and browse share their filters (account, -q, --tags, --featured)
through a flattened Filters struct, and one page fetch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: pick products with product list -i, not a browse command

One way to the picker: -i on list takes the same filters and refuses the
printing-only flags (--json, --format, -L, --cursor).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: start the list -i server through api_server, as #24's tests do

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
-i now means interactive wherever a product is involved:
- list -i moves through products with a picker of our own (on console,
  which dialoguer already brings in), since dialoguer's take no
  shortcuts: arrows or j/k move, → or Enter opens, ← Esc or q quits, /
  filters, and b, e, d open in the browser, edit or delete the
  highlighted product.
- view PRODUCT -i shows the product with the same b/e/d shortcuts and ←
  to leave; without a product it picks one from public products first.
- edit -i and delete -i pick one of the signed-in account's products
  (from /whoami), then run as if it had been named.

Edit and delete run the commands' own code. The fuzzy-select feature is
no longer needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- api: refuse URLs outside the API's origin, so the bearer token can't
  be sent to another host
- cache: create cache files 0600 before writing them
- picker: read keys raw so Ctrl-C restores the cursor; fit every line
  to the terminal so redraws don't wrap
- session: no access token from a session that asked for no audience
- .env.staging: point SOURCE_API_URL at staging
- product: report a failed re-fetch after an edit instead of ending -i;
  show the --cursor hint after a pager that stopped at -L; convert dates
  to UTC before labelling them so
- draft: clear a draft sent back with --from-file
- main: an empty SOURCE_TOKEN is unset
- read_fields reuses api_cmd::read_json

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pick and choose were the same loop over a Listing; browse_products does
both, with a choosing flag. The -i arms in run and view use let-else, and
keypress no longer reports an Open nobody acted on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rror exit

Esc (or q) on a select, confirm or multi-select returns prompt::CANCELLED
from the Prompter itself, so every command and record type gets it. The
type-the-name step before something irreversible is
prompt::confirm_by_name, where Enter on nothing backs out; product delete
is its first user and now checks sign-in before asking. main's fail()
reports every command's error, printing a cancel without "Error:".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
prompt::report holds the rule main's fail used; act in the -i picker uses
it too, so Esc in an edit or delete prompt reads as a cancel there as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shared src/style.rs paints a record's ID (account/product) bright black,
lighter than the name beside it, in the details view, the table and the
-i picker, and holds the one NO_COLOR/terminal rule every output uses.
The table pads the ID before painting it, so columns still line up; the
picker filters on and highlights an item's plain text, so styling neither
matches a filter nor cuts the highlight short.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As the highlight comes within three items of the end of what's loaded
(or a filter matches nothing), the picker shows "Loading more…" and
returns Action::More; the caller fetches the next page and browses again
from a prompt::Place, which keeps the highlight, the filter and the lines
on screen, so the list grows in place. A filter thus searches on through
later pages. The caller keeps one Place for the session, so coming back
from a product also keeps your position and filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-i now takes the terminal's alternate screen, as less and vim do: each
list and product screen clears and redraws instead of piling up, and
leaving restores the terminal as it was (also on Ctrl+C mid-request).
What a shortcut did (Edited, Deleted, Cancelled, an error) shows once on
the redrawn screen as a notice instead of scrolling away.

In the list of all public products, Enter on a / filter searches the
server (q, keeping --tags/--featured) and browses the results; Back
returns to the whole list. Typing still narrows what's loaded at once,
and account listings, which the API can't search, keep that filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
view (and list's details) now shows every field, saying (none) or no
rather than leaving a row out: Disabled and Featured as yes/no, Tags,
each mirror's connection and prefix with the primary first and marked,
and Created/Updated with how long ago. The -i list's rows end with the
product's visibility, colored as in view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list's shortcuts are now only b (browser): editing or deleting takes
opening the product first, so a stray key on a long list changes
nothing, and its hint says so. The scripted prompter refuses a key the
list wasn't given. The README's key table drops e/d from lists and
describes the server search, paging and full screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach and others added 7 commits October 13, 2026 22:11
dialoguer's text prompts ignore Esc, so editing a title or description
had no way out. picker::line replaces them: the default starts in the
line to keep or edit, ←/→, Home/End and Ctrl+A move, Backspace/Delete
erase whole characters (accents, emoji), a line wider than the terminal
scrolls sideways, empty is refused unless allowed, and Esc returns
CANCELLED, so every text question in every command can be abandoned.

Long text opens $VISUAL/$EDITOR on Ctrl+E rather than on answering
"e", which made "e" itself unsayable. console reads Ctrl+E as End, so
at the end of the line it opens the editor and elsewhere moves there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create, asked interactively, offers only the accounts source.coop lets
you create products under, read from /whoami as its createRepository
rule decides: your own with create_repositories, and each organization
you're an org-wide owner or maintainer of (admins may also name another).
With one, it says so and goes straight to the product ID; with none, it
explains why before asking anything. check_signed_in returns the
/whoami session it fetches.

Ctrl+C prints "Interrupted." once, without "Error:", and exits 130.
dialoguer's choices have console raise it as SIGINT, which ended the
process with their cursor hidden; one handler now restores the cursor
(and leaves the full screen) for every interactive command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In create's prompts, ↑ (or ← at the start of a line) in a text question
and ← in a list go back to the question before, which starts from the
answer given there; later answers are kept. Questions answered by a flag,
--from-file or a resumed draft are skipped both ways, and the draft is
offered once per product named.

- prompt: input_or_back, select_or_back and long_text_or_back, opt-in,
  defaulting to the plain prompts; scripted Answer::Back
- picker: line takes back; a small select of our own, since dialoguer's
  can't tell ← from Esc (Esc still cancels)
- product: ask_create runs create's questions as steps and replaces
  ask_new_product and prompt_new_product

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once create has asked anything, it shows the product, title, first line
of the description, data connection and visibility, and asks "Create
this product?" (Yes / No, cancel). ← goes back to the last question;
declining cancels with nothing sent. A run given every field by flag or
file goes straight through, as before.

The summary is drawn as part of the question (picker::select takes
details), so going back clears it with the question.
Prompter::confirm_or_back carries it, and the scripted prompter records
it. The README describes going back and the review, and Ctrl+E for the
editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le ones

AGENTS.md collects how the CLI behaves (commands, input, prompts and going
back, the -i picker, output, stored data, testing, git), each rule
pointing at the code that already does it; CLAUDE.md imports it. It
leads with keeping README.md in sync with every user-visible change.

Two tests enforce what can be checked: every resource command but create
takes -i and the reads take --json, and every command appears in
README.md as `source-coop <command>`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`auth login` is now where you log in, beside `auth token`; the top-level
`login` still runs it but is hidden from --help. Every "run 'source-coop
login'" hint, the README and .env.staging say `auth login`. AGENTS.md
records that signing-in commands live under `auth`, that `creds` stays
top-level for AWS credential_process configs, and that renamed commands
keep their old names as hidden aliases. The README check skips hidden
commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alukach

alukach commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #45 (shared foundation + the api command) and #47 (product commands), with the auth login move now in #46. The work was split so auth and products don't depend on each other; the branch is kept.

@alukach alukach closed this Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant