diff --git a/README.md b/README.md index 4302564..58e8e9f 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,41 @@ The CLI reads files from its own filesystem namespace. A container or remote age JSON commands return `{ "code": number, "data": object | null, "message": string }`. Exit codes: `0` success, `1` failure, `2` usage, `3` app not running, `4` authentication or revocation, `5` conflict, `6` partial batch, `7` protected storage unavailable, `8` unsupported API or capability. A partial batch reports every file and a resume ID. No binary content or credential appears in command output. +## Profiles + +Profile commands require an app that advertises `profileApiVersion: "v1"` and the relevant `profileRead`, `profileWrite`, `profileSelection`, or `profileActions` capability. They require the capability-compatible application proposed in [ExtraBrain PR #1047](https://github.com/ExtraBrainApp/ExtraBrain/pull/1047); released apps may not yet expose these routes. Like document and session commands, they use the token-free loopback listener and `EXTRABRAIN_PORT`, never launch the app, and never write application settings directly. + +```sh +extrabrain --json profiles list +extrabrain --json profiles get system assistant +extrabrain --json profiles get custom +extrabrain --json profiles selection + +extrabrain --json profiles create --name "Research" --description "Evidence first" --prompt "Inspect the evidence" --icon brain --use-full-session-context +extrabrain --json profiles update custom --description "" --reset-use-full-session-context +extrabrain --json profiles update system --disabled +extrabrain --json profiles delete custom + +extrabrain --json profiles pin system assistant +extrabrain --json profiles pin custom +extrabrain --json profiles auto +``` + +Use `--enabled` or `--disabled` for profiles and actions. Profile behavior flags are `--expand-window-on-analysis`, `--analyze-on-region-capture`, `--use-full-session-context`, `--auto-analyze-topics`, and `--auto-analyze-questions`; add `--no-` to set false or `--reset-` to remove a stored override. Empty quoted text clears `description` or `prompt`. Create and update also accept one strict JSON object with `--input ''` or `--input-file path`; JSON input cannot be mixed with field flags. The app enforces the published limits and icon allowlists, and the CLI rejects malformed local inputs before sending them. + +```sh +extrabrain --json profiles actions list system assistant +extrabrain --json profiles actions get custom +extrabrain --json profiles actions create custom --name "Inspect" --prompt "Inspect evidence" --icon target +extrabrain --json profiles actions update system assistant --disabled +extrabrain --json profiles actions delete custom +extrabrain --json profiles actions order system assistant +``` + +Action ordering must list every action owned by that profile exactly once, including disabled actions. The profile path binds ownership. Built-in actions may only be enabled or disabled and cannot be deleted; the app also enforces the different Pro rules for custom profiles, custom actions, system controls, and existing custom actions under system profiles. + +Every mutation sends `expectedRevision` and `requestId`. By default the CLI reads the target first to use its current revision and generates a UUID request ID, which it returns with the expected revision in JSON data and human output. Supply those values with `--revision --request-id ` to make an exact retry. For a lost response, reuse the exact command, route, payload, revision, and ID. The CLI never silently retries conflicts or uncertain results. On `REVISION_CONFLICT`, read again, merge, and submit a new request ID. On `REQUEST_ID_REUSE` or `OPERATION_STATE_UNKNOWN`, inspect profiles/actions before deciding whether a new intent is needed. Recorded app replays retain their `replayed` and `revision` response fields. + ## Document groups Create a named group or append another batch to the same group: diff --git a/src/profileCommands.service.ts b/src/profileCommands.service.ts new file mode 100644 index 0000000..5bdd45e --- /dev/null +++ b/src/profileCommands.service.ts @@ -0,0 +1,321 @@ +import { randomUUID } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { CliApiError, type DocumentApiClient } from './apiClient.service' +import { requestJson } from './request.service' +import { CliExitCode, type CliResult, type ParsedArguments } from './types' + +type JsonObject = Record +type ProfileKind = 'system' | 'custom' + +const PROFILE_SETTINGS = [ + 'expandWindowOnAnalysis', + 'analyzeOnRegionCapture', + 'useFullSessionContext', + 'autoAnalyzeTopics', + 'autoAnalyzeQuestions' +] as const + +const PROFILE_ICONS = new Set([ + 'assistant', 'interview', 'system_design', 'behavioral', 'meeting', 'hr_screen', 'bug', + 'search', 'braces', 'database', 'sparkles', 'terminal', 'lightbulb', 'target', + 'message_square', 'brain', 'book_open', 'presentation', 'rocket', 'shield', 'wrench', + 'flask', 'chart', 'cpu', 'camera', 'microphone', 'pen_tool', 'palette', + 'graduation_cap', 'clipboard_check', 'list_checks', 'network', 'git_branch', 'hammer', + 'puzzle', 'file_text', 'bot', 'compass', 'life_buoy', 'zap', 'trophy', 'star', + 'megaphone', 'headphones', 'monitor', 'globe', 'map', 'workflow' +]) +const ACTION_ICONS = new Set([ + 'book_open_text', 'circle_question', 'message_reply', 'messages_square', 'pen_line', + 'clipboard_list', 'wand', ...PROFILE_ICONS +]) + +const PROFILE_BEHAVIOR_FLAGS = PROFILE_SETTINGS.flatMap((name) => { + const flagName = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) + return [flagName, `no-${flagName}`, `reset-${flagName}`] + }) + +const assertFlags = (parsed: ParsedArguments, allowed: readonly string[]): void => { + const permitted = new Set(allowed) + for (const name of parsed.flags.keys()) { + if (!permitted.has(name)) throw new Error(`--${name} is not supported by this profiles command`) + } +} + +const assertArity = (command: readonly string[], expected: number): void => { + if (command.length !== expected) throw new Error('This profiles command has unexpected positional arguments') +} + +const READ_FLAGS = ['json'] as const +const MUTATION_FLAGS = ['json', 'revision', 'request-id'] as const +const PROFILE_INPUT_FLAGS = [ + ...MUTATION_FLAGS, + 'input', 'input-file', 'name', 'description', 'prompt', 'icon', 'enabled', 'disabled', + ...PROFILE_BEHAVIOR_FLAGS +] as const +const SYSTEM_PROFILE_INPUT_FLAGS = [...MUTATION_FLAGS, 'input', 'input-file', 'enabled', 'disabled'] as const +const ACTION_INPUT_FLAGS = [...MUTATION_FLAGS, 'input', 'input-file', 'name', 'prompt', 'icon', 'enabled', 'disabled'] as const + +const flag = (parsed: ParsedArguments, name: string): string | undefined => { + const value = parsed.flags.get(name) + if (value === true) throw new Error(`--${name} requires a value`) + return value +} + +const required = (values: readonly string[], index: number, label: string): string => { + const value = values[index] + if (!value) throw new Error(`${label} is required`) + return value +} + +const kind = (value: string): ProfileKind => { + if (value === 'system' || value === 'custom') return value + throw new Error('profile kind must be system or custom') +} + +const id = (value: string, label: string): string => { + if (!/^[a-zA-Z0-9_-]{1,128}$/.test(value)) throw new Error(`${label} must be a valid profile or action ID`) + return value +} + +const revision = (value: string | undefined): number | undefined => { + if (value === undefined) return undefined + if (!/^(0|[1-9][0-9]*)$/.test(value) || !Number.isSafeInteger(Number(value))) { + throw new Error('--revision must be a nonnegative safe integer') + } + return Number(value) +} + +const readInput = async (parsed: ParsedArguments): Promise => { + const inline = flag(parsed, 'input') + const file = flag(parsed, 'input-file') + if (inline !== undefined && file !== undefined) throw new Error('--input and --input-file are mutually exclusive') + const source = file === undefined ? inline : await readFile(file, 'utf8') + if (source === undefined) return undefined + try { + const parsedInput: unknown = JSON.parse(source) + if (!parsedInput || typeof parsedInput !== 'object' || Array.isArray(parsedInput)) { + throw new Error('input must be a JSON object') + } + return parsedInput as JsonObject + } catch (error) { + if (error instanceof Error && error.message === 'input must be a JSON object') throw error + throw new Error('input must contain valid JSON') + } +} + +const assertKeys = (input: JsonObject, allowed: readonly string[], label: string): void => { + const unknown = Object.keys(input).filter((key) => !allowed.includes(key)) + if (unknown.length) throw new Error(`${label} contains unsupported fields: ${unknown.join(', ')}`) +} + +const text = (input: JsonObject, key: string, max: number, requiredField: boolean): void => { + const value = input[key] + if (value === undefined && !requiredField) return + if (typeof value !== 'string' || value.length > max) throw new Error(`${key} must be text of at most ${max} characters`) +} + +const boolean = (input: JsonObject, key: string): void => { + if (input[key] !== undefined && typeof input[key] !== 'boolean') throw new Error(`${key} must be true or false`) +} + +const settings = (input: JsonObject): void => { + if (input.settings === undefined) return + if (!input.settings || typeof input.settings !== 'object' || Array.isArray(input.settings)) throw new Error('settings must be an object') + const settingInput = input.settings as JsonObject + assertKeys(settingInput, PROFILE_SETTINGS, 'settings') + for (const key of PROFILE_SETTINGS) { + if (settingInput[key] !== undefined && settingInput[key] !== null && typeof settingInput[key] !== 'boolean') { + throw new Error(`settings.${key} must be true, false, or null`) + } + } +} + +const validateProfile = (input: JsonObject, create: boolean): void => { + assertKeys(input, ['name', 'description', 'prompt', 'icon', 'enabled', 'settings'], 'profile input') + text(input, 'name', 64, create) + text(input, 'description', 300, create) + text(input, 'prompt', 2000, create) + if (input.icon !== undefined && (typeof input.icon !== 'string' || !PROFILE_ICONS.has(input.icon))) throw new Error('icon is not a supported profile icon') + boolean(input, 'enabled') + settings(input) + if (!create && (Object.keys(input).length === 0 || (Object.keys(input).length === 1 && Object.keys(input.settings as JsonObject ?? {}).length === 0))) { + throw new Error('profile patch must contain an editable field') + } +} + +const validateAction = (input: JsonObject, create: boolean): void => { + assertKeys(input, ['name', 'prompt', 'icon', 'enabled'], 'action input') + text(input, 'name', 40, create) + text(input, 'prompt', 1000, create) + if (input.icon !== undefined && (typeof input.icon !== 'string' || !ACTION_ICONS.has(input.icon))) throw new Error('icon is not a supported action icon') + boolean(input, 'enabled') + if (!create && Object.keys(input).length === 0) throw new Error('action patch must contain an editable field') +} + +const commandFields = (parsed: ParsedArguments, action: 'profile' | 'action'): JsonObject => { + const fields: JsonObject = {} + for (const name of ['name', 'description', 'prompt', 'icon']) { + const value = flag(parsed, name) + if (value !== undefined) fields[name] = value + } + if (parsed.flags.has('enabled') && parsed.flags.has('disabled')) throw new Error('--enabled and --disabled are mutually exclusive') + if (parsed.flags.has('enabled')) fields.enabled = true + if (parsed.flags.has('disabled')) fields.enabled = false + if (action === 'profile') { + const value: JsonObject = {} + for (const name of PROFILE_SETTINGS) { + const cliName = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) + const reset = `reset-${cliName}` + if (parsed.flags.has(cliName) && parsed.flags.has(`no-${cliName}`)) throw new Error(`--${cliName} and --no-${cliName} are mutually exclusive`) + if (parsed.flags.has(cliName)) value[name] = true + if (parsed.flags.has(`no-${cliName}`)) value[name] = false + if (parsed.flags.has(reset)) { + if (value[name] !== undefined) throw new Error(`--${reset} cannot be combined with --${cliName}`) + value[name] = null + } + } + if (Object.keys(value).length) fields.settings = value + } + return fields +} + +const mergeInput = async (parsed: ParsedArguments, action: 'profile' | 'action'): Promise => { + const json = await readInput(parsed) + const flags = commandFields(parsed, action) + if (json && Object.keys(flags).length) throw new Error('JSON input cannot be combined with field flags') + return json ?? flags +} + +const currentRevision = async (client: DocumentApiClient, path: string): Promise => { + const data = await requestJson(client, path) + const value = data.revision + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new CliApiError('INVALID_RESPONSE', 'ExtraBrain did not return a valid settings revision') + } + return value +} + +const mutation = async ( + client: DocumentApiClient, + method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', + path: string, + revisionPath: string, + parsed: ParsedArguments, + field: string, + value?: unknown +): Promise => { + const expectedRevision = revision(flag(parsed, 'revision')) ?? await currentRevision(client, revisionPath) + const requestId = flag(parsed, 'request-id') ?? randomUUID() + if (requestId.length < 1 || requestId.length > 200) throw new Error('--request-id must contain 1 to 200 characters') + const data = method === 'DELETE' + ? await requestJson(client, path, method, undefined, { + 'x-extrabrain-expected-revision': String(expectedRevision), + 'x-extrabrain-request-id': requestId + }) + : await requestJson(client, path, method, { requestId, expectedRevision, [field]: value }) + return { + code: CliExitCode.SUCCESS, + data: { ...data, expectedRevision, requestId }, + message: `Profile operation completed. Request ID: ${requestId}; expected revision: ${expectedRevision}${data.replayed === true ? ' (replayed)' : ''}.` + } +} + +const profilePath = (profileKind: ProfileKind, profileId: string): string => + `/api/v1/profiles/${profileKind}/${encodeURIComponent(profileId)}` + +const actionsPath = (profileKind: ProfileKind, profileId: string): string => + `${profilePath(profileKind, profileId)}/actions` + +const read = async (client: DocumentApiClient, path: string, message: string): Promise => ({ + code: CliExitCode.SUCCESS, data: await requestJson(client, path), message +}) + +export const executeProfileCommand = async (parsed: ParsedArguments, client: DocumentApiClient): Promise => { + const command = parsed.command + const operation = command[1] + if (operation === 'list') { + assertArity(command, 2); assertFlags(parsed, READ_FLAGS) + return read(client, '/api/v1/profiles', 'Profiles listed.') + } + if (operation === 'get') { + assertArity(command, 4); assertFlags(parsed, READ_FLAGS) + return read(client, profilePath(kind(required(command, 2, 'profile kind')), id(required(command, 3, 'profile ID'), 'profile ID')), 'Profile retrieved.') + } + if (operation === 'selection') { + assertArity(command, 2); assertFlags(parsed, READ_FLAGS) + return read(client, '/api/v1/profiles/selection', 'Profile selection retrieved.') + } + if (operation === 'pin') { + assertArity(command, 4); assertFlags(parsed, MUTATION_FLAGS) + const profileKind = kind(required(command, 2, 'profile kind')) + const profileId = id(required(command, 3, 'profile ID'), 'profile ID') + return mutation(client, 'PATCH', '/api/v1/profiles/selection', '/api/v1/profiles/selection', parsed, 'selection', { mode: 'pinned', profile: { kind: profileKind, id: profileId } }) + } + if (operation === 'auto') { + assertArity(command, 2); assertFlags(parsed, MUTATION_FLAGS) + return mutation(client, 'PATCH', '/api/v1/profiles/selection', '/api/v1/profiles/selection', parsed, 'selection', { mode: 'auto' }) + } + if (operation === 'create') { + assertArity(command, 2); assertFlags(parsed, PROFILE_INPUT_FLAGS) + const input = await mergeInput(parsed, 'profile') + validateProfile(input, true) + return mutation(client, 'POST', '/api/v1/profiles/custom', '/api/v1/profiles', parsed, 'profile', input) + } + if (operation === 'update') { + assertArity(command, 4) + const profileKind = kind(required(command, 2, 'profile kind')) + const profileId = id(required(command, 3, 'profile ID'), 'profile ID') + assertFlags(parsed, profileKind === 'system' ? SYSTEM_PROFILE_INPUT_FLAGS : PROFILE_INPUT_FLAGS) + const input = await mergeInput(parsed, 'profile') + if (profileKind === 'system') { + if (Object.keys(input).length !== 1 || typeof input.enabled !== 'boolean') throw new Error('system profile updates require exactly --enabled or --disabled') + if (profileId === 'assistant' && input.enabled === false) throw new Error('Assistant must remain enabled') + } else validateProfile(input, false) + return mutation(client, 'PATCH', profilePath(profileKind, profileId), profilePath(profileKind, profileId), parsed, 'patch', input) + } + if (operation === 'delete') { + assertArity(command, 4); assertFlags(parsed, MUTATION_FLAGS) + const profileKind = kind(required(command, 2, 'profile kind')) + const profileId = id(required(command, 3, 'profile ID'), 'profile ID') + if (profileKind === 'system') throw new Error('system profiles cannot be deleted') + return mutation(client, 'DELETE', profilePath(profileKind, profileId), profilePath(profileKind, profileId), parsed, 'unused') + } + if (operation !== 'actions') throw new Error('Unknown profiles command') + const action = required(command, 2, 'profiles actions command') + const profileKind = kind(required(command, 3, 'profile kind')) + const profileId = id(required(command, 4, 'profile ID'), 'profile ID') + const base = actionsPath(profileKind, profileId) + if (action === 'list') { + assertArity(command, 5); assertFlags(parsed, READ_FLAGS) + return read(client, base, 'Profile actions listed.') + } + if (action === 'get') { + assertArity(command, 6); assertFlags(parsed, READ_FLAGS) + return read(client, `${base}/${encodeURIComponent(id(required(command, 5, 'action ID'), 'action ID'))}`, 'Profile action retrieved.') + } + if (action === 'create') { + assertArity(command, 5); assertFlags(parsed, ACTION_INPUT_FLAGS) + const input = await mergeInput(parsed, 'action') + validateAction(input, true) + return mutation(client, 'POST', base, base, parsed, 'action', input) + } + if (action === 'update') { + assertArity(command, 6); assertFlags(parsed, ACTION_INPUT_FLAGS) + const actionId = id(required(command, 5, 'action ID'), 'action ID') + const input = await mergeInput(parsed, 'action') + validateAction(input, false) + return mutation(client, 'PATCH', `${base}/${encodeURIComponent(actionId)}`, `${base}/${encodeURIComponent(actionId)}`, parsed, 'patch', input) + } + if (action === 'delete') { + assertArity(command, 6); assertFlags(parsed, MUTATION_FLAGS) + const actionId = id(required(command, 5, 'action ID'), 'action ID') + return mutation(client, 'DELETE', `${base}/${encodeURIComponent(actionId)}`, `${base}/${encodeURIComponent(actionId)}`, parsed, 'unused') + } + if (action === 'order') { + assertFlags(parsed, MUTATION_FLAGS) + const actionIds = command.slice(5).map((value) => id(value, 'action ID')) + if (!actionIds.length) throw new Error('at least one action ID is required for order') + return mutation(client, 'PUT', `${base}/order`, base, parsed, 'actionIds', actionIds) + } + throw new Error('Unknown profiles actions command') +} diff --git a/src/protocol.ts b/src/protocol.ts index 33127c8..429b6f0 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -10,6 +10,12 @@ export type DocumentCapability = | 'originalExport' | 'revisionSafeDelete' +export type ProfileCapability = + | 'profileRead' + | 'profileWrite' + | 'profileSelection' + | 'profileActions' + export const requireCapabilities = ( discovery: Record, required: readonly DocumentCapability[] @@ -59,3 +65,31 @@ export const requireSessionCapabilities = ( } } } + +export const requiredProfileCapabilities = (command: readonly string[]): ProfileCapability[] => { + const operation = command[1] + if (operation === 'list' || operation === 'get') return ['profileRead'] + if (operation === 'selection') return ['profileSelection'] + if (operation === 'create' || operation === 'update' || operation === 'delete') return ['profileWrite'] + if (operation === 'pin' || operation === 'auto') return ['profileSelection'] + if (operation === 'actions') return ['profileActions'] + return [] +} + +export const requireProfileCapabilities = ( + discovery: Record, + required: readonly ProfileCapability[] +): void => { + if (discovery.apiVersion !== 'v1' || discovery.profileApiVersion !== 'v1') { + throw new CliApiError('UNSUPPORTED_API_VERSION', 'ExtraBrain profile API v1 is required') + } + const capabilities = discovery.capabilities + if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) { + throw new CliApiError('UNSUPPORTED_CAPABILITY', 'ExtraBrain did not advertise profile capabilities') + } + for (const capability of required) { + if ((capabilities as Record)[capability] !== true) { + throw new CliApiError('UNSUPPORTED_CAPABILITY', `ExtraBrain does not support ${capability}`) + } + } +} diff --git a/src/run.service.ts b/src/run.service.ts index abe10fc..2f5a82e 100644 --- a/src/run.service.ts +++ b/src/run.service.ts @@ -1,8 +1,9 @@ import { LocalPrincipalScope } from './scopes' import { CliApiError } from './apiClient.service' import { executeDocumentsCommand } from './documentCommands.service' +import { executeProfileCommand } from './profileCommands.service' import { FileExpansionError } from './fileExpansion.service' -import { requireCapabilities, requiredDocumentCapabilities } from './protocol' +import { requireCapabilities, requireProfileCapabilities, requiredDocumentCapabilities, requiredProfileCapabilities } from './protocol' import { executeSessionCommand, parseSessionCommand } from './sessionCommands.service' import { updateCli } from './update' import { CLI_VERSION } from './version' @@ -39,6 +40,13 @@ Commands: sessions export --output sessions screenshot export --output [--representation ] sessions analyses export --output + profiles list + profiles get + profiles create (--input | --input-file | --name --description --prompt ) [profile flags] + profiles update [profile flags] + profiles delete [--revision ] [--request-id ] + profiles selection | profiles pin | profiles auto + profiles actions [action-id ...] update --version @@ -72,7 +80,14 @@ const VALUE_FLAGS = new Set([ 'since', 'until', 'snapshot', - 'representation' + 'representation', + 'input', + 'input-file', + 'name', + 'description', + 'prompt', + 'icon', + 'request-id' ]) const setFlag = (flags: Map, name: string, value: string | true): void => { @@ -104,7 +119,7 @@ const parseArguments = (args: readonly string[]): ParsedArguments => { if (inlineValue !== undefined) setFlag(flags, name, inlineValue) else if (VALUE_FLAGS.has(name)) { const next = args[index + 1] - if (!next || next.startsWith('--')) throw new Error(`--${name} requires a value`) + if (next === undefined || next.startsWith('--')) throw new Error(`--${name} requires a value`) setFlag(flags, name, next) index += 1 } else setFlag(flags, name, true) @@ -211,6 +226,41 @@ const mapError = (error: unknown): CliResult => { if (error.code === 'PROTECTED_STORAGE_UNAVAILABLE') { return { code: CliExitCode.PROTECTED_STORAGE_UNAVAILABLE, message } } + if (error.code === 'PRO_REQUIRED') { + return { + code: CliExitCode.FAILURE, + data: { error: error.code, details: error.details ?? null }, + message: `${message}. This mutation requires ExtraBrain Pro; reads and eligible system controls remain available.` + } + } + if (error.code === 'READ_ONLY') { + return { + code: CliExitCode.FAILURE, + data: { error: error.code, details: error.details ?? null }, + message: `${message}. Read the resource and use only its editable fields.` + } + } + if (error.code === 'OPERATION_STATE_UNKNOWN') { + return { + code: CliExitCode.CONFLICT, + data: { error: error.code, details: error.details ?? null }, + message: `${message}. Inspect current profiles or actions before deciding whether a new intent is necessary.` + } + } + if (error.code === 'REQUEST_ID_REUSE') { + return { + code: CliExitCode.CONFLICT, + data: { error: error.code, details: error.details ?? null }, + message: `${message}. Retry only with the exact original route, body, revision, and request ID; otherwise use a new intent.` + } + } + if (error.code === 'REVISION_CONFLICT') { + return { + code: CliExitCode.CONFLICT, + data: { error: error.code, details: error.details ?? null }, + message: `${message}. Read the latest resource revision, merge the change, and submit a new request ID.` + } + } if (['PAIRING_REQUIRED', 'AUTHENTICATION_REQUIRED', 'FORBIDDEN'].includes(error.code) || error.status === 401 || error.status === 403) { return { code: CliExitCode.AUTHENTICATION, @@ -271,6 +321,11 @@ const executeCommand = async ( const request = parseSessionCommand(parsed) return executeSessionCommand(request, dependencies.apiFactory(null)) } + if (command === 'profiles') { + const publicClient = dependencies.apiFactory(null) + requireProfileCapabilities(await publicClient.discovery(), requiredProfileCapabilities(parsed.command)) + return executeProfileCommand(parsed, publicClient) + } throw new Error(HELP) } diff --git a/test/profileCommands.service.test.ts b/test/profileCommands.service.test.ts new file mode 100644 index 0000000..a05c2a0 --- /dev/null +++ b/test/profileCommands.service.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest' +import { CliApiError, type DocumentApiClient } from '../src/apiClient.service' +import { ResumeStore } from '../src/resumeStore.service' +import { runCli } from '../src/run.service' +import { CliExitCode, type CliDependencies } from '../src/types' + +const discovery = { + apiVersion: 'v1', + profileApiVersion: 'v1', + capabilities: { profileRead: true, profileWrite: true, profileSelection: true, profileActions: true } +} + +type JsonMock = ReturnType Promise>>> + +const harness = (json: JsonMock = vi.fn(async () => ({ revision: 9 }))): { dependencies: CliDependencies; json: JsonMock; out: string[]; errors: string[] } => { + const out: string[] = [] + const errors: string[] = [] + return { + dependencies: { + apiFactory: () => ({ discovery: async () => discovery, json }) as unknown as DocumentApiClient, + credentialStore: { clear: vi.fn(), read: vi.fn(), write: vi.fn() }, + output: { write: (value) => out.push(value), error: (value) => errors.push(value) }, + resumeStore: new ResumeStore('/tmp/extrabrain-profile-command-test') + }, + json, + out, + errors + } +} + +describe('profile commands', () => { + it('sends every documented mutation route with revision, request ID, and documented payload or headers', async () => { + const json: JsonMock = vi.fn(async () => ({ revision: 10, replayed: false })) + const test = harness(json) + const commands = [ + ['profiles', 'create', '--revision', '9', '--request-id', 'create-1', '--name', 'Research', '--description', '', '--prompt', '', '--icon', 'brain', '--disabled', '--use-full-session-context'], + ['profiles', 'update', 'custom', 'profile1', '--revision', '9', '--request-id', 'update-1', '--description', '', '--reset-use-full-session-context'], + ['profiles', 'delete', 'custom', 'profile1', '--revision', '9', '--request-id', 'delete-1'], + ['profiles', 'update', 'system', 'assistant', '--revision', '9', '--request-id', 'system-1', '--enabled'], + ['profiles', 'pin', 'system', 'assistant', '--revision', '9', '--request-id', 'pin-1'], + ['profiles', 'auto', '--revision', '9', '--request-id', 'auto-1'], + ['profiles', 'actions', 'create', 'custom', 'profile1', '--revision', '9', '--request-id', 'action-create', '--name', '', '--prompt', '', '--icon', 'wand'], + ['profiles', 'actions', 'update', 'system', 'assistant', 'action1', '--revision', '9', '--request-id', 'action-update', '--disabled'], + ['profiles', 'actions', 'delete', 'system', 'assistant', 'action1', '--revision', '9', '--request-id', 'action-delete'], + ['profiles', 'actions', 'order', 'system', 'assistant', 'action1', 'action2', '--revision', '9', '--request-id', 'order-1'] + ] + for (const command of commands) await expect(runCli(['--json', ...command], test.dependencies)).resolves.toBe(0) + const calls = json.mock.calls.filter(([path]) => path !== '/.well-known/extrabrain') + expect(calls.map(([path, init]) => [path, init?.method])).toEqual([ + ['/api/v1/profiles/custom', 'POST'], + ['/api/v1/profiles/custom/profile1', 'PATCH'], + ['/api/v1/profiles/custom/profile1', 'DELETE'], + ['/api/v1/profiles/system/assistant', 'PATCH'], + ['/api/v1/profiles/selection', 'PATCH'], + ['/api/v1/profiles/selection', 'PATCH'], + ['/api/v1/profiles/custom/profile1/actions', 'POST'], + ['/api/v1/profiles/system/assistant/actions/action1', 'PATCH'], + ['/api/v1/profiles/system/assistant/actions/action1', 'DELETE'], + ['/api/v1/profiles/system/assistant/actions/order', 'PUT'] + ]) + const createBody = JSON.parse(String(calls[0]?.[1]?.body)) + expect(createBody).toEqual({ requestId: 'create-1', expectedRevision: 9, profile: { name: 'Research', description: '', prompt: '', icon: 'brain', enabled: false, settings: { useFullSessionContext: true } } }) + expect(JSON.parse(String(calls[1]?.[1]?.body)).patch).toEqual({ description: '', settings: { useFullSessionContext: null } }) + expect(new Headers(calls[2]?.[1]?.headers).get('x-extrabrain-expected-revision')).toBe('9') + expect(new Headers(calls[2]?.[1]?.headers).get('x-extrabrain-request-id')).toBe('delete-1') + expect(JSON.parse(String(calls[9]?.[1]?.body)).actionIds).toEqual(['action1', 'action2']) + expect(JSON.parse(test.out[0]!).data.requestId).toBe('create-1') + }) + + it('reads a current revision for a new intent and keeps a supplied retry exactly controlled', async () => { + const json: JsonMock = vi.fn(async (_path: string, init?: RequestInit) => { + if (!init?.method || init.method === 'GET') return { revision: 27 } + return { revision: 28, replayed: true } + }) + const test = harness(json) + await expect(runCli(['--json', 'profiles', 'auto'], test.dependencies)).resolves.toBe(0) + expect(json.mock.calls.map(([path, init]) => [path, init?.method ?? 'GET'])).toEqual([ + ['/api/v1/profiles/selection', 'GET'], + ['/api/v1/profiles/selection', 'PATCH'] + ]) + const body = JSON.parse(String(json.mock.calls[1]?.[1]?.body)) + expect(body.expectedRevision).toBe(27) + expect(body.requestId).toMatch(/^[0-9a-f-]{36}$/) + expect(JSON.parse(test.out[0]!).data).toMatchObject({ revision: 28, replayed: true, requestId: body.requestId }) + }) + + it('rejects invalid local inputs before a request and preserves explicit reset and empty text semantics', async () => { + const test = harness() + await expect(runCli(['profiles', 'update', 'custom', 'profile1'], test.dependencies)).resolves.toBe(CliExitCode.USAGE) + await expect(runCli(['profiles', 'create', '--name', 'x', '--description', 'x', '--prompt', 'x', '--icon', 'nope'], test.dependencies)).resolves.toBe(CliExitCode.USAGE) + await expect(runCli(['profiles', 'actions', 'update', 'system', 'assistant', 'action1', '--name', 'forbidden'], test.dependencies)).resolves.toBe(CliExitCode.SUCCESS) + expect(test.errors[0]).toContain('editable field') + expect(test.errors[1]).toContain('supported profile icon') + }) + + it('rejects unknown, inapplicable, impossible, and trailing input before opening a profile request', async () => { + const json: JsonMock = vi.fn(async () => ({ revision: 9 })) + const test = harness(json) + const invalid = [ + ['profiles', 'create', '--name', 'n', '--description', 'd', '--prompt', 'p', '--enabledd'], + ['profiles', 'delete', 'custom', 'profile1', '--name', 'ignored'], + ['profiles', 'delete', 'system', 'assistant'], + ['profiles', 'update', 'system', 'assistant', '--disabled'], + ['profiles', 'pin', 'custom', 'profile1', 'trailing'], + ['profiles', 'actions', 'get', 'system', 'assistant', 'action1', 'trailing'] + ] + for (const command of invalid) await expect(runCli(command, test.dependencies)).resolves.toBe(CliExitCode.USAGE) + expect(json).not.toHaveBeenCalled() + }) + + it('checks the profile API version and operation capability without affecting older document commands', async () => { + const out: string[] = [] + const dependencies: CliDependencies = { + apiFactory: () => ({ discovery: async () => ({ apiVersion: 'v1', available: true, capabilities: { documentMetadata: true } }), json: vi.fn() }) as unknown as DocumentApiClient, + credentialStore: { clear: vi.fn(), read: vi.fn(), write: vi.fn() }, + output: { write: (value) => out.push(value), error: (value) => out.push(value) }, + resumeStore: new ResumeStore('/tmp/extrabrain-profile-compat-test') + } + await expect(runCli(['--json', 'profiles', 'list'], dependencies)).resolves.toBe(CliExitCode.UNSUPPORTED) + await expect(runCli(['--json', 'documents', 'list'], dependencies)).resolves.toBe(CliExitCode.SUCCESS) + expect(out[0]).toContain('UNSUPPORTED_API_VERSION') + }) + + it.each([ + [['profiles', 'list'], 'profileRead'], + [['profiles', 'create', '--name', 'n', '--description', 'd', '--prompt', 'p'], 'profileWrite'], + [['profiles', 'selection'], 'profileSelection'], + [['profiles', 'actions', 'list', 'system', 'assistant'], 'profileActions'] + ])('requires the advertised %s capability', async (command, capability) => { + const out: string[] = [] + const dependencies: CliDependencies = { + apiFactory: () => ({ discovery: async () => ({ apiVersion: 'v1', profileApiVersion: 'v1', capabilities: {} }), json: vi.fn() }) as unknown as DocumentApiClient, + credentialStore: { clear: vi.fn(), read: vi.fn(), write: vi.fn() }, + output: { write: (value) => out.push(value), error: (value) => out.push(value) }, + resumeStore: new ResumeStore('/tmp/extrabrain-profile-capability-test') + } + await expect(runCli(['--json', ...command], dependencies)).resolves.toBe(CliExitCode.UNSUPPORTED) + expect(out[0]).toContain(capability) + }) + + it.each([ + ['REVISION_CONFLICT', 409, CliExitCode.CONFLICT, 'Read the latest resource revision'], + ['REQUEST_ID_REUSE', 409, CliExitCode.CONFLICT, 'exact original route'], + ['OPERATION_STATE_UNKNOWN', 409, CliExitCode.CONFLICT, 'Inspect current profiles'], + ['PRO_REQUIRED', 403, CliExitCode.FAILURE, 'requires ExtraBrain Pro'], + ['READ_ONLY', 400, CliExitCode.FAILURE, 'editable fields'], + ['NOT_FOUND', 404, CliExitCode.UNSUPPORTED, 'Missing'] + ])('maps %s with actionable recovery', async (code, status, exitCode, message) => { + const test = harness(vi.fn(async () => { throw new CliApiError(code, 'Missing', status) })) + await expect(runCli(['--json', 'profiles', 'list'], test.dependencies)).resolves.toBe(exitCode) + expect((test.out[0] ?? test.errors[0])).toContain(message) + }) +})