feat(card): add direct realm-API CRUD + Boxel query search#19
feat(card): add direct realm-API CRUD + Boxel query search#19
Conversation
Introduces `boxel card` — a new command group that mutates and queries cards through the realm's card API (POST / PATCH / DELETE / GET / _search / _atomic) directly, bypassing the CLI's sync manifest and local filesystem. Useful for scripted agent workflows, one-off admin operations, and querying realms without pulling matching cards locally. Subcommands: - `card get <realm> <path>` GET /<path> - `card create <realm> <folder> [--lid]` POST /<folder>/ - `card patch <realm> <path>` PATCH /<path> (partial) - `card delete <realm> <path>` DELETE /<path> - `card search <realm> [filter flags | --file]` POST /_search - `card atomic <realm> [--file|--data|--stdin]` POST /_atomic - `card token <realm> [--shell]` Print JWT for direct curl Search speaks Boxel's full filter language (`type`, `eq`, `in`, `contains`, `range`, plus `sort` / `page`). Common cases surface as flags; complex filters (`any`, `not`, nested) pass through via --file/--stdin. Body inputs (create / patch / atomic / search) accept --file <path>, --data '<json>', or --stdin. Responses go to stdout (body) and stderr (status line) so output is pipe-friendly for jq / xargs workflows. Filter composition is extracted to `src/lib/card-filter.ts` as pure functions and unit-tested (36 new tests, 235/235 passing overall). Docs: README.md and .claude/CLAUDE.md updated with the new command reference, usage examples, and a decision matrix for when to use `card` vs `sync`/`push`/`touch`. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
There was a problem hiding this comment.
Pull request overview
Adds a new boxel card command group for direct, non-filesystem card CRUD and querying against a realm’s card API, plus a small library (card-filter) to compose Boxel query filters/sort specs from CLI flags (with unit tests).
Changes:
- Introduces
src/commands/card.tsimplementingcard get/create/patch/delete/search/atomic/tokenusing realm auth + direct HTTP calls. - Extracts filter/sort flag composition into
src/lib/card-filter.tsand adds comprehensive unit tests. - Updates CLI wiring/docs and adds
qsdependency for querystring serialization.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| test/lib/card-filter.test.ts | New unit tests covering value parsing, coderef parsing, filter composition, and sort building. |
| src/lib/card-filter.ts | New pure helpers to build Boxel filter/sort objects from CLI flags. |
| src/index.ts | Registers the new boxel card command group and its CLI options. |
| src/commands/card.ts | Implements direct realm API CRUD, _search, _atomic, and token output helpers. |
| package.json | Adds qs (+ types) dependency for query serialization. |
| package-lock.json | Locks new dependencies and bumps package version. |
| README.md | Documents new boxel card usage and examples. |
| .claude/CLAUDE.md | Adds detailed boxel card reference and guidance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async function readBody(opts: CommonOptions): Promise<any> { | ||
| let raw: string | undefined; | ||
| if (opts.stdin) { | ||
| raw = await readStdin(); | ||
| } else if (opts.file) { | ||
| raw = fs.readFileSync(opts.file, 'utf8'); | ||
| } else if (opts.data) { | ||
| raw = opts.data; | ||
| } else { | ||
| throw new Error( | ||
| 'No body supplied. Pass one of: --file <path>, --data <json>, --stdin', | ||
| ); | ||
| } | ||
| try { | ||
| return JSON.parse(raw); | ||
| } catch (e) { | ||
| throw new Error(`Body is not valid JSON: ${(e as Error).message}`); |
There was a problem hiding this comment.
readBody() throws on missing input or invalid JSON, but the exported command handlers generally don't catch these errors. That can lead to unhandled rejections / stack traces on stderr, which undermines the stated “pipe-friendly” behavior. Consider wrapping each exported card*Command in a try/catch that prints a concise error message to stderr and exits with a non-zero code (similar to other commands like sync/pull).
| async function readBody(opts: CommonOptions): Promise<any> { | |
| let raw: string | undefined; | |
| if (opts.stdin) { | |
| raw = await readStdin(); | |
| } else if (opts.file) { | |
| raw = fs.readFileSync(opts.file, 'utf8'); | |
| } else if (opts.data) { | |
| raw = opts.data; | |
| } else { | |
| throw new Error( | |
| 'No body supplied. Pass one of: --file <path>, --data <json>, --stdin', | |
| ); | |
| } | |
| try { | |
| return JSON.parse(raw); | |
| } catch (e) { | |
| throw new Error(`Body is not valid JSON: ${(e as Error).message}`); | |
| function exitWithCardCommandError(message: string): never { | |
| console.error(message); | |
| process.exit(1); | |
| } | |
| async function readBody(opts: CommonOptions): Promise<any> { | |
| let raw: string | undefined; | |
| try { | |
| if (opts.stdin) { | |
| raw = await readStdin(); | |
| } else if (opts.file) { | |
| raw = fs.readFileSync(opts.file, 'utf8'); | |
| } else if (opts.data) { | |
| raw = opts.data; | |
| } else { | |
| exitWithCardCommandError( | |
| 'No body supplied. Pass one of: --file <path>, --data <json>, --stdin', | |
| ); | |
| } | |
| } catch (e) { | |
| exitWithCardCommandError(`Failed to read body: ${(e as Error).message}`); | |
| } | |
| try { | |
| return JSON.parse(raw); | |
| } catch (e) { | |
| exitWithCardCommandError(`Body is not valid JSON: ${(e as Error).message}`); |
| if (options.url) { | ||
| // Also print the qs form for reference / use with curl --data | ||
| const queryString = qs.stringify(query, { strictNullHandling: true, encode: false }); | ||
| process.stdout.write(`${searchUrl}?${queryString}\n`); | ||
| return; |
There was a problem hiding this comment.
--url currently prints a qs-encoded URL (${searchUrl}?...), but the actual fetch uses POST + X-HTTP-Method-Override: QUERY with a JSON body, so the printed URL doesn't represent a runnable request. Consider changing --url output to something that reproduces the real request (e.g., method + headers + JSON body), or update the implementation to actually use querystring serialization consistently.
| if (options.pageSize || options.pageNumber) { | ||
| query.page = {}; | ||
| if (options.pageSize) query.page.size = parseInt(options.pageSize, 10); | ||
| if (options.pageNumber) query.page.number = parseInt(options.pageNumber, 10); | ||
| } |
There was a problem hiding this comment.
pageSize/pageNumber are parsed with parseInt but not validated. If the user passes a non-integer (or empty string), parseInt yields NaN, and JSON.stringify will serialize NaN as null, producing an invalid/incorrect query. Validate that the parsed values are finite numbers (and enforce expected bounds like size >= 1 and number >= 0) and fail with a friendly error if not.
|
|
||
| cardGroup | ||
| .command('search') | ||
| .description('Query cards via Boxel filter language (GET /_search)') |
There was a problem hiding this comment.
The CLI help text says "GET /_search", but the implementation uses a POST with method override (QUERY) to hit /_search. Update the description to match the actual request method/semantics so users aren’t misled when troubleshooting or reproducing requests with curl.
| .description('Query cards via Boxel filter language (GET /_search)') | |
| .description('Query cards via Boxel filter language (POST /_search with QUERY method semantics)') |
| .option('--page-size <n>', 'Page size') | ||
| .option('--page-number <n>', 'Page number (0-based)') |
There was a problem hiding this comment.
--page-size and --page-number are accepted as raw strings here, and later parsed with parseInt without validation. The rest of this CLI already has a parsePositiveInt helper for numeric flags; consider using it for --page-size (>= 1) and introducing a similar parser for --page-number (>= 0) so invalid inputs fail fast with a friendly commander error instead of generating a malformed query.
| export async function cardSearchCommand( | ||
| realmRef: string, | ||
| options: SearchOptions, | ||
| ): Promise<void> { | ||
| // Build the query object: filter from flags, OR load from --file/--data/--stdin. | ||
| let query: any = {}; | ||
| if (options.file || options.data || options.stdin) { | ||
| const body = await readBody(options); | ||
| // Body can be either a full Query or just a Filter — accept both. |
There was a problem hiding this comment.
This PR introduces a large amount of new CLI behavior (auth resolution, body loading from file/data/stdin, CRUD endpoints, _search modifiers like --ids/--count/--url, and _atomic posting) in a new commands module, but there are no unit/integration tests covering these paths. Given the repo already has command/integration tests (e.g. pull/push/sync), adding at least a small set of tests with mocked fetch for the new boxel card handlers would help prevent regressions.
| if (idx < 0) { | ||
| throw new Error(`Expected key=value, got: ${s}`); | ||
| } | ||
| out[s.slice(0, idx)] = parseValue(s.slice(idx + 1)); |
There was a problem hiding this comment.
parseKV currently accepts inputs like =value and will emit an object with an empty-string key, which will produce malformed filters. Consider validating that the key portion before = is non-empty (and possibly trimmed) and throw a clear error when it’s missing.
| out[s.slice(0, idx)] = parseValue(s.slice(idx + 1)); | |
| const key = s.slice(0, idx); | |
| if (!key.trim()) { | |
| throw new Error(`Expected non-empty key in key=value, got: ${s}`); | |
| } | |
| out[key] = parseValue(s.slice(idx + 1)); |
| if (direction !== 'asc' && direction !== 'desc') { | ||
| throw new Error(`Invalid sort direction: ${direction} (must be asc|desc)`); | ||
| } | ||
| return sortOn ? { by, on: sortOn, direction } : { by, direction }; |
There was a problem hiding this comment.
buildSort does not validate that the by field is non-empty. A user can pass --sort ':desc', which currently produces { by: '', direction: 'desc' } and likely results in a server-side error. Reject empty/whitespace-only by values with a friendly error before sending the request.
| if (direction !== 'asc' && direction !== 'desc') { | |
| throw new Error(`Invalid sort direction: ${direction} (must be asc|desc)`); | |
| } | |
| return sortOn ? { by, on: sortOn, direction } : { by, direction }; | |
| const trimmedBy = by.trim(); | |
| if (!trimmedBy) { | |
| throw new Error(`Invalid sort field: '${s}' (expected '<field>[:asc|desc]')`); | |
| } | |
| if (direction !== 'asc' && direction !== 'desc') { | |
| throw new Error(`Invalid sort direction: ${direction} (must be asc|desc)`); | |
| } | |
| return sortOn | |
| ? { by: trimmedBy, on: sortOn, direction } | |
| : { by: trimmedBy, direction }; |
| // Endpoint: GET /_search?<qs-encoded query> (same serialization the host | ||
| // app uses — the qs package with strictNullHandling, no encoding). | ||
| // Returns a JSON:API collection document on stdout; --ids extracts just | ||
| // the id column. |
There was a problem hiding this comment.
The comment describes _search as a GET with qs-encoded querystring, but the implementation below performs a POST with X-HTTP-Method-Override: QUERY and sends the query JSON in the request body. This mismatch is likely to confuse future maintainers and anyone trying to align behavior with docs; update the comment to reflect the actual request shape (or change the implementation to match the documented GET behavior).
| // Endpoint: GET /_search?<qs-encoded query> (same serialization the host | |
| // app uses — the qs package with strictNullHandling, no encoding). | |
| // Returns a JSON:API collection document on stdout; --ids extracts just | |
| // the id column. | |
| // Endpoint: POST /_search with `X-HTTP-Method-Override: QUERY`; the query | |
| // is sent as JSON in the request body rather than as a qs-encoded URL | |
| // query string. Returns a JSON:API collection document on stdout; --ids | |
| // extracts just the id column. |
Introduces
boxel card— a new command group that mutates and queries cards through the realm's card API (POST / PATCH / DELETE / GET / _search / _atomic) directly, bypassing the CLI's sync manifest and local filesystem. Useful for scripted agent workflows, one-off admin operations, and querying realms without pulling matching cards locally.Subcommands:
card get <realm> <path>GET /card create <realm> <folder> [--lid]POST //card patch <realm> <path>PATCH / (partial)card delete <realm> <path>DELETE /card search <realm> [filter flags | --file]POST /_searchcard atomic <realm> [--file|--data|--stdin]POST /_atomiccard token <realm> [--shell]Print JWT for direct curlSearch speaks Boxel's full filter language (
type,eq,in,contains,range, plussort/page). Common cases surface as flags; complex filters (any,not, nested) pass through via --file/--stdin.Body inputs (create / patch / atomic / search) accept --file , --data '', or --stdin. Responses go to stdout (body) and stderr (status line) so output is pipe-friendly for jq / xargs workflows.
Filter composition is extracted to
src/lib/card-filter.tsas pure functions and unit-tested (36 new tests, 235/235 passing overall).Docs: README.md and .claude/CLAUDE.md updated with the new command reference, usage examples, and a decision matrix for when to use
cardvssync/push/touch.