diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index 5200856c7..6940e004f 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -167,7 +167,7 @@ not the Markdown presentation or an intermediate Suspense fallback, so existing receipt consumers do not change when a command becomes rendered. `src/operations/` owns shared operation handlers and schemas; -`src/cli-command.ts` defines their small typed definition helper. Domain logic +`src/cli-command.ts` names the `{ signal }` context every handler receives. Domain logic remains in `src/` over `foundation.ts` and `media-process.ts`, while `src/index.ts` remains the package library entry. diff --git a/examples/audiobook-curator/src/cli-command.ts b/examples/audiobook-curator/src/cli-command.ts index e8801b650..488515437 100644 --- a/examples/audiobook-curator/src/cli-command.ts +++ b/examples/audiobook-curator/src/cli-command.ts @@ -1,37 +1,11 @@ /** - * The operation-definition helper behind `src/operations/*.ts`: one shared - * core (`id`, `inputSchema`, `handler`, `resultSchema`) that the generated - * MCP routes and the routed `src/cli/` commands both consume. The manual - * CLI projection (`cli.parse`/`usage`/`exitCode`) and its `runCliCommands` - * dispatcher were retired by the #102 stage-3 migration — the framework - * compiles `src/cli/**` routes into the executable instead. + * The context every `src/operations/*.ts` handler receives from the generated + * MCP routes and the routed `src/cli/` commands. The manual CLI projection + * (`cli.parse`/`usage`/`exitCode`) and its `runCliCommands` dispatcher were + * retired by the #102 stage-3 migration — the framework compiles `src/cli/**` + * routes into the executable instead. */ export interface CliCommandContext { readonly signal: AbortSignal; } - -interface Schema { - readonly _output: Output; - parse(value: unknown): Output; -} - -/** Exported so consumer declaration emit can name the registry types (#174). */ -export interface CliCommandDefinition< - InputSchema extends Schema, - ResultSchema extends Schema, - HandlerInput, -> { - readonly handler: (input: HandlerInput, context: CliCommandContext) => unknown; - readonly id: string; - readonly inputSchema: InputSchema; - readonly resultSchema: ResultSchema; -} - -export const defineCliCommand = < - InputSchema extends Schema, - ResultSchema extends Schema, - HandlerInput, ->( - definition: CliCommandDefinition, -): CliCommandDefinition => Object.freeze(definition); diff --git a/examples/audiobook-curator/src/cli/acoustic-identify.ts b/examples/audiobook-curator/src/cli/acoustic-identify.ts index 45c397c4b..fd6a6e74f 100644 --- a/examples/audiobook-curator/src/cli/acoustic-identify.ts +++ b/examples/audiobook-curator/src/cli/acoustic-identify.ts @@ -1,9 +1,9 @@ import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { defaultEvidenceOperations, evidenceOperations } from '../operations/evidence.js'; +import { evidenceOperations } from '../operations/evidence.js'; -const operation = evidenceOperations(defaultEvidenceOperations).acousticIdentify; +const operation = evidenceOperations.acousticIdentify; export const config = { description: 'Try score-ranked, deduplicated Audible candidates and retain per-candidate evidence.', diff --git a/examples/audiobook-curator/src/cli/acoustic-verify.ts b/examples/audiobook-curator/src/cli/acoustic-verify.ts index a8f5116f7..49ec94abb 100644 --- a/examples/audiobook-curator/src/cli/acoustic-verify.ts +++ b/examples/audiobook-curator/src/cli/acoustic-verify.ts @@ -1,9 +1,9 @@ import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { defaultEvidenceOperations, evidenceOperations } from '../operations/evidence.js'; +import { evidenceOperations } from '../operations/evidence.js'; -const operation = evidenceOperations(defaultEvidenceOperations).acousticVerify; +const operation = evidenceOperations.acousticVerify; export const config = { description: 'Compare one bounded Audible sample with local audio through optional Audiolocate.', diff --git a/examples/audiobook-curator/src/cli/apply-chapters.ts b/examples/audiobook-curator/src/cli/apply-chapters.ts index 45594ae98..9cd796e67 100644 --- a/examples/audiobook-curator/src/cli/apply-chapters.ts +++ b/examples/audiobook-curator/src/cli/apply-chapters.ts @@ -1,9 +1,9 @@ import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { defaultMediaMutationOperations, mediaMutationOperations } from '../operations/media-mutation.js'; +import { mediaMutationOperations } from '../operations/media-mutation.js'; -const operation = mediaMutationOperations(defaultMediaMutationOperations).applyChapters; +const operation = mediaMutationOperations.applyChapters; export const config = { description: 'Plan or apply verified generic or Audible chapter rows without changing encoded audio.', diff --git a/examples/audiobook-curator/src/cli/apply-metadata.ts b/examples/audiobook-curator/src/cli/apply-metadata.ts index fa71ff34d..caf85b9bb 100644 --- a/examples/audiobook-curator/src/cli/apply-metadata.ts +++ b/examples/audiobook-curator/src/cli/apply-metadata.ts @@ -1,9 +1,9 @@ import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { defaultMediaMutationOperations, mediaMutationOperations } from '../operations/media-mutation.js'; +import { mediaMutationOperations } from '../operations/media-mutation.js'; -const operation = mediaMutationOperations(defaultMediaMutationOperations).applyMetadata; +const operation = mediaMutationOperations.applyMetadata; export const config = { description: 'Plan or apply verified Audible metadata and artwork without changing encoded audio.', diff --git a/examples/audiobook-curator/src/cli/audible-cache.ts b/examples/audiobook-curator/src/cli/audible-cache.ts index 8ab422c30..d201fa526 100644 --- a/examples/audiobook-curator/src/cli/audible-cache.ts +++ b/examples/audiobook-curator/src/cli/audible-cache.ts @@ -1,9 +1,9 @@ import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { audibleOperations, defaultAudibleOperations } from '../operations/audible.js'; +import { audibleOperations } from '../operations/audible.js'; -const operation = audibleOperations(defaultAudibleOperations).audibleCache; +const operation = audibleOperations.audibleCache; export const config = { description: 'Cache one reviewed Audible product, chapters, artwork, and source URLs.', diff --git a/examples/audiobook-curator/src/cli/audible-search.tsx b/examples/audiobook-curator/src/cli/audible-search.tsx index ea3f80853..02a20f319 100644 --- a/examples/audiobook-curator/src/cli/audible-search.tsx +++ b/examples/audiobook-curator/src/cli/audible-search.tsx @@ -6,9 +6,9 @@ import { z } from 'zod'; import type { AudibleSearchReceipt } from '../audible.js'; import { SearchRanking } from '../components/candidate-ranking.js'; import { audibleSearchHeadline } from '../components/headlines.js'; -import { audibleOperations, audibleRegionList, defaultAudibleOperations } from '../operations/audible.js'; +import { audibleOperations, audibleRegionList } from '../operations/audible.js'; -const operation = audibleOperations(defaultAudibleOperations).audibleSearch; +const operation = audibleOperations.audibleSearch; export const config = { description: 'Search and rank Audible identity candidates across reviewed regions.', diff --git a/examples/audiobook-curator/src/cli/audible-select.ts b/examples/audiobook-curator/src/cli/audible-select.ts index def576ae7..da8aa024d 100644 --- a/examples/audiobook-curator/src/cli/audible-select.ts +++ b/examples/audiobook-curator/src/cli/audible-select.ts @@ -1,9 +1,9 @@ import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { audibleOperations, defaultAudibleOperations } from '../operations/audible.js'; +import { audibleOperations } from '../operations/audible.js'; -const operation = audibleOperations(defaultAudibleOperations).audibleSelect; +const operation = audibleOperations.audibleSelect; export const config = { description: 'Record one explicit human-reviewed Audible edition choice.', diff --git a/examples/audiobook-curator/src/cli/audit.tsx b/examples/audiobook-curator/src/cli/audit.tsx index 88c50a499..3fc2934ab 100644 --- a/examples/audiobook-curator/src/cli/audit.tsx +++ b/examples/audiobook-curator/src/cli/audit.tsx @@ -7,9 +7,9 @@ import { ChapterOutline, chaptersFromAuditReceipt } from '../components/chapter- import { integrityAuditHeadline } from '../components/headlines.js'; import { IntegrityAuditReport } from '../components/integrity-report.js'; import type { IntegrityAuditReceipt } from '../integrity-audit.js'; -import { defaultOutputOperations, outputOperations } from '../operations/output.js'; +import { outputOperations } from '../operations/output.js'; -const operation = outputOperations(defaultOutputOperations).audit; +const operation = outputOperations.audit; export const config = { description: 'Validate metadata, chapters, source mapping, hashes, and optional complete decode.', diff --git a/examples/audiobook-curator/src/cli/convert.tsx b/examples/audiobook-curator/src/cli/convert.tsx index 14562fcb8..1620ad9bf 100644 --- a/examples/audiobook-curator/src/cli/convert.tsx +++ b/examples/audiobook-curator/src/cli/convert.tsx @@ -8,9 +8,9 @@ import { convertHeadline } from '../components/headlines.js'; import { ConversionIntegrityReport } from '../components/integrity-report.js'; import { ConversionMutation } from '../components/mutation-receipt.js'; import type { ConvertReceipt } from '../conversion.js'; -import { defaultOutputOperations, outputOperations } from '../operations/output.js'; +import { outputOperations } from '../operations/output.js'; -const operation = outputOperations(defaultOutputOperations).convert; +const operation = outputOperations.convert; export const config = { description: 'Plan or apply a verified conversion to one chaptered M4B.', diff --git a/examples/audiobook-curator/src/cli/inspect.ts b/examples/audiobook-curator/src/cli/inspect.ts index 6f0070f97..4f5d3f04e 100644 --- a/examples/audiobook-curator/src/cli/inspect.ts +++ b/examples/audiobook-curator/src/cli/inspect.ts @@ -1,9 +1,9 @@ import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; +import { discoveryOperations } from '../operations/discovery.js'; -const operation = discoveryOperations(defaultDiscoveryOperations).inspect; +const operation = discoveryOperations.inspect; export const config = { description: 'Inspect a bounded audiobook source tree without changing it.', diff --git a/examples/audiobook-curator/src/cli/inventory.tsx b/examples/audiobook-curator/src/cli/inventory.tsx index 326f5c327..a3b41de4e 100644 --- a/examples/audiobook-curator/src/cli/inventory.tsx +++ b/examples/audiobook-curator/src/cli/inventory.tsx @@ -6,9 +6,9 @@ import { z } from 'zod'; import { inventoryHeadline } from '../components/headlines.js'; import { InventoryShelf } from '../components/library-shelf.js'; import type { InventoryReceipt } from '../library.js'; -import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; +import { discoveryOperations } from '../operations/discovery.js'; -const operation = discoveryOperations(defaultDiscoveryOperations).inventory; +const operation = discoveryOperations.inventory; export const config = { description: 'Probe source audio without changing it.', diff --git a/examples/audiobook-curator/src/cli/library-audit.tsx b/examples/audiobook-curator/src/cli/library-audit.tsx index f684ead56..950f11225 100644 --- a/examples/audiobook-curator/src/cli/library-audit.tsx +++ b/examples/audiobook-curator/src/cli/library-audit.tsx @@ -7,9 +7,9 @@ import { libraryAuditCliHeadline } from '../components/headlines.js'; import { LibraryAnalysis } from '../components/library-analysis.js'; import { DataList } from '../components/primitives.js'; import type { LibraryAuditReceipt } from '../library.js'; -import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; +import { discoveryOperations } from '../operations/discovery.js'; -const operation = discoveryOperations(defaultDiscoveryOperations).libraryAudit; +const operation = discoveryOperations.libraryAudit; /** * The rendered command of this CLI (#102 stage 3): the audit is the diff --git a/examples/audiobook-curator/src/cli/prepare.ts b/examples/audiobook-curator/src/cli/prepare.ts index 0902b0b27..92562c0f8 100644 --- a/examples/audiobook-curator/src/cli/prepare.ts +++ b/examples/audiobook-curator/src/cli/prepare.ts @@ -1,9 +1,9 @@ import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { defaultOutputOperations, outputOperations } from '../operations/output.js'; +import { outputOperations } from '../operations/output.js'; -const operation = outputOperations(defaultOutputOperations).prepare; +const operation = outputOperations.prepare; export const config = { description: 'Plan an M4B output or apply the plan when explicitly requested.', diff --git a/examples/audiobook-curator/src/cli/select.tsx b/examples/audiobook-curator/src/cli/select.tsx index 9a74076b8..7f07f9483 100644 --- a/examples/audiobook-curator/src/cli/select.tsx +++ b/examples/audiobook-curator/src/cli/select.tsx @@ -6,9 +6,9 @@ import { z } from 'zod'; import { selectionHeadline } from '../components/headlines.js'; import { SelectionShelf } from '../components/library-shelf.js'; import type { SelectionReceipt } from '../library.js'; -import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; +import { discoveryOperations } from '../operations/discovery.js'; -const operation = discoveryOperations(defaultDiscoveryOperations).select; +const operation = discoveryOperations.select; export const config = { description: 'Choose the strongest source among normalized collisions.', diff --git a/examples/audiobook-curator/src/cli/whisper-verify.ts b/examples/audiobook-curator/src/cli/whisper-verify.ts index 4efff57c2..66a75c03f 100644 --- a/examples/audiobook-curator/src/cli/whisper-verify.ts +++ b/examples/audiobook-curator/src/cli/whisper-verify.ts @@ -1,9 +1,9 @@ import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { defaultEvidenceOperations, evidenceOperations } from '../operations/evidence.js'; +import { evidenceOperations } from '../operations/evidence.js'; -const operation = evidenceOperations(defaultEvidenceOperations).whisperVerify; +const operation = evidenceOperations.whisperVerify; export const config = { description: 'Transcribe distributed audiobook windows for human language and identity review.', diff --git a/examples/audiobook-curator/src/foundation.ts b/examples/audiobook-curator/src/foundation.ts index 0fd594198..7143e2a7c 100644 --- a/examples/audiobook-curator/src/foundation.ts +++ b/examples/audiobook-curator/src/foundation.ts @@ -1,6 +1,8 @@ import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; import { open, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, extname, join, resolve } from 'node:path'; +import { pipeline } from 'node:stream/promises'; export const audioExtensions = Object.freeze(new Set([ '.aac', '.aax', '.aaxc', '.aif', '.aiff', '.flac', '.m4a', '.m4b', @@ -49,21 +51,9 @@ export const escapeFfmetadata = (value: string): string => value .replaceAll('\n', '\\n'); export const sha256File = async (path: string): Promise => { - const handle = await open(path, 'r'); - try { - const hash = createHash('sha256'); - const buffer = Buffer.allocUnsafe(1024 * 1024); - let position = 0; - while (true) { - const result = await handle.read(buffer, 0, buffer.length, position); - if (result.bytesRead === 0) break; - hash.update(buffer.subarray(0, result.bytesRead)); - position += result.bytesRead; - } - return hash.digest('hex'); - } finally { - await handle.close(); - } + const hash = createHash('sha256'); + await pipeline(createReadStream(path), hash); + return hash.digest('hex'); }; export const mapWithConcurrency = async ( diff --git a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx index ded361c21..86a0339a5 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx @@ -7,10 +7,10 @@ import { CurationShelf, ShelfUnavailable } from '../../../components/curation-sh import { ChapterIntegrityReport } from '../../../components/integrity-report.js'; import { ChapterMutation } from '../../../components/mutation-receipt.js'; import type { ChapterReceipt } from '../../../media-mutation.js'; -import { defaultMediaMutationOperations, mediaMutationOperations } from '../../../operations/media-mutation.js'; +import { mediaMutationOperations } from '../../../operations/media-mutation.js'; import { CurationShelfStateSchema } from '../../../state.js'; -const operation = mediaMutationOperations(defaultMediaMutationOperations).applyChapters; +const operation = mediaMutationOperations.applyChapters; export const config = { annotations: { destructiveHint: true, readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx index d6ddb59a5..645065af7 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx @@ -6,10 +6,10 @@ import { CurationShelf, ShelfUnavailable } from '../../../components/curation-sh import { MetadataIntegrityReport } from '../../../components/integrity-report.js'; import { MetadataMutation } from '../../../components/mutation-receipt.js'; import type { MetadataReceipt } from '../../../media-mutation.js'; -import { defaultMediaMutationOperations, mediaMutationOperations } from '../../../operations/media-mutation.js'; +import { mediaMutationOperations } from '../../../operations/media-mutation.js'; import { CurationShelfStateSchema } from '../../../state.js'; -const operation = mediaMutationOperations(defaultMediaMutationOperations).applyMetadata; +const operation = mediaMutationOperations.applyMetadata; export const config = { annotations: { destructiveHint: true, readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx index 93266445e..00a34f226 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx @@ -6,9 +6,9 @@ import { ChapterOutline, chaptersFromAuditReceipt } from '../../../components/ch import { integrityAuditHeadline } from '../../../components/headlines.js'; import { IntegrityAuditReport } from '../../../components/integrity-report.js'; import type { IntegrityAuditReceipt } from '../../../integrity-audit.js'; -import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; +import { outputOperations } from '../../../operations/output.js'; -const operation = outputOperations(defaultOutputOperations).audit; +const operation = outputOperations.audit; export const config = { annotations: { readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx index c6720db12..40db3181d 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx @@ -6,9 +6,9 @@ import { libraryAuditHeadline } from '../../../components/headlines.js'; import { LibraryAnalysis } from '../../../components/library-analysis.js'; import { AuditFileCards, AuditSummary } from '../../../components/library-shelf.js'; import type { LibraryAuditReceipt } from '../../../library.js'; -import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; +import { discoveryOperations } from '../../../operations/discovery.js'; -const operation = discoveryOperations(defaultDiscoveryOperations).libraryAudit; +const operation = discoveryOperations.libraryAudit; export const config = { annotations: { readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx b/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx index 3d2ae5af8..9bbd91357 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx @@ -4,9 +4,9 @@ import type { ToolRouteProps } from 'agent-bundle'; import type { AudibleCacheReceipt } from '../../../audible.js'; import { Callout, DataList } from '../../../components/primitives.js'; -import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; +import { audibleOperations } from '../../../operations/audible.js'; -const operation = audibleOperations(defaultAudibleOperations).audibleCache; +const operation = audibleOperations.audibleCache; export const config = { annotations: { openWorldHint: true, readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx index fd3a2906d..3c4fbc7fd 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx @@ -7,9 +7,9 @@ import { convertHeadline } from '../../../components/headlines.js'; import { ConversionIntegrityReport } from '../../../components/integrity-report.js'; import { ConversionMutation } from '../../../components/mutation-receipt.js'; import type { ConvertReceipt } from '../../../conversion.js'; -import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; +import { outputOperations } from '../../../operations/output.js'; -const operation = outputOperations(defaultOutputOperations).convert; +const operation = outputOperations.convert; export const config = { annotations: { destructiveHint: true, readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx b/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx index eb575754e..bffd650bf 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx @@ -5,9 +5,9 @@ import type { ToolRouteProps } from 'agent-bundle'; import { IdentifyRanking } from '../../../components/candidate-ranking.js'; import { IdentifyTrail } from '../../../components/evidence-trail.js'; import type { AcousticIdentifyReceipt } from '../../../evidence.js'; -import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; +import { evidenceOperations } from '../../../operations/evidence.js'; -const operation = evidenceOperations(defaultEvidenceOperations).acousticIdentify; +const operation = evidenceOperations.acousticIdentify; export const config = { annotations: { openWorldHint: true, readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx index d3d08c800..f4e044b6a 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx @@ -4,9 +4,9 @@ import type { ToolRouteProps } from 'agent-bundle'; import { InspectionShelf } from '../../../components/library-shelf.js'; import type { InspectionReceipt } from '../../../curator-core.js'; -import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; +import { discoveryOperations } from '../../../operations/discovery.js'; -const operation = discoveryOperations(defaultDiscoveryOperations).inspect; +const operation = discoveryOperations.inspect; export const config = { annotations: { readOnlyHint: true }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx index 0440a965b..0c782f895 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx @@ -6,9 +6,9 @@ import { z } from 'zod'; import { inventoryHeadline } from '../../../components/headlines.js'; import { InventoryShelf } from '../../../components/library-shelf.js'; import type { InventoryReceipt } from '../../../library.js'; -import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; +import { discoveryOperations } from '../../../operations/discovery.js'; -const operation = discoveryOperations(defaultDiscoveryOperations).inventory; +const operation = discoveryOperations.inventory; export const config = { annotations: { readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx index b8ad97228..d627c2334 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx @@ -4,9 +4,9 @@ import type { ToolRouteProps } from 'agent-bundle'; import { PrepareMutation } from '../../../components/mutation-receipt.js'; import type { PrepareReceipt } from '../../../curator-core.js'; -import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; +import { outputOperations } from '../../../operations/output.js'; -const operation = outputOperations(defaultOutputOperations).prepare; +const operation = outputOperations.prepare; export const config = { annotations: { destructiveHint: true, readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx b/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx index 5044964e1..7c5b6e19d 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx @@ -5,9 +5,9 @@ import type { ToolRouteProps } from 'agent-bundle'; import type { AudibleSearchReceipt } from '../../../audible.js'; import { SearchRanking } from '../../../components/candidate-ranking.js'; import { audibleSearchHeadline } from '../../../components/headlines.js'; -import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; +import { audibleOperations } from '../../../operations/audible.js'; -const operation = audibleOperations(defaultAudibleOperations).audibleSearch; +const operation = audibleOperations.audibleSearch; export const config = { annotations: { openWorldHint: true, readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx b/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx index 59c720960..fab72f5fd 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx @@ -5,10 +5,10 @@ import type { ToolRouteProps } from 'agent-bundle'; import type { AudibleSelectionReceipt } from '../../../audible.js'; import { SelectionRanking } from '../../../components/candidate-ranking.js'; import { CurationShelf, ShelfUnavailable } from '../../../components/curation-shelf.js'; -import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; +import { audibleOperations } from '../../../operations/audible.js'; import { CurationShelfStateSchema } from '../../../state.js'; -const operation = audibleOperations(defaultAudibleOperations).audibleSelect; +const operation = audibleOperations.audibleSelect; export const config = { annotations: { readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx index 132a48f82..0457d1f75 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx @@ -5,9 +5,9 @@ import type { ToolRouteProps } from 'agent-bundle'; import { selectionHeadline } from '../../../components/headlines.js'; import { SelectionShelf } from '../../../components/library-shelf.js'; import type { SelectionReceipt } from '../../../library.js'; -import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; +import { discoveryOperations } from '../../../operations/discovery.js'; -const operation = discoveryOperations(defaultDiscoveryOperations).select; +const operation = discoveryOperations.select; export const config = { annotations: { readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx b/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx index 6b036e8a1..597de4c31 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx @@ -4,9 +4,9 @@ import type { ToolRouteProps } from 'agent-bundle'; import { AcousticTrail } from '../../../components/evidence-trail.js'; import type { AcousticReceipt } from '../../../evidence.js'; -import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; +import { evidenceOperations } from '../../../operations/evidence.js'; -const operation = evidenceOperations(defaultEvidenceOperations).acousticVerify; +const operation = evidenceOperations.acousticVerify; export const config = { annotations: { openWorldHint: true, readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx b/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx index c78c62126..a71e00b80 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx @@ -4,9 +4,9 @@ import type { ToolRouteProps } from 'agent-bundle'; import { WhisperTrail } from '../../../components/evidence-trail.js'; import type { WhisperReceipt } from '../../../evidence.js'; -import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; +import { evidenceOperations } from '../../../operations/evidence.js'; -const operation = evidenceOperations(defaultEvidenceOperations).whisperVerify; +const operation = evidenceOperations.whisperVerify; export const config = { annotations: { readOnlyHint: false }, diff --git a/examples/audiobook-curator/src/operations/audible.ts b/examples/audiobook-curator/src/operations/audible.ts index a9e287b14..d7f35e6fe 100644 --- a/examples/audiobook-curator/src/operations/audible.ts +++ b/examples/audiobook-curator/src/operations/audible.ts @@ -3,32 +3,21 @@ * and `audible-cache`, backed by `../audible.ts`. Ranking is evidence only; * `audible-select` records the required human edition choice. */ -import { defineCliCommand, type CliCommandContext } from '../cli-command.js'; import { z } from 'zod'; import { cacheAudibleEdition, searchAudible, selectAudibleEdition, - type AudibleCacheInput, type AudibleCacheReceipt, type AudibleRegion, - type AudibleSearchInput, type AudibleSearchReceipt, type AudibleSelectionReceipt, } from '../audible.ts'; +import type { CliCommandContext } from '../cli-command.js'; import { readJson, writeReceipt } from '../foundation.ts'; import { audibleRegions, audibleRegionSchema, parityReceiptSchema, pathSchema } from './schemas.ts'; -export interface AudibleOperations { - readonly audibleCache?: (input: AudibleCacheInput, options: CliCommandContext) => Promise; - readonly audibleSearch?: (input: AudibleSearchInput, options: CliCommandContext) => Promise; - readonly audibleSelect?: ( - input: { readonly candidate: number; readonly candidates: string; readonly note?: string; readonly receipt?: string }, - options: CliCommandContext, - ) => Promise; -} - const audibleEvidenceSchema = z.object({ authorMatch: z.boolean(), durationDifferencePercent: z.number().nonnegative().optional(), language: z.string().optional(), languageMatch: z.boolean(), narratorMatch: z.boolean(), score: z.number(), strictIdentityMatch: z.boolean(), @@ -46,21 +35,6 @@ export const audibleSearchResultSchema: z.ZodType = z.obje const audibleSelectResultSchema = parityReceiptSchema('audible-select'); const audibleCacheResultSchema = parityReceiptSchema('audible-cache'); -export const defaultAudibleOperations: Required = { - audibleCache: (input, options) => cacheAudibleEdition(input, options), - audibleSearch: (input, options) => searchAudible(input, options), - audibleSelect: async (input) => { - const report = audibleSearchResultSchema.parse(await readJson(input.candidates)); - const receipt = selectAudibleEdition(report, { - candidate: input.candidate, - candidateReport: input.candidates, - ...(input.note === undefined ? {} : { note: input.note }), - }); - if (input.receipt !== undefined) await writeReceipt(input.receipt, receipt, [input.candidates]); - return receipt; - }, -}; - /** Parses the CLI's comma-separated `--regions` list; shared with the routed `audible-search` command. */ export const audibleRegionList = (value: string): readonly AudibleRegion[] => value.split(',').map((region) => { const candidate = region.trim().toLowerCase(); @@ -68,9 +42,9 @@ export const audibleRegionList = (value: string): readonly AudibleRegion[] => va return candidate as AudibleRegion; }); -export const audibleOperations = (operations: Required) => ({ - audibleSearch: defineCliCommand({ - handler: operations.audibleSearch, +export const audibleOperations = Object.freeze({ + audibleSearch: { + handler: searchAudible, id: 'audible-search', inputSchema: z.object({ attempts: z.number().int().min(1).max(10).optional(), author: z.string().min(1).max(512).optional(), @@ -79,20 +53,32 @@ export const audibleOperations = (operations: Required) => ({ report: pathSchema.optional(), title: z.string().min(1).max(1024), }).strict(), resultSchema: audibleSearchResultSchema, - }), - audibleSelect: defineCliCommand({ - handler: operations.audibleSelect, + }, + audibleSelect: { + handler: async ( + input: { readonly candidate: number; readonly candidates: string; readonly note?: string; readonly receipt?: string }, + _context: CliCommandContext, + ) => { + const report = audibleSearchResultSchema.parse(await readJson(input.candidates)); + const receipt = selectAudibleEdition(report, { + candidate: input.candidate, + candidateReport: input.candidates, + ...(input.note === undefined ? {} : { note: input.note }), + }); + if (input.receipt !== undefined) await writeReceipt(input.receipt, receipt, [input.candidates]); + return receipt; + }, id: 'audible-select', inputSchema: z.object({ candidate: z.number().int().min(1).max(500), candidates: pathSchema, note: z.string().max(4096).optional(), receipt: pathSchema.optional() }).strict(), resultSchema: audibleSelectResultSchema, - }), - audibleCache: defineCliCommand({ - handler: operations.audibleCache, + }, + audibleCache: { + handler: cacheAudibleEdition, id: 'audible-cache', inputSchema: z.object({ asin: z.string().min(1).max(64), attempts: z.number().int().min(1).max(10).optional(), cacheDirectory: pathSchema, receipt: pathSchema.optional(), region: audibleRegionSchema.optional(), }).strict(), resultSchema: audibleCacheResultSchema, - }), + }, }); diff --git a/examples/audiobook-curator/src/operations/discovery.ts b/examples/audiobook-curator/src/operations/discovery.ts index 891cefc6f..daa91c4cc 100644 --- a/examples/audiobook-curator/src/operations/discovery.ts +++ b/examples/audiobook-curator/src/operations/discovery.ts @@ -3,10 +3,10 @@ * `library-audit`, and `select`, backed by `../curator-core.ts` and * `../library.ts`. All four retain evidence and never mutate media. */ -import { defineCliCommand, type CliCommandContext } from '../cli-command.js'; import { z } from 'zod'; -import { inspectSources, type InspectionReceipt } from '../curator-core.ts'; +import type { CliCommandContext } from '../cli-command.js'; +import { inspectSources } from '../curator-core.ts'; import { readJson, writeReceipt } from '../foundation.ts'; import { auditLibrary, @@ -18,25 +18,6 @@ import { } from '../library.ts'; import { parityReceiptSchema, pathSchema, probeShape } from './schemas.ts'; -export interface DiscoveryOperations { - readonly inspect: ( - input: { readonly maxFiles?: number; readonly root: string }, - options: CliCommandContext, - ) => Promise; - readonly inventory?: ( - input: { readonly report?: string; readonly source: string; readonly strict?: boolean }, - options: CliCommandContext, - ) => Promise; - readonly libraryAudit?: ( - input: { readonly concurrency?: number; readonly report?: string; readonly sources: readonly string[]; readonly strict?: boolean }, - options: CliCommandContext, - ) => Promise; - readonly select?: ( - input: { readonly inventory: string; readonly report?: string }, - options: CliCommandContext, - ) => Promise; -} - const inventoryResultSchema = parityReceiptSchema('inventory'); const libraryResultSchema = parityReceiptSchema('library-audit'); const selectionResultSchema = parityReceiptSchema('quality-selection'); @@ -57,41 +38,35 @@ const inspectResultSchema = z.object({ totalBytes: z.number().int().nonnegative(), }).strict(); -export const defaultDiscoveryOperations: Required = { - inspect: (input, options) => inspectSources(input, options), - inventory: async (input, options) => { - const receipt = await createInventory(input, options); - if (input.report !== undefined) await writeReceipt(input.report, receipt, [input.source]); - return receipt; - }, - libraryAudit: async (input, options) => { - const receipt = await auditLibrary(input, options); - if (input.report !== undefined) await writeReceipt(input.report, receipt, input.sources); - return receipt; - }, - select: async (input) => { - const inventory = inventoryResultSchema.parse(await readJson(input.inventory)); - const receipt = selectInventorySources(inventory, input.inventory); - if (input.report !== undefined) await writeReceipt(input.report, receipt, [input.inventory]); - return receipt; - }, -}; - -export const discoveryOperations = (operations: Required) => ({ - inspect: defineCliCommand({ - handler: operations.inspect, +export const discoveryOperations = Object.freeze({ + inspect: { + handler: inspectSources, id: 'inspect', inputSchema: inspectInputSchema, resultSchema: inspectResultSchema, - }), - inventory: defineCliCommand({ - handler: operations.inventory, + }, + inventory: { + handler: async ( + input: { readonly report?: string; readonly source: string; readonly strict?: boolean }, + options: CliCommandContext, + ) => { + const receipt = await createInventory(input, options); + if (input.report !== undefined) await writeReceipt(input.report, receipt, [input.source]); + return receipt; + }, id: 'inventory', inputSchema: z.object({ report: pathSchema.optional(), source: pathSchema, strict: z.boolean().optional() }).strict(), resultSchema: inventoryResultSchema, - }), - libraryAudit: defineCliCommand({ - handler: operations.libraryAudit, + }, + libraryAudit: { + handler: async ( + input: { readonly concurrency?: number; readonly report?: string; readonly sources: readonly string[]; readonly strict?: boolean }, + options: CliCommandContext, + ) => { + const receipt = await auditLibrary(input, options); + if (input.report !== undefined) await writeReceipt(input.report, receipt, input.sources); + return receipt; + }, id: 'library-audit', inputSchema: z.object({ concurrency: z.number().int().min(1).max(8).optional(), @@ -100,11 +75,19 @@ export const discoveryOperations = (operations: Required) = strict: z.boolean().optional(), }).strict(), resultSchema: libraryResultSchema, - }), - select: defineCliCommand({ - handler: operations.select, + }, + select: { + handler: async ( + input: { readonly inventory: string; readonly report?: string }, + _context: CliCommandContext, + ) => { + const inventory = inventoryResultSchema.parse(await readJson(input.inventory)); + const receipt = selectInventorySources(inventory, input.inventory); + if (input.report !== undefined) await writeReceipt(input.report, receipt, [input.inventory]); + return receipt; + }, id: 'select', inputSchema: z.object({ inventory: pathSchema, report: pathSchema.optional() }).strict(), resultSchema: selectionResultSchema, - }), + }, }); diff --git a/examples/audiobook-curator/src/operations/evidence.ts b/examples/audiobook-curator/src/operations/evidence.ts index d4578924d..d7c38478b 100644 --- a/examples/audiobook-curator/src/operations/evidence.ts +++ b/examples/audiobook-curator/src/operations/evidence.ts @@ -4,53 +4,27 @@ */ import type { JsonObject } from '@agent-bundle/runtime'; -import { defineCliCommand, type CliCommandContext } from '../cli-command.js'; import { z } from 'zod'; +import type { CliCommandContext } from '../cli-command.js'; import { identifyAudibleSample, verifyAudibleSample, verifyWithWhisper, type AcousticIdentifyReceipt, type AcousticReceipt, - type AcousticVerifyInput, - type WhisperInput, type WhisperReceipt, } from '../evidence.ts'; import { readJson } from '../foundation.ts'; -import { audibleRegions, audibleRegionSchema, parityReceiptSchema, pathSchema } from './schemas.ts'; - -export interface EvidenceOperations { - readonly acousticIdentify?: ( - input: { readonly all?: boolean; readonly attempts?: number; readonly candidates: string; readonly chunkSeconds?: number; readonly file: string; readonly receipt?: string; readonly top?: number; readonly verbose?: boolean }, - options: CliCommandContext, - ) => Promise; - readonly acousticVerify?: (input: AcousticVerifyInput, options: CliCommandContext) => Promise; - readonly whisperVerify?: (input: WhisperInput, options: CliCommandContext) => Promise; -} - -export const defaultEvidenceOperations: Required = { - acousticIdentify: async (input, options) => { - const payload = await readJson(input.candidates); - const rows = z.object({ candidates: z.array(z.record(z.string(), z.unknown())).max(500) }) - .passthrough().parse(payload).candidates as JsonObject[]; - return identifyAudibleSample({ - ...input, - candidates: rows, - candidatesReport: input.candidates, - }, options); - }, - acousticVerify: (input, options) => verifyAudibleSample(input, options), - whisperVerify: (input, options) => verifyWithWhisper(input, options), -}; +import { audibleRegionSchema, parityReceiptSchema, pathSchema } from './schemas.ts'; const acousticResultSchema = parityReceiptSchema('audiolocate'); const acousticIdentifyResultSchema = parityReceiptSchema('acoustic-identify'); const whisperResultSchema = parityReceiptSchema('whisper-identity'); -export const evidenceOperations = (operations: Required) => ({ - acousticVerify: defineCliCommand({ - handler: operations.acousticVerify, +export const evidenceOperations = Object.freeze({ + acousticVerify: { + handler: verifyAudibleSample, id: 'acoustic-verify', inputSchema: z.object({ asin: z.string().min(1).max(64), attempts: z.number().int().min(1).max(10).optional(), audiolocatePython: pathSchema.optional(), @@ -58,9 +32,21 @@ export const evidenceOperations = (operations: Required) => region: audibleRegionSchema.optional(), sampleUrl: z.url().optional(), verbose: z.boolean().optional(), }).strict(), resultSchema: acousticResultSchema, - }), - acousticIdentify: defineCliCommand({ - handler: operations.acousticIdentify, + }, + acousticIdentify: { + handler: async ( + input: { readonly all?: boolean; readonly attempts?: number; readonly candidates: string; readonly chunkSeconds?: number; readonly file: string; readonly receipt?: string; readonly top?: number; readonly verbose?: boolean }, + options: CliCommandContext, + ) => { + const payload = await readJson(input.candidates); + const rows = z.object({ candidates: z.array(z.record(z.string(), z.unknown())).max(500) }) + .passthrough().parse(payload).candidates as JsonObject[]; + return identifyAudibleSample({ + ...input, + candidates: rows, + candidatesReport: input.candidates, + }, options); + }, id: 'acoustic-identify', inputSchema: z.object({ all: z.boolean().optional(), attempts: z.number().int().min(1).max(10).optional(), candidates: pathSchema, @@ -68,9 +54,9 @@ export const evidenceOperations = (operations: Required) => top: z.number().int().min(1).max(10).optional(), verbose: z.boolean().optional(), }).strict(), resultSchema: acousticIdentifyResultSchema, - }), - whisperVerify: defineCliCommand({ - handler: operations.whisperVerify, + }, + whisperVerify: { + handler: verifyWithWhisper, id: 'whisper-verify', inputSchema: z.object({ author: z.string().max(512).optional(), file: pathSchema, language: z.string().min(1).max(64).optional(), @@ -79,5 +65,5 @@ export const evidenceOperations = (operations: Required) => whisperCli: pathSchema.optional(), windowSeconds: z.number().int().min(1).max(3600).optional(), }).strict(), resultSchema: whisperResultSchema, - }), + }, }); diff --git a/examples/audiobook-curator/src/operations/media-mutation.ts b/examples/audiobook-curator/src/operations/media-mutation.ts index 3e3b7c364..887febe7a 100644 --- a/examples/audiobook-curator/src/operations/media-mutation.ts +++ b/examples/audiobook-curator/src/operations/media-mutation.ts @@ -2,35 +2,22 @@ * Plan-first derived-media repair operations: `apply-metadata` and * `apply-chapters`, backed by `../media-mutation.ts`. */ -import { defineCliCommand, type CliCommandContext } from '../cli-command.js'; import { z } from 'zod'; import { applyAudiobookChapters, applyAudiobookMetadata, - type ChapterInput, type ChapterReceipt, - type MetadataInput, type MetadataReceipt, } from '../media-mutation.ts'; import { parityReceiptSchema, pathSchema } from './schemas.ts'; -export interface MediaMutationOperations { - readonly applyChapters?: (input: ChapterInput, options: CliCommandContext) => Promise; - readonly applyMetadata?: (input: MetadataInput, options: CliCommandContext) => Promise; -} - -export const defaultMediaMutationOperations: Required = { - applyChapters: (input, options) => applyAudiobookChapters(input, options), - applyMetadata: (input, options) => applyAudiobookMetadata(input, options), -}; - const metadataResultSchema = parityReceiptSchema('apply-metadata'); const chaptersResultSchema = parityReceiptSchema('apply-chapters'); -export const mediaMutationOperations = (operations: Required) => ({ - applyMetadata: defineCliCommand({ - handler: operations.applyMetadata, +export const mediaMutationOperations = Object.freeze({ + applyMetadata: { + handler: applyAudiobookMetadata, id: 'apply-metadata', inputSchema: z.object({ apply: z.boolean().optional(), artwork: pathSchema.optional(), author: z.string().max(512).optional(), file: pathSchema, @@ -38,11 +25,11 @@ export const mediaMutationOperations = (operations: Required Promise; - readonly convert?: (input: ConvertInput, options: CliCommandContext) => Promise; - readonly prepare: (input: PrepareInput, options: CliCommandContext) => Promise; -} - -export const defaultOutputOperations: Required = { - audit: (input, options) => auditAudiobookIntegrity(input, options), - convert: (input, options) => convertAudiobook(input, options), - prepare: (input, options) => prepareAudiobook(input, options), -}; - const convertResultSchema = parityReceiptSchema('convert'); const auditResultSchema = parityReceiptSchema('audit'); const prepareInputSchema = z.object({ @@ -44,9 +27,9 @@ const prepareResultSchema = z.object({ source: pathSchema, }).strict(); -export const outputOperations = (operations: Required) => ({ - convert: defineCliCommand({ - handler: operations.convert, +export const outputOperations = Object.freeze({ + convert: { + handler: convertAudiobook, id: 'convert', inputSchema: z.object({ apply: z.boolean().optional(), artwork: pathSchema.optional(), audioBitrate: z.string().min(2).max(32).optional(), @@ -57,17 +40,17 @@ export const outputOperations = (operations: Required) => ({ selection: pathSchema, title: z.string().min(1).max(1024), year: z.string().min(1).max(64).optional(), }).strict(), resultSchema: convertResultSchema, - }), - prepare: defineCliCommand({ - handler: operations.prepare, + }, + prepare: { + handler: prepareAudiobook, id: 'prepare', inputSchema: prepareInputSchema, resultSchema: prepareResultSchema, - }), - audit: defineCliCommand({ - handler: operations.audit, + }, + audit: { + handler: auditAudiobookIntegrity, id: 'audit', inputSchema: z.object({ conversionReceipt: pathSchema.optional(), file: pathSchema, fullDecode: z.boolean().optional(), receipt: pathSchema.optional() }).strict(), resultSchema: auditResultSchema, - }), + }, }); diff --git a/examples/host-test/src/mcp/host-test/tools/slow.tsx b/examples/host-test/src/mcp/host-test/tools/slow.tsx index 7c7660f08..1fdf58c9c 100644 --- a/examples/host-test/src/mcp/host-test/tools/slow.tsx +++ b/examples/host-test/src/mcp/host-test/tools/slow.tsx @@ -1,3 +1,5 @@ +import { setTimeout } from 'node:timers/promises'; + import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; import type { ToolConfig, ToolRouteProps } from 'agent-bundle'; import React from 'react'; @@ -30,17 +32,8 @@ export const resultSchema = z.object({ ticks: z.number().int().nonnegative(), }).strict(); -const sleep = (ms: number, signal: AbortSignal): Promise<'aborted' | 'elapsed'> => new Promise((resolve) => { - if (signal.aborted) { - resolve('aborted'); - return; - } - const timer = setTimeout(() => resolve('elapsed'), ms); - signal.addEventListener('abort', () => { - clearTimeout(timer); - resolve('aborted'); - }, { once: true }); -}); +const sleep = (ms: number, signal: AbortSignal): Promise<'aborted' | 'elapsed'> => + setTimeout(ms, 'elapsed' as const, { signal }).catch(() => 'aborted' as const); export default async function Slow({ input, signal }: ToolRouteProps) { const observed = await capture({ kind: 'mcp', observed: { holdMs: input.holdMs, tickMs: input.tickMs, tool: 'slow' } }); diff --git a/examples/rsc-agent-runtime/src/dev/canonical-json.ts b/examples/rsc-agent-runtime/src/dev/canonical-json.ts index 25ef09d60..33c53bb33 100644 --- a/examples/rsc-agent-runtime/src/dev/canonical-json.ts +++ b/examples/rsc-agent-runtime/src/dev/canonical-json.ts @@ -1,5 +1,7 @@ import { createHash } from 'node:crypto'; +import type { JsonValue } from 'agent-bundle'; + /** * Key-sorted, undefined-skipping canonical JSON used for runtime metadata * digests. Throws on non-finite numbers and non-JSON values so digests can @@ -22,3 +24,30 @@ export const canonicalJson = (value: unknown): string => { export const digestValue = (value: unknown): string => createHash('sha256').update(canonicalJson(value)).digest('hex'); + +/** + * Deep-frozen, undefined-skipping JSON copy of `value` in its original key + * order; rejects the same non-JSON values as `canonicalJson` plus cycles. + */ +export const freezeJson = (value: unknown, seen = new WeakSet()): JsonValue => { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.'); + return value; + } + if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.'); + if (seen.has(value)) throw new TypeError('Runtime metadata cannot contain cyclic values.'); + seen.add(value); + try { + if (Array.isArray(value)) return Object.freeze(value.map((item) => freezeJson(item, seen))); + const input = value as Record; + const output: Record = {}; + for (const key of Object.keys(input)) { + const item = input[key]; + if (item !== undefined) output[key] = freezeJson(item, seen); + } + return Object.freeze(output); + } finally { + seen.delete(value); + } +}; diff --git a/examples/rsc-agent-runtime/src/dev/definition-entry.ts b/examples/rsc-agent-runtime/src/dev/definition-entry.ts index a76cbb6ef..6f1039a90 100644 --- a/examples/rsc-agent-runtime/src/dev/definition-entry.ts +++ b/examples/rsc-agent-runtime/src/dev/definition-entry.ts @@ -1,23 +1,4 @@ import { serializeRuntimeDefinition } from '../build/serialize-definition.js'; +import { canonicalJson } from './canonical-json.js'; -type JsonValue = null | boolean | number | string | JsonValue[] | { readonly [key: string]: JsonValue }; - -const canonicalize = (value: unknown): JsonValue => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new TypeError('Runtime definition must contain finite JSON numbers.'); - return value; - } - if (Array.isArray(value)) return value.map(canonicalize); - if (typeof value !== 'object') throw new TypeError('Runtime definition must be JSON serializable.'); - - const input = value as Record; - const output: Record = {}; - for (const key of Object.keys(input).sort()) { - const item = input[key]; - if (item !== undefined) output[key] = canonicalize(item); - } - return output; -}; - -process.stdout.write(`${JSON.stringify(canonicalize(serializeRuntimeDefinition()))}\n`); +process.stdout.write(`${canonicalJson(serializeRuntimeDefinition())}\n`); diff --git a/examples/rsc-agent-runtime/src/dev/durable-tree.ts b/examples/rsc-agent-runtime/src/dev/durable-tree.ts new file mode 100644 index 000000000..d1a21ab64 --- /dev/null +++ b/examples/rsc-agent-runtime/src/dev/durable-tree.ts @@ -0,0 +1,87 @@ +import { createHash } from 'node:crypto'; +import { lstat, mkdir, open, readdir } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +/** + * Containment and durability rules shared by environment checkpoint staging + * and generation capture: regular files and directories only, no symbolic + * links, no unsafe path segments, and every written file and directory + * fsynced before its tree is trusted. + */ + +export const digestBytes = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex'); + +export const isSafeSegment = (value: string): boolean => + value.length > 0 && value !== '.' && value !== '..' && !value.includes('/') && !value.includes('\\') && !value.includes('\0'); + +export const assertInside = (root: string, target: string): void => { + const path = relative(resolve(root), resolve(target)); + if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) { + throw new Error(`${JSON.stringify(target)} escaped its root ${JSON.stringify(root)}.`); + } +}; + +export const fsyncPath = async (path: string): Promise => { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +}; + +/** Creates `destination` (refusing to overwrite), writes `bytes`, and fsyncs it. */ +export const writeFileDurably = async (destination: string, bytes: Uint8Array): Promise => { + const handle = await open(destination, 'wx'); + try { + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle.close(); + } +}; + +/** + * Walks `sourceRoot` in sorted order, recreating its directories under the + * new `destinationRoot` and calling `onFile(path, source, destination)` for + * every regular file, where `path` is slash-separated and relative to the + * roots. Each directory is fsynced after its entries land. + */ +export const copyTree = async ( + sourceRoot: string, + destinationRoot: string, + onFile: (path: string, source: string, destination: string) => Promise, +): Promise => { + const sourceStatus = await lstat(sourceRoot); + if (!sourceStatus.isDirectory() || sourceStatus.isSymbolicLink()) { + throw new Error(`${JSON.stringify(sourceRoot)} must be a regular directory.`); + } + await mkdir(destinationRoot, { recursive: false }); + + const copyDirectory = async (source: string, destination: string, prefix: string): Promise => { + assertInside(sourceRoot, source); + assertInside(destinationRoot, destination); + const entries = await readdir(source, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!isSafeSegment(entry.name)) throw new Error('Compiler output contains an unsafe path segment.'); + const sourcePath = join(source, entry.name); + const destinationPath = join(destination, entry.name); + assertInside(sourceRoot, sourcePath); + assertInside(destinationRoot, destinationPath); + const status = await lstat(sourcePath); + if (status.isSymbolicLink()) throw new Error('Compiler output cannot contain symbolic links.'); + const path = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`; + if (status.isDirectory()) { + await mkdir(destinationPath, { recursive: false }); + await copyDirectory(sourcePath, destinationPath, path); + } else if (status.isFile()) { + await onFile(path, sourcePath, destinationPath); + } else { + throw new Error('Compiler output can contain only regular files and directories.'); + } + } + await fsyncPath(destination); + }; + + await copyDirectory(sourceRoot, destinationRoot, ''); +}; diff --git a/examples/rsc-agent-runtime/src/dev/environment-checkpoint-store.ts b/examples/rsc-agent-runtime/src/dev/environment-checkpoint-store.ts index 75b2f2fdb..65685fc4e 100644 --- a/examples/rsc-agent-runtime/src/dev/environment-checkpoint-store.ts +++ b/examples/rsc-agent-runtime/src/dev/environment-checkpoint-store.ts @@ -1,6 +1,7 @@ -import { createHash } from 'node:crypto'; -import { lstat, mkdir, open, readFile, readdir, rm } from 'node:fs/promises'; -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { mkdir, readFile, rm } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { copyTree, digestBytes, writeFileDurably } from './durable-tree.js'; /** * Immutable per-environment output staging (#74). @@ -91,79 +92,17 @@ export interface RscEnvironmentCheckpointStore { const maximumSupersededHashHistory = 64; -const digestBytes = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex'); - -const isSafeSegment = (value: string): boolean => - value.length > 0 && value !== '.' && value !== '..' && !value.includes('/') && !value.includes('\\') && !value.includes('\0'); - -const assertInside = (root: string, target: string): void => { - const path = relative(resolve(root), resolve(target)); - if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) { - throw new Error('Environment checkpoint path escaped its root.'); - } -}; - -const fsyncPath = async (path: string): Promise => { - const handle = await open(path, 'r'); - try { - await handle.sync(); - } finally { - await handle.close(); - } -}; - -const copyFileDigested = async (source: string, destination: string): Promise => { - const bytes = await readFile(source); - const handle = await open(destination, 'wx'); - try { - await handle.writeFile(bytes); - await handle.sync(); - } finally { - await handle.close(); - } - return digestBytes(bytes); -}; - /** * Copies one completed compiler output root into an immutable staging - * directory, digesting every file. Applies the same containment rules as - * generation capture: regular files and directories only, no symbolic links, - * no unsafe path segments. + * directory, digesting every file. */ const stageTree = async (sourceRoot: string, destinationRoot: string): Promise> => { - const sourceStatus = await lstat(sourceRoot); - if (!sourceStatus.isDirectory() || sourceStatus.isSymbolicLink()) { - throw new Error(`Compiler environment ${JSON.stringify(sourceRoot)} must be a regular directory.`); - } - await mkdir(destinationRoot, { recursive: false }); const files = new Map(); - - const copyDirectory = async (source: string, destination: string, prefix: string): Promise => { - assertInside(sourceRoot, source); - assertInside(destinationRoot, destination); - const entries = await readdir(source, { withFileTypes: true }); - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (!isSafeSegment(entry.name)) throw new Error('Compiler output contains an unsafe path segment.'); - const sourcePath = join(source, entry.name); - const destinationPath = join(destination, entry.name); - assertInside(sourceRoot, sourcePath); - assertInside(destinationRoot, destinationPath); - const status = await lstat(sourcePath); - if (status.isSymbolicLink()) throw new Error('Compiler output cannot contain symbolic links.'); - const path = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`; - if (status.isDirectory()) { - await mkdir(destinationPath, { recursive: false }); - await copyDirectory(sourcePath, destinationPath, path); - } else if (status.isFile()) { - files.set(path, await copyFileDigested(sourcePath, destinationPath)); - } else { - throw new Error('Compiler output can contain only regular files and directories.'); - } - } - await fsyncPath(destination); - }; - - await copyDirectory(sourceRoot, destinationRoot, ''); + await copyTree(sourceRoot, destinationRoot, async (path, source, destination) => { + const bytes = await readFile(source); + await writeFileDurably(destination, bytes); + files.set(path, digestBytes(bytes)); + }); return files; }; diff --git a/examples/rsc-agent-runtime/src/dev/generation-materializer.ts b/examples/rsc-agent-runtime/src/dev/generation-materializer.ts index a80a95afe..3d2624e3a 100644 --- a/examples/rsc-agent-runtime/src/dev/generation-materializer.ts +++ b/examples/rsc-agent-runtime/src/dev/generation-materializer.ts @@ -1,9 +1,9 @@ -import { createHash } from 'node:crypto'; -import { open, lstat, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { lstat, mkdir, readdir, readFile } from 'node:fs/promises'; import { spawn } from 'node:child_process'; -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { dirname, isAbsolute, join } from 'node:path'; -import { canonicalJson, digestValue } from './canonical-json.js'; +import { canonicalJson, digestValue, freezeJson } from './canonical-json.js'; +import { assertInside, copyTree, digestBytes, fsyncPath, isSafeSegment, writeFileDurably } from './durable-tree.js'; import { emitRuntimeArtifacts } from '../build/emit-artifacts.js'; import type { RscEnvironmentCheckpointValidator, @@ -89,44 +89,9 @@ export interface MaterializeRuntimeGenerationOptions { readonly store: DevRuntimeGenerationStore; } -const digestBytes = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex'); - -const freezeJson = (value: unknown, seen = new WeakSet()): JsonValue => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.'); - return value; - } - if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.'); - if (seen.has(value)) throw new TypeError('Runtime metadata cannot contain cyclic values.'); - seen.add(value); - try { - if (Array.isArray(value)) return Object.freeze(value.map((item) => freezeJson(item, seen))); - const input = value as Record; - const output: Record = {}; - for (const key of Object.keys(input)) { - const item = input[key]; - if (item !== undefined) output[key] = freezeJson(item, seen); - } - return Object.freeze(output); - } finally { - seen.delete(value); - } -}; - const isJsonObject = (value: JsonValue): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value); -const isSafeSegment = (value: string): boolean => - value.length > 0 && value !== '.' && value !== '..' && !value.includes('/') && !value.includes('\\') && !value.includes('\0'); - -const assertInside = (root: string, target: string): void => { - const path = relative(resolve(root), resolve(target)); - if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) { - throw new Error('Runtime generation path escaped its root.'); - } -}; - const assertRelativeAssetPath = (value: unknown): string => { if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || value.includes('\0') || isAbsolute(value)) { throw new TypeError('Runtime asset path must be a contained slash-separated path.'); @@ -138,15 +103,6 @@ const assertRelativeAssetPath = (value: unknown): string => { return segments.join('/'); }; -const fsync = async (path: string): Promise => { - const handle = await open(path, 'r'); - try { - await handle.sync(); - } finally { - await handle.close(); - } -}; - /** * Copies one staged checkpoint file, requiring its bytes to match the digest * recorded when the checkpoint was staged. A mismatch means the immutable @@ -162,54 +118,18 @@ const copyCheckpointFile = async ( if (checkpoint.files.get(path) !== digestBytes(bytes)) { throw new Error(`Staged ${checkpoint.environment} checkpoint no longer matches its recorded digest for ${JSON.stringify(path)}.`); } - const handle = await open(destination, 'wx'); - try { - await handle.writeFile(bytes); - await handle.sync(); - } finally { - await handle.close(); - } + await writeFileDurably(destination, bytes); }; const copyCheckpointTree = async ( checkpoint: RscStagedEnvironmentCheckpoint, destinationRoot: string, ): Promise => { - const sourceRoot = checkpoint.root; - const sourceStatus = await lstat(sourceRoot); - if (!sourceStatus.isDirectory() || sourceStatus.isSymbolicLink()) { - throw new Error(`Staged ${checkpoint.environment} checkpoint must be a regular directory.`); - } - await mkdir(destinationRoot, { recursive: false }); let copied = 0; - - const copyDirectory = async (source: string, destination: string, prefix: string): Promise => { - assertInside(sourceRoot, source); - assertInside(destinationRoot, destination); - const entries = await readdir(source, { withFileTypes: true }); - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (!isSafeSegment(entry.name)) throw new Error('Compiler output contains an unsafe path segment.'); - const sourcePath = join(source, entry.name); - const destinationPath = join(destination, entry.name); - assertInside(sourceRoot, sourcePath); - assertInside(destinationRoot, destinationPath); - const status = await lstat(sourcePath); - if (status.isSymbolicLink()) throw new Error('Compiler output cannot contain symbolic links.'); - const path = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`; - if (status.isDirectory()) { - await mkdir(destinationPath, { recursive: false }); - await copyDirectory(sourcePath, destinationPath, path); - } else if (status.isFile()) { - await copyCheckpointFile(checkpoint, path, sourcePath, destinationPath); - copied += 1; - } else { - throw new Error('Compiler output can contain only regular files and directories.'); - } - } - await fsync(destination); - }; - - await copyDirectory(sourceRoot, destinationRoot, ''); + await copyTree(checkpoint.root, destinationRoot, async (path, source, destination) => { + await copyCheckpointFile(checkpoint, path, source, destination); + copied += 1; + }); if (copied !== checkpoint.files.size) { throw new Error(`Staged ${checkpoint.environment} checkpoint no longer matches its recorded file set.`); } @@ -254,7 +174,7 @@ const copyDeclaredRscAssets = async ( await copyCheckpointFile(checkpoint, path, source, destination); } for (const directory of [...destinationDirectories].sort((left, right) => right.length - left.length)) { - await fsync(directory); + await fsyncPath(directory); } }; @@ -748,14 +668,12 @@ export const captureRuntimeGenerationSnapshot = async ( const runtimeAssets = await parseRuntimeAssets(rsc.root); await copyDeclaredRscAssets(rsc, runtimeAssets, candidateRsc); const definition = await runDefinitionExecutable(join(candidateRsc, 'dev', 'definition.js')); - const definitionBytes = Buffer.from(canonicalJson(definition)); - await writeFile(join(candidateRsc, 'runtime-definition.json'), definitionBytes, { encoding: 'utf8', flag: 'wx' }); - await fsync(join(candidateRsc, 'runtime-definition.json')); + await writeFileDurably(join(candidateRsc, 'runtime-definition.json'), Buffer.from(canonicalJson(definition))); await emitRuntimeArtifacts(candidateRsc, definition); - await fsync(candidateRsc); + await fsyncPath(candidateRsc); await copyCheckpointTree(app, join(input.candidate.root, 'app')); await copyCheckpointTree(widget, join(input.candidate.root, 'widget')); - await fsync(input.candidate.root); + await fsyncPath(input.candidate.root); const assets = await walkRegularFiles(input.candidate.root); return Object.freeze({ assets, diff --git a/examples/rsc-agent-runtime/src/flight/request-render.ts b/examples/rsc-agent-runtime/src/flight/request-render.ts index 6ce375acf..1e43abeaf 100644 --- a/examples/rsc-agent-runtime/src/flight/request-render.ts +++ b/examples/rsc-agent-runtime/src/flight/request-render.ts @@ -15,7 +15,7 @@ export const maximumFlightRenderBytes = 4 * 1024 * 1024; export const maximumFlightRenderStderrBytes = 256 * 1024; export const maximumFlightRenderMetadataBytes = 128; -const defaultTerminationGraceMs = 100; +const terminationGraceMs = 100; export interface FlightRenderResult { readonly flight: Uint8Array; @@ -30,9 +30,7 @@ export interface AgentDocumentFlightRenderResult extends FlightRenderResult { export interface FlightRenderOptions { readonly maximumFlightBytes?: number; - readonly maximumStderrBytes?: number; readonly signal?: AbortSignal; - readonly terminationGraceMs?: number; } const positiveSafeInteger = (value: number, name: string): number => { @@ -78,8 +76,6 @@ export const requestFlightRenderWithFlight = async ( options: FlightRenderOptions = {}, ): Promise => { const maximumFlightBytes = positiveSafeInteger(options.maximumFlightBytes ?? maximumFlightRenderBytes, 'maximumFlightBytes'); - const maximumStderrBytes = positiveSafeInteger(options.maximumStderrBytes ?? maximumFlightRenderStderrBytes, 'maximumStderrBytes'); - const terminationGraceMs = positiveSafeInteger(options.terminationGraceMs ?? defaultTerminationGraceMs, 'terminationGraceMs'); return new Promise((resolveRender, rejectRender) => { const currentDirectory = dirname(fileURLToPath(import.meta.url)); @@ -131,11 +127,11 @@ export const requestFlightRenderWithFlight = async ( stdout.once('error', () => terminate(new Error('RSC worker Flight stream failed.'))); stderr.on('data', (chunk: Buffer | string) => { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - const retained = Math.min(buffer.byteLength, Math.max(0, maximumStderrBytes - stderrBytes)); + const retained = Math.min(buffer.byteLength, Math.max(0, maximumFlightRenderStderrBytes - stderrBytes)); if (retained > 0) diagnostics.push(buffer.subarray(0, retained)); stderrBytes += buffer.byteLength; - if (stderrBytes > maximumStderrBytes) { - terminate(new Error(`RSC worker stderr exceeded ${maximumStderrBytes} bytes.`)); + if (stderrBytes > maximumFlightRenderStderrBytes) { + terminate(new Error(`RSC worker stderr exceeded ${maximumFlightRenderStderrBytes} bytes.`)); } }); stderr.once('error', () => terminate(new Error('RSC worker stderr stream failed.'))); diff --git a/scripts/classify-docs-only.mjs b/scripts/classify-docs-only.mjs index 27d1939f2..5a7159f3c 100644 --- a/scripts/classify-docs-only.mjs +++ b/scripts/classify-docs-only.mjs @@ -1,5 +1,6 @@ import { appendFile, readFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; /** * Hosted CI docs-only allowlist. Nested markdown outside docs/, website/, @@ -72,45 +73,26 @@ export const classifyDocsOnlyListing = ({ return { docsOnly: true, reason: 'docs-only' }; }; -const parseArgs = (argv) => { - const options = { - changedFilesCount: undefined, - listing: undefined, - listingError: false, - }; - for (let index = 0; index < argv.length; index += 1) { - const argument = argv[index]; - if (argument === '--listing-error') { - options.listingError = true; - continue; - } - if (argument === '--changed-files-count') { - options.changedFilesCount = argv[index + 1]; - index += 1; - continue; - } - if (argument === '--listing') { - options.listing = argv[index + 1]; - index += 1; - continue; - } - throw new Error(`Unknown argument: ${argument}`); - } - return options; -}; - export const runClassify = async ({ argv = process.argv.slice(2), env = process.env, } = {}) => { - const options = parseArgs(argv); + const { values: options } = parseArgs({ + args: argv, + options: { + 'changed-files-count': { type: 'string' }, + listing: { type: 'string' }, + 'listing-error': { type: 'boolean', default: false }, + }, + strict: true, + }); const listingText = options.listing === undefined ? '' : await readFile(options.listing, 'utf8'); const result = classifyDocsOnlyListing({ - changedFilesCount: options.changedFilesCount, + changedFilesCount: options['changed-files-count'], entries: parseGhFilesListing(listingText), - listingOk: !options.listingError, + listingOk: !options['listing-error'], }); const githubOutput = env.GITHUB_OUTPUT; diff --git a/scripts/host-cli-pins.mjs b/scripts/host-cli-pins.mjs index 57c2510d4..63c33b09c 100644 --- a/scripts/host-cli-pins.mjs +++ b/scripts/host-cli-pins.mjs @@ -16,11 +16,11 @@ import { execFile as executeFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { appendFile, readFile } from 'node:fs/promises'; import { delimiter, dirname, join, resolve } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; const execFile = promisify(executeFile); -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repositoryRoot = resolve(import.meta.dirname, '..'); export const hostCliHosts = Object.freeze(['claude', 'codex']); diff --git a/scripts/local-ci.mjs b/scripts/local-ci.mjs index 87392de45..185e3ec9c 100644 --- a/scripts/local-ci.mjs +++ b/scripts/local-ci.mjs @@ -52,13 +52,12 @@ import { existsSync } from 'node:fs'; import { chmod, mkdir, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { availableParallelism, homedir, tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { removeOwnedRstestWorkerRoots } from './rstest-worker-roots.mjs'; const execFile = promisify(executeFile); -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repositoryRoot = resolve(import.meta.dirname, '..'); const usage = `Usage: pnpm check:local-ci [--current-node-only] [--fresh] --current-node-only Run a single Verify leg on the Node currently on PATH diff --git a/scripts/measure-hook-cold-start.mjs b/scripts/measure-hook-cold-start.mjs index 1aac69521..a69da22de 100644 --- a/scripts/measure-hook-cold-start.mjs +++ b/scripts/measure-hook-cold-start.mjs @@ -6,7 +6,7 @@ * docs/effect-cold-start-baseline.json, or checks it with --check. */ -import { spawn } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -34,32 +34,23 @@ const median = (values) => { : sorted[middle]; }; -const run = (command, args, options) => - new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: options.cwd, - env: options.env ?? process.env, - stdio: options.input === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'], - }); - let stderr = ''; - let stdout = ''; - child.stderr?.on('data', (chunk) => { - stderr += chunk.toString(); - }); - child.stdout?.on('data', (chunk) => { - stdout += chunk.toString(); - }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stderr, stdout })); - if (options.input !== undefined) child.stdin?.end(options.input); +const run = (command, args, options) => { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: 'utf8', + input: options.input, + stdio: options.input === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'], }); + if (result.error !== undefined) throw result.error; + return result; +}; -const measureOnce = async (hookPath) => { +const measureOnce = (hookPath) => { const started = performance.now(); - const result = await run(process.execPath, [hookPath], { input: nativeSessionStart }); + const result = run(process.execPath, [hookPath], { input: nativeSessionStart }); const elapsedMs = performance.now() - started; - if (result.code !== 0) { - throw new Error(`Generated hook exited ${String(result.code)}: ${result.stderr || result.stdout}`); + if (result.status !== 0) { + throw new Error(`Generated hook exited ${String(result.status)}: ${result.stderr || result.stdout}`); } return elapsedMs; }; @@ -98,16 +89,16 @@ const measure = async () => { "export default () => ({ additionalContext: 'cold-start', outcome: 'continue' as const });\n", ), ]); - const built = await run(process.execPath, [cli, 'build', '--root', root, '--output', output], { + const built = run(process.execPath, [cli, 'build', '--root', root, '--output', output], { cwd: workspaceRoot, }); - if (built.code !== 0) { + if (built.status !== 0) { throw new Error(`agent-bundle build failed:\n${built.stderr || built.stdout}`); } const hookPath = await findGeneratedHook(output); const samplesMs = []; for (let index = 0; index < samples; index += 1) { - samplesMs.push(await measureOnce(hookPath)); + samplesMs.push(measureOnce(hookPath)); } const rounded = samplesMs.map((value) => Math.round(value * 100) / 100); return { diff --git a/scripts/record-claude-hooks-schema-fixtures.mjs b/scripts/record-claude-hooks-schema-fixtures.mjs index 4666a4f1a..0df4620e6 100644 --- a/scripts/record-claude-hooks-schema-fixtures.mjs +++ b/scripts/record-claude-hooks-schema-fixtures.mjs @@ -22,12 +22,11 @@ import { execFile as executeFile } from 'node:child_process'; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; const execFile = promisify(executeFile); -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repositoryRoot = resolve(import.meta.dirname, '..'); const fixtureRoot = join(repositoryRoot, 'packages/agent-bundle/tests/fixtures/claude-hooks-schema'); const pluginPathPlaceholder = '/bundle/claude'; const claudeBinary = process.env.CLAUDE_BIN ?? 'claude'; diff --git a/scripts/rsc-runtime-topology.mjs b/scripts/rsc-runtime-topology.mjs index c7d6591f6..1d03ed218 100644 --- a/scripts/rsc-runtime-topology.mjs +++ b/scripts/rsc-runtime-topology.mjs @@ -3,7 +3,7 @@ import { execFile as executeFile } from 'node:child_process'; import { readFile, realpath, writeFile } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { promisify } from 'node:util'; +import { parseArgs, promisify } from 'node:util'; const execFile = promisify(executeFile); @@ -111,28 +111,15 @@ const usage = () => { }; const parseArguments = (arguments_) => { - let root; - let output; - let check = false; - for (let index = 0; index < arguments_.length; index += 1) { - const argument = arguments_[index]; - if (argument === '--check') { - if (check) usage(); - check = true; - continue; - } - if (argument !== '--root' && argument !== '--output') usage(); - const value = arguments_[index + 1]; - if (value === undefined || value.startsWith('--')) usage(); - index += 1; - if (argument === '--root') { - if (root !== undefined) usage(); - root = value; - } else { - if (output !== undefined) usage(); - output = value; - } - } + const { values: { check, output, root } } = parseArgs({ + args: arguments_, + options: { + check: { type: 'boolean', default: false }, + output: { type: 'string' }, + root: { type: 'string' }, + }, + strict: true, + }); if (root === undefined || output === undefined || isAbsolute(output)) usage(); return Object.freeze({ check, output, root }); }; diff --git a/scripts/run-packed-native-smoke.mjs b/scripts/run-packed-native-smoke.mjs index 64c84e8b0..f4b06f51b 100644 --- a/scripts/run-packed-native-smoke.mjs +++ b/scripts/run-packed-native-smoke.mjs @@ -6,7 +6,7 @@ // --no-audit --no-fund`. Under pnpm the same proof fails before any host runs // ("npm pack --json returned 4 entries", then "Unknown options: 'omit'"). -import { spawn } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; const host = process.argv[2]; const optIn = host === 'claude' @@ -20,18 +20,15 @@ if (optIn === undefined) { process.exitCode = 2; } else { const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - const run = (args, environment = process.env) => new Promise((resolvePromise, reject) => { - const child = spawn(npm, args, { env: environment, stdio: 'inherit' }); - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code === 0) resolvePromise(); - else reject(new Error(`npm ${args.join(' ')} failed (${signal ?? code ?? 'unknown'}).`)); - }); - }); + const run = (args, environment = process.env) => { + const { error, signal, status } = spawnSync(npm, args, { env: environment, stdio: 'inherit' }); + if (error !== undefined) throw error; + if (status !== 0) throw new Error(`npm ${args.join(' ')} failed (${signal ?? status ?? 'unknown'}).`); + }; try { - await run(['run', 'build']); - await run(['run', 'test:packed:native'], { ...process.env, [optIn]: '1' }); + run(['run', 'build']); + run(['run', 'test:packed:native'], { ...process.env, [optIn]: '1' }); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; diff --git a/scripts/sync-license-files.mjs b/scripts/sync-license-files.mjs index 34245414d..2b40a1768 100644 --- a/scripts/sync-license-files.mjs +++ b/scripts/sync-license-files.mjs @@ -1,6 +1,5 @@ import { copyFile } from 'node:fs/promises'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { join, resolve } from 'node:path'; /** * Copies the repository's LICENSE and NOTICE into every publishable package so @@ -16,7 +15,7 @@ export const publishablePackageDirectories = Object.freeze([ 'packages/create-agent-bundle', ]); -export const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const repositoryRoot = resolve(import.meta.dirname, '..'); export const syncLicenseFiles = async (root = repositoryRoot) => { await Promise.all(publishablePackageDirectories.flatMap((directory) => (