Skip to content

feat(card): add direct realm-API CRUD + Boxel query search#19

Open
christse wants to merge 1 commit intomainfrom
card-crud-commands
Open

feat(card): add direct realm-API CRUD + Boxel query search#19
christse wants to merge 1 commit intomainfrom
card-crud-commands

Conversation

@christse
Copy link
Copy Markdown
Contributor

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 /_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 , --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.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.

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]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts implementing card get/create/patch/delete/search/atomic/token using realm auth + direct HTTP calls.
  • Extracts filter/sort flag composition into src/lib/card-filter.ts and adds comprehensive unit tests.
  • Updates CLI wiring/docs and adds qs dependency 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.

Comment thread src/commands/card.ts
Comment on lines +46 to +62
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}`);
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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}`);

Copilot uses AI. Check for mistakes.
Comment thread src/commands/card.ts
Comment on lines +287 to +291
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;
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Copilot uses AI. Check for mistakes.
Comment thread src/commands/card.ts
Comment on lines +274 to +278
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);
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/index.ts

cardGroup
.command('search')
.description('Query cards via Boxel filter language (GET /_search)')
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
.description('Query cards via Boxel filter language (GET /_search)')
.description('Query cards via Boxel filter language (POST /_search with QUERY method semantics)')

Copilot uses AI. Check for mistakes.
Comment thread src/index.ts
Comment on lines +370 to +371
.option('--page-size <n>', 'Page size')
.option('--page-number <n>', 'Page number (0-based)')
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Copilot uses AI. Check for mistakes.
Comment thread src/commands/card.ts
Comment on lines +252 to +260
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.
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/card-filter.ts
if (idx < 0) {
throw new Error(`Expected key=value, got: ${s}`);
}
out[s.slice(0, idx)] = parseValue(s.slice(idx + 1));
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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));

Copilot uses AI. Check for mistakes.
Comment thread src/lib/card-filter.ts
Comment on lines +160 to +163
if (direction !== 'asc' && direction !== 'desc') {
throw new Error(`Invalid sort direction: ${direction} (must be asc|desc)`);
}
return sortOn ? { by, on: sortOn, direction } : { by, direction };
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 };

Copilot uses AI. Check for mistakes.
Comment thread src/commands/card.ts
Comment on lines +238 to +241
// 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.
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
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.

2 participants