From 0a54e20096d102ed77492a99b78e2ac09e70faa1 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Mon, 31 Aug 2026 20:26:27 -0400 Subject: [PATCH] fix: streamline library review and read workflows --- docs/command-reference.md | 14 ++++---- schemas/command-tree.json | 14 ++++---- src/cli/commands/development.ts | 20 ++++++++--- src/cli/commands/library.ts | 36 +++++++++++++++++-- src/cli/commands/operator.ts | 14 +++++--- src/cli/runtime.ts | 3 +- src/cli/support/human-renderer.ts | 16 +++++++++ .../command-boundary/human-renderer.test.ts | 12 +++++++ 8 files changed, 103 insertions(+), 26 deletions(-) diff --git a/docs/command-reference.md b/docs/command-reference.md index 4ae814c..8b937c9 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -2199,7 +2199,7 @@ Operation: read. Result schema: `treeseed.command.library.show/v1`. Execution: `local.library.show`. - `--json`: Emit the stable JSON envelope. -- `--ref `: Exact commit or protected library ref. +- `--ref `: Earlier historical revision; omit for the current library. ### trsd library status @@ -2209,7 +2209,7 @@ Operation: read. Result schema: `treeseed.command.library.status/v1`. Execution: `local.library.status`. - `--json`: Emit the stable JSON envelope. -- `--ref `: Exact commit or protected library ref. +- `--ref `: Earlier historical revision; omit for the current library. ### trsd library paths @@ -2219,7 +2219,7 @@ Operation: read. Result schema: `treeseed.command.library.paths/v1`. Execution: `local.library.paths`. - `--json`: Emit the stable JSON envelope. -- `--ref `: Exact commit or protected library ref. +- `--ref `: Earlier historical revision; omit for the current library. - `--prefix `: Repository-relative path prefix. - `--limit `: Page size. - `--cursor `: Opaque page cursor. @@ -2232,7 +2232,7 @@ Operation: read. Result schema: `treeseed.command.library.read/v1`. Execution: `local.library.read`. - `--json`: Emit the stable JSON envelope. -- `--ref `: Exact commit or protected library ref. +- `--ref `: Earlier historical revision; omit for the current library. ### trsd library search @@ -2242,7 +2242,7 @@ Operation: read. Result schema: `treeseed.command.library.search/v1`. Execution: `local.library.search`. - `--json`: Emit the stable JSON envelope. -- `--ref `: Exact commit or protected library ref. +- `--ref `: Earlier historical revision; omit for the current library. - `--path `: Restrict search to a repository-relative path. - `--limit `: Page size. - `--cursor `: Opaque page cursor. @@ -2255,7 +2255,7 @@ Operation: read. Result schema: `treeseed.command.library.query/v1`. Execution: `local.library.query`. - `--json`: Emit the stable JSON envelope. -- `--ref `: Exact commit or protected library ref. +- `--ref `: Earlier historical revision; omit for the current library. - `--model `: TreeDX content model. - `--input `: YAML or JSON query body. @@ -2267,7 +2267,7 @@ Operation: read. Result schema: `treeseed.command.library.context/v1`. Execution: `local.library.context`. - `--json`: Emit the stable JSON envelope. -- `--ref `: Exact commit or protected library ref. +- `--ref `: Earlier historical revision; omit for the current library. - `--max-items `: Maximum context items. - `--max-tokens `: Maximum context tokens. diff --git a/schemas/command-tree.json b/schemas/command-tree.json index 73dd364..54a4721 100644 --- a/schemas/command-tree.json +++ b/schemas/command-tree.json @@ -5469,7 +5469,7 @@ "options": [ { "name": "--ref", - "description": "Exact commit or protected library ref.", + "description": "Earlier historical revision; omit for the current library.", "type": "string" } ], @@ -5494,7 +5494,7 @@ "options": [ { "name": "--ref", - "description": "Exact commit or protected library ref.", + "description": "Earlier historical revision; omit for the current library.", "type": "string" } ], @@ -5519,7 +5519,7 @@ "options": [ { "name": "--ref", - "description": "Exact commit or protected library ref.", + "description": "Earlier historical revision; omit for the current library.", "type": "string" }, { @@ -5564,7 +5564,7 @@ "options": [ { "name": "--ref", - "description": "Exact commit or protected library ref.", + "description": "Earlier historical revision; omit for the current library.", "type": "string" } ], @@ -5594,7 +5594,7 @@ "options": [ { "name": "--ref", - "description": "Exact commit or protected library ref.", + "description": "Earlier historical revision; omit for the current library.", "type": "string" }, { @@ -5639,7 +5639,7 @@ "options": [ { "name": "--ref", - "description": "Exact commit or protected library ref.", + "description": "Earlier historical revision; omit for the current library.", "type": "string" }, { @@ -5679,7 +5679,7 @@ "options": [ { "name": "--ref", - "description": "Exact commit or protected library ref.", + "description": "Earlier historical revision; omit for the current library.", "type": "string" }, { diff --git a/src/cli/commands/development.ts b/src/cli/commands/development.ts index a289bc0..3ffaaea 100644 --- a/src/cli/commands/development.ts +++ b/src/cli/commands/development.ts @@ -298,9 +298,14 @@ async function restartConsumer(input: { state: LocalSessionState; runtime: Devel if (target.operations.cleanup) runOneShotOperation(state, target.operations.cleanup, worktree, mode, context.env, { TREESEED_DEVELOPMENT_CLEANUP_SCOPE: 'runtime' }); const resolved = await invoke(context, 'local.dev.environment', { sessionId: state.sessionId, projectId: runtime.project.id, targetId: target.id }) as { environment?: NodeJS.ProcessEnv }; if (target.operations.setup) runOneShotOperation(state, target.operations.setup, worktree, mode, context.env, resolved.environment ?? {}); - startOperation(state, runtime, target, worktree, mode, context.env, resolved.environment ?? {}); - saveState(state, context.env); - await waitForDirectReadiness(target, target.ready.kind === 'process' ? target.ready.graceSeconds : target.ready.timeoutSeconds, state, key); + if (!target.operations.start && target.kind === 'rebuild-restart' && target.operations.build) { + runOneShotOperation(state, target.operations.build, worktree, mode, context.env, resolved.environment ?? {}); + await waitForDirectReadiness(target, target.ready.kind === 'process' ? target.ready.graceSeconds : target.ready.timeoutSeconds); + } else { + startOperation(state, runtime, target, worktree, mode, context.env, resolved.environment ?? {}); + saveState(state, context.env); + await waitForDirectReadiness(target, target.ready.kind === 'process' ? target.ready.graceSeconds : target.ready.timeoutSeconds, state, key); + } if (input.recordGeneration !== false) await markRebuilt(context, state.sessionId, runtime.project.id, target.id, mode, target); } @@ -337,8 +342,13 @@ async function rebuild(invocation: ParsedInvocation, context: CommandContext, st if (target.kind === 'package-watch') await rebuildPackage({ state, runtime, target, worktree: repository.worktree, mode, context }); else if (target.kind === 'rebuild-restart') { if (!target.operations.build) throw new Error(`${selection.projectId}.${selection.targetId} does not declare a build operation.`); - runOneShotOperation(state, target.operations.build, repository.worktree, mode, context.env); - await restartConsumer({ state, runtime, target, worktree: repository.worktree, mode, context }); + const resolved = await invoke(context, 'local.dev.environment', { sessionId, projectId: runtime.project.id, targetId: target.id }) as { environment?: NodeJS.ProcessEnv }; + runOneShotOperation(state, target.operations.build, repository.worktree, mode, context.env, resolved.environment ?? {}); + if (target.operations.start) await restartConsumer({ state, runtime, target, worktree: repository.worktree, mode, context }); + else { + await waitForDirectReadiness(target, target.ready.kind === 'process' ? target.ready.graceSeconds : target.ready.timeoutSeconds); + await markRebuilt(context, sessionId, runtime.project.id, target.id, mode, target); + } } else await restartConsumer({ state, runtime, target, worktree: repository.worktree, mode, context }); const manual: string[] = []; for (const dependent of dependentReactions(record.runtimes, selection.projectId, selection.targetId)) { diff --git a/src/cli/commands/library.ts b/src/cli/commands/library.ts index 77ce9a9..8cfdb81 100644 --- a/src/cli/commands/library.ts +++ b/src/cli/commands/library.ts @@ -15,13 +15,44 @@ const number = (value: unknown) => value === undefined ? undefined : Number(valu async function inputFile(invocation: ParsedInvocation, context: CommandContext) { const file = text(invocation.options.input); if (!file) return {}; - const parsed = parseYaml(await readFile(resolve(context.cwd, file), 'utf8')); + let source = ''; + if (file === '-') { + process.stdin.setEncoding('utf8'); + for await (const chunk of process.stdin) source += String(chunk); + } else source = await readFile(resolve(context.cwd, file), 'utf8'); + const parsed = parseYaml(source); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw Object.assign(new Error('Library input must be one YAML or JSON object.'), { category: 'invalid_input', code: 'library_input_invalid' }); return parsed as Record; } +export async function resolveActiveTeamLibraryProject(invocation: ParsedInvocation, context: CommandContext) { + if (context.operationInvoke) return; + const requestedProject = String(invocation.arguments[0] ?? ''); + if (!requestedProject) return; + const realClient = await createControlPlaneClient(invocation, context, true); + const activeTeamId = realClient.session.activeTeam?.id; + let cursor: string | undefined; + const matches: Record[] = []; + do { + const response = data(await realClient.client.invoke(controlPlaneOperation('projects.list'), { + path: {}, query: { limit: 200, ...(cursor ? { cursor } : {}) }, + })); + for (const project of response.items ?? []) if ([project.id, project.slug].includes(requestedProject) + && (!activeTeamId || String(project.teamId ?? project.team_id ?? '') === activeTeamId)) matches.push(project); + cursor = text(response.page?.nextCursor ?? response.nextCursor); + } while (cursor && matches.length < 2); + if (matches.length !== 1) { + const code = matches.length ? 'project_ambiguous' : 'project_not_found'; + throw Object.assign(new Error(matches.length ? `Project ${requestedProject} is ambiguous.` : `Project ${requestedProject} was not found.`), { + category: matches.length ? 'ambiguous_context' : 'not_found', code, + }); + } + invocation.arguments[0] = String(matches[0]!.id); +} + export async function runLibrary(invocation: ParsedInvocation, context: CommandContext) { const realClient = context.operationInvoke ? null : await createControlPlaneClient(invocation, context, true); + const activeTeamId = realClient?.session?.activeTeam?.id; const invoke = async (operationId: string, input: Input) => context.operationInvoke ? context.operationInvoke(operationId, input) : realClient!.client.invoke(controlPlaneOperation(operationId), input); @@ -30,7 +61,8 @@ export async function runLibrary(invocation: ParsedInvocation, context: CommandC const matches: Record[] = []; do { const response = data(await invoke('projects.list', { path: {}, query: { limit: 200, ...(cursor ? { cursor } : {}) } })); - for (const project of response.items ?? []) if ([project.id, project.slug].includes(requestedProject)) matches.push(project); + for (const project of response.items ?? []) if ([project.id, project.slug].includes(requestedProject) + && (!activeTeamId || String(project.teamId ?? project.team_id ?? '') === activeTeamId)) matches.push(project); cursor = text(response.page?.nextCursor ?? response.nextCursor); } while (cursor && matches.length < 2); if (matches.length !== 1) { diff --git a/src/cli/commands/operator.ts b/src/cli/commands/operator.ts index a4484bc..31d8ff6 100644 --- a/src/cli/commands/operator.ts +++ b/src/cli/commands/operator.ts @@ -40,9 +40,16 @@ function transform(value: unknown, binding: CommandInputBinding) { return value; } +async function inputDocument(fileValue: string, context: CommandContext) { + if (fileValue !== '-') return readFile(resolve(context.cwd, fileValue), 'utf8'); + let source = ''; + process.stdin.setEncoding('utf8'); + for await (const chunk of process.stdin) source += String(chunk); + return source; +} + async function portableSeedBundle(fileValue: string, context: CommandContext) { - const file = resolve(context.cwd, fileValue); - const parsed = parseYaml(await readFile(file, 'utf8')); + const parsed = parseYaml(await inputDocument(fileValue, context)); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw Object.assign(new Error('Seed file must contain one portable seed bundle object.'), { category: 'invalid_input', code: 'seed_bundle_file_invalid' }); return parsed as Record; } @@ -80,8 +87,7 @@ async function operationInput(invocation: ParsedInvocation, context: CommandCont input.body.bundle = parsed; } if (!operation.descriptor.operationId.startsWith('seeds.') && typeof input.body.file === 'string') { - const file = resolve(context.cwd, input.body.file); - const parsed = parseYaml(await readFile(file, 'utf8')); + const parsed = parseYaml(await inputDocument(input.body.file, context)); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw Object.assign(new Error('Input file must contain one YAML or JSON object.'), { category: 'invalid_input', code: 'command_input_file_invalid' }); delete input.body.file; Object.assign(input.body, parsed); diff --git a/src/cli/runtime.ts b/src/cli/runtime.ts index e910fa3..d6e9f05 100644 --- a/src/cli/runtime.ts +++ b/src/cli/runtime.ts @@ -7,7 +7,7 @@ import { isCommandBranch, resolveCommand } from './registry.js'; import { runSecrets } from './commands/secrets.js'; import { runHost } from './commands/host.js'; import { runUsers } from './commands/users.js'; -import { runLibrary } from './commands/library.js'; +import { resolveActiveTeamLibraryProject, runLibrary } from './commands/library.js'; import { runTeams } from './commands/teams.js'; import { runDevelopment } from './commands/development.js'; import { runInbox } from './commands/inbox.js'; @@ -70,6 +70,7 @@ async function execute(invocation: ParsedInvocation, context: CommandContext) { if (invocation.command.execution.kind === 'local' && invocation.command.execution.handlerId.startsWith('local.host.')) return runHost(invocation, context); if (invocation.command.execution.kind === 'local' && invocation.command.path[0] === 'dev') return runDevelopment(invocation, context); if (invocation.command.execution.kind === 'local' && invocation.command.path[0] === 'library') return runLibrary(invocation, context); + if (invocation.command.path.join(' ') === 'library workspace create') await resolveActiveTeamLibraryProject(invocation, context); if (invocation.command.execution.kind === 'local' && invocation.command.path[0] === 'inbox') return runInbox(invocation, context); if (invocation.command.execution.kind === 'local' && invocation.command.path[0] === 'platform') return runPlatform(invocation, context); return runOperator(invocation, context); diff --git a/src/cli/support/human-renderer.ts b/src/cli/support/human-renderer.ts index 5f5fdfd..d4c24da 100644 --- a/src/cli/support/human-renderer.ts +++ b/src/cli/support/human-renderer.ts @@ -82,6 +82,21 @@ export function renderCommunicationResponses(result: Record, op }).join('\n\n\n'); } +function renderLibraryRead(result: Record, options: RenderOptions = {}) { + const payload = result.result && typeof result.result === 'object' && !Array.isArray(result.result) + ? result.result as Record : result; + const files = Array.isArray(payload.files) ? payload.files : payload.file && typeof payload.file === 'object' ? [payload.file] : []; + const ref = payload.resolvedRef ?? payload.ref; + return files.map((value) => { + const file = value as Record; + const source = scalar(file.sourcePath ?? file.path); + const logical = file.logicalPath && file.logicalPath !== file.sourcePath ? ` (${scalar(file.logicalPath)})` : ''; + const heading = `${ansi(options.color === true, '1;36', source)}${ansi(options.color === true, '2', logical)}`; + const provenance = ref ? ansi(options.color === true, '2', `Ref: ${scalar(ref)}`) : ''; + return [heading, provenance, '', markdown(String(file.content ?? file.body ?? ''), options)].filter((line, index) => line || index >= 2).join('\n'); + }).join(`\n\n${ansi(options.color === true, '2;36', '═'.repeat(Math.max(48, Math.min(160, Number(options.width) || 100))))}\n\n`); +} + export function renderHumanCommandResult(value: unknown, options: RenderOptions = {}) { if (!value || typeof value !== 'object') return scalar(value); const envelope = value as { commandPath?: string[]; ok?: boolean; result?: unknown; warnings?: unknown[]; nextActions?: unknown[] }; @@ -121,6 +136,7 @@ export function renderHumanCommandResult(value: unknown, options: RenderOptions } if (path === 'inbox' && result?.interactiveSession === true) return ''; if (path === 'send' && result) return result.humanStreamed === true || result.interactiveSession === true ? '' : renderCommunicationResponses(result, options); + if (path === 'library read' && result) return renderLibraryRead(result, options) || 'No file content was returned.'; const rendered = lines(envelope.result); const warnings = Array.isArray(envelope.warnings) && envelope.warnings.length ? [`Warnings: ${envelope.warnings.map(scalar).join('; ')}`] : []; const next = Array.isArray(envelope.nextActions) && envelope.nextActions.length ? ['Next actions:', ...envelope.nextActions.map((item) => `- ${scalar(item)}`)] : []; diff --git a/tests/unit/command-boundary/human-renderer.test.ts b/tests/unit/command-boundary/human-renderer.test.ts index 9100637..cfeb4d5 100644 --- a/tests/unit/command-boundary/human-renderer.test.ts +++ b/tests/unit/command-boundary/human-renderer.test.ts @@ -19,6 +19,18 @@ test('generic human output renders labels and values without JSON punctuation', assert.doesNotMatch(output, /[{}]/u); }); +test('library read displays exact file content and provenance', () => { + const output = renderHumanCommandResult({ commandPath: ['library', 'read'], ok: true, result: { result: { + resolvedRef: 'abc123', files: [{ logicalPath: 'objectives/core', sourcePath: 'objectives/core.md', + content: '---\ntitle: Core objective\n---\n\n# Direction\n\nBuild **carefully**.' }], + } } }); + assert.match(output, /objectives\/core\.md \(objectives\/core\)/u); + assert.match(output, /Ref: abc123/u); + assert.match(output, /title: Core objective/u); + assert.match(output, /# Direction/u); + assert.match(output, /Build carefully\./u); +}); + test('host security initialization is summarized without dumping its receipt', () => { const output = renderHumanCommandResult({ commandPath: ['host', 'security', 'initialize'], ok: true, result: { verified: true, recoveryBundleVerified: true, receipt: { receiptId: 'security-test', state: 'known-good', sandbox: { brokerReady: true, guestImageDigests: ['sha256:a', 'sha256:a'] }, providerVolume: { encrypted: true } },