diff --git a/packages/boxel-cli/api.ts b/packages/boxel-cli/api.ts index cdbb610d283..7f4fcbcfe04 100644 --- a/packages/boxel-cli/api.ts +++ b/packages/boxel-cli/api.ts @@ -19,6 +19,13 @@ export { type CancelIndexingResult, } from './src/lib/boxel-cli-client'; +export { + getToolDefinitions, + requireStringArg, + type BoxelToolDefinition, + type BoxelToolConfig, +} from './src/lib/tool-definitions'; + export { resetProfileManager, setProfileManager, diff --git a/packages/boxel-cli/src/commands/realm/push.ts b/packages/boxel-cli/src/commands/realm/push.ts index 043532bc0fa..50ef03c53f8 100644 --- a/packages/boxel-cli/src/commands/realm/push.ts +++ b/packages/boxel-cli/src/commands/realm/push.ts @@ -27,6 +27,7 @@ interface PushOptions extends SyncOptions { class RealmPusher extends RealmSyncBase { hasError = false; + uploadedFiles: string[] = []; constructor( private pushOptions: PushOptions, @@ -220,6 +221,7 @@ class RealmPusher extends RealmSyncBase { for (const { rel, hash } of uploaded) { newManifest.files[rel] = hash; } + this.uploadedFiles.push(...result.succeeded); } } @@ -362,11 +364,16 @@ export function registerPushCommand(realm: Command): void { ); } -export async function pushCommand( +export interface PushResult { + files: string[]; + error?: string; +} + +export async function push( localDir: string, realmUrl: string, options: PushCommandOptions, -): Promise { +): Promise { let authenticator: RealmAuthenticator; if (options.authenticator) { authenticator = options.authenticator; @@ -377,15 +384,16 @@ export async function pushCommand( profileManager: options.profileManager, }); if (!resolution.ok) { - console.error(`Error: ${resolution.error}`); - process.exit(1); + return { files: [], error: resolution.error }; } authenticator = resolution.authenticator; } if (!(await pathExists(localDir))) { - console.error(`Local directory does not exist: ${localDir}`); - process.exit(1); + return { + files: [], + error: `Local directory does not exist: ${localDir}`, + }; } try { @@ -403,13 +411,31 @@ export async function pushCommand( await pusher.sync(); if (pusher.hasError) { - console.log('Push did not complete successfully. View logs for details'); - process.exit(2); - } else { - console.log('Push completed successfully'); + return { + files: pusher.uploadedFiles.sort(), + error: + 'Push completed with errors. Some files may not have been uploaded.', + }; } + + return { files: pusher.uploadedFiles.sort() }; } catch (error) { - console.error('Push failed:', error); - process.exit(1); + return { + files: [], + error: `Push failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +export async function pushCommand( + localDir: string, + realmUrl: string, + options: PushCommandOptions, +): Promise { + const result = await push(localDir, realmUrl, options); + if (result.error) { + console.error(`Error: ${result.error}`); + process.exit(result.files.length > 0 ? 2 : 1); } + console.log('Push completed successfully'); } diff --git a/packages/boxel-cli/src/commands/run-command.ts b/packages/boxel-cli/src/commands/run-command.ts index 8269de7e738..f3943311155 100644 --- a/packages/boxel-cli/src/commands/run-command.ts +++ b/packages/boxel-cli/src/commands/run-command.ts @@ -1,5 +1,10 @@ import type { Command } from 'commander'; -import { getProfileManager, type ProfileManager } from '../lib/profile-manager'; +import { + getProfileManager, + NO_ACTIVE_PROFILE_ERROR, + type ProfileManager, +} from '../lib/profile-manager'; +import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { FG_GREEN, FG_RED, FG_CYAN, DIM, RESET } from '../lib/colors'; import { cliLog } from '../lib/cli-log'; @@ -13,6 +18,8 @@ export interface RunCommandOptions { input?: Record; json?: boolean; profileManager?: ProfileManager; + /** Override the realm server URL. Defaults to the active profile's. */ + realmServerUrl?: string; } interface RunCommandCliOptions { @@ -27,15 +34,18 @@ export async function runCommand( options?: RunCommandOptions, ): Promise { let pm = options?.profileManager ?? getProfileManager(); - let active = pm.getActiveProfile(); - if (!active) { - throw new Error( - 'No active profile. Run `boxel profile add` to create one.', - ); + let realmServerUrl = options?.realmServerUrl; + if (!realmServerUrl) { + let active = pm.getActiveProfile(); + if (!active) { + return { + status: 'error', + error: NO_ACTIVE_PROFILE_ERROR, + }; + } + realmServerUrl = active.profile.realmServerUrl; } - - let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); - let url = `${realmServerUrl}/_run-command`; + let url = `${ensureTrailingSlash(realmServerUrl)}_run-command`; let body = { data: { @@ -140,15 +150,7 @@ export function registerRunCommand(program: Command): void { } } - let result: RunCommandResult; - try { - result = await runCommand(commandSpecifier, opts.realm, { input }); - } catch (err) { - console.error( - `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(1); - } + let result = await runCommand(commandSpecifier, opts.realm, { input }); if (opts.json) { cliLog.output(JSON.stringify(result, null, 2)); diff --git a/packages/boxel-cli/src/lib/boxel-cli-client.ts b/packages/boxel-cli/src/lib/boxel-cli-client.ts index 0ff19b13938..4c0906d2e03 100644 --- a/packages/boxel-cli/src/lib/boxel-cli-client.ts +++ b/packages/boxel-cli/src/lib/boxel-cli-client.ts @@ -19,12 +19,17 @@ import { type ReadTranspiledResult, } from '../commands/read-transpiled'; import { write as coreWrite, type WriteResult } from '../commands/file/write'; +import { + runCommand as coreRunCommand, + type RunCommandResult, +} from '../commands/run-command'; import { cancelIndexing as coreCancelIndexing, type CancelIndexingResult, } from '../commands/realm/cancel-indexing'; import { createRealm as coreCreateRealm } from '../commands/realm/create'; import { pull as realmPull } from '../commands/realm/pull'; +import { push as realmPush, type PushResult } from '../commands/realm/push'; import { sync as realmSync, type SyncResult } from '../commands/realm/sync'; import { waitForReady as coreWaitForReady } from '../commands/realm/wait-for-ready'; import { getProfileManager, type ProfileManager } from './profile-manager'; @@ -35,6 +40,7 @@ export type { ListFilesResult, ReadTranspiledResult, SyncResult, + PushResult, SearchResult, SearchCommandOptions, }; @@ -73,6 +79,15 @@ export interface PullResult { error?: string; } +export interface PushOptions { + /** Delete remote files that don't exist locally (default: false). */ + delete?: boolean; + /** Preview without making changes. */ + dryRun?: boolean; + /** Upload all files, even if unchanged (default: false). */ + force?: boolean; +} + export interface SyncOptions { /** Resolve conflicts by keeping the local version. */ preferLocal?: boolean; @@ -88,13 +103,7 @@ export interface SyncOptions { export type { DeleteResult }; export type { WriteResult }; - -export interface RunCommandResult { - status: 'ready' | 'error' | 'unusable'; - /** Serialized command result (JSON string), or null. */ - result?: string | null; - error?: string | null; -} +export type { RunCommandResult }; export type { LintMessage, LintResult }; @@ -233,57 +242,11 @@ export class BoxelCLIClient { command: string, commandInput?: Record, ): Promise { - let url = `${ensureTrailingSlash(realmServerUrl)}_run-command`; - let body = { - data: { - type: 'run-command', - attributes: { - realmURL: realmUrl, - command, - commandInput: commandInput ?? null, - }, - }, - }; - - let response: Response; - try { - response = await this.pm.authedRealmServerFetch(url, { - method: 'POST', - headers: { - Accept: MIME.JSONAPI, - 'Content-Type': MIME.JSONAPI, - }, - body: JSON.stringify(body), - }); - } catch (err) { - return { - status: 'error', - error: `run-command fetch failed: ${err instanceof Error ? err.message : String(err)}`, - }; - } - - if (!response.ok) { - return { - status: 'error', - error: `run-command HTTP ${response.status}: ${await response.text().catch(() => '(no body)')}`, - }; - } - - let json = (await response.json()) as { - data?: { - attributes?: { - status?: string; - cardResultString?: string | null; - error?: string | null; - }; - }; - }; - let attrs = json.data?.attributes; - return { - status: (attrs?.status as RunCommandResult['status']) ?? 'error', - result: attrs?.cardResultString ?? null, - error: attrs?.error ?? null, - }; + return coreRunCommand(command, realmUrl, { + realmServerUrl, + input: commandInput, + profileManager: this.pm, + }); } /** @@ -430,6 +393,24 @@ export class BoxelCLIClient { }); } + /** + * One-way upload from a local workspace to a realm. Thin wrapper + * around the `realm push` command's programmatic `push()` function so + * the CLI and programmatic API share one implementation. + */ + async push( + realmUrl: string, + localDir: string, + options?: PushOptions, + ): Promise { + return realmPush(localDir, realmUrl, { + delete: options?.delete, + dryRun: options?.dryRun, + force: options?.force, + profileManager: this.pm, + }); + } + /** * Bidirectional sync between a local workspace and a realm. Thin wrapper * around the `realm sync` command's programmatic `sync()` function so the diff --git a/packages/boxel-cli/src/lib/tool-definitions.ts b/packages/boxel-cli/src/lib/tool-definitions.ts new file mode 100644 index 00000000000..2350a17d44f --- /dev/null +++ b/packages/boxel-cli/src/lib/tool-definitions.ts @@ -0,0 +1,635 @@ +/** + * Tool definitions that boxel-cli publishes for factory (and future MCP) + * consumption. Each tool wraps a BoxelCLIClient method with name, + * description, JSON Schema parameters, and an execute function. + * + * The factory imports `getToolDefinitions()` and spreads the result into + * its FactoryTool[] array — no manual redefinition needed. + * + * Target-realm files live in the agent's local workspace directory; the + * agent edits them with its native filesystem tools (no dedicated + * read_file / write_file tool here, since wrapping fs in a tool is strictly + * less capable than what the agent can do natively). + * + * The realm-server-side tools published here take an explicit `realm-url` + * argument and hit the realm over HTTP. The factory's + * `TARGET_REALM_BYPASS_TOOLS` guard rejects `realm_read_file` / + * `realm_write_file` / `realm_delete_file` when `realm-url` matches the + * target realm — those are reserved for non-target realms (scratch, + * source, catalog, base, etc.) where the agent has no local workspace. + */ + +import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; + +import type { BoxelCLIClient } from './boxel-cli-client'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface BoxelToolDefinition { + name: string; + description: string; + parameters: Record; + execute: (args: Record) => Promise; +} + +export interface BoxelToolConfig { + targetRealmUrl: string; + realmServerUrl: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Enforce that a required string argument is present and non-empty. Returns + * the trimmed value or throws a clear error that propagates back to the + * model as a tool-call result. + */ +export function requireStringArg( + args: Record, + name: string, + toolName: string, +): string { + let raw = args[name]; + if (typeof raw !== 'string' || raw.trim() === '') { + throw new Error( + `Tool "${toolName}" requires a non-empty string "${name}" argument; received ${JSON.stringify(raw)}. ` + + `Re-send the tool call with every required argument filled in.`, + ); + } + return raw.trim(); +} + +function resolveRealmUrl( + config: BoxelToolConfig, + _realm: string | undefined, +): string { + return config.targetRealmUrl; +} + +// --------------------------------------------------------------------------- +// Realm API tools (parameterized — work on any realm; non-target only for +// read / write / delete via the factory's TARGET_REALM_BYPASS_TOOLS guard) +// --------------------------------------------------------------------------- + +const NON_TARGET_GUIDANCE = + 'For target-realm I/O, edit files in the local workspace using your native filesystem tools — this tool is reserved for non-target realms (scratch, source, catalog, base, etc.).'; + +function buildRealmReadTool(client: BoxelCLIClient): BoxelToolDefinition { + return { + name: 'realm_read_file', + description: `Read a file from a non-target realm as card source. ${NON_TARGET_GUIDANCE} Auth: per-realm JWT.`, + parameters: { + type: 'object', + properties: { + 'realm-url': { + type: 'string', + description: 'Absolute URL of the realm to read from.', + }, + path: { + type: 'string', + description: 'Realm-relative file path.', + }, + }, + required: ['realm-url', 'path'], + }, + execute: async (args) => { + let realmUrl = requireStringArg(args, 'realm-url', 'realm_read_file'); + let path = requireStringArg(args, 'path', 'realm_read_file'); + let result = await client.read(realmUrl, path); + if (!result.ok) { + return { error: result.error, status: result.status }; + } + try { + return JSON.parse(result.content ?? ''); + } catch { + return { content: result.content }; + } + }, + }; +} + +function buildRealmWriteTool(client: BoxelCLIClient): BoxelToolDefinition { + return { + name: 'realm_write_file', + description: `Write a file to a non-target realm. The path must include the file extension. ${NON_TARGET_GUIDANCE} Auth: per-realm JWT.`, + parameters: { + type: 'object', + properties: { + 'realm-url': { + type: 'string', + description: 'Absolute URL of the realm to write to.', + }, + path: { + type: 'string', + description: + 'Realm-relative file path with extension (e.g., "my-card.gts", "Card/1.json").', + }, + content: { type: 'string', description: 'File content.' }, + }, + required: ['realm-url', 'path', 'content'], + }, + execute: async (args) => { + let realmUrl = requireStringArg(args, 'realm-url', 'realm_write_file'); + let path = requireStringArg(args, 'path', 'realm_write_file'); + let content = requireStringArg(args, 'content', 'realm_write_file'); + return client.write(realmUrl, path, content); + }, + }; +} + +function buildRealmDeleteTool(client: BoxelCLIClient): BoxelToolDefinition { + return { + name: 'realm_delete_file', + description: `Delete a file from a non-target realm. ${NON_TARGET_GUIDANCE} Auth: per-realm JWT.`, + parameters: { + type: 'object', + properties: { + 'realm-url': { + type: 'string', + description: 'Absolute URL of the realm to delete from.', + }, + path: { + type: 'string', + description: 'Realm-relative file path to delete.', + }, + }, + required: ['realm-url', 'path'], + }, + execute: async (args) => { + let realmUrl = requireStringArg(args, 'realm-url', 'realm_delete_file'); + let path = requireStringArg(args, 'path', 'realm_delete_file'); + return client.delete(realmUrl, path); + }, + }; +} + +function buildRealmSearchTool(client: BoxelCLIClient): BoxelToolDefinition { + return { + name: 'realm_search', + description: + 'Search for cards in a realm using a structured query. Works for both target and non-target realms — realm-index queries have no workspace-fs equivalent. Auth: per-realm JWT.', + parameters: { + type: 'object', + properties: { + 'realm-url': { + type: 'string', + description: 'Absolute URL of the realm to search.', + }, + query: { + type: ['object', 'string'], + description: + 'Search query (filter, sort, page) — JSON object or JSON-encoded string.', + }, + }, + required: ['realm-url', 'query'], + }, + execute: async (args) => { + let realmUrl = requireStringArg(args, 'realm-url', 'realm_search'); + let raw = args.query; + let query: Record; + if (typeof raw === 'string') { + try { + query = JSON.parse(raw) as Record; + } catch { + return { + error: + "Invalid JSON for 'query' in realm_search: expected valid JSON.", + }; + } + } else if (raw && typeof raw === 'object') { + query = raw as Record; + } else { + return { + error: + "Invalid 'query' argument for realm_search: expected a JSON object or JSON-encoded string.", + }; + } + let result = await client.search(realmUrl, query); + return result.ok + ? { data: result.data } + : { error: result.error, status: result.status }; + }, + }; +} + +// --------------------------------------------------------------------------- +// Other client wrappers (kept target-bound — they have no non-target use case +// in the factory loop, or are server-level) +// --------------------------------------------------------------------------- + +function buildFetchTranspiledModuleTool( + client: BoxelCLIClient, + config: BoxelToolConfig, +): BoxelToolDefinition { + return { + name: 'read_transpiled', + description: + "Debugging tool ONLY for investigating runtime errors in .gts modules you've written. Use when an eval or instantiate validation error reports a line/column number — those line numbers refer to the transpiled output, not your .gts source, so fetching the transpiled output is how you locate the offending source construct. Never use the transpiled output as a reference for how to write code. Do NOT copy its patterns (setComponentTemplate, precompileTemplate, wire-format templates, base64 CSS imports) into source — always write idiomatic Ember /