diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 38576b1048..77c595db18 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -3,8 +3,16 @@ # IMAGE_ID is an env var that only macOS agents need. # Defining it at the root level propagates it too all agents, which can seem unnecessary but is a the same time convenient and DRY. +# +# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 prevents the `playwright install chromium` +# postinstall (pulled in transitively by the `data-liberation` agent dep) from +# downloading ~150 MB of Chromium during every CI `npm install` / `npm ci`. The +# DLA runtime bootstraps Chromium lazily on first use of a Wix/Squarespace +# adapter, so CI builds never need it. End-user `npm install -g wp-studio` runs +# the postinstall normally and pays the cost once on first install. env: IMAGE_ID: $IMAGE_ID + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" e2e_config: &e2e_config command: bash .buildkite/commands/run-e2e-tests.sh "{{matrix.platform}}" "{{matrix.arch}}" diff --git a/.buildkite/release-build-and-distribute.yml b/.buildkite/release-build-and-distribute.yml index 742c55b4f3..c4b48cbd8c 100644 --- a/.buildkite/release-build-and-distribute.yml +++ b/.buildkite/release-build-and-distribute.yml @@ -9,9 +9,16 @@ # Each step checks out the release branch to ensure it builds the latest commit. --- -# Used by mac agents only +# IMAGE_ID is used by mac agents only. +# +# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 prevents the `playwright install chromium` +# postinstall (pulled in transitively by the `data-liberation` agent dep) from +# downloading ~150 MB of Chromium during every release-build `npm install`. The +# DLA runtime bootstraps Chromium lazily on first use of a Wix/Squarespace +# adapter, so release builds never need it bundled. env: IMAGE_ID: $IMAGE_ID + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" steps: - group: ๐Ÿ“ฆ Build for Mac diff --git a/.buildkite/release-pipelines/code-freeze.yml b/.buildkite/release-pipelines/code-freeze.yml index 78ce67dd58..7cd9765669 100644 --- a/.buildkite/release-pipelines/code-freeze.yml +++ b/.buildkite/release-pipelines/code-freeze.yml @@ -1,8 +1,13 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json --- +# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 prevents the `playwright install chromium` +# postinstall (pulled in transitively by the `data-liberation` agent dep) from +# downloading ~150 MB of Chromium during the code-freeze `npm install`. The +# DLA runtime bootstraps Chromium lazily on first use, so CI never needs it. env: IMAGE_ID: $IMAGE_ID + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" steps: - label: ":snowflake: Code Freeze" diff --git a/.github/workflows/publish-npm-package.yml b/.github/workflows/publish-npm-package.yml index ee797deba4..4e86eae04c 100644 --- a/.github/workflows/publish-npm-package.yml +++ b/.github/workflows/publish-npm-package.yml @@ -12,6 +12,15 @@ permissions: contents: read id-token: write # Required for npm trusted publishing (OIDC) +# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 prevents the `playwright install chromium` +# postinstall (pulled in transitively by the `data-liberation` agent dep) from +# downloading ~150 MB of Chromium during `npm ci`. The DLA runtime bootstraps +# Chromium lazily on first use of a Wix/Squarespace adapter, so the publish job +# doesn't need it. End-user `npm install -g wp-studio` runs the postinstall +# normally and pays the cost once on first install. +env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + jobs: publish: runs-on: ubuntu-latest diff --git a/apps/cli/README.md b/apps/cli/README.md index c4e76e6ec5..30fa39f70d 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -27,6 +27,7 @@ The Studio CLI lets you: - [Quick start](#quick-start) - [Usage](#usage) - [Studio Code](#studio-code) +- [Migrate from a closed platform](#migrate-from-a-closed-platform) - [Import and export](#import-and-export) - [Sync with WordPress.com and Pressable](#sync-with-wordpresscom-and-pressable) - [Preview sites](#preview-sites) @@ -98,6 +99,28 @@ studio code sessions list studio code sessions resume ``` +## Migrate from a closed platform + +The Studio CLI can move a site off a closed web platform โ€” GoDaddy Websites & Marketing, Hostinger, HubSpot, Shopify, Squarespace, Webflow, Weebly, and Wix โ€” into a fresh local WordPress site. The migration inspects the source, extracts its content into a WXR archive plus media, verifies the result, and lands everything in a new Studio site under `~/Studio/`. Powered by [Data Liberation Agent](https://github.com/Automattic/data-liberation-agent). + +The recommended path is the `/liberate` slash command inside `studio code`. The agent walks you through detect, extract, verify, site-create, and import, confirming each heavier step before it runs. This path is gated behind a feature flag while it stabilizes โ€” start `studio code` with `STUDIO_DLA_ENABLED=1` to enable it, then invoke the skill with or without a URL: + +```bash +STUDIO_DLA_ENABLED=1 studio code +# inside the session: +# /liberate +# /liberate https://example.com +``` + +For headless or scripted use, run the standalone command instead. It spawns the Data Liberation Agent CLI directly with no agent in the loop, streaming progress straight to your terminal: + +```bash +studio liberate https://example.com +studio liberate https://example.com --output ./out --non-interactive +``` + +Two source platforms need credentials before the extract step: Webflow requires `LIBERATION_TOKEN` (a Webflow site token) and Shopify requires `SHOPIFY_ADMIN_TOKEN` (a Shopify Admin API token with read access to products and orders). Set them in your shell before running either path. Installing the Studio CLI also downloads a Playwright Chromium build (~150 MB) used for the Wix and Squarespace adapters, so the initial `npm install -g wp-studio` pulls more than the base CLI does. + ## Import and export The Studio CLI allows you to import and export local backups. diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 64289648ae..9ff1299001 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -18,6 +18,7 @@ import { SettingsManager, type AgentSession, type AgentSessionEvent, + type ExtensionFactory, type SessionManager, type ToolDefinition, } from '@earendil-works/pi-coding-agent'; @@ -28,6 +29,12 @@ import { type AiModelId, } from '@studio/common/ai/models'; import { getAiPayloadsPath, getConfigDirectory } from '@studio/common/lib/well-known-paths'; +import { + createDlaPolicyFactory, + defaultPolicyBuckets, + startDlaBridge, + type DlaBridge, +} from '@studio/dla'; import { buildSystemPrompt } from 'cli/ai/system-prompt'; import { resolveStudioToolDefinitions } from 'cli/ai/tools'; import { createAskUserQuestionTool } from 'cli/ai/tools/ask-user-question'; @@ -220,9 +227,16 @@ async function runAgentSessionTurn( let session: AgentSession | undefined; let unsubscribe: ( () => void ) | undefined; + const dlaBridge = await maybeStartDlaBridge( config ); const payloadGuardState: StudioToolPayloadGuardState = {}; try { - session = await createStudioAgentSession( config, family, resolved.creds, payloadGuardState ); + session = await createStudioAgentSession( + config, + family, + resolved.creds, + payloadGuardState, + dlaBridge + ); setActiveSession( session ); unsubscribe = session.subscribe( ( event ) => { updateStudioToolPayloadGuardState( event, payloadGuardState ); @@ -252,6 +266,53 @@ async function runAgentSessionTurn( unsubscribe?.(); session?.dispose(); setActiveSession( undefined ); + await dlaBridge?.dispose(); + } +} + +/** + * Bring up the Data Liberation Agent (DLA) MCP bridge for this session, + * gated on `STUDIO_DLA_ENABLED === '1'`. + * + * Failures to spawn or connect the bridge are non-fatal: a warning is + * logged and `undefined` is returned so the session still starts with the + * usual Studio tools. The bridge itself also degrades gracefully (returning + * `degraded: true` with an empty tool list) when `listTools` fails โ€” in + * that case the bridge handle is still returned so the caller can dispose + * it during teardown. + * + * The wpcom access token, when present in the session config, is forwarded + * to the DLA child as `STUDIO_WPCOM_TOKEN` so DLA can authenticate against + * WordPress.com APIs when surfacing remote-site flows. + * + * @param config - The resolved session config for this turn. + * @returns A `DlaBridge` handle, or `undefined` when DLA is disabled or + * the bridge could not be constructed at all. + */ +async function maybeStartDlaBridge( + config: ResolvedStudioAgentTurnConfig +): Promise< DlaBridge | undefined > { + if ( config.env.STUDIO_DLA_ENABLED !== '1' ) { + return undefined; + } + try { + const bridge = await startDlaBridge( { + wpcomToken: config.wpcomAccessToken, + } ); + if ( bridge.degraded ) { + console.warn( + `[studio code] DLA bridge degraded; continuing without DLA tools (${ + bridge.degradationReason ?? 'unknown reason' + }).` + ); + } + return bridge; + } catch ( error ) { + const reason = error instanceof Error ? error.message : String( error ); + console.warn( + `[studio code] DLA bridge failed to start; continuing without DLA tools (${ reason }).` + ); + return undefined; } } @@ -259,7 +320,8 @@ async function createStudioAgentSession( config: ResolvedStudioAgentTurnConfig, family: AiModelFamily, creds: ResolvedCredentials, - payloadGuardState: StudioToolPayloadGuardState + payloadGuardState: StudioToolPayloadGuardState, + dlaBridge?: DlaBridge ): Promise< AgentSession > { const model = buildModel( config.model, family, creds ); const isRemoteSite = Boolean( config.activeSite?.remote && config.activeSite?.wpcomSiteId ); @@ -279,10 +341,16 @@ async function createStudioAgentSession( : { chatArtifactsEnabled, remoteSession } ); - const tools = buildAgentTools( config, chatArtifactsEnabled, remoteSession ); - const toolDefinitions = tools.map( ( tool ) => toToolDefinition( tool, payloadGuardState ) ); + const toolDefinitions = buildAgentTools( + config, + chatArtifactsEnabled, + remoteSession, + payloadGuardState, + dlaBridge + ); const { authStorage, modelRegistry } = createModelRegistry( model, family, creds ); const settingsManager = createSettingsManager( config.env ); + const extensionFactories = resolveDlaExtensionFactories( config.env ); const resourceLoader = new DefaultResourceLoader( { cwd: STUDIO_SITES_ROOT, agentDir: STUDIO_AGENT_DIR, @@ -293,6 +361,7 @@ async function createStudioAgentSession( noThemes: true, noContextFiles: true, systemPrompt, + extensionFactories, } ); await resourceLoader.reload(); @@ -436,6 +505,33 @@ function createSettingsManager( _env: Record< string, string > ): SettingsManage } ); } +/** + * Build the list of pi `ExtensionFactory` values to feed into + * `DefaultResourceLoader.extensionFactories` based on the session's env. + * + * Inline `extensionFactories` are loaded even when `noExtensions: true` + * is set on `DefaultResourceLoader` (verified at `resource-loader.js`'s + * `loadExtensionFactories` path), so the runtime keeps the rest of the + * extension discovery surface disabled while still mounting the DLA + * policy hook when the feature flag is on. + * + * The DLA policy factory is only mounted when + * `STUDIO_DLA_ENABLED === '1'`. v1 ships with the flag off by default; + * the same flag also gates the DLA bridge spawn in `runAgentSessionTurn` + * (handled separately) so the policy hook is a no-op when the bridge is + * inactive. + * + * @param env - The resolved process env for this session. + * @returns An array of factories to forward to `DefaultResourceLoader`. + * Empty when DLA is disabled. + */ +function resolveDlaExtensionFactories( env: Record< string, string > ): ExtensionFactory[] { + if ( env.STUDIO_DLA_ENABLED !== '1' ) { + return []; + } + return [ createDlaPolicyFactory( defaultPolicyBuckets ) ]; +} + function toToolDefinition( tool: AgentToolAny, payloadGuardState: StudioToolPayloadGuardState @@ -461,11 +557,37 @@ function toToolDefinition( }; } +/** + * Build the `customTools` list passed to pi's `createAgentSession`. The + * Studio-native tools (returned as `AgentTool` values) are converted to + * pi's `ToolDefinition` shape via `toToolDefinition`, and the bridged DLA + * tools โ€” already `ToolDefinition[]` โ€” are spliced into the local-site + * tool list when `dlaBridge` is supplied. + * + * The DLA tools intentionally do not appear in the remote-site branch + * because the remote flow already steers callers toward WordPress.com + * APIs (`wpcom_request`), and bridging DLA there risks recursive migration + * loops back into Studio itself. + * + * @param config - The resolved per-turn session config. + * @param chatArtifactsEnabled - Whether chat artifacts should be emitted + * (true when the runtime is forked by the desktop app). + * @param remoteSession - Whether this is a remote, WPCOM-backed session. + * @param payloadGuardState - Mutable guard state threaded into each tool's + * `execute` to enforce payload limits and incomplete-tool-call checks. + * @param dlaBridge - Optional DLA bridge handle; when present, its + * `tools` are spliced into the local-site tool list. When absent + * (e.g. `STUDIO_DLA_ENABLED` is unset or the bridge failed to spawn) + * the result matches the legacy tool list exactly. + * @returns The fully assembled `ToolDefinition[]` for the session. + */ function buildAgentTools( config: ResolvedStudioAgentTurnConfig, chatArtifactsEnabled: boolean, - remoteSession: boolean -): AgentToolAny[] { + remoteSession: boolean, + payloadGuardState: StudioToolPayloadGuardState, + dlaBridge?: DlaBridge +): ToolDefinition[] { const isRemoteSite = Boolean( config.activeSite?.remote && config.activeSite?.wpcomSiteId && config.wpcomAccessToken ); @@ -491,7 +613,7 @@ function buildAgentTools( ]; if ( isRemoteSite ) { - return [ + const remoteTools: AgentToolAny[] = [ createWpcomRequestTool( config.wpcomAccessToken!, config.activeSite!.wpcomSiteId! ), takeScreenshotTool, createSiteTool, @@ -500,6 +622,7 @@ function buildAgentTools( ...askUserTool, ...skillTool, ]; + return remoteTools.map( ( tool ) => toToolDefinition( tool, payloadGuardState ) ); } const piTools: AgentToolAny[] = [ @@ -515,7 +638,11 @@ function buildAgentTools( emitChatArtifacts: chatArtifactsEnabled, remoteSession, } ) as unknown as AgentToolAny[]; - return [ ...studioTools, ...askUserTool, ...skillTool, ...piTools ]; + const localTools: AgentToolAny[] = [ ...studioTools, ...askUserTool, ...skillTool, ...piTools ]; + return [ + ...localTools.map( ( tool ) => toToolDefinition( tool, payloadGuardState ) ), + ...( dlaBridge?.tools ?? [] ), + ]; } function parseJsonHeaderEnv( value: string | undefined ): Record< string, string > | undefined { diff --git a/apps/cli/ai/skills/liberate/SKILL.md b/apps/cli/ai/skills/liberate/SKILL.md new file mode 100644 index 0000000000..49524d0c3c --- /dev/null +++ b/apps/cli/ai/skills/liberate/SKILL.md @@ -0,0 +1,140 @@ +--- +name: liberate +description: Liberate a site from a closed web platform (GoDaddy Websites & Marketing, Hostinger, HubSpot, Shopify, Squarespace, Webflow, Weebly, Wix) into a fresh local WordPress site via the Data Liberation Agent toolchain. +--- + +# Liberate + +Move a site off a closed web platform and into a fresh local WordPress site. The skill orchestrates the Data Liberation Agent (DLA) tools to inspect the source, extract its content into a WXR archive plus media, then hands the artifacts off to Studio to create and populate the new site. + +Based on the [Data Liberation Agent](https://github.com/Automattic/data-liberation-agent). + +## On Startup + +When the user invokes this skill, introduce yourself: + +> **Welcome to Liberate!** I'll move your site off a closed platform (Wix, Squarespace, Shopify, Webflow, GoDaddy, HubSpot, Hostinger, or Weebly) into a fresh local WordPress site. +> +> I'll inspect the source, extract its content and media, verify the result, and then create a new Studio site populated with everything I find. You confirm at each step before I run the heavier extract and import phases. + +Then move to Step 1. + +## Step 1: Identify the source + +If the user already gave a URL in their prompt (e.g. `/liberate https://example.com`), use it. + +Otherwise, ask the user for the source URL of the site they want to liberate. Plain prose is fine โ€” wait for their reply before continuing. + +## Step 2: Inspect + +Call `liberate_inspect` with the source URL. The tool fingerprints the platform, walks the sitemap, and samples a few pages to surface content counts and feature flags. + +Tell the user what was detected: + +- Detected platform (Wix, Shopify, etc.) +- Approximate page count, post count, and product count +- Any platform features that affect the migration (Shopify products, Wix dynamic pages, etc.) + +## Step 3: Confirm + +Use `AskUserQuestion` to confirm the user wants to proceed with the detected platform, with options like: + +- "Yes, extract this site" +- "No, cancel" + +**For Webflow:** if the detected platform is Webflow and `LIBERATION_TOKEN` is not set in the environment, tell the user they need to export a Webflow site token and set `LIBERATION_TOKEN=` before re-running, then stop. + +**For Shopify:** if the detected platform is Shopify and `SHOPIFY_ADMIN_TOKEN` is not set, tell the user they need a Shopify Admin API token with read access to products/orders and to set `SHOPIFY_ADMIN_TOKEN=` before re-running, then stop. + +If the platform is anything else, no extra credentials are required โ€” proceed to Step 4. + +## Step 4: Extract + +Call `liberate_extract` with the source URL. The tool walks the discovered URLs, populates a WXR (WordPress eXtended RSS) archive, downloads media into the output directory, and writes a redirect map. + +Narrate progress as the tool emits log events: which page is being fetched, media counts, and any retryable errors. Extraction can take several minutes for large sites; do not interrupt unless the user asks. + +When extract completes, summarize what was produced: total pages and posts extracted, media files downloaded, output directory path, and any items that failed (these will be revisited in Step 5). + +## Step 5: Verify + +Call `liberate_verify` against the output directory. The verifier compares the WXR back against the source and reports a quality score plus actionable issues: stale CDN URLs, failed media downloads, parsing gaps, and the like. + +Surface the report to the user: + +- Quality score (good / needs improvement / poor) +- The top few issues that affect import fidelity +- Whether to proceed, retry extract, or cancel + +If the score is poor and the user wants to retry, return to Step 4. Otherwise continue to Step 6. + +## Step 6: Setup (delegate) + +Call `liberate_setup` with `delegate: true`. In delegate mode the tool does not connect to a live WordPress REST endpoint โ€” it returns a manifest describing the requirements Studio needs to satisfy on the destination side (WordPress version constraints, plugins to activate, media path mapping, etc.). + +Read the manifest. The values you need from it for Step 7 and Step 8 flow through unchanged. + +## Step 7: Create the Studio site + +Derive a slug from the source domain โ€” strip the scheme, drop `www.`, replace remaining dots and slashes with dashes, lowercase. E.g. `https://example.com/foo` becomes `example-com`. If the slug collides with an existing local site, append a numeric suffix (`example-com-2`). + +Call Studio's `site_create` tool with that slug and a blueprint that inlines the WXR via `importWxr` (a `LiteralReference` to the WXR contents). This is the critical trick from DLA's preview path: importing the WXR **during** site creation routes through Playground and dodges Studio's WP-CLI IPC 120-second no-activity timeout. The post-creation `wp_cli` path is reserved for follow-up steps like products import. + +Blueprint shape (sketch โ€” fill the literal from the manifest's `wxrFile`): + +```json +{ + "preferredVersions": { "php": "8.2", "wp": "latest" }, + "steps": [ + { + "step": "importWxr", + "file": { + "resource": "literal", + "name": "output.wxr", + "contents": "<...WXR contents from manifest.wxrFile...>" + } + } + ] +} +``` + +When `site_create` returns, capture the new site path and URL for Step 8 and Step 9. + +## Step 8: Import (delegate) + +Call `liberate_import` with `delegate: true`. The destructive mode is gated behind delegate โ€” never call `liberate_import` without it from this skill. The tool returns a manifest: + +``` +{ wxrFile, outputDir, mediaDir, productsCsv?, redirectMap, importAuthors } +``` + +For each piece of the manifest: + +- **`mediaDir`**: Copy media files from `mediaDir` into the newly created site's `wp-content/uploads/` using `Bash` or `Write` as appropriate. +- **`redirectMap`**: Hand the redirect map to the user (write it to disk inside the site directory) so they can wire it into a redirect plugin later. +- **`importAuthors`**: For each author entry, ensure the corresponding WP user exists. Use Studio's `wp_cli` tool with `user create` if needed. +- **`productsCsv`** (Shopify only): If present, run Studio's `wp_cli` tool with `wc product_importer ` to import the products. This requires WooCommerce to be active โ€” verify with `wp_cli plugin list` and activate `woocommerce` first if it's not running. + +The WXR import itself was already handled inside `site_create` via the `importWxr` blueprint step (Step 7), so do not re-import the WXR with `wp_cli import` here. + +## Step 9: Wrap up + +Summarize what landed: site name, URL, pages and posts imported, media count, products (if any). + +Use `AskUserQuestion` to ask whether to open the site in the browser: + +- "Open the site in my browser" +- "Stay in the CLI" + +If the user picks open, call the appropriate site-open tool to launch the new URL. Otherwise, just print the URL. + +## Important Notes + +- **Use the Studio tools** (`site_create`, `wp_cli`, `site_info`, etc.) โ€” not shell commands โ€” for anything WordPress-side. +- **Never call `liberate_import` without `delegate: true`** โ€” the non-delegate path mutates a remote WordPress site and Studio's policy blocks it. +- **Tool names here are bare** โ€” call `liberate_inspect`, `liberate_extract`, etc. directly. DLA's tools surface as Studio's local `customTools`, not as MCP-prefixed remote tools, so no prefix is needed. +- **The `STUDIO_DLA_ENABLED` env var** must be set when `studio code` starts, or the DLA tools will not be present in the tool list. If you do not see `liberate_inspect` in your available tools, ask the user to restart `studio code` with `STUDIO_DLA_ENABLED=1`. + +## Headless mode + +Headless / non-interactive liberation is not handled by this skill. For a one-shot, non-agent run (CI scripts, bulk runs, etc.), point the user at the standalone `studio liberate ` CLI command instead โ€” it spawns DLA's CLI directly and streams progress to the terminal without an agent in the loop. diff --git a/apps/cli/ai/tests/liberate-skill.test.ts b/apps/cli/ai/tests/liberate-skill.test.ts new file mode 100644 index 0000000000..4cf8c47dcf --- /dev/null +++ b/apps/cli/ai/tests/liberate-skill.test.ts @@ -0,0 +1,74 @@ +import fs from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { findSkill, loadSkills } from 'cli/ai/skills'; + +/** + * The `/liberate` wrapper skill is parsed by `loadSkills()` at runtime. The + * loader only reads `name` and `description` from frontmatter (everything + * else is body), so these tests assert both the frontmatter contract and a + * loose set of body references that lock in the bare-tool-name convention. + * + * Bare names matter: DLA's MCP tools surface through the bridge as plain + * `customTools` (e.g. `liberate_inspect`), not as MCP-prefixed remote + * tools (`mcp__data-liberation__liberate_inspect`). The skill body must + * reference the bare names for the model to call them correctly. + */ +describe( '/liberate skill', () => { + const skillPath = path.resolve( import.meta.dirname, '..', 'skills', 'liberate', 'SKILL.md' ); + + it( 'exists at apps/cli/ai/skills/liberate/SKILL.md', () => { + expect( fs.existsSync( skillPath ) ).toBe( true ); + } ); + + it( 'is discovered by loadSkills()', () => { + const skills = loadSkills(); + const names = skills.map( ( s ) => s.name ); + expect( names ).toContain( 'liberate' ); + } ); + + it( 'parses frontmatter with name=liberate and a non-empty description', () => { + const skill = findSkill( 'liberate' ); + expect( skill ).toBeDefined(); + expect( skill!.name ).toBe( 'liberate' ); + expect( skill!.description ).toBeTruthy(); + expect( skill!.description.length ).toBeGreaterThan( 0 ); + } ); + + it( 'body references the bare DLA tool names (no MCP prefix)', () => { + const skill = findSkill( 'liberate' ); + const body = skill!.body; + + // Bare DLA tools the skill orchestrates. + expect( body ).toContain( 'liberate_inspect' ); + expect( body ).toContain( 'liberate_extract' ); + expect( body ).toContain( 'liberate_verify' ); + expect( body ).toContain( 'liberate_setup' ); + expect( body ).toContain( 'liberate_import' ); + + // Studio's local tools the skill hands work off to. + expect( body ).toContain( 'site_create' ); + expect( body ).toContain( 'wp_cli' ); + } ); + + it( 'does not use the MCP-prefixed tool names', () => { + const skill = findSkill( 'liberate' ); + const body = skill!.body; + expect( body ).not.toContain( 'mcp__data-liberation__' ); + expect( body ).not.toContain( 'mcp__studio__' ); + } ); + + it( 'enforces the delegate:true contract for liberate_import', () => { + const skill = findSkill( 'liberate' ); + const body = skill!.body; + // The body must instruct the model to pass delegate:true; a loose + // grep is enough โ€” exact phrasing is allowed to drift. + expect( body ).toMatch( /delegate:\s*true/i ); + } ); + + it( 'points users to `studio liberate` for headless mode', () => { + const skill = findSkill( 'liberate' ); + const body = skill!.body; + expect( body.toLowerCase() ).toContain( 'studio liberate' ); + } ); +} ); diff --git a/apps/cli/ai/tests/runtime-dla-bridge.test.ts b/apps/cli/ai/tests/runtime-dla-bridge.test.ts new file mode 100644 index 0000000000..ef47e73c5a --- /dev/null +++ b/apps/cli/ai/tests/runtime-dla-bridge.test.ts @@ -0,0 +1,288 @@ +import { SessionManager } from '@earendil-works/pi-coding-agent'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runStudioAgentTurn, type StudioAgentTurnConfig } from 'cli/ai/runtimes/pi'; +import type { + AgentSessionEvent, + CreateAgentSessionOptions, + ToolDefinition, +} from '@earendil-works/pi-coding-agent'; +import type { DlaBridge, StartDlaBridgeOptions } from '@studio/dla'; + +const mocks = vi.hoisted( () => ( { + createAgentSession: vi.fn(), + customToolsCalls: [] as ToolDefinition[][], + startDlaBridge: vi.fn(), + dlaBridgeDisposeOrder: [] as string[], + startDlaBridgeCalls: [] as StartDlaBridgeOptions[], +} ) ); + +vi.mock( '@studio/dla', async ( importOriginal ) => { + const actual = await importOriginal< typeof import('@studio/dla') >(); + return { + ...actual, + startDlaBridge: ( opts?: StartDlaBridgeOptions ) => { + mocks.startDlaBridgeCalls.push( opts ?? {} ); + return mocks.startDlaBridge( opts ); + }, + }; +} ); + +vi.mock( '@earendil-works/pi-coding-agent', async ( importOriginal ) => { + const actual = await importOriginal< typeof import('@earendil-works/pi-coding-agent') >(); + const stub = ( name: string ) => ( { + name, + label: name, + description: name, + parameters: {}, + execute: async () => ( { content: [ { type: 'text', text: '' } ], details: undefined } ), + } ); + + return { + ...actual, + createAgentSession: mocks.createAgentSession, + createReadTool: () => stub( 'Read' ), + createWriteTool: () => stub( 'Write' ), + createEditTool: () => stub( 'Edit' ), + createBashTool: () => stub( 'Bash' ), + createGrepTool: () => stub( 'Grep' ), + createFindTool: () => stub( 'Glob' ), + createLsTool: () => stub( 'Ls' ), + }; +} ); + +class FakeSession { + private listener?: ( event: AgentSessionEvent ) => void; + public disposed = false; + + constructor( public options: CreateAgentSessionOptions ) {} + + subscribe( listener: ( event: AgentSessionEvent ) => void ): () => void { + this.listener = listener; + return () => {}; + } + + async prompt(): Promise< void > { + this.listener?.( { type: 'agent_end', messages: [], willRetry: false } ); + } + + async abort(): Promise< void > {} + + dispose(): void { + this.disposed = true; + mocks.dlaBridgeDisposeOrder.push( 'session.dispose' ); + } +} + +const newSession = () => SessionManager.inMemory( '/tmp/eval-dla-bridge' ); + +async function runRuntime( config: Omit< StudioAgentTurnConfig, 'onEvent' > ): Promise< void > { + const handle = runStudioAgentTurn( { ...config, onEvent: () => {} } ); + await handle.result; +} + +const baseEnv = { + OPENAI_API_KEY: 'sk-test', + OPENAI_BASE_URL: 'https://proxy.example.com/v1', +}; + +/** + * Build a stub {@link ToolDefinition} suitable for testing โ€” name only; + * `execute()` is never called in these tests. + */ +function fakeDlaTool( name: string ): ToolDefinition { + return { + name, + label: name, + description: name, + parameters: {}, + execute: async () => ( { content: [ { type: 'text', text: '' } ], details: undefined } ), + } as unknown as ToolDefinition; +} + +describe( 'pi runtime DLA bridge wiring', () => { + beforeEach( () => { + mocks.customToolsCalls.length = 0; + mocks.startDlaBridgeCalls.length = 0; + mocks.dlaBridgeDisposeOrder.length = 0; + mocks.startDlaBridge.mockReset(); + mocks.createAgentSession.mockReset(); + mocks.createAgentSession.mockImplementation( async ( options: CreateAgentSessionOptions ) => { + mocks.customToolsCalls.push( ( options.customTools ?? [] ) as ToolDefinition[] ); + const session = new FakeSession( options ); + return { session, extensionsResult: { extensions: [], errors: [], runtime: {} } }; + } ); + } ); + + afterEach( () => { + vi.restoreAllMocks(); + } ); + + it( 'wires DLA bridge tools into customTools when STUDIO_DLA_ENABLED=1', async () => { + const dlaTools = [ + fakeDlaTool( 'liberate_detect' ), + fakeDlaTool( 'liberate_inspect' ), + fakeDlaTool( 'liberate_extract' ), + ]; + const bridge: DlaBridge = { + tools: dlaTools, + degraded: false, + dispose: vi.fn( async () => { + mocks.dlaBridgeDisposeOrder.push( 'bridge.dispose' ); + } ), + }; + mocks.startDlaBridge.mockResolvedValue( bridge ); + + await runRuntime( { + prompt: 'hello', + env: { ...baseEnv, STUDIO_DLA_ENABLED: '1' }, + model: 'gpt-5.5', + session: newSession(), + } ); + + expect( mocks.startDlaBridgeCalls ).toHaveLength( 1 ); + expect( mocks.customToolsCalls ).toHaveLength( 1 ); + const toolNames = mocks.customToolsCalls[ 0 ].map( ( tool ) => tool.name ); + expect( toolNames ).toEqual( + expect.arrayContaining( [ 'liberate_detect', 'liberate_inspect', 'liberate_extract' ] ) + ); + } ); + + it( 'passes the wpcomAccessToken through to startDlaBridge when present', async () => { + mocks.startDlaBridge.mockResolvedValue( { + tools: [], + degraded: false, + dispose: vi.fn( async () => {} ), + } satisfies DlaBridge ); + + await runRuntime( { + prompt: 'hello', + env: { ...baseEnv, STUDIO_DLA_ENABLED: '1' }, + model: 'gpt-5.5', + session: newSession(), + wpcomAccessToken: 'wpcom-bearer-token', + } ); + + expect( mocks.startDlaBridgeCalls ).toHaveLength( 1 ); + expect( mocks.startDlaBridgeCalls[ 0 ].wpcomToken ).toBe( 'wpcom-bearer-token' ); + } ); + + it( 'does not spawn bridge when STUDIO_DLA_ENABLED is unset', async () => { + await runRuntime( { + prompt: 'hello', + env: { ...baseEnv }, + model: 'gpt-5.5', + session: newSession(), + } ); + + expect( mocks.startDlaBridge ).not.toHaveBeenCalled(); + expect( mocks.customToolsCalls ).toHaveLength( 1 ); + const toolNames = mocks.customToolsCalls[ 0 ].map( ( tool ) => tool.name ); + expect( toolNames ).not.toEqual( + expect.arrayContaining( [ 'liberate_detect', 'liberate_inspect' ] ) + ); + } ); + + it( 'does not spawn bridge when STUDIO_DLA_ENABLED=0', async () => { + await runRuntime( { + prompt: 'hello', + env: { ...baseEnv, STUDIO_DLA_ENABLED: '0' }, + model: 'gpt-5.5', + session: newSession(), + } ); + + expect( mocks.startDlaBridge ).not.toHaveBeenCalled(); + } ); + + it( 'disposes the bridge after the session in the finally block', async () => { + const disposeMock = vi.fn( async () => { + mocks.dlaBridgeDisposeOrder.push( 'bridge.dispose' ); + } ); + mocks.startDlaBridge.mockResolvedValue( { + tools: [ fakeDlaTool( 'liberate_detect' ) ], + degraded: false, + dispose: disposeMock, + } satisfies DlaBridge ); + + await runRuntime( { + prompt: 'hello', + env: { ...baseEnv, STUDIO_DLA_ENABLED: '1' }, + model: 'gpt-5.5', + session: newSession(), + } ); + + expect( disposeMock ).toHaveBeenCalledTimes( 1 ); + expect( mocks.dlaBridgeDisposeOrder ).toEqual( [ 'session.dispose', 'bridge.dispose' ] ); + } ); + + it( 'disposes the bridge even when session.prompt throws', async () => { + const disposeMock = vi.fn( async () => { + mocks.dlaBridgeDisposeOrder.push( 'bridge.dispose' ); + } ); + mocks.startDlaBridge.mockResolvedValue( { + tools: [], + degraded: false, + dispose: disposeMock, + } satisfies DlaBridge ); + + mocks.createAgentSession.mockImplementationOnce( + async ( options: CreateAgentSessionOptions ) => { + mocks.customToolsCalls.push( ( options.customTools ?? [] ) as ToolDefinition[] ); + const session = new FakeSession( options ); + session.prompt = async () => { + throw new Error( 'boom' ); + }; + return { session, extensionsResult: { extensions: [], errors: [], runtime: {} } }; + } + ); + + await runRuntime( { + prompt: 'hello', + env: { ...baseEnv, STUDIO_DLA_ENABLED: '1' }, + model: 'gpt-5.5', + session: newSession(), + } ); + + expect( disposeMock ).toHaveBeenCalledTimes( 1 ); + expect( mocks.dlaBridgeDisposeOrder ).toEqual( [ 'session.dispose', 'bridge.dispose' ] ); + } ); + + it( 'continues session start when bridge degrades', async () => { + const warnSpy = vi.spyOn( console, 'warn' ).mockImplementation( () => {} ); + const disposeMock = vi.fn( async () => {} ); + mocks.startDlaBridge.mockResolvedValue( { + tools: [], + degraded: true, + degradationReason: 'spawn ENOENT', + dispose: disposeMock, + } satisfies DlaBridge ); + + await runRuntime( { + prompt: 'hello', + env: { ...baseEnv, STUDIO_DLA_ENABLED: '1' }, + model: 'gpt-5.5', + session: newSession(), + } ); + + expect( mocks.createAgentSession ).toHaveBeenCalledTimes( 1 ); + expect( mocks.customToolsCalls ).toHaveLength( 1 ); + const toolNames = mocks.customToolsCalls[ 0 ].map( ( tool ) => tool.name ); + expect( toolNames ).not.toEqual( expect.arrayContaining( [ 'liberate_detect' ] ) ); + expect( warnSpy ).toHaveBeenCalled(); + expect( disposeMock ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'continues session start when startDlaBridge itself rejects', async () => { + const warnSpy = vi.spyOn( console, 'warn' ).mockImplementation( () => {} ); + mocks.startDlaBridge.mockRejectedValue( new Error( 'fatal spawn error' ) ); + + await runRuntime( { + prompt: 'hello', + env: { ...baseEnv, STUDIO_DLA_ENABLED: '1' }, + model: 'gpt-5.5', + session: newSession(), + } ); + + expect( mocks.createAgentSession ).toHaveBeenCalledTimes( 1 ); + expect( warnSpy ).toHaveBeenCalled(); + } ); +} ); diff --git a/apps/cli/ai/tests/runtime-dla-policy.test.ts b/apps/cli/ai/tests/runtime-dla-policy.test.ts new file mode 100644 index 0000000000..73def06d50 --- /dev/null +++ b/apps/cli/ai/tests/runtime-dla-policy.test.ts @@ -0,0 +1,172 @@ +import { SessionManager } from '@earendil-works/pi-coding-agent'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { runStudioAgentTurn, type StudioAgentTurnConfig } from 'cli/ai/runtimes/pi'; +import type { + AgentSessionEvent, + CreateAgentSessionOptions, + DefaultResourceLoader as DefaultResourceLoaderType, + ExtensionFactory, +} from '@earendil-works/pi-coding-agent'; + +type DefaultResourceLoaderOptions = ConstructorParameters< typeof DefaultResourceLoaderType >[ 0 ]; + +const mocks = vi.hoisted( () => ( { + createAgentSession: vi.fn(), + resourceLoaderOptions: [] as DefaultResourceLoaderOptions[], + dlaPolicyFactoryToken: Symbol( 'dla-policy-factory-token' ), + createDlaPolicyFactoryCalls: [] as unknown[], +} ) ); + +vi.mock( '@studio/dla', async ( importOriginal ) => { + const actual = await importOriginal< typeof import('@studio/dla') >(); + const sentinelFactory = ( () => { + // Tag the factory so tests can identify it by reference equality + // in the captured `DefaultResourceLoader` constructor options. + const factory: ExtensionFactory = () => {}; + ( factory as unknown as { __token: symbol } ).__token = mocks.dlaPolicyFactoryToken; + return factory; + } )(); + return { + ...actual, + createDlaPolicyFactory: ( buckets?: unknown ) => { + mocks.createDlaPolicyFactoryCalls.push( buckets ); + return sentinelFactory; + }, + }; +} ); + +vi.mock( '@earendil-works/pi-coding-agent', async ( importOriginal ) => { + const actual = await importOriginal< typeof import('@earendil-works/pi-coding-agent') >(); + const stub = ( name: string ) => ( { + name, + label: name, + description: name, + parameters: {}, + execute: async () => ( { content: [ { type: 'text', text: '' } ], details: undefined } ), + } ); + + // Capture every construction so tests can assert on the + // `extensionFactories` slot for the relevant turn. + class CapturingResourceLoader extends actual.DefaultResourceLoader { + constructor( options: DefaultResourceLoaderOptions ) { + super( options ); + mocks.resourceLoaderOptions.push( options ); + } + } + + return { + ...actual, + createAgentSession: mocks.createAgentSession, + DefaultResourceLoader: CapturingResourceLoader, + createReadTool: () => stub( 'Read' ), + createWriteTool: () => stub( 'Write' ), + createEditTool: () => stub( 'Edit' ), + createBashTool: () => stub( 'Bash' ), + createGrepTool: () => stub( 'Grep' ), + createFindTool: () => stub( 'Glob' ), + createLsTool: () => stub( 'Ls' ), + }; +} ); + +class FakeSession { + private listener?: ( event: AgentSessionEvent ) => void; + public aborted = false; + public disposed = false; + + constructor( public options: CreateAgentSessionOptions ) {} + + subscribe( listener: ( event: AgentSessionEvent ) => void ): () => void { + this.listener = listener; + return () => {}; + } + + async prompt(): Promise< void > { + this.listener?.( { type: 'agent_end', messages: [], willRetry: false } ); + } + + async abort(): Promise< void > { + this.aborted = true; + } + + dispose(): void { + this.disposed = true; + } +} + +const newSession = () => SessionManager.inMemory( '/tmp/eval-dla-policy' ); + +async function runRuntime( config: Omit< StudioAgentTurnConfig, 'onEvent' > ): Promise< void > { + const handle = runStudioAgentTurn( { ...config, onEvent: () => {} } ); + await handle.result; +} + +const baseEnv = { + OPENAI_API_KEY: 'sk-test', + OPENAI_BASE_URL: 'https://proxy.example.com/v1', +}; + +describe( 'pi runtime DLA policy wiring', () => { + beforeEach( () => { + mocks.resourceLoaderOptions.length = 0; + mocks.createDlaPolicyFactoryCalls.length = 0; + mocks.createAgentSession.mockReset(); + mocks.createAgentSession.mockImplementation( async ( options: CreateAgentSessionOptions ) => { + const session = new FakeSession( options ); + return { session, extensionsResult: { extensions: [], errors: [], runtime: {} } }; + } ); + } ); + + it( 'mounts the DLA policy factory on DefaultResourceLoader when STUDIO_DLA_ENABLED=1', async () => { + await runRuntime( { + prompt: 'hello', + env: { ...baseEnv, STUDIO_DLA_ENABLED: '1' }, + model: 'gpt-5.5', + session: newSession(), + } ); + + expect( mocks.resourceLoaderOptions ).toHaveLength( 1 ); + const factories = mocks.resourceLoaderOptions[ 0 ].extensionFactories; + expect( factories ).toHaveLength( 1 ); + expect( ( factories![ 0 ] as unknown as { __token: symbol } ).__token ).toBe( + mocks.dlaPolicyFactoryToken + ); + expect( mocks.createDlaPolicyFactoryCalls ).toHaveLength( 1 ); + } ); + + it( 'does not mount the DLA policy factory when STUDIO_DLA_ENABLED is unset', async () => { + await runRuntime( { + prompt: 'hello', + env: { ...baseEnv }, + model: 'gpt-5.5', + session: newSession(), + } ); + + expect( mocks.resourceLoaderOptions ).toHaveLength( 1 ); + expect( mocks.resourceLoaderOptions[ 0 ].extensionFactories ).toEqual( [] ); + expect( mocks.createDlaPolicyFactoryCalls ).toHaveLength( 0 ); + } ); + + it( 'does not mount the DLA policy factory when STUDIO_DLA_ENABLED=0', async () => { + await runRuntime( { + prompt: 'hello', + env: { ...baseEnv, STUDIO_DLA_ENABLED: '0' }, + model: 'gpt-5.5', + session: newSession(), + } ); + + expect( mocks.resourceLoaderOptions ).toHaveLength( 1 ); + expect( mocks.resourceLoaderOptions[ 0 ].extensionFactories ).toEqual( [] ); + expect( mocks.createDlaPolicyFactoryCalls ).toHaveLength( 0 ); + } ); + + it( 'leaves noExtensions: true intact when DLA is enabled (inline factories still load)', async () => { + await runRuntime( { + prompt: 'hello', + env: { ...baseEnv, STUDIO_DLA_ENABLED: '1' }, + model: 'gpt-5.5', + session: newSession(), + } ); + + expect( mocks.resourceLoaderOptions[ 0 ].noExtensions ).toBe( true ); + } ); +} ); diff --git a/apps/cli/commands/liberate/index.ts b/apps/cli/commands/liberate/index.ts new file mode 100644 index 0000000000..21a3e1095a --- /dev/null +++ b/apps/cli/commands/liberate/index.ts @@ -0,0 +1,233 @@ +/** + * The `studio liberate` command is a thin yargs wrapper around the Data + * Liberation Agent (DLA) CLI. It is the non-agent, headless escape-hatch + * path for users who want DLA's full Ink-rendered UI without Studio's + * AI agent in the loop. + * + * Implementation summary: + * + * - Resolves DLA's CLI entry (`data-liberation/src/cli.ts`) and the `tsx` + * loader (`tsx/cli` โ€” the public exports key, equivalent to + * `tsx/dist/cli.mjs` but spelled the way Node ESM resolution accepts). + * - Spawns `process.execPath` with `[ tsx, dlaCli, , ...args ]` and + * inherits the parent's stdio so DLA writes directly to the user's tty. + * - Forwards SIGINT and SIGTERM to the child; exits with the child's + * exit code (or 128+signal for signal-terminated exits) so shell users + * can chain `studio liberate` like any other Unix command. + * - Passes through only `LIBERATION_TOKEN` and `SHOPIFY_ADMIN_TOKEN` from + * the environment, plus DLA's existing env vars (`WP_APP_PASSWORD`, + * `NO_COLOR`, etc.). `STUDIO_WPCOM_TOKEN` is explicitly *not* forwarded + * because DLA's CLI never reads it โ€” only DLA's MCP server does, and + * that path is not exercised here. + * + * Yargs-level flags are intentionally minimal โ€” the URL is the only + * positional, and `--output` / `--non-interactive` are surfaced for + * discoverability. Any additional DLA flags can be passed verbatim + * (yargs is configured non-strict so unknown args flow into the child). + */ + +import { spawn, type ChildProcess } from 'child_process'; +import { __ } from '@wordpress/i18n'; +import { resolveDlaCliEntry, resolveTsxCli } from 'cli/commands/liberate/resolvers'; +import { StudioArgv } from 'cli/types'; + +/** + * The signals forwarded from the parent Studio CLI process down to the + * spawned DLA child process. Listed explicitly so the parent installs + * matching handlers up-front and removes them once the child exits. + */ +const FORWARDED_SIGNALS: NodeJS.Signals[] = [ 'SIGINT', 'SIGTERM' ]; + +/** + * Environment variables forwarded from the parent into the DLA child. + * `LIBERATION_TOKEN` and `SHOPIFY_ADMIN_TOKEN` are end-user-supplied + * secrets that DLA reads directly. `WP_APP_PASSWORD` is DLA's import-path + * secret; `NO_COLOR` and `CI` honor the user's terminal preferences. + * + * `STUDIO_WPCOM_TOKEN` is *not* forwarded โ€” DLA's CLI does not read it + * (only the MCP server does, via `@studio/dla/bridge`). + */ +const PASSTHROUGH_ENV_KEYS = [ + 'LIBERATION_TOKEN', + 'SHOPIFY_ADMIN_TOKEN', + 'WP_APP_PASSWORD', + 'NO_COLOR', + 'CI', +] as const; + +/** + * Build the environment forwarded into the DLA child process. Starts + * from `process.env` so DLA inherits PATH and other host-level state, + * then prunes secrets that should not leak across. + * + * @returns The child environment with selectively-pruned variables. + */ +function buildChildEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + // Explicitly drop tokens that the DLA CLI does not consume; reduces + // the surface area in case DLA changes upstream. + delete env.STUDIO_WPCOM_TOKEN; + // Re-assert the passthrough keys from the parent in case the spread + // above missed them (it shouldn't, but this keeps intent explicit). + for ( const key of PASSTHROUGH_ENV_KEYS ) { + const value = process.env[ key ]; + if ( typeof value === 'string' && value.length > 0 ) { + env[ key ] = value; + } + } + return env; +} + +/** + * Translate a child-process exit (`code`, `signal`) into the parent's + * exit code. Mirrors the standard shell convention: a normal exit + * propagates its code; a signal-terminated exit becomes `128 + N` so + * shell users can chain `studio liberate || echo failed`. + * + * @param code - The child's numeric exit code, or `null` if it was killed by a signal. + * @param signal - The signal that terminated the child, if any. + * @returns The exit code the parent should adopt. + */ +function computeExitCode( code: number | null, signal: NodeJS.Signals | null ): number { + if ( typeof code === 'number' ) { + return code; + } + if ( signal ) { + // `os.constants.signals` would be more correct but pulls in extra + // surface โ€” the two signals we care about (SIGINT=2, SIGTERM=15) + // are mapped directly. + const signalNumbers: Record< string, number > = { + SIGHUP: 1, + SIGINT: 2, + SIGQUIT: 3, + SIGTERM: 15, + }; + const n = signalNumbers[ signal ]; + if ( typeof n === 'number' ) { + return 128 + n; + } + } + return 1; +} + +/** + * Spawn DLA's CLI and stream its output through to the user's terminal. + * + * @param url - The site URL DLA should liberate (forwarded as the + * bare-argument extract target). + * @param extraArgs - Additional CLI flags passed verbatim after the URL + * (e.g. `[ '--output', '/tmp/out', '--non-interactive' ]`). + * @returns A promise that resolves when the child exits. The parent's + * `process.exitCode` is set to mirror the child's exit code. + * + * @example + * await runCommand( 'https://example.com', [ '--output', './out', '--non-interactive' ] ); + */ +export async function runCommand( url: string, extraArgs: string[] ): Promise< void > { + let tsxCli: string; + let dlaCli: string; + try { + tsxCli = resolveTsxCli(); + dlaCli = resolveDlaCliEntry(); + } catch ( error ) { + const reason = error instanceof Error ? error.message : String( error ); + console.error( + __( + 'studio liberate could not locate the Data Liberation Agent CLI. Reinstall Studio or run `npm install` to restore the dependency.' + ) + ); + console.error( reason ); + process.exitCode = 1; + return; + } + + const args = [ tsxCli, dlaCli, url, ...extraArgs ]; + const child: ChildProcess = spawn( process.execPath, args, { + stdio: 'inherit', + env: buildChildEnv(), + } ); + + const forwardSignal = ( signal: NodeJS.Signals ) => { + try { + child.kill( signal ); + } catch { + // Child may already have exited; nothing to do. + } + }; + const signalHandlers = new Map< NodeJS.Signals, ( signal: NodeJS.Signals ) => void >(); + for ( const signal of FORWARDED_SIGNALS ) { + const handler = ( s: NodeJS.Signals ) => forwardSignal( s ); + signalHandlers.set( signal, handler ); + process.on( signal, handler ); + } + + try { + await new Promise< void >( ( resolve ) => { + let settled = false; + const settle = () => { + if ( settled ) { + return; + } + settled = true; + resolve(); + }; + child.on( 'error', ( error ) => { + const reason = error instanceof Error ? error.message : String( error ); + console.error( __( 'studio liberate failed to spawn the Data Liberation Agent CLI.' ) ); + console.error( reason ); + process.exitCode = 1; + settle(); + } ); + child.on( 'exit', ( code, signal ) => { + process.exitCode = computeExitCode( code, signal ); + settle(); + } ); + } ); + } finally { + for ( const [ signal, handler ] of signalHandlers ) { + process.off( signal, handler ); + } + } +} + +/** + * Register the `studio liberate ` command on the given yargs + * instance. Surfaces a minimal set of flags; unknown args flow through + * to DLA so future DLA flags work without a Studio-side release. + * + * @param yargs - The studio-typed yargs instance to register on. + */ +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'liberate ', + describe: __( 'Liberate a site from a closed platform using Data Liberation Agent' ), + builder: ( yargs ) => { + return yargs + .positional( 'url', { + type: 'string', + demandOption: true, + describe: __( 'URL of the site to liberate' ), + } ) + .option( 'output', { + type: 'string', + describe: __( 'Output directory for extracted content (default: ./output)' ), + } ) + .option( 'non-interactive', { + type: 'boolean', + describe: __( 'Skip the post-extraction import prompt' ), + } ) + .strict( false ); + }, + handler: async ( argv ) => { + const url = argv.url as string; + const extraArgs: string[] = []; + if ( typeof argv.output === 'string' && argv.output.length > 0 ) { + extraArgs.push( '--output', argv.output ); + } + if ( argv.nonInteractive ) { + extraArgs.push( '--non-interactive' ); + } + await runCommand( url, extraArgs ); + }, + } ); +}; diff --git a/apps/cli/commands/liberate/resolvers.ts b/apps/cli/commands/liberate/resolvers.ts new file mode 100644 index 0000000000..22f64b99d1 --- /dev/null +++ b/apps/cli/commands/liberate/resolvers.ts @@ -0,0 +1,31 @@ +/** + * Module-resolution helpers for the `studio liberate` command. Extracted + * into their own file so tests can mock the resolution layer (which + * normally walks `node_modules`) without touching the spawn pipeline. + */ + +import { createRequire } from 'node:module'; + +const require = createRequire( import.meta.url ); + +/** + * Resolve DLA's CLI entry to an absolute path. + * + * @returns Absolute path to `data-liberation/src/cli.ts`. + */ +export function resolveDlaCliEntry(): string { + return require.resolve( 'data-liberation/src/cli.ts' ); +} + +/** + * Resolve the `tsx` runtime CLI to an absolute path. + * + * Spelled as `tsx/cli` (the public exports key) rather than + * `tsx/dist/cli.mjs` because the package's `exports` map does not + * expose the `dist/` subpath directly. + * + * @returns Absolute path to `tsx/dist/cli.mjs`. + */ +export function resolveTsxCli(): string { + return require.resolve( 'tsx/cli' ); +} diff --git a/apps/cli/commands/liberate/tests/liberate.test.ts b/apps/cli/commands/liberate/tests/liberate.test.ts new file mode 100644 index 0000000000..60e7918572 --- /dev/null +++ b/apps/cli/commands/liberate/tests/liberate.test.ts @@ -0,0 +1,242 @@ +import { EventEmitter } from 'events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +class MockChildProcess extends EventEmitter { + pid = 9999; + kill = vi.fn(); +} + +const spawnMock = vi.fn(); + +vi.mock( 'child_process', () => { + const mockedModule = { + spawn: spawnMock, + }; + return { + ...mockedModule, + default: mockedModule, + }; +} ); + +const resolveDlaCliEntryMock = vi.fn(); +const resolveTsxCliMock = vi.fn(); + +vi.mock( 'cli/commands/liberate/resolvers', () => ( { + resolveDlaCliEntry: () => resolveDlaCliEntryMock(), + resolveTsxCli: () => resolveTsxCliMock(), +} ) ); + +describe( 'CLI: studio liberate', () => { + const tsxCliPath = '/abs/path/to/tsx/dist/cli.mjs'; + const dlaCliPath = '/abs/path/to/data-liberation/src/cli.ts'; + let child: MockChildProcess; + let originalExitCode: typeof process.exitCode; + let processOnSpy: ReturnType< typeof vi.spyOn >; + let processOffSpy: ReturnType< typeof vi.spyOn >; + let signalHandlers: Map< string, ( ( ...args: unknown[] ) => void )[] >; + + beforeEach( () => { + vi.clearAllMocks(); + + child = new MockChildProcess(); + spawnMock.mockReturnValue( child ); + resolveDlaCliEntryMock.mockReturnValue( dlaCliPath ); + resolveTsxCliMock.mockReturnValue( tsxCliPath ); + + originalExitCode = process.exitCode; + + // Capture signal handlers registered by runCommand without + // actually wiring them to the test process. + signalHandlers = new Map(); + processOnSpy = vi.spyOn( process, 'on' ).mockImplementation( ( ( + event: string, + handler: ( ...args: unknown[] ) => void + ) => { + const existing = signalHandlers.get( event ) ?? []; + existing.push( handler ); + signalHandlers.set( event, existing ); + return process; + } ) as never ); + processOffSpy = vi.spyOn( process, 'off' ).mockImplementation( ( ( + event: string, + handler: ( ...args: unknown[] ) => void + ) => { + const existing = signalHandlers.get( event ) ?? []; + signalHandlers.set( + event, + existing.filter( ( h ) => h !== handler ) + ); + return process; + } ) as never ); + } ); + + afterEach( () => { + process.exitCode = originalExitCode; + processOnSpy.mockRestore(); + processOffSpy.mockRestore(); + } ); + + it( 'spawns the DLA CLI under tsx with process.execPath and the URL forwarded', async () => { + const { runCommand } = await import( '../index' ); + + const runPromise = runCommand( 'https://example.com', [] ); + // Simulate clean child exit. + child.emit( 'exit', 0, null ); + await runPromise; + + expect( spawnMock ).toHaveBeenCalledTimes( 1 ); + const [ command, args, options ] = spawnMock.mock.calls[ 0 ]; + expect( command ).toBe( process.execPath ); + expect( args ).toEqual( [ tsxCliPath, dlaCliPath, 'https://example.com' ] ); + expect( options.stdio ).toBe( 'inherit' ); + } ); + + it( 'appends pass-through args after the URL', async () => { + const { runCommand } = await import( '../index' ); + + const runPromise = runCommand( 'https://example.com', [ + '--output', + '/tmp/out', + '--non-interactive', + ] ); + child.emit( 'exit', 0, null ); + await runPromise; + + const [ , args ] = spawnMock.mock.calls[ 0 ]; + expect( args ).toEqual( [ + tsxCliPath, + dlaCliPath, + 'https://example.com', + '--output', + '/tmp/out', + '--non-interactive', + ] ); + } ); + + it( 'forwards LIBERATION_TOKEN and SHOPIFY_ADMIN_TOKEN but never STUDIO_WPCOM_TOKEN', async () => { + const { runCommand } = await import( '../index' ); + + const previousLiberation = process.env.LIBERATION_TOKEN; + const previousShopify = process.env.SHOPIFY_ADMIN_TOKEN; + const previousWpcom = process.env.STUDIO_WPCOM_TOKEN; + process.env.LIBERATION_TOKEN = 'liberation-secret'; + process.env.SHOPIFY_ADMIN_TOKEN = 'shopify-secret'; + process.env.STUDIO_WPCOM_TOKEN = 'wpcom-secret'; + + try { + const runPromise = runCommand( 'https://example.com', [] ); + child.emit( 'exit', 0, null ); + await runPromise; + } finally { + process.env.LIBERATION_TOKEN = previousLiberation; + process.env.SHOPIFY_ADMIN_TOKEN = previousShopify; + process.env.STUDIO_WPCOM_TOKEN = previousWpcom; + } + + const [ , , options ] = spawnMock.mock.calls[ 0 ]; + expect( options.env.LIBERATION_TOKEN ).toBe( 'liberation-secret' ); + expect( options.env.SHOPIFY_ADMIN_TOKEN ).toBe( 'shopify-secret' ); + expect( options.env.STUDIO_WPCOM_TOKEN ).toBeUndefined(); + } ); + + it( 'forwards SIGINT to the child process', async () => { + const { runCommand } = await import( '../index' ); + + const runPromise = runCommand( 'https://example.com', [] ); + + const sigintHandlers = signalHandlers.get( 'SIGINT' ) ?? []; + expect( sigintHandlers.length ).toBeGreaterThanOrEqual( 1 ); + sigintHandlers[ 0 ]( 'SIGINT' ); + + expect( child.kill ).toHaveBeenCalledWith( 'SIGINT' ); + + child.emit( 'exit', 0, null ); + await runPromise; + } ); + + it( 'forwards SIGTERM to the child process', async () => { + const { runCommand } = await import( '../index' ); + + const runPromise = runCommand( 'https://example.com', [] ); + + const sigtermHandlers = signalHandlers.get( 'SIGTERM' ) ?? []; + expect( sigtermHandlers.length ).toBeGreaterThanOrEqual( 1 ); + sigtermHandlers[ 0 ]( 'SIGTERM' ); + + expect( child.kill ).toHaveBeenCalledWith( 'SIGTERM' ); + + child.emit( 'exit', 0, null ); + await runPromise; + } ); + + it( 'sets process.exitCode to the child exit code on non-zero exit', async () => { + const { runCommand } = await import( '../index' ); + + const runPromise = runCommand( 'https://example.com', [] ); + child.emit( 'exit', 7, null ); + await runPromise; + + expect( process.exitCode ).toBe( 7 ); + } ); + + it( 'maps a signal-terminated exit to a 128+signal exit code', async () => { + const { runCommand } = await import( '../index' ); + + const runPromise = runCommand( 'https://example.com', [] ); + child.emit( 'exit', null, 'SIGINT' ); + await runPromise; + + // 128 + 2 (SIGINT) + expect( process.exitCode ).toBe( 130 ); + } ); + + it( 'reports a clean error when DLA cannot be resolved instead of crashing', async () => { + resolveDlaCliEntryMock.mockImplementation( () => { + const err = new Error( "Cannot find module 'data-liberation/src/cli.ts'" ); + ( err as NodeJS.ErrnoException ).code = 'MODULE_NOT_FOUND'; + throw err; + } ); + + const consoleErrorSpy = vi.spyOn( console, 'error' ).mockImplementation( () => undefined ); + + const { runCommand } = await import( '../index' ); + + await runCommand( 'https://example.com', [] ); + + expect( spawnMock ).not.toHaveBeenCalled(); + expect( process.exitCode ).toBe( 1 ); + expect( consoleErrorSpy ).toHaveBeenCalled(); + const message = consoleErrorSpy.mock.calls.map( ( call ) => call.join( ' ' ) ).join( '\n' ); + expect( message ).toMatch( /data-liberation/i ); + consoleErrorSpy.mockRestore(); + } ); + + it( 'reports a clean error when the child emits an error event', async () => { + const consoleErrorSpy = vi.spyOn( console, 'error' ).mockImplementation( () => undefined ); + + const { runCommand } = await import( '../index' ); + + const runPromise = runCommand( 'https://example.com', [] ); + child.emit( 'error', new Error( 'spawn ENOENT' ) ); + await runPromise; + + expect( process.exitCode ).toBe( 1 ); + expect( consoleErrorSpy ).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + } ); + + it( 'registers the liberate command on yargs with the expected metadata', async () => { + const yargs = ( await import( 'yargs' ) ).default; + const { registerCommand } = await import( '../index' ); + + const argv = yargs().option( 'path', { + type: 'string', + normalize: true, + default: process.cwd(), + } ); + registerCommand( argv as never ); + + const helpOutput = await argv.getHelp(); + expect( helpOutput ).toMatch( /liberate / ); + } ); +} ); diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 3e61f24cda..627fc5bc52 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -5,6 +5,7 @@ import semver from 'semver'; import yargs from 'yargs'; import { registerCommand as registerExportCommand } from 'cli/commands/export'; import { registerCommand as registerImportCommand } from 'cli/commands/import'; +import { registerCommand as registerLiberateCommand } from 'cli/commands/liberate'; import { registerCommand as registerMcpCommand } from 'cli/commands/mcp'; import { registerCommand as registerPullCommand } from 'cli/commands/pull'; import { registerCommand as registerPullReprintCommand } from 'cli/commands/pull-reprint'; @@ -193,6 +194,7 @@ async function main() { registerExportCommand( studioArgv ); registerImportCommand( studioArgv ); registerMcpCommand( studioArgv ); + registerLiberateCommand( studioArgv ); studioArgv.command( 'preview', __( 'Manage preview sites' ), async ( previewYargs ) => { const [ diff --git a/apps/cli/package.json b/apps/cli/package.json index f4a53bb44b..3123071e7c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -46,6 +46,7 @@ "atomically": "^2.1.1", "chokidar": "^5.0.0", "cli-table3": "^0.6.5", + "data-liberation": "github:Automattic/data-liberation-agent#17219c42b0420267302b138bf402930508006e0e", "fs-extra": "^11.3.4", "http-proxy": "^1.18.1", "ignore": "^7.0.5", @@ -56,6 +57,7 @@ "semver": "^7.7.4", "tar": "^7.5.13", "trash": "^10.0.1", + "tsx": "^4.19.0", "yargs": "^18.0.0", "yargs-parser": "^22.0.0", "yauzl": "^3.3.0", @@ -65,7 +67,7 @@ "build": "vite build --config ./vite.config.dev.ts", "build:prod": "vite build --config ./vite.config.prod.ts", "build:npm": "vite build --config ./vite.config.npm.ts", - "install:bundle": "npm install --omit=dev --no-package-lock --no-progress --install-links --no-workspaces && patch-package && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs", + "install:bundle": "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install --omit=dev --no-package-lock --no-progress --install-links --no-workspaces && patch-package && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs", "package": "npm run install:bundle && npm run build:prod", "lint": "eslint .", "watch": "vite build --config ./vite.config.dev.ts --watch", @@ -75,6 +77,7 @@ }, "devDependencies": { "@studio/common": "file:../../tools/common", + "@studio/dla": "file:../../tools/dla", "@types/archiver": "^7.0.0", "@types/http-proxy": "^1.17.17", "@types/node-forge": "^1.3.14", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 732525ee61..ea4d130717 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -5,7 +5,9 @@ "moduleResolution": "bundler", "paths": { "cli/*": [ "./*" ], - "@studio/common/*": [ "../../tools/common/*" ] + "@studio/common/*": [ "../../tools/common/*" ], + "@studio/dla": [ "../../tools/dla/index.ts" ], + "@studio/dla/*": [ "../../tools/dla/*" ] } }, "include": [ "**/*" ], diff --git a/apps/cli/vite.config.base.ts b/apps/cli/vite.config.base.ts index e0bcb32366..e83de8727a 100644 --- a/apps/cli/vite.config.base.ts +++ b/apps/cli/vite.config.base.ts @@ -117,6 +117,7 @@ export const baseConfig = defineConfig( { alias: { cli: resolve( __dirname, '.' ), '@studio/common': resolve( __dirname, '../../tools/common' ), + '@studio/dla': resolve( __dirname, '../../tools/dla' ), '@wp-playground/blueprints/blueprint-schema-validator': resolve( __dirname, '../../node_modules/@wp-playground/blueprints/blueprint-schema-validator.js' diff --git a/apps/cli/vite.config.prod.ts b/apps/cli/vite.config.prod.ts index f2ce360ab7..ce64812019 100644 --- a/apps/cli/vite.config.prod.ts +++ b/apps/cli/vite.config.prod.ts @@ -12,6 +12,14 @@ export default mergeConfig( baseConfig, defineConfig( { plugins: [ + viteStaticCopy( { + targets: [ + { + src: 'ai/skills', + dest: '.', + }, + ], + } ), ...( existsSync( cliNodeModulesPath ) ? [ viteStaticCopy( { diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index ab70aa882f..3e420282e9 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -18,6 +18,7 @@ export default mergeConfig( alias: { cli: path.resolve( __dirname, '.' ), '@studio/common': path.resolve( __dirname, '../../tools/common' ), + '@studio/dla': path.resolve( __dirname, '../../tools/dla' ), '@wp-playground/blueprints/blueprint-schema-validator': path.resolve( __dirname, '../../node_modules/@wp-playground/blueprints/blueprint-schema-validator.js' diff --git a/docs/design-docs/cli.md b/docs/design-docs/cli.md index 18c1bd9531..416cd1e0bb 100644 --- a/docs/design-docs/cli.md +++ b/docs/design-docs/cli.md @@ -63,3 +63,74 @@ This approach of forking CLI processes to run business logic has both pros and c The biggest pro is that when the CLI becomes capable of running Studio sites, we can move the Playground dependencies entirely to the CLI and avoid bundling them twice (which would increase the size of the app by several hundred MBs). Moreover, it consolidates the business logic and creates increased incentives for developers to focus on the CLI when shipping new features. The biggest con is that it decreases control in the Studio code, particularly when it comes to error handling. We mitigate this by creating as clear a structure as possible around the `process.send` IPC calls. + +## Data Liberation Agent integration + +The `studio code` agent and the `studio liberate` command both delegate platform-extraction work to the [Data Liberation Agent](https://github.com/Automattic/data-liberation-agent) (DLA). This section documents the integration internals. For the user-facing surface, see `apps/cli/README.md`. For the trade-off rationale, see `issues/rsm-3143-dla-pi-research/research-report.md`. + +### Topology + +- DLA ships as a `github:` npm dependency pinned by SHA in `apps/cli/package.json` (`data-liberation: github:Automattic/data-liberation-agent#`). Bumping is a one-line edit โ€” DLA has no semver releases and no automatic version tracking. +- Once installed, DLA lives at `node_modules/data-liberation/`. Its MCP server entry is `data-liberation/src/mcp-server.ts` and its standalone CLI entry is `data-liberation/src/cli.ts`. +- Studio's integration layer is a workspace package at `tools/dla/` (`@studio/dla`), consumed from `apps/cli/ai/runtimes/pi/index.ts`. The package owns three modules: `bridge.ts` (process spawn + MCP client), `agent-tool-adapter.ts` (MCP-tool โ†’ pi `ToolDefinition` shape conversion), and `policy.ts` (permission buckets + extension factory). + +### Bridge spawn + +`startDlaBridge` in `tools/dla/bridge.ts` spawns DLA's stdio MCP server as a child process and connects an MCP `Client` over stdio. The spawn pipeline: + +- `process.execPath` runs Node (the same Electron-as-Node binary the CLI itself uses). +- `tsx` is loaded as the loader entry. Both the bridge (`tools/dla/bridge.ts`) and the standalone `studio liberate` path (`apps/cli/commands/liberate/resolvers.ts`) resolve it as `tsx/cli` โ€” the canonical key in tsx's package `exports` map. The deep `tsx/dist/cli.mjs` subpath is intentionally not exposed by `exports` and throws `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime; the regression tests in `tools/dla/tests/bridge.test.ts` (the `defaultTransportProvider โ€” real require.resolve paths` block) lock both invariants in so the bridge can never silently regress back to the deep subpath. +- DLA's MCP server is resolved via `require.resolve('data-liberation/src/mcp-server.ts')`. +- The spawn passes a sanitised env: `PATH`, plus a passthrough allowlist (`LIBERATION_TOKEN`, `SHOPIFY_ADMIN_TOKEN`, `NODE_PATH`, `NODE_OPTIONS`), plus `STUDIO_WPCOM_TOKEN` injected from the session's resolved wpcom access token. The parent never has to set `STUDIO_WPCOM_TOKEN` on its own environment. +- `listTools` is called with a 10-second `AbortSignal.timeout` cap. Failures to spawn or list resolve to a bridge handle with `degraded: true` and an empty `tools` array โ€” a missing or broken DLA install warns and continues, never crashes session startup. +- `dispose()` calls `client.close()` (sends EOF on the child's stdin), then schedules a SIGKILL on the child pid after a 2-second grace period. DLA's MCP server normally exits on stdin EOF, but `liberate_extract` can hold a long-running adapter loop open, so the SIGKILL is the safety net. + +### Tool wrapping + +`tools/dla/agent-tool-adapter.ts` exports `adaptMcpToolToPi`, which converts each MCP `Tool` descriptor returned by `listTools` into a pi `ToolDefinition`: + +- `inputSchema` is forwarded as-is via `inputSchema as unknown as TSchema`. This cast is safe because pi-ai's `validateToolArguments` accepts plain JSON Schema โ€” no TypeBox metadata required at runtime. This is the inverse of Studio's existing pi โ†’ MCP shim at `apps/cli/ai/mcp-server.ts`, which uses the same idiom in the other direction. +- The wrapper's `execute()` consults the policy via `shouldBlock` before forwarding, then calls `client.callTool` with pi's `AbortSignal` plumbed through `RequestOptions.signal`. MCP's SDK emits `notifications/cancelled` on abort โ€” see the orphan-work caveat below for what DLA does with it. +- Returned `CallToolResult.content[]` blocks are adapted to pi's narrower content shape via `content-adapter.ts`; `structuredContent` surfaces as `AgentToolResult.details`. `result.isError === true` is rethrown so pi's `executePreparedToolCall` reports it as a tool-call error in the model transcript. + +### Permission gating + +`tools/dla/policy.ts` provides two cooperating policy layers: + +- **Adapter-layer**: `shouldBlock(toolName, input, buckets)` runs inside each adapted tool's `execute()` wrapper. Tools are assigned a bucket (`read-only`, `network-read`, `fs-write`, `destructive`, `delegate-only`); unknown tool names default to a hard block. The destructive bucket (today only `liberate_import`) is blocked unless the call carries `delegate: true`. +- **Runtime-layer**: `createDlaPolicyFactory(buckets)` returns a pi `ExtensionFactory` that subscribes to `pi.on('tool_call', handler)` and returns `{ block: true, reason }` for the same set of blocking decisions. Defence in depth โ€” any future tool registration path that bypasses the adapter still hits the runtime hook. + +The runtime factory is wired in `apps/cli/ai/runtimes/pi/index.ts` via `resolveDlaExtensionFactories`, which is passed into `DefaultResourceLoader`'s `extensionFactories` slot. Inline `extensionFactories` are loaded even when `noExtensions: true` (which Studio sets to disable user-installed extensions), so no other resource-loader flag flips. + +### Feature flag + +The v1 integration is gated behind `STUDIO_DLA_ENABLED=1`. Both the bridge spawn (`maybeStartDlaBridge` in `apps/cli/ai/runtimes/pi/index.ts`) and the runtime-layer policy factory (`resolveDlaExtensionFactories`) check the same env var. With the flag unset the runtime behaves identically to pre-integration: no child process is spawned, no DLA tools land in `customTools`, and the extension factory list is empty. + +### Tool name surface + +DLA's tools are exposed as plain pi `customTools`. They surface to the model under their bare names โ€” `liberate_inspect`, `liberate_extract`, `liberate_setup`, `liberate_import`, etc. โ€” and **not** under the `mcp__data-liberation__*` prefix that pi reserves for first-party MCP registrations. The wrapper skill at `apps/cli/ai/skills/liberate/SKILL.md` references the bare names. + +### `delegate: true` handoff + +`liberate_setup` and `liberate_import` accept a `delegate: true` argument. In delegate mode DLA returns a manifest of artifact paths โ€” `wxrFile`, `outputDir`, `mediaDir`, `productsCsv?`, `redirectMap`, `importAuthors` โ€” without writing to any live WordPress site. Studio's own tools then act on the manifest: `site_create` consumes the WXR via an inline `importWxr` blueprint step (routed through Playground to dodge the WP-CLI IPC 120-second no-activity timeout), and `wp_cli` handles follow-up steps like author creation and product import. The destructive `liberate_import` bucket forces this contract โ€” calling it without `delegate: true` is blocked by both policy layers. + +### Surfaces: `/liberate` slash and standalone CLI + +DLA is reachable through two independent surfaces: + +- **`/liberate` skill inside `studio code`**: the agent walks the user through detect โ†’ extract โ†’ verify โ†’ site-create โ†’ import using the bridged DLA tools and Studio's own tools, with `AskUserQuestion` confirmations between heavier steps. Routes through the agent + bridge. +- **`studio liberate `**: a thin yargs wrapper in `apps/cli/commands/liberate/index.ts` that spawns DLA's CLI (`data-liberation/src/cli.ts`) directly via `process.execPath` + `tsx`, inheriting stdio. No agent is involved; DLA's own Ink UI streams to the terminal. Useful for CI, bulk runs, and any context where an LLM loop is unwanted. The standalone path prunes `STUDIO_WPCOM_TOKEN` from the child env because DLA's CLI does not read it (only DLA's MCP server does). + +### Caveat: orphan in-flight work on abort + +DLA's MCP server **does not honor `notifications/cancelled` from the client**. When a user aborts a tool call (e.g. cancels `liberate_extract` mid-flight), the cancellation reaches the bridge (`client.callTool`'s `AbortSignal` triggers, the MCP SDK emits `notifications/cancelled`), but DLA's server-side work continues to completion. The filesystem footprint is bounded by DLA's resume-safe protocol โ€” partial extracts are recoverable on the next run rather than orphaned indefinitely โ€” but the child process keeps spending CPU and network after the user's "cancel" until either the work finishes or the bridge's `dispose()` SIGKILLs the process at session teardown. + +This is a candidate upstream issue against `Automattic/data-liberation-agent`. Studio's bridge cannot fix it client-side. + +### Caveat: Playwright Chromium postinstall + +DLA depends on Playwright Chromium for the Wix and Squarespace platform adapters. The `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` env var is set in Studio's CI configs as defensive forward-compat, but it is currently **inert against modern Playwright**: neither `playwright` nor `playwright-core` has a postinstall hook, and DLA's own postinstall invokes `installBrowsers()` directly without consulting the env var. The setting still lands as zero-cost future-proofing. End-users pay the ~150 MB download cost on `npm install -g wp-studio`, driven by DLA's `postinstall: "playwright install chromium"` hook. + +### Update cadence + +DLA is pinned by SHA in `apps/cli/package.json`. Bumping is a one-line edit, but there is no automatic version-tracking because DLA does not publish semver releases. Each bump should re-verify the `defaultPolicyBuckets` table in `tools/dla/policy.ts` against DLA's current `mcp-server.ts` tool list โ€” new tools without a bucket assignment hard-block by default, which is safe but surprising. diff --git a/eslint.config.mjs b/eslint.config.mjs index 1f55fbec89..755429e4ff 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -44,6 +44,7 @@ export default defineConfig( 'apps/studio/tailwind.config.js', 'eslint.config.mjs', 'vitest.config.ts', + 'tools/dla/vitest.config.ts', 'tools/eslint-plugin-studio/vitest.config.ts', 'tools/eslint-plugin-studio/src/index.js', 'tools/eslint-plugin-studio/src/rules/*.js', @@ -62,6 +63,7 @@ export default defineConfig( path.join( import.meta.dirname, 'apps/studio/tsconfig.json' ), path.join( import.meta.dirname, 'apps/ui/tsconfig.json' ), path.join( import.meta.dirname, 'tools/common/tsconfig.json' ), + path.join( import.meta.dirname, 'tools/dla/tsconfig.json' ), path.join( import.meta.dirname, 'tools/compare-perf/tsconfig.json' ), path.join( import.meta.dirname, 'tools/metrics/tsconfig.json' ), ], @@ -92,6 +94,8 @@ export default defineConfig( ignore: [ '@wp-playground/blueprints/blueprint-schema-validator', '@modelcontextprotocol/sdk/server/stdio\\.js$', + '@modelcontextprotocol/sdk/client/index\\.js$', + '@modelcontextprotocol/sdk/client/stdio\\.js$', ], }, ], diff --git a/issues/rsm-3143-dla-pi-research/PR-description.md b/issues/rsm-3143-dla-pi-research/PR-description.md new file mode 100644 index 0000000000..2e5987e3df --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/PR-description.md @@ -0,0 +1,181 @@ +## Related issues + +- Related to RSM-3143 (research artifact + implementation, lands in this PR) + +> **Naming note:** The command was renamed from `/migrate` to `/liberate` (and `studio migrate` to `studio liberate`) post-implementation per owner direction, to align with DLA's `liberate_*` tool prefix and its underlying `data-liberation` package. The PR description below has been updated in place; historical research artifacts (`research-report.md`, `plan.md`, `wave-1-findings/`, `prior-art/`) deliberately retain the original `/migrate` recommendation as evidence of the design conversation. +- Supersedes RSM-1639 (research, Done โ€” host-side findings stale; runtime shifted from `@anthropic-ai/claude-agent-sdk` to `@mariozechner/pi-coding-agent`) +- Supersedes RSM-1675 (impl Approach A, Cancelled โ€” vendor + fetch script) +- Supersedes RSM-3139 (impl Approach C, Cancelled โ€” npm-dep against the old claude-agent-sdk) +- Supersedes PR #3277 (closed) + +## How AI was used in this PR + +This PR was orchestrated end-to-end via the `/orchestrator` skill across two phases. The full agent cascade: + +**Research phase (RSM-3143):** +- 1 research-lead delegating sub-questions +- 5 parallel wave-1 researchers (pi extensibility surface; MCP bridge feasibility; vendor-as-AgentTools; subprocess revisit; upstream and bundling) +- Research-lead synthesised the report after evaluating no wave-2 was required + +**Spec-to-code phase:** +- 1 planner converting the research recommendation into an 11-task plan +- 9 implementer agents (one per code task: T1 scaffolding, T2 deps, T3 bridge, T4 policy wiring, T5 skill + vite prod fix, T6 bridge bring-up/teardown, T7 slash command, T8 standalone `studio liberate` (originally `studio migrate`), T9 Playwright env in CI) +- 1 fix-implementer to resolve the `tsx/dist/cli.mjs` โ†’ `tsx/cli` resolution bug surfaced by code-review +- 1 code-reviewer running twice (rejected after T1โ€“T9, approved after the fix at commit `65ce8848`) +- 2 documentator agents (T10 README, T11 design doc) +- 1 doc-reviewer (this pass) + +Reviewers should especially scrutinise: the `tools/dla/` bridge contract (250 LOC, type-safe but uses one documented `inputSchema as unknown as TSchema` cast โ€” see `wave-1-mcp-bridge-feasibility.md` ยง2 for why this is safe); the two-layer permission policy in `tools/dla/policy.ts`; the `STUDIO_DLA_ENABLED` feature-flag gating in `apps/cli/ai/runtimes/pi/index.ts`; and the `/liberate` skill body at `apps/cli/ai/skills/liberate/SKILL.md`. + +**Draft PR โ€” not for immediate merge.** Per owner direction, both the research artifacts and the implementation land in the same PR. The PR is opened as a draft pending human review on a real Wix/Squarespace test site with `STUDIO_DLA_ENABLED=1` and pending product decisions on the feature-flag default for v1 (currently off). + +## Proposed Changes + +### Research artifacts (RSM-3143) + +- `issues/rsm-3143-dla-pi-research/research-report.md` โ€” synthesis report. Recommends MCP-stdio bridge as the canonical `/liberate` path against pi-coding-agent, with Subprocess as a separate `studio liberate ` standalone CLI command and Vendor-as-AgentTools as a documented fallback. (Research artifact still references the original `/migrate` name; the as-shipped command is `/liberate`.) +- `issues/rsm-3143-dla-pi-research/research-plan.md` โ€” research plan with wave-1 findings log. +- `issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-*.md` โ€” five wave-1 researcher findings: pi extensibility surface, MCP bridge feasibility, vendor-as-AgentTools, subprocess revisit, upstream + bundling. +- `issues/rsm-3143-dla-pi-research/prior-art/` โ€” preserved prior-art bundle (RSM-1639 + RSM-3139 specs/plans/notes). +- `issues/rsm-3143-dla-pi-research/plan.md` โ€” 11-task implementation plan derived from the research. +- `issues/rsm-3143-dla-pi-research/review-1.md` + `review-2.md` โ€” code-review verdicts. +- `issues/rsm-3143-dla-pi-research/doc-review-1.md` + this `PR-description.md`. + +### Code deliverables (T1โ€“T9) + +- **T1 โ€” `tools/dla/` workspace package scaffold** (commit `a3b2be96`): new `@studio/dla` workspace package as a sibling of `tools/common/`. Adds `tsconfig.json`, `package.json`, alias wiring in `apps/cli/tsconfig.json` and `apps/cli/vite.config.base.ts`. +- **T2 โ€” DLA + tsx runtime deps** (commit `2df39446`): pins `"data-liberation": "github:Automattic/data-liberation-agent#17219c42b0420267302b138bf402930508006e0e"` and `"tsx": "^4.19.0"` in `apps/cli/package.json` `dependencies`. `tsx` lives in runtime deps so it survives `--omit=dev`. +- **T3 โ€” MCP-stdio bridge** (commit `22d5144a`): `tools/dla/bridge.ts`, `agent-tool-adapter.ts`, `content-adapter.ts`, `policy.ts`, `index.ts`, plus four vitest files (45 tests). Spawns DLA's MCP server as a child process via `process.execPath` + `tsx`, connects an MCP `Client` over stdio, lists tools, and adapts each into a pi `ToolDefinition`. Defaults `degraded: true` on spawn or `listTools` failure rather than crashing session startup. +- **T4 โ€” DLA policy extension factory wired into pi runtime** (commit `286a4c50`): `apps/cli/ai/runtimes/pi/index.ts` constructs `DefaultResourceLoader` with `extensionFactories: [ createDlaPolicyFactory(defaultPolicyBuckets) ]` when `STUDIO_DLA_ENABLED=1`. Inline `extensionFactories` load even with `noExtensions: true`, so no other loader flags flip. +- **T5 โ€” `/liberate` skill + vite prod skills-copy fix** (commit `0de39aa0`): `apps/cli/ai/skills/liberate/SKILL.md` (frontmatter `name` + `description` only; body uses bare DLA tool names and steers callers toward `delegate: true`). `apps/cli/vite.config.prod.ts` learns the same `ai/skills` static-copy target that `dev.ts` and `npm.ts` already had โ€” previously, the prod-bundled CLI silently shipped without skills. +- **T6 โ€” DLA bridge bring-up + teardown** (commit `aedb5e7b`): `maybeStartDlaBridge` runs before `createStudioAgentSession`; the bridge handle is threaded through `buildAgentTools` and its `tools` spliced into the local-site tool list (not the remote-site branch, to avoid recursive migrations back into Studio). Teardown lives in the existing `finally` block alongside `session.dispose()`. +- **T7 โ€” `/liberate` slash command** (commit `b1bebaaa`): registers `{ name: 'liberate', description: __(...) }` in `tools/common/ai/slash-commands.ts`'s `AI_SKILL_COMMANDS`. The existing skill-dispatcher in `apps/cli/commands/ai/index.ts` routes through `runAgentTurn(buildSkillInvocationPrompt('liberate'))`. +- **T8 โ€” `studio liberate ` standalone command** (commit `b42a8286`): new yargs command at `apps/cli/commands/liberate/`. Thin wrapper that spawns DLA's CLI via `process.execPath` + `tsx`, inherits stdio, forwards `SIGINT` / `SIGTERM`, propagates exit code. No agent in the loop โ€” DLA's own Ink UI streams to the terminal. +- **T9 โ€” Skip Playwright Chromium download in CI build pipelines** (commit `43a7d920`): sets `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` in `.buildkite/pipeline.yml`, `.buildkite/release-build-and-distribute.yml`, `.buildkite/release-pipelines/code-freeze.yml`, `.github/workflows/publish-npm-package.yml`, and `apps/cli/package.json`'s `install:bundle` script. See the **Playwright env-var caveat** below. +- **Fix (post-review-1)** (commit `65ce8848`): `tools/dla/bridge.ts` resolves `tsx` as `tsx/cli` (the public exports key) rather than `tsx/dist/cli.mjs`, which throws `ERR_PACKAGE_PATH_NOT_EXPORTED` against `tsx@4.21.0`. Adds three regression tests against the production `defaultTransportProvider` resolution path. + +### Doc deliverables (T10, T11) + +- **T10 โ€” `apps/cli/README.md`** (commit `7c083282`): new "Migrate from a closed platform" section between "Studio Code" and "Import and export". Covers user-facing surface only โ€” both invocation modes (`/liberate` inside `studio code` with `STUDIO_DLA_ENABLED=1`, and `studio liberate ` standalone), platform credential env vars (`LIBERATION_TOKEN`, `SHOPIFY_ADMIN_TOKEN`), and the Playwright Chromium cost. +- **T11 โ€” `docs/design-docs/cli.md`** (commit `0cd93cab`): new "Data Liberation Agent integration" section. Documents the as-built architecture: dep pin model, `tools/dla/` layout, bridge spawn pipeline, tool wrapping, two-layer permission policy, feature-flag gating, bare-name tool surface, `delegate: true` handoff contract, both user surfaces, the orphan-work caveat (DLA does not honor `notifications/cancelled`), the Playwright env-var caveat, and the update cadence. + +### Scope + +All code changes live in `apps/cli/` and the new `tools/dla/` workspace package. **No `apps/studio/` changes.** No Electron-side touchpoints. + +## Testing Instructions + +### Build + unit tests + +```bash +npm install +npm run cli:build +npm test +``` + +Expected: 1721+ tests pass across all workspaces (45 in `tools/dla/` alone). `npm run typecheck` passes for all workspaces including `@studio/dla`. `npx eslint tools/dla apps/cli/ai/runtimes/pi/index.ts apps/cli/commands/liberate` returns 0 errors. + +### Exercise `/liberate` end-to-end (agent path) + +The agent integration is gated behind `STUDIO_DLA_ENABLED=1` for v1. Without the flag, `studio code` behaves identically to pre-PR. + +```bash +STUDIO_DLA_ENABLED=1 node apps/cli/dist/cli/main.mjs code +# inside the session: +/liberate https://your-test-wix-or-squarespace-site.example +``` + +Expected: the agent introduces the skill, calls `liberate_inspect`, narrates results, asks `AskUserQuestion` to confirm, runs `liberate_extract` โ†’ `liberate_verify` โ†’ `liberate_setup` (with `delegate: true`), creates a Studio site via `site_create` with an inline `importWxr` blueprint step, then calls `liberate_import` with `delegate: true` and handles the returned manifest (media copy, redirect map, authors, optional Shopify products via `wp_cli`). + +Smoke-check the bridge is active: + +```bash +STUDIO_DLA_ENABLED=1 node apps/cli/dist/cli/main.mjs code --json "list all tools available" +``` + +Expected: the response mentions `liberate_inspect`, `liberate_extract`, etc. alongside Studio's own tools. If the bridge fails to spawn, the runtime logs `[studio code] DLA bridge degraded; continuing without DLA tools (...)` and proceeds without DLA โ€” the agent still answers, but `liberate_*` tools are absent. + +For Webflow / Shopify test sites, also set `LIBERATION_TOKEN=...` / `SHOPIFY_ADMIN_TOKEN=...` before launching. + +### Exercise `studio liberate ` (standalone path) + +```bash +node apps/cli/dist/cli/main.mjs liberate --help +node apps/cli/dist/cli/main.mjs liberate https://example.com --output ./out --non-interactive +``` + +Expected: DLA's Ink UI streams directly to the terminal. `--output` and `--non-interactive` are forwarded; any additional DLA flags work too (yargs is non-strict for this command). Exit code propagates DLA's exit code; `SIGINT`/`SIGTERM` are forwarded to the child. + +### Permission policy + +The destructive `liberate_import` bucket blocks calls without `delegate: true`. The block fires in two layers: the adapter-layer `shouldBlock()` check inside the tool's `execute()` wrapper, and the runtime-layer `pi.on('tool_call', ...)` extension hook. Either layer alone is sufficient; the second is defence-in-depth. + +To smoke-test policy, watch the agent transcript for any `liberate_import` call without `delegate: true` โ€” it should fail with the Studio policy error rather than hitting DLA. (The skill body explicitly instructs the agent never to call `liberate_import` without `delegate: true`.) + +### Verify CI Playwright env + +`PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` is now set in: + +- `.buildkite/pipeline.yml` +- `.buildkite/release-build-and-distribute.yml` +- `.buildkite/release-pipelines/code-freeze.yml` +- `.github/workflows/publish-npm-package.yml` +- `apps/cli/package.json` โ†’ `install:bundle` script + +See the **Playwright env-var caveat** below for what this actually does today. + +## Pre-merge Checklist + +Five gates require an explicit human decision before this PR is mergeable. Each gate explains the state of the world and why the decision can't be deferred past merge. + +**Owner's planned path:** the PR will first be tested in its current form (with DLA as a `github:` SHA pin) via Gate 1's end-to-end run. **If the test succeeds, DLA will be published to npm before this PR merges**, and the dep declaration in `apps/cli/package.json` will be flipped from the `github:` SHA pin to a standard npm semver range. That sequencing turns Gate 4 from "manual SHA decision at every release" into a normal npm-dep workflow (Dependabot / Renovate bumps, standard lockfile semantics). Gates 1 โ†’ 3 โ†’ (publish DLA) โ†’ 4-flip โ†’ 5-issue-filed โ†’ merge. + +### Gate 1 โ€” Real-site `/liberate` lifecycle verified + +- [ ] Reviewer manually runs `STUDIO_DLA_ENABLED=1 studio code` against at least one live Wix or Squarespace test site, walks through `/liberate ` end-to-end, and confirms the new Studio site contains the expected pages, posts, and media. + +**What's happening:** All Studio CLI implementation work landed and passes the 1724-test unit/integration suite. The bridge spawns DLA, tools register, the policy gates work, the wrapper-skill body parses cleanly. **But none of the implementer agents nor the code-reviewer drove an actual `/liberate` flow against a real source site.** Doing so requires DLA's adapters to perform real network I/O against a closed platform, drive headless Chromium against Wix/Squarespace, produce a real WXR, and import it into a fresh Studio site. + +**Why it's a merge-time gate:** Unit tests exercise the bridge, the adapter, the policy, and the runtime wiring โ€” they don't exercise DLA's adapter code paths or the agent's reasoning over real DLA outputs. There may be runtime issues that only surface end-to-end: a wrong tool-argument shape the wrapper skill emits, the content adapter mishandling some MCP response variant DLA produces against a real site, the `importWxr` blueprint failing on an unexpected WXR shape, the `delegate: true` manifest containing an unexpected field. The orchestrator's agents had no way to perform this test (no disposable test site, no credentials, no human in the loop to evaluate "did the migration actually work"). A human reviewer should do it once before merging. + +### Gate 2 โ€” Feature-flag default decided for v1 + +- [ ] Owner confirms whether `STUDIO_DLA_ENABLED` ships **off by default** (current state) or **on by default** for v1. If on, README and design-doc references to the flag must be updated. + +**What's happening:** Both the bridge spawn (in `runAgentSessionTurn`) and the policy extension factory (in `DefaultResourceLoader.extensionFactories`) check `STUDIO_DLA_ENABLED === '1'` and early-return when it's unset or `'0'`. With the flag off (the v1 default we shipped), the runtime is byte-for-byte identical to before this PR: no bridge child process, no DLA tools in `customTools`, no policy factory in the resource loader. Users running `studio code` see no `/liberate`, no DLA tools, nothing different. + +**Why it's a merge-time gate:** This is a product decision, not a code one. Shipping with the flag **off** means `/liberate` is invisible to users until someone manually sets the env var โ€” fine for a staged rollout, but defeats the discovery story (no one will find `/liberate` by accident; the slash menu won't autocomplete it). Shipping with the flag **on** means every user gets `/liberate` by default โ€” better discoverability, but takes on the bridge child-process lifecycle, the ~150 MB Chromium download, and the cancellation-orphan caveat (Gate 5) for the entire user base on first install. The reviewer should pick which posture matches the project's rollout plan. Could also be conditional (on for opt-in beta tracks, off for stable). Either choice is defensible; the orchestrator does not have the project context to choose. + +### Gate 3 โ€” Playwright Chromium download story decided + +- [ ] Owner decides whether to accept the ~150 MB CI cost, upstream a fix to DLA's postinstall, vendor-patch DLA via `patch-package`, or pre-populate `PLAYWRIGHT_BROWSERS_PATH` from a CI cache. + +**What's happening:** DLA's `package.json` has `"postinstall": "playwright install chromium"`. When any `npm install` pulls DLA in (CI, dev machines, end-user `npm install -g wp-studio`), DLA's postinstall fires and downloads ~150 MB of headless Chromium binaries. Wix and Squarespace adapters use that Chromium to scrape JavaScript-rendered pages. T9 set `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` in `.buildkite/`, `.github/workflows/`, and the `install:bundle` script โ€” the documented Playwright way to skip the download. + +**Why it's a merge-time gate:** The T9 implementer discovered empirically that **the env var is currently inert.** Modern Playwright (the one DLA pins) no longer has a postinstall hook of its own โ€” that hook was the only place `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` got consulted. DLA's postinstall calls Playwright's `installBrowsers()` function directly, which doesn't check the env var. So today, setting it has no effect โ€” Chromium still downloads on every CI build. The env var landed anyway as zero-cost forward-compat (if Playwright re-adds postinstall behavior, or we patch DLA, it's already wired everywhere), but **the ~150 MB CI cost is currently unmitigated.** The reviewer should pick a mitigation strategy (or explicitly accept the cost) before merge so the gap is closed in the same release cycle, not deferred indefinitely. + +### Gate 4 โ€” DLA dep flipped from `github:` SHA pin to npm semver range + +- [ ] **Planned resolution:** owner publishes DLA to npm after Gate 1's end-to-end test succeeds (see "Owner's planned path" above). This PR's dep declaration is then changed from `"data-liberation": "github:Automattic/data-liberation-agent#"` to a normal npm semver range (e.g. `"^0.1.0"`) before merge. +- [ ] **After the flip:** re-run the smoke build (`npm install --omit=dev` resolves DLA from npm; `node apps/cli/dist/cli/main.mjs liberate --help` still works). Verify `tools/dla/policy.ts` `defaultPolicyBuckets` covers any tools DLA added between the SHA-audit point (2026-05-07) and the published version. +- [ ] **If the npm publish doesn't happen** (e.g., test fails, npm publish blocked): the fallback is to bump the SHA pin to the current `Automattic/data-liberation-agent` HEAD instead, and the gate stays a manual decision at every future Studio release. + +**What's happening:** DLA isn't published to npm and has no git tags as of this PR. To get a reproducible install, we pinned it to a specific commit SHA: `"data-liberation": "github:Automattic/data-liberation-agent#17219c42b0420267302b138bf402930508006e0e"` in `apps/cli/package.json`. That SHA was the DLA HEAD on 2026-05-07 (when wave-1 audited DLA's source). The `github:` pin works for testing this PR end-to-end but creates ongoing maintenance burden: DLA has no semver discipline, no automated bump PRs (Dependabot / Renovate don't track `github:` deps reliably), and every Studio release needs a manual SHA review. + +**Why it's a merge-time gate:** the owner's plan is to test the PR with the current SHA pin and, on success, **publish DLA to npm before merging this PR**. After the publish, the dep declaration flips to a standard npm semver range and the gate effectively dissolves into normal npm-dep semantics: Dependabot opens auto-bump PRs, CI exercises the bridge integration on each bump, `package-lock.json` captures the resolved version, and a future Studio engineer doesn't need to manually compare SHAs at release time. The flip is a one-line edit; it needs to land **in this same PR** (not as a follow-up) because the version of DLA the bridge resolves at runtime determines whether `defaultPolicyBuckets` is complete โ€” and that's part of what reviewers will be checking when they approve the merge. + +**Specifically, before merge:** if DLA's published version exposes a 14th MCP tool that wasn't in the wave-1 inventory of 13, the defensive "unknown DLA tool โ†’ deny" path in `tools/dla/policy.ts` will hide the new tool from `/liberate` until the bucket table is extended. A `git diff` of DLA between the wave-1 SHA and the published version will surface this; if a new tool is present, add it to `defaultPolicyBuckets` in the same merge commit. + +### Gate 5 โ€” Upstream-DLA issue for `notifications/cancelled` filed + +- [ ] Team lead files an issue against `Automattic/data-liberation-agent` requesting that DLA's MCP server wire `notifications/cancelled` (and ideally `progressToken`) into its tool handlers. + +**What's happening:** MCP has a standard protocol message called `notifications/cancelled` that a client sends to the server when it wants to abort an in-flight tool call. Well-behaved MCP servers receive it and stop the work. Studio's bridge wires this correctly โ€” when the agent's `AbortSignal` fires (Ctrl+C, model decides to bail), the bridge forwards the abort to DLA via `notifications/cancelled`. But DLA's MCP server doesn't honor it โ€” DLA receives the message and keeps working. + +**Why it's a merge-time gate:** Concretely: if the agent kicks off `liberate_extract` against a Wix site and 30 seconds in the user cancels, Studio sees the tool call as cancelled and the agent moves on. From DLA's side, the extraction continues silently to completion, writing partial output to disk. This is bounded โ€” DLA's resume-safe protocol (`extraction-log.jsonl`, `session.json`, `media-stubs.json`) detects and reuses those partial outputs on the next `liberate_extract` run, so it's not data loss. But it is wasted CPU/network/disk in the background and mildly surprising semantics (the user thinks they cancelled, but source-platform requests keep going for a while). The orchestrator left filing the upstream issue to a human because it's cross-team coordination, not an in-repo implementation change. Filing it before merge surfaces the gap, lets DLA's maintainers plan a fix, and tracks the eventual update to Studio's docs. + +### Standard checklist + +- [ ] Have you checked for TypeScript, React or other console errors? โ€” Yes (`npm run typecheck` clean across all workspaces; lint clean on touched files). +- [ ] Is the PR scoped? โ€” Combined research + implementation, intentionally bundled by owner direction. The implementation is the **direct response** to the research recommendation; both artifacts read in tandem. +- [ ] Does the PR avoid `apps/studio/` changes? โ€” Yes (`git diff --stat 46d83870..HEAD -- 'apps/studio/'` is empty). diff --git a/issues/rsm-3143-dla-pi-research/doc-review-1.md b/issues/rsm-3143-dla-pi-research/doc-review-1.md new file mode 100644 index 0000000000..b0980d2339 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/doc-review-1.md @@ -0,0 +1,96 @@ +# Documentation Review 1 + +## Verdict: rejected + +## Summary + +Both doc commits are well-written and well-scoped: T10 (`apps/cli/README.md`) lands a clean user-facing "Migrate from a closed platform" section in the right place in the ToC, and T11 (`docs/design-docs/cli.md`) lands a thorough "Data Liberation Agent integration" architecture section that covers topology, bridge spawn, tool wrapping, two-layer permission policy, feature-flag gating, bare-name tool surface, `delegate: true` handoff, both user surfaces, and the orphan-work + Playwright caveats. Voice, structure, and CLI/App scope separation are all sound โ€” neither doc reaches into `apps/studio/` territory and the cross-link between the two docs is correct ("for the user-facing surface, see `apps/cli/README.md`"). + +The rejection is for two factual inaccuracies. The first is a hard miss in T11's "Bridge spawn" section: the design doc says the bridge resolves `tsx/dist/cli.mjs`, but T11 was committed at 01:06:09, **before** the post-review-1 fix at 01:18:41 (commit `65ce8848`) changed the resolution to `tsx/cli`. The current code uses `tsx/cli`, so the design doc is now stale and contradicts the implementation it claims to document โ€” including a paragraph that explicitly suggests "reconciling" to `tsx/cli` as a future TODO, when in fact the reconciliation has already happened. The second is in T10: the README tells users the Playwright Chromium download is "on demand โ€ฆ before the first extract starts", but DLA's `package.json` has `"postinstall": "playwright install chromium"`, which runs at `npm install -g wp-studio` time, not on first migration. (The design doc itself gets this partially right on one line and wrong on another โ€” see Issue 3 for that internal contradiction.) + +Both fixes are small and bounded โ€” a paragraph rewrite for Issue 1, a one-sentence rewrite for Issue 2, and a one-sentence reconciliation for Issue 3. + +## Issues + +### Issue 1: T11 documents the pre-fix tsx resolution path that no longer exists + +**What's wrong:** The "Bridge spawn" section in `docs/design-docs/cli.md` says: + +> `tsx` is loaded as the loader entry. The bridge resolves it as `tsx/dist/cli.mjs` via `createRequire(import.meta.url).resolve('tsx/dist/cli.mjs')`. The standalone `studio migrate` path in `apps/cli/commands/migrate/resolvers.ts` resolves the same binary via `tsx/cli` instead โ€” the public exports key. Both paths land on the same file; the bridge's spelling depends on hoisting layout while `tsx/cli` is the canonical subpath. This discrepancy is harmless today but should be reconciled (prefer `tsx/cli`). + +The actual code at `tools/dla/bridge.ts:269` (post-fix commit `65ce8848`) reads `require.resolve('tsx/cli')`, identical to `apps/cli/commands/migrate/resolvers.ts:30`. There is no longer a discrepancy. The design doc was written before the fix landed (T11 committed at 01:06:09; fix at 01:18:41) and was never updated. + +Worse, the doc is actively misleading: it tells future maintainers the bridge "should be reconciled (prefer `tsx/cli`)" when the reconciliation is already in the tree. It also misstates that `tsx/dist/cli.mjs` is "harmless" โ€” code-review-1 demonstrated it throws `ERR_PACKAGE_PATH_NOT_EXPORTED` against `tsx@4.21.0`, and that bug was the entire reason for the fix commit and the new regression tests in `tools/dla/tests/bridge.test.ts`. + +**Where:** `docs/design-docs/cli.md`, "Data Liberation Agent integration" โ†’ "Bridge spawn" section, second bullet (currently lines 81โ€“82). + +**Expected:** Replace the bullet with something like: + +> `tsx` is loaded as the loader entry. Both the bridge (`tools/dla/bridge.ts`) and the standalone `studio migrate` path (`apps/cli/commands/migrate/resolvers.ts`) resolve it as `tsx/cli` โ€” the public `exports` key. The deep `tsx/dist/cli.mjs` subpath is intentionally not exposed by `tsx`'s `exports` map and throws `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime; see the regression tests in `tools/dla/tests/bridge.test.ts` that guard against re-introducing the deep subpath. + +A short pointer to the resolution-bug history (or the fix commit) is optional but useful for future maintainers. + +**Severity:** must-fix + +### Issue 2: T10 misrepresents when the Playwright Chromium download happens + +**What's wrong:** The README says: + +> The first migration on a machine also downloads a Playwright Chromium build on demand (~150 MB), so expect a one-time delay before the first extract starts. + +But `node_modules/data-liberation/package.json` has `"postinstall": "playwright install chromium"` โ€” the Chromium download fires at `npm install -g wp-studio` time, not lazily on first migration. End-users who install via `npm install -g wp-studio` will see the ~150 MB delay during install, not during their first `studio migrate` run. + +This matters because the README is the place where a new user decides whether to install Studio CLI. Telling them "expect a one-time delay before the first extract starts" both understates the install-time footprint (which they pay even if they never run `/migrate`) and overstates the first-run delay (which won't actually happen). + +**Where:** `apps/cli/README.md`, "Migrate from a closed platform" section, final paragraph (currently around line 122). + +**Expected:** Replace the trailing sentence with something like: + +> Installing the Studio CLI also downloads a Playwright Chromium build (~150 MB) used for the Wix and Squarespace adapters, so the first install pulls more than the base CLI does. + +If the team prefers to keep the user-facing surface very compact, even a shorter "Installing the CLI pulls a ~150 MB Playwright Chromium binary used for Wix and Squarespace extraction" is fine. + +**Severity:** must-fix + +### Issue 3: T11 internally contradicts itself on when Chromium downloads + +**What's wrong:** The design doc's "Caveat: Playwright Chromium postinstall" section says both: + +> End-users pay the ~150 MB download cost on `npm install -g wp-studio` + +and immediately after: + +> Chromium auto-installs lazily on first use by the platform adapters that need it. + +The first sentence is correct against `data-liberation/package.json`'s `postinstall: "playwright install chromium"`. The second sentence describes a different (lazy-install) flow that does not match what DLA actually does. Pick one; they cannot both be true for the same install. + +Concretely: the postinstall hook fires at `npm install` time and runs `playwright install chromium`, which downloads the Chromium build into Playwright's user cache. There is no separate lazy-install path on first use unless the postinstall failed or was skipped. + +**Where:** `docs/design-docs/cli.md`, "Data Liberation Agent integration" โ†’ "Caveat: Playwright Chromium postinstall" (currently around lines 130โ€“132). + +**Expected:** Drop the "auto-installs lazily on first use" sentence (or rewrite it to clarify it only applies as a fallback when the postinstall is skipped). The rest of the caveat โ€” `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` being inert against modern Playwright because neither `playwright` nor `playwright-core` has a postinstall hook that consults it, and DLA's own `playwright install chromium` postinstall doing the actual download unconditionally โ€” is correct and lands well. + +**Severity:** must-fix + +## Items that are fine + +For the record, I cross-checked these claims against the as-built code and they match: + +- T11 topology section: `tools/dla/` layout (`bridge.ts`, `agent-tool-adapter.ts`, `policy.ts`, plus `content-adapter.ts` and `index.ts`), `@studio/dla` workspace package name, dep pin `github:Automattic/data-liberation-agent#` in `apps/cli/package.json`. All correct. +- T11 env passthrough list: `LIBERATION_TOKEN`, `SHOPIFY_ADMIN_TOKEN`, `NODE_PATH`, `NODE_OPTIONS`, plus `STUDIO_WPCOM_TOKEN` injected from session config. Matches `tools/dla/bridge.ts` `PASSTHROUGH_ENV_KEYS`. +- T11 listTools timeout (10s), SIGKILL grace period (2s), degraded-bridge fallback. All match `bridge.ts`. +- T11 `inputSchema as unknown as TSchema` cast claim and the inverse-shim reference to `apps/cli/ai/mcp-server.ts`. Correct. +- T11 two-layer policy (`shouldBlock` in the adapter wrapper; `pi.on('tool_call', ...)` in the extension factory). Matches `policy.ts`. +- T11 feature-flag gating (`maybeStartDlaBridge` + `resolveDlaExtensionFactories` both early-return on unset `STUDIO_DLA_ENABLED`). Matches `apps/cli/ai/runtimes/pi/index.ts:260` and `:468`. +- T11 bare tool names (no `mcp__data-liberation__` prefix) and the skill body cross-reference. Matches `apps/cli/ai/skills/migrate/SKILL.md`. +- T11 `delegate: true` manifest fields (`wxrFile`, `outputDir`, `mediaDir`, `productsCsv?`, `redirectMap`, `importAuthors`). Matches the skill body's Step 8. +- T11 orphan-work caveat. Sourced from research-report.md and reads cleanly. +- T11 standalone-CLI characterisation (yargs wrapper, inherits stdio, prunes `STUDIO_WPCOM_TOKEN`). Matches `apps/cli/commands/migrate/index.ts` and `resolvers.ts`. +- T10 user-facing voice, ToC placement, eight platforms listed, both invocation modes covered, `LIBERATION_TOKEN` / `SHOPIFY_ADMIN_TOKEN` notes. All correct. +- T10 / T11 CLI/App scope separation. Neither doc reaches into `apps/studio/`. + +## Notes for the next fix pass + +- T11 is the design-doc owner of the bridge contract โ€” please make sure the corrected bullet **explains why** `tsx/cli` is the canonical spelling and **points at the regression test** that guards against the deep subpath, so a future maintainer doesn't repeat the bug. +- T10 should stay user-facing โ€” keep the new paragraph short, no architecture detail. +- No other commits since the last code-review (review-2 approved at `a835eb03`) affect documentation; this review covers all in-scope doc surface. diff --git a/issues/rsm-3143-dla-pi-research/doc-review-2.md b/issues/rsm-3143-dla-pi-research/doc-review-2.md new file mode 100644 index 0000000000..8d55425eba --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/doc-review-2.md @@ -0,0 +1,41 @@ +# Documentation Review 2 + +## Verdict: approved + +## Summary + +Re-review of the three must-fix issues from `doc-review-1.md` after the documentator's fix commit `892493c1`. The fix is exactly the right shape: three sentence-level edits across two files (`apps/cli/README.md` +1/-1, `docs/design-docs/cli.md` +2/-2), no prose drift, no scope creep into `apps/studio/`. All three issues are cleanly resolved and the PR description (committed at `8bd351e0`, untouched here) remains accurate against the current branch state. + +## Verification of doc-review-1 must-fix issues + +### Issue 1: T11 bridge-spawn tsx path โ€” resolved + +The "Bridge spawn" bullet in `docs/design-docs/cli.md` (line 82) now reads: + +> Both the bridge (`tools/dla/bridge.ts`) and the standalone `studio migrate` path (`apps/cli/commands/migrate/resolvers.ts`) resolve it as `tsx/cli` โ€” the canonical key in tsx's package `exports` map. The deep `tsx/dist/cli.mjs` subpath is intentionally not exposed by `exports` and throws `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime; the regression tests in `tools/dla/tests/bridge.test.ts` (the `defaultTransportProvider โ€” real require.resolve paths` block) lock both invariants in so the bridge can never silently regress back to the deep subpath. + +Cross-checked: `tools/dla/bridge.ts:269` and `apps/cli/commands/migrate/resolvers.ts:30` both call `require.resolve( 'tsx/cli' )`. The referenced test block exists at `tools/dla/tests/bridge.test.ts:235` (`describe( 'defaultTransportProvider โ€” real require.resolve paths', () => { ... } )`). The "should be reconciled" TODO is gone, the `ERR_PACKAGE_PATH_NOT_EXPORTED` failure mode is named, and the regression test is pointed at โ€” all three asks from the original review's "Notes for the next fix pass" are satisfied. + +### Issue 2: T10 README Playwright timing โ€” resolved + +The trailing sentence in the Migrate section (line 122 of `apps/cli/README.md`) now reads: + +> Installing the Studio CLI also downloads a Playwright Chromium build (~150 MB) used for the Wix and Squarespace adapters, so the initial `npm install -g wp-studio` pulls more than the base CLI does. + +The "on demand" / "before the first extract starts" framing is gone. The cost is attributed to install time and the `npm install -g wp-studio` invocation is named explicitly, matching DLA's `postinstall: "playwright install chromium"` behaviour. + +### Issue 3: T11 design-doc Playwright contradiction โ€” resolved + +The Playwright caveat section (line 132 of `docs/design-docs/cli.md`) now reads: + +> End-users pay the ~150 MB download cost on `npm install -g wp-studio`, driven by DLA's `postinstall: "playwright install chromium"` hook. + +The contradictory "Chromium auto-installs lazily on first use by the platform adapters that need it" sentence is gone. The remaining statement is internally consistent and matches DLA's actual postinstall behaviour. The rest of the caveat (about `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` being inert against modern Playwright, neither `playwright` nor `playwright-core` having a postinstall hook, DLA's own postinstall calling `installBrowsers()` directly) is preserved unchanged and remains correct. + +## Drift check + +`git diff 8bd351e0..892493c1 --stat` confirms the fix is scoped to exactly the two doc files with 3 insertions and 3 deletions. No surrounding prose was edited and no other files were touched. The PR description at `issues/rsm-3143-dla-pi-research/PR-description.md` remains accurate โ€” no implementation has changed since the doc-review-1 commit, only the three sentence-level doc fixes addressed above. + +## Issues + +None. diff --git a/issues/rsm-3143-dla-pi-research/plan.md b/issues/rsm-3143-dla-pi-research/plan.md new file mode 100644 index 0000000000..0b10849fe4 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/plan.md @@ -0,0 +1,301 @@ +# RSM-3164 Implementation Plan + +> **Note:** The command was renamed from `/migrate` to `/liberate` (and `studio migrate` to `studio liberate`) post-implementation per owner direction (see latest rename commit on this branch). This plan preserves the original `/migrate` task descriptions as evidence of the implementation conversation. + +Spec: `issues/rsm-3143-dla-pi-research/research-report.md` ("Recommendation" โ†’ 9 concrete next steps). + +Scope: `apps/cli/` and `tools/dla/` (new workspace package). No changes to `apps/studio/`. + +Branch / PR: lands on `rsm-3143-dla-pi-research`, same PR (#3478). + +## Ordering & dependencies + +T1 (workspace scaffolding) is the foundation โ€” it unblocks T3 (bridge), T4 (policy extension), and T6 (`studio code` wiring). T2 (deps) is parallelizable with T1. T5 (skill) and T7 (slash-command) are independent and can land in any order after T1. T8 (`studio migrate`) only needs T2. T9 (Playwright env) is independent. T10 and T11 are docs and depend on T2-T8 being merged. + +Suggested order: T1 โ†’ T2 โ†’ T3 โ†’ T4 โ†’ T6 โ†’ T5 โ†’ T7 โ†’ T8 โ†’ T9 โ†’ T10 โ†’ T11. + +## Ambiguities resolved while planning + +1. **`tsconfig.base.json` path-alias plan from the spec is stale.** The repo's root `tsconfig.base.json` has no `paths` block โ€” path aliases live in `apps/cli/tsconfig.json` (`"@studio/common/*": [ "tools/common/*" ]`). T1 adds the `@studio/dla/*` entry in `apps/cli/tsconfig.json` (not `tsconfig.base.json`). +2. **`@studio/common` is consumed as a `devDependencies` entry with the `file:` protocol** (`apps/cli/package.json:75`: `"@studio/common": "file:../../tools/common"`). T2 mirrors that for `@studio/dla`. +3. **`vite.config.prod.ts` is missing the `ai/skills` static-copy target** โ€” confirmed by reading all three configs. `dev.ts` and `npm.ts` both have it; `prod.ts` doesn't. T5 fixes it as part of skill landing (instead of a standalone task โ€” the gap blocks the prod build of the new skill). +4. **The DLA SHA `17219c42b0420267302b138bf402930508006e0e` still points to HEAD of `Automattic/data-liberation-agent`** (verified via `git ls-remote`), so the spec's pin is current โ€” no need to bump. +5. **Bridge file layout uses `tools/dla/` (workspace package), not `apps/cli/ai/dla/`** (owner direction; sketch in `wave-1-mcp-bridge-feasibility` ยง6 used the old path). +6. **Owner files the upstream DLA `signal`/`progressToken` issue manually** (step 9 in spec) โ€” planner only ensures the orphan-work caveat is documented in the design doc (T11). No task created for filing the upstream issue. +7. **`@modelcontextprotocol/sdk` is already in `apps/cli/package.json` as `^1.27.1`** (resolved to 1.29.0 per wave-1 findings) โ€” no new MCP-SDK dep needed. +8. **`tsconfig.json` for `tools/dla/`** mirrors `tools/common/tsconfig.json` exactly (`composite: true`, `emitDeclarationOnly: true`). +9. **`runAgentSessionTurn` finally block teardown** lives at `apps/cli/ai/runtimes/pi/index.ts:222-225`; bridge dispose hooks in there. `createStudioAgentSession`'s `DefaultResourceLoader` construction is at lines 256-267 โ€” that's where `extensionFactories` plugs in. +10. **No separate test tasks** โ€” tests live in the same task as implementation per the orchestrator instructions. + +--- + +## Tasks + +### T1 โ€” [code] Scaffold `tools/dla/` workspace package + +**What:** Create `tools/dla/` as a sibling workspace package to `tools/common/`. Add: + +- `tools/dla/package.json` with `name: "@studio/dla"`, `private: true`, `version: "1.0.0"`, `type: "module"`. List dependencies that the bridge code will import (`@modelcontextprotocol/sdk`, `@mariozechner/pi-agent-core`, `@mariozechner/pi-coding-agent` โ€” confirm whether dev- or runtime-dep based on how `tools/common` does it; the pi packages are devDeps in `tools/common`). Scripts mirror `tools/common`: `build`, `lint`, `typecheck`. +- `tools/dla/tsconfig.json` mirroring `tools/common/tsconfig.json` (composite, declaration-only). +- Empty `tools/dla/index.ts` placeholder exporting an empty object (will be filled in T3). +- Add `@studio/dla` path alias to `apps/cli/tsconfig.json` `paths`: `"@studio/dla/*": [ "tools/dla/*" ]`. +- Add `@studio/dla` resolve alias to `apps/cli/vite.config.base.ts` mirroring the `@studio/common` entry. +- Add `"@studio/dla": "file:../../tools/dla"` to `apps/cli/package.json` `devDependencies`. + +**Acceptance criteria:** +- `npm install` succeeds. +- `npm run typecheck` in `apps/cli/` resolves `import {} from '@studio/dla'` (no module-not-found). +- `npm run cli:build` (dev config) succeeds. +- A simple vitest in `apps/cli/tests/` importing `@studio/dla` resolves without error. + +**Files likely involved:** +- `tools/dla/package.json` (new) +- `tools/dla/tsconfig.json` (new) +- `tools/dla/index.ts` (new, placeholder) +- `apps/cli/tsconfig.json` +- `apps/cli/vite.config.base.ts` +- `apps/cli/package.json` + +--- + +### T2 โ€” [code] Add `data-liberation` + `tsx` to `apps/cli/` dependencies + +**What:** +- Add to `apps/cli/package.json` `dependencies`: + - `"data-liberation": "github:Automattic/data-liberation-agent#17219c42b0420267302b138bf402930508006e0e"` + - `"tsx": "^4.19.0"` (or whatever current major) +- Run `npm install`. Verify lockfile updates cleanly. +- Verify `node_modules/data-liberation/src/mcp-server.ts` is present after install. +- Verify `node_modules/.bin/tsx` exists. + +**Acceptance criteria:** +- `npm install` succeeds without flagging missing peerDeps or hash mismatches. +- `node node_modules/data-liberation/src/cli.ts --help` (run through tsx) prints a usage banner. +- Lockfile diff is reviewable (no unrelated bumps). + +**Files likely involved:** +- `apps/cli/package.json` +- `package-lock.json` (root) + +--- + +### T3 โ€” [code] Implement the MCP-stdio bridge in `tools/dla/` + +**What:** Implement the bridge per `wave-1-mcp-bridge-feasibility` ยง6 (now living at `tools/dla/` instead of `apps/cli/ai/dla/`). + +Files (all under `tools/dla/`): + +- `bridge.ts` โ€” `DlaBridge` class wrapping `Client` + `StdioClientTransport`. Spawns `process.execPath` with `[ tsxImport, mcpServerEntry ]` (use `createRequire(import.meta.url).resolve('tsx')` and `createRequire(...).resolve('data-liberation/src/mcp-server.ts')`). `connect()` calls `client.connect(transport)` then `client.listTools(undefined, { signal: AbortSignal.timeout(10_000) })`. Caches the raw tool list. `dispose()` calls `client.close()`; belt-and-braces `process.kill(pid, 'SIGKILL')` after a 2s grace period. +- `agent-tool-adapter.ts` โ€” `adaptRemoteTool(client, remoteTool, policy)` โ†’ returns an `AgentTool`. Schema cast is `inputSchema as unknown as TSchema`. `execute` forwards `signal` into `client.callTool(_, _, { signal })`. On `result.isError`, throws with the first text content; otherwise maps `result.content` via `content-adapter.ts` and surfaces `result.structuredContent` as `details`. +- `content-adapter.ts` โ€” `mcpContentToPiContent()` mapper: keep `text`/`image`; flatten `resource` text-variant; serialize `resource_link` into a `text` block; drop `audio` with a console.warn. +- `policy.ts` โ€” `createDlaPolicyFactory(buckets)` returns an `ExtensionFactory` that subscribes to `pi.on('tool_call', ...)`. Buckets per RSM-3139 (`liberate_detect/discover/inspect/status/verify/setup/preview_stop/map_apis/probe` = safe; `liberate_extract/qa/preview` = fs-write; `liberate_import` = destructive). Defense-in-depth: hard-block `liberate_import` when `args.delegate !== true`. (This file is the policy extension factory only; the wiring to `DefaultResourceLoader` lives in T4.) +- `index.ts` โ€” exports `startDlaBridge(opts): Promise`, the `DlaBridge` type, and `createDlaPolicyFactory(buckets)`. + +Tests (in `tools/dla/__tests__/` or wherever `tools/common` puts its tests): +- Schema cast: pass a hand-rolled MCP-style `{ type: 'object', properties, required }` through `adaptRemoteTool` and assert the returned `AgentTool.parameters` is the same object reference (or shape-equivalent) and that `pi-ai`'s `validateToolArguments` (mocked or real) accepts a valid arg payload. +- Content adapter: text passthrough, image passthrough, resource text-flattening, resource_link serialization, audio drops with warning. +- Policy: each bucket โ†’ expected verdict (`safe` โ†’ undefined return, `destructive` โ†’ `{ block: true, reason }`, `liberate_import` with `delegate: true` โ†’ no block, `liberate_import` without `delegate` โ†’ block). +- Bridge lifecycle: mock `Client.connect` / `listTools` / `close`; assert `startDlaBridge` returns expected `tools` array and `dispose()` calls `client.close()`. + +**Acceptance criteria:** +- `tools/dla/__tests__/*.test.ts` all pass via `npm test`. +- `npm run typecheck` in `apps/cli/` passes (importing `@studio/dla` resolves all exports). +- No `any` casts beyond the `inputSchema as unknown as TSchema` line (which gets an eslint-disable comment with a link to wave-1 findings ยง2). + +**Files likely involved:** +- `tools/dla/bridge.ts` (new) +- `tools/dla/agent-tool-adapter.ts` (new) +- `tools/dla/content-adapter.ts` (new) +- `tools/dla/policy.ts` (new) +- `tools/dla/index.ts` (filled in from T1 placeholder) +- `tools/dla/__tests__/*.test.ts` (new) + +--- + +### T4 โ€” [code] Wire the DLA policy extension factory into the pi runtime + +**What:** In `apps/cli/ai/runtimes/pi/index.ts`: + +- Import `createDlaPolicyFactory` from `@studio/dla`. +- At the `DefaultResourceLoader` construction site (lines 256-267), add `extensionFactories: [ createDlaPolicyFactory(DLA_PERMISSION_BUCKETS) ]` (or whatever the canonical bucket constant exports as from `@studio/dla`). +- Verify `noExtensions: true` does NOT suppress inline `extensionFactories` (per wave-1 findings โ€” confirmed at `resource-loader.js:272-278`). No other DefaultResourceLoader flag changes. + +Tests: +- Unit test in `apps/cli/ai/tests/`: assert that constructing the runtime with `customTools` containing a `liberate_import` tool, and prompting with an arg `{ delegate: false }`, results in a blocked tool call (mock the `pi.on('tool_call', ...)` event chain). +- Snapshot/assertion test that `extensionFactories` is populated when DLA is enabled. + +**Acceptance criteria:** +- New unit tests pass. +- `npm test` for `apps/cli/` green. +- Manual smoke check: `npm run cli:build && node apps/cli/dist/cli/main.mjs code` starts without crashing (no extensions runtime error). + +**Files likely involved:** +- `apps/cli/ai/runtimes/pi/index.ts` +- `apps/cli/ai/tests/runtime-dla-policy.test.ts` (new) + +--- + +### T5 โ€” [code] Ship the `/migrate` skill + fix `vite.config.prod.ts` skills-copy gap + +**What:** +- Create `apps/cli/ai/skills/migrate/SKILL.md` reusing RSM-3139's skill body conceptually (`prior-art/rsm-3139-spec.md` ยง5). Verify the tool list at the top of the SKILL matches the actual DLA tool surface at HEAD (13 tools per `wave-1-dla-inventory.md`). The body is runtime-agnostic but the bridged tool names are bare (`liberate_inspect`, not `mcp__data-liberation__liberate_inspect`) because the bridge adapter forwards them as customTools, not as MCP-prefixed tools. Frontmatter must use `name: migrate`, `description: ...`, `user-invocable: true` (with C โ€” not the existing typo in other skills; per RSM-3139 ยง5). +- Fix `apps/cli/vite.config.prod.ts`: add the `viteStaticCopy({ targets: [{ src: 'ai/skills', dest: '.' }] })` plugin to match `vite.config.dev.ts` and `vite.config.npm.ts`. Without this, the prod-bundled CLI ships without the new skill (and any existing skills) and `loadSkills()` falls back to the empty list with a warning. + +Tests: +- Snapshot/parser test in `apps/cli/ai/tests/` that loads the `migrate` skill via `loadSkills()`/`findSkill('migrate')` and asserts `name`, `description`, and a known string from the body are present. +- Assert that the SKILL.md frontmatter contains `user-invocable: true` (not `user-invokable`). + +**Acceptance criteria:** +- `findSkill('migrate')` returns the skill body. +- `npm run cli:build` (prod config) emits `dist/cli/ai/skills/migrate/SKILL.md`. +- Skill body references real DLA tool names (no `mcp__data-liberation__` prefix, no stale `liberate_preview` if the skill says Studio drives site creation). + +**Files likely involved:** +- `apps/cli/ai/skills/migrate/SKILL.md` (new) +- `apps/cli/vite.config.prod.ts` +- `apps/cli/ai/tests/skills.test.ts` (new or extended) + +--- + +### T6 โ€” [code] Wire DLA bridge bring-up + teardown into `studio code` session + +**What:** In `apps/cli/ai/runtimes/pi/index.ts`: + +- Extend `runAgentSessionTurn` to call `startDlaBridge(...)` before `createStudioAgentSession`; cache the returned `DlaBridge` in scope. Use a feature-flag env var (e.g. `STUDIO_DLA_ENABLED`) to make it opt-in for v1. +- Thread the `DlaBridge` (or `undefined`) into `createStudioAgentSession` as a new parameter. +- In `createStudioAgentSession`, pass `dlaBridge` into `buildAgentTools`. +- Extend `buildAgentTools(config, isForkedByDesktop, remoteSession, dlaBridge?)` to spread `dlaBridge?.tools ?? []` into the returned `customTools` array. Keep the existing structural analog (`wpcom_request` tool ordering) in mind โ€” the DLA tools belong in the local-site branch (not the remote-site branch, per spec ยง Cons / recursion-into-Studio hazard). +- In the `finally` block at lines 222-225, add `await dlaBridge?.dispose()` after `session?.dispose()`. +- On bridge bring-up failure (timeout, spawn error), log a warning via the existing `Logger` and continue without DLA tools (graceful degradation โ€” skill body handles "tools missing" preflight). + +Tests: +- Mock `startDlaBridge` to return a stub with `tools: []` and assert that `buildAgentTools(..., bridge)` includes the fake tool in the customTools array. +- Mock bridge bring-up failure and assert the runtime still constructs successfully with `dlaTools = []`. +- Assert `dispose()` is called in `finally` when an error throws mid-session. + +**Acceptance criteria:** +- `npm test` for `apps/cli/` green. +- Manual smoke check with `STUDIO_DLA_ENABLED=1`: `studio code` starts; `liberate_detect` shows up in the tool list. With env unset: existing behavior unchanged. +- No DLA child spawned when `STUDIO_DLA_ENABLED` is unset (idle-cost protection). + +**Files likely involved:** +- `apps/cli/ai/runtimes/pi/index.ts` +- `apps/cli/ai/tests/runtime-dla-bridge.test.ts` (new) + +--- + +### T7 โ€” [code] Register `/migrate` slash command + +**What:** Append a `{ name: 'migrate', description: __('Migrate a site from a closed web platform to WordPress') }` entry to `AI_SKILL_COMMANDS` in `tools/common/ai/slash-commands.ts`. The existing dispatcher (`apps/cli/commands/ai/index.ts:600-633`) auto-routes handler-less skill commands through `runAgentTurn(buildSkillInvocationPrompt('migrate'))`. + +Tests: +- Extend `tools/common/__tests__/` (or wherever `AI_SKILL_COMMANDS` is currently tested) to assert the `migrate` entry exists. +- Assert that `buildSkillInvocationPrompt('migrate')` returns the literal `"Run the /migrate skill using the Skill tool."`. + +**Acceptance criteria:** +- `/migrate` appears in the autocomplete list when running `studio code` interactively. +- Tests for `AI_SKILL_COMMANDS` membership pass. + +**Files likely involved:** +- `tools/common/ai/slash-commands.ts` +- `tools/common/__tests__/slash-commands.test.ts` (new or extended) + +--- + +### T8 โ€” [code] Ship `studio migrate ` standalone CLI command (Subprocess approach) + +**What:** Add a new yargs command at `apps/cli/commands/migrate/` (and register it in `apps/cli/commands/index.ts` or wherever the command registry lives). The command spawns DLA's CLI as a child process (~60 LOC, per `wave-1-subprocess-revisit` ยง6). Reference shape: + +- `studio migrate ` โ†’ ` apps/cli/node_modules/.bin/tsx node_modules/data-liberation/src/cli.ts --non-interactive [...flags]`. +- Pass through `--output`, `--limit`, `--token`, `--admin-token`, `--shop-domain`, `--non-interactive`, `--dry-run`. +- Forward stdio (stream child stdout/stderr to user's terminal โ€” no ANSI stripping needed when we own the terminal). +- Set `NO_COLOR=1` only if not a TTY. +- Exit with the child's exit code. + +Tests: +- Mock `child_process.spawn`; assert correct argv, env, cwd. +- Assert `studio migrate --help` prints usage including pass-through flags. +- Assert exit code propagation. + +**Acceptance criteria:** +- `studio migrate --help` prints usage. +- `studio migrate http://example.com --dry-run` invokes DLA's CLI with the right argv (verified via test mock). +- `npm test` for `apps/cli/` green. + +**Files likely involved:** +- `apps/cli/commands/migrate/index.ts` (new) +- `apps/cli/commands/migrate/__tests__/index.test.ts` (new) +- `apps/cli/index.ts` or `apps/cli/commands/index.ts` (registry update) + +--- + +### T9 โ€” [code] Set `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` in build pipelines + +**What:** Add `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` as an env var in: + +- `.buildkite/pipeline.yml` (the agent block(s) that run `npm install` / `cli:build`). +- `.buildkite/release-build-and-distribute.yml` (release pipeline `install` steps). +- Any GitHub Actions workflow that runs `npm install` (`.github/workflows/publish-npm-package.yml` is the obvious one; check others). +- `apps/cli/package.json` `install:bundle` script โ€” prefix with `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` (or restructure to a `cross-env` invocation if cross-platform is needed). + +End-user `npm install -g wp-studio` pays the 150 MB Chromium download on first install (intentional โ€” Wix/Squarespace adapters need it at runtime). + +**Acceptance criteria:** +- All three CI/build config files set the env var. +- `apps/cli/install:bundle` script skips Chromium download (verify by dry-running or by checking the env var is exported before `npm install`). +- A note in `apps/cli/README.md` (covered in T10) mentions the end-user install cost. + +**Files likely involved:** +- `.buildkite/pipeline.yml` +- `.buildkite/release-build-and-distribute.yml` +- `.github/workflows/publish-npm-package.yml` +- `.github/workflows/build-php-cli-binaries.yml` (if it touches `npm install`) +- `apps/cli/package.json` (`scripts.install:bundle`) + +--- + +### T10 โ€” [docs] Add "Migrate from a closed platform" section to `apps/cli/README.md` + +**What:** Add a new top-level section between "Studio Code" and "Import and export" in `apps/cli/README.md`. Update the table of contents. + +Content (cover): +- What DLA is (one paragraph) and which 8 platforms it supports (GoDaddy Websites & Marketing, Hostinger, HubSpot, Shopify, Squarespace, Webflow, Weebly, Wix). +- Two invocation modes: + - **Inside `studio code` (agent mode):** `/migrate ` (mention behind feature flag `STUDIO_DLA_ENABLED=1` if still gated). Walks through inspect โ†’ extract โ†’ verify โ†’ site-create โ†’ import. + - **Standalone:** `studio migrate ` for the non-agent headless flow. +- Optional `LIBERATION_TOKEN` (Webflow) / `SHOPIFY_ADMIN_TOKEN` (Shopify) env vars. +- Note about Playwright Chromium download (~150 MB on first install for end users). +- Brief "Powered by [Data Liberation Agent](https://github.com/Automattic/data-liberation-agent)" credit. + +**Acceptance criteria:** +- README ToC has a "Migrate from a closed platform" entry pointing to the new section. +- The section reads end-to-end without referring to internals (no MCP, no `tools/dla/`, no extension factories). +- Both invocation paths (`/migrate` and `studio migrate`) are documented. + +**Files likely involved:** +- `apps/cli/README.md` + +--- + +### T11 โ€” [docs] Add "Data Liberation Agent integration" section to `docs/design-docs/cli.md` + +**What:** Append a new "Data Liberation Agent integration" section to `docs/design-docs/cli.md`. Cover the architecture as built: + +- Dep declaration: `github:Automattic/data-liberation-agent#` pin model; one-line bump cadence. +- Bridge layout in `tools/dla/` (`bridge.ts`, `agent-tool-adapter.ts`, `content-adapter.ts`, `policy.ts`, `index.ts`). +- Runtime spawn shape: `process.execPath` + `tsx` + DLA's `src/mcp-server.ts`. +- Per-session lifecycle: bridge spawned at `runAgentSessionTurn` startup, torn down in the `finally` block alongside `session.dispose()`. +- Permission gating via `extensionFactories` on `DefaultResourceLoader` (`pi.on('tool_call', ...)`); per-tool buckets reused from RSM-3139. +- `delegate: true` contract for `liberate_import` (returns manifest; Studio drives import via `wp_cli` / `site_create`). +- **Orphan-work caveat:** DLA does not honor `notifications/cancelled`. Cancelled `liberate_extract` keeps crawling server-side; filesystem cleanup is bounded by DLA's resume-safe protocol (`extraction-log.jsonl`, `session.json`). Note the upstream issue will be filed manually by the team lead. +- Subprocess path (`studio migrate `): separate yargs command, child-process spawn of DLA's CLI, non-agent headless flow. +- Build pipeline note: `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` in CI to avoid the 150 MB Chromium pull at build time. +- Reference `issues/rsm-3143-dla-pi-research/research-report.md` for trade-off rationale. + +**Acceptance criteria:** +- Section reads as the canonical architecture reference for the integration. +- Explicitly documents the orphan-work caveat and the `delegate: true` contract. +- Cross-links to the research report. + +**Files likely involved:** +- `docs/design-docs/cli.md` diff --git a/issues/rsm-3143-dla-pi-research/prior-art/rsm-1639-research-report.md b/issues/rsm-3143-dla-pi-research/prior-art/rsm-1639-research-report.md new file mode 100644 index 0000000000..e6767fe83d --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/prior-art/rsm-1639-research-report.md @@ -0,0 +1,164 @@ +# RSM-1639: Bringing the Data Liberation Agent into Studio Code + +**Status:** research, no code changes +**Scope:** Studio CLI (`apps/cli/`) only. Electron-side touches are flagged, never proposed. +**Deliverable framing:** What happens when a user types `/migrate` inside `studio code`? + +--- + +## Executive Summary + +The Data Liberation Agent (DLA, `Automattic/data-liberation-agent`) is not an LLM agent โ€” it is a deterministic Node/TypeScript extraction toolkit for eight closed web platforms (GoDaddy WM, Hostinger, HubSpot, Shopify, Squarespace, Webflow, Weebly, Wix) that ships pre-wired as a Claude Code plugin, a Codex plugin, a Gemini extension, a generic stdio MCP server, and a thin CLI โ€” *all driven by the same `src/mcp-server.ts` (13 tools) plus a `skills/` markdown tree*. The Claude Agent SDK that Studio Code already uses (`@anthropic-ai/claude-agent-sdk@0.2.117`) accepts both `plugins: [{ type: 'local', path }]` and `mcpServers: { : stdio | sse | http | sdk }`, and DLA is shaped exactly to be consumed through both โ€” a `.claude-plugin/plugin.json` that declares an `mcp` block plus a `skills/` directory with phased workflow markdown. + +**Recommended path:** vendor DLA's plugin tree (its `.claude-plugin/`, `skills/`, `commands/`, `prompts/`, plus the `src/` it needs at runtime and any vendored PHP) under `apps/cli/ai/dla/` at a pinned git SHA via a build-time fetch script (modeled on the existing `scripts/download-agent-skills.ts`), load it as a *second* local plugin in `apps/cli/ai/agent.ts:130-149` (`plugins: [studioPluginPath, dlaPluginPath]`), boot DLA's MCP server as a stdio child-process entry alongside Studio's in-process MCP (`mcpServers: { studio: ..., 'data-liberation': { command, args, env } }`), and surface `/migrate` as a skill-based slash command that wraps DLA's `liberate` skill plus a Studio-side post-extract handoff. The user types `/migrate `, the agent walks DLA's `skills/liberate/SKILL.md` workflow, calls DLA's `liberate_*` MCP tools, then on import calls `liberate_setup` / `liberate_import` with `delegate: true` โ€” DLA returns a manifest of artifact paths, Studio's `site_create` MCP tool (already in `apps/cli/ai/tools.ts`) creates the Studio site, and Studio's existing `wp_cli` Bash plumbing imports the WXR. This shape preserves Studio's "works offline once installed" posture, gives DLA a clean version-pin point per Studio CLI release, and avoids any Electron-side change beyond fixing the (already-suspected) `vite.config.prod.ts` plugin-copy gap. + +The big trade-offs we accept: DLA goes 0โ€“6 weeks stale per Studio CLI release window (mitigated by a `/migrate --update` opt-in that re-runs the fetch script), Studio inherits DLA's ~150 MB Playwright Chromium postinstall (currently mandatory in DLA), and `permissionMode: 'auto'` extends to DLA's MCP tools by default (mitigated via `McpServerToolPolicy` allowlists per tool). Two facts are non-negotiable for picking *this* shape over alternatives: (a) DLA's `liberate_setup` / `liberate_import` already expose a `delegate: true` mode that returns a structured manifest specifically for "local dev tools with direct database/CLI access" โ€” Studio Code is the canonical caller; (b) DLA already speaks Studio (`src/lib/preview/studio.ts`) for previews and inlines WXR via the `importWxr` blueprint step into `blueprint.studio.json` to dodge the WP-CLI 120s IPC timeout, so the integration is not greenfield. + +--- + +## Approaches Investigated + +We evaluate five integration shapes. Each names *exactly* where it plugs into the Studio CLI, what flows at `/migrate` time, and what we ship. + +### A. Vendor DLA as a second local plugin + stdio MCP (recommended) + +**How it works.** A build-time fetch script (sibling of `scripts/download-agent-skills.ts`) clones DLA at a pinned git SHA into `apps/cli/ai/dla/`. The directory tree is preserved verbatim โ€” `.claude-plugin/plugin.json`, `skills/`, `commands/`, `prompts/`, `src/`, vendored PHP under `src/lib/preview/scripts/`. Vite's `viteStaticCopy` already copies `apps/cli/ai/plugin/` โ†’ `dist/cli/plugin/`; we add a second target for `apps/cli/ai/dla/` โ†’ `dist/cli/dla/` (mirror it across `vite.config.dev.ts`, `vite.config.npm.ts`, and the currently-incomplete `vite.config.prod.ts`). At runtime, `apps/cli/ai/agent.ts:130-149` becomes: + +```ts +plugins: [ + { type: 'local', path: path.resolve(import.meta.dirname, 'plugin') }, + { type: 'local', path: path.resolve(import.meta.dirname, 'dla') }, +], +mcpServers: { + studio: isRemoteSite ? createRemoteSiteTools(...) : createStudioTools(...), + 'data-liberation': { + command: 'npx', + args: ['tsx', path.resolve(import.meta.dirname, 'dla/src/mcp-server.ts')], + cwd: path.resolve(import.meta.dirname, 'dla'), + env: { + // pass through Studio's resolved Anthropic env (irrelevant for DLA itself) + // and the WPCOM auth token for any future DLA tools that need it + STUDIO_WPCOM_TOKEN: (await readAuthToken())?.accessToken ?? '', + }, + }, +}, +``` + +`/migrate` is registered in `tools/common/ai/slash-commands.ts:8-13` as a skill-based command pointing at DLA's `liberate` skill (so the slash hint reads, e.g., "Migrate a site from a closed platform into Studio"). On invoke, `runAgentTurn(buildSkillInvocationPrompt('liberate'))` produces "Run the /liberate skill using the Skill tool." The SDK loads DLA's `skills/liberate/SKILL.md`, the model walks its workflow, and tool calls flow as `mcp__data-liberation__liberate_detect`, `..._discover`, `..._extract`, `..._verify`, `..._setup` (with `delegate: true`), `..._import` (with `delegate: true`). On `delegate: true` the model receives a JSON manifest (`{ wxrFile, outputDir, mediaDir, productsCsv, redirectMap, importAuthors }`) and is instructed by the skill to hand off to the host โ€” i.e. to call Studio's `mcp__studio__site_create` with the right blueprint, then run the post-create import via Studio's existing `wp_cli` tool (which is already in the `claude_code` preset Studio enables). + +**Evidence.** +- DLA's plugin manifest, MCP-tool list (13 tools, including `_preview` which auto-detects `studio` on PATH), and `delegate: true` design: `wave-1-dla-inventory.md` ยง3-4, ยง9. +- Studio's plugin path resolution and `mcpServers` extension point: `apps/cli/ai/agent.ts:80-84, 130-149` (`wave-1-studio-skill-plumbing.md` ยง3a, ยง5b/5c). +- SDK accepts `plugins: SdkPluginConfig[]` (an array โ€” multiple `type: 'local'` plugins supported) and `mcpServers: Record` with stdio child-process variant: `wave-1-claude-plugin-mechanics.md` ยง1, ยง5. +- Vendoring precedent: `scripts/download-agent-skills.ts` already fetches third-party skills at build time (`wave-1-bundling-distribution.md` ยง3, ยง5). + +**Pros.** +- **No Electron-side proposal needed** beyond the `vite.config.prod.ts` plugin-copy fix (which is independently flagged as a probable existing bug). +- **Offline-correct.** Same posture as `wp-files/latest/`: DLA is bundled, no first-run network requirement. +- **Auth and credentials inherited.** DLA's MCP server doesn't need an Anthropic key (none of its tools call an LLM); WPCOM token can be passed via `mcpServers..env`. +- **Sessions / replay work without changes.** `apps/cli/ai/sessions/{recorder,replay}.ts` are tool-name-agnostic. +- **Single integration touch-point per release.** DLA is pinned by SHA; bumping it is a one-line change in the fetch script. +- **`delegate: true` is exactly the contract we want.** The manifest is small, structured, and *already exists* โ€” we don't need DLA to change. + +**Cons / costs.** +- **DLA goes stale.** 0โ€“6 weeks behind upstream (1โ€“2 weeks typical given `wp-studio` weekly cadence). Mitigation: a `/migrate --refresh` opt-in that re-runs the fetch script outside the npm install path, or a postinstall environment override. +- **Playwright Chromium download (~150 MB).** DLA's `postinstall: playwright install chromium` is unconditional. Adopting DLA via vendoring runs *DLA's own* postinstall when its `node_modules` are installed for the bundled CLI path. Mitigation: run DLA without its `node_modules` (DLA imports come through Studio's hoisted root `node_modules` via the workspace) โ€” Playwright is already in the root tree (`node_modules/playwright + playwright-core` ~14 MB per `wave-1-bundling-distribution.md` ยง1), and Chromium auto-downloads on first use only if the adapter hits a Wix/Squarespace path. Verify before shipping. +- **`tsx` at runtime.** DLA's `.mcp.json` uses `npx tsx src/mcp-server.ts`. We must either pre-compile DLA to JS in the fetch script (TypeScript compilation pass against a pinned `tsconfig.json`) or include `tsx` as a Studio CLI runtime dep. Pre-compile is cleaner because it avoids `npx` warm-up latency at every `/migrate`. +- **Vendored PHP scripts at absolute paths.** DLA's preview path resolves `import-wxr.php` and `import-products.php` from `import.meta.url`. The fetch script must preserve `src/lib/preview/scripts/` next to the JS โ€” which is already how `viteStaticCopy` recursively copies trees, so this is "do nothing" if we keep the directory layout. +- **Permission mode bleed.** `permissionMode: 'auto'` (Studio's mode) classifies *every* tool call across plugins; DLA's MCP tools inherit auto-approval. Mitigation: declare `tools` policies in the stdio MCP config โ€” but `McpServerToolPolicy` per `wave-1-claude-plugin-mechanics.md` ยง4 only applies to `sse`/`http`/`sdk` variants, not stdio. So we either (a) restrict via skill `allowed-tools` frontmatter (we control the slash entry, so we ship our own thin "migrate" skill that calls DLA's), (b) use the `canUseTool` callback (`sdk.d.ts:1142-1145`), or (c) accept the auto-approval. + +### B. Vendor DLA as a second local plugin + in-process SDK MCP + +Same shape as A, but instead of spawning DLA's MCP server as a stdio child, we re-implement DLA's tool surface in-process using `createSdkMcpServer({ name: 'data-liberation', tools: [...] })` and bind directly to DLA's `src/lib/*` modules. + +**Pros.** No subprocess; no `tsx`/compile step; faster invocation; cleaner permission policies via tool annotations. +**Cons.** Massive duplication โ€” every DLA MCP tool would need a Studio-side adapter; we'd be re-deriving DLA's manifest layer instead of consuming it. Drift risk is high: the moment DLA adds a 14th tool we lose it. Also: DLA's MCP server is ~620 LOC including stateful extraction-log management; reproducing that in Studio is a fork. **Rejected** as primary path. + +### C. DLA as an npm dependency + +Wait for DLA to publish to npm (`name: "data-liberation"`, currently `0.1.0` and unpublished). Add to `apps/cli/package.json` dependencies and reference its plugin tree via `node_modules/data-liberation/.claude-plugin/`. + +**Pros.** Semver discipline (when DLA adopts it). No vendoring scripts. Update by `npm update data-liberation`. Clean separation. +**Cons.** **Blocked today** โ€” DLA repo has no tags, no releases, no published artifact (`wave-1-dla-inventory.md` ยง10). DLA being a private repo means even `git+ssh:` deps require a deploy key. Even if DLA published, Studio's npm install path uses `--no-package-lock` (`apps/cli/package.json:63`) โ€” semver caret can drift unobserved. Future-good but **not viable now**. + +### D. DLA as a runtime fetch / install + +`/migrate` checks for `~/.studio/dla/` and lazy-installs DLA at first use (e.g. `git clone` or tarball fetch + `npm install`). + +**Pros.** Smallest Studio CLI footprint. DLA always at HEAD. +**Cons.** **Breaks Studio's "works offline once installed" posture.** First-run network requirement. Mid-migration `npm install -g wp-studio@x` could clobber DLA's working files. Adds retry/cache/version-negotiation/security-review surface. `wave-1-bundling-distribution.md` ยง3 calls this "best decoupling, worst trust/security story." **Rejected.** + +### E. Spawn `data-liberation` CLI as a child process from a handler-only slash command + +Skip the MCP/skill route entirely. Make `/migrate` a handler-based slash (like `/preview`) that spawns DLA's `cli.js` and pipes output back via `captureCommandOutput` (precedent: `apps/cli/ai/tools.ts:225`). The agent is never invoked. + +**Pros.** Simplest mental model. No SDK plugin/MCP plumbing. DLA's Ink UI shows up as terminal output. +**Cons.** **Loses the agent.** The deliverable's framing ("`/migrate` slash command in `studio code`, the AI-agent CLI") implies the agent does the orchestration: ask the user for a URL, decide platform, narrate progress, ask for credentials. A handler-only command doesn't get the model in the loop. Also: handler-only commands are NOT picked up by Electron's IPC dispatcher (`apps/studio/src/ipc-handlers.ts:295-306`) โ€” they'd work in CLI but not in the desktop slash list. Also: DLA's Ink UI conflicts visually with `studio code`'s own TUI. **Rejected** as primary; possibly useful as an *escape hatch* (`/migrate --headless`). + +--- + +## Comparison + +| Dimension | A. Vendor + stdio MCP (rec.) | B. Vendor + in-process MCP | C. npm dep | D. Runtime fetch | E. Handler + CLI spawn | +|---|---|---|---|---|---| +| Viable today | yes | yes (lots of code) | **no** (DLA unpublished) | yes (with caveats) | yes | +| Agent in the loop | yes | yes | yes | yes | **no** | +| Offline after install | yes | yes | yes | **no** | yes (after fetch) | +| Electron-side change | none beyond `vite.config.prod.ts` fix | none beyond `vite.config.prod.ts` fix | minor | network-policy review | dispatcher gap (`/migrate` won't appear in desktop slash menu) | +| DLA staleness | 0โ€“6 weeks (typical 1โ€“2) | same | semver-driven (best) | none (always HEAD) | depends on bundling choice | +| Implementation cost | lowโ€“medium (fetch script + 2 lines in `agent.ts` + 1 in `slash-commands.ts`) | high (re-implement 13 tools) | low (when unblocked) | medium-high (runtime install plumbing) | low (handler), but loses agent UX | +| Auth handoff | trivial (env on `mcpServers` entry) | trivial (in-process) | trivial | env on spawn | env on spawn | +| Permission scoping | weak (auto-approve, mitigations exist) | strong (per-tool annotations) | depends | weak | n/a | +| Drift risk | low (one pin point) | **high** (re-derive every change) | low | medium | medium | +| Bundling cost | DLA tree + `node_modules` subset | DLA tree only (no MCP server bin) | DLA tree | none in installer | DLA tree | +| Precedent in repo | `download-agent-skills.ts`, plugin tree | in-process `studio` MCP | none | postinstall fetches (build-time, not runtime) | `/preview` handler | + +--- + +## Recommendation + +**Adopt Approach A: vendor DLA as a second local plugin under `apps/cli/ai/dla/` and load its MCP server as a stdio child-process entry alongside Studio's in-process MCP.** Surface `/migrate` as a skill-based slash command in `tools/common/ai/slash-commands.ts:8-13`. + +### Concrete changes (CLI-only, named precisely) + +1. **Build-time fetch.** Add `scripts/download-data-liberation-agent.ts` modeled on `scripts/download-agent-skills.ts`. Pins DLA by git SHA, clones a shallow checkout, runs `tsc` against DLA's tsconfig to produce `dist-vendored/`, copies the resulting tree (compiled JS + the `skills/`, `commands/`, `prompts/`, `.claude-plugin/`, vendored PHP at `src/lib/preview/scripts/`) into `apps/cli/ai/dla/`. Skip DLA's own `node_modules` install โ€” DLA's runtime deps already overlap with Studio's hoisted root (`@modelcontextprotocol/sdk`, `@wp-playground/cli`, `playwright`, `cheerio`, etc.). Where DLA needs deps Studio doesn't have (`fast-xml-parser`, `papaparse`, `ink`), add them to the root or to `apps/cli/package.json` so the workspace hoist resolves them. +2. **Static-copy.** Add `viteStaticCopy({ targets: [{ src: 'ai/dla', dest: '.' }] })` to `vite.config.dev.ts` and `vite.config.npm.ts`. **Also fix the existing gap in `vite.config.prod.ts`:** add the same target *and* the missing `ai/plugin` target so Electron-bundled `studio code` actually loads the SDK plugin tree (this is independent of DLA but blocks the integration). +3. **Plugin wiring.** In `apps/cli/ai/agent.ts:130-149`, extend `plugins` to a two-element array with both local plugin paths. +4. **MCP wiring.** In `apps/cli/ai/agent.ts:80-84`, extend `mcpServers` to add a `'data-liberation'` stdio entry with `command: process.execPath`, `args: [path.resolve(import.meta.dirname, 'dla/src/mcp-server.js')]` (post-compile; falls back to `npx tsx ...src/mcp-server.ts` only if we keep TS-at-runtime), and `env` carrying `STUDIO_WPCOM_TOKEN` from `readAuthToken()` plus `LIBERATION_TOKEN` / `SHOPIFY_ADMIN_TOKEN` from the user's resolved Studio config. +5. **Slash command.** Add `{ name: 'migrate', description: __('Migrate a site from a closed platform into Studio') }` to `AI_SKILL_COMMANDS` in `tools/common/ai/slash-commands.ts:8-13`. The agent's slash dispatcher (`apps/cli/commands/ai/index.ts:684-705`) automatically picks this up via the existing skill flow (Flow B, `wave-1-studio-skill-plumbing.md` ยง1). +6. **A thin "migrate" skill in Studio's plugin** (not DLA's). Drop `apps/cli/ai/plugin/skills/migrate/SKILL.md`. Frontmatter uses `user-invocable: true` (with C โ€” note: existing Studio skills use the kebab-K typo `user-invokable`; this is a free chance to fix it but check before changing en masse). Body: instruct the model to (a) ask for the source URL if not provided, (b) call `mcp__data-liberation__liberate_inspect`, (c) confirm platform with the user, (d) call `mcp__data-liberation__liberate_extract` with progress narration, (e) call `liberate_verify`, (f) call `liberate_setup` and `liberate_import` with `delegate: true`, (g) on the returned manifest call `mcp__studio__site_create` with the appropriate blueprint and let Studio's existing `wp_cli`-driven import paths handle the WXR. Setting `allowed-tools` to the precise list keeps the auto-permission classifier scoped. +7. **End-to-end UX (`/migrate` deliverable framing).** + - User types `/migrate` in `studio code`. + - Skill prompt: "What's the URL of the site you want to migrate?" (or `/migrate https://example.wixsite.com/foo` accepts inline arg via `argument-hint` frontmatter). + - Agent calls `liberate_inspect` โ†’ narrates "Detected Wix. Found 47 pages and 12 blog posts." + - Agent asks the user to confirm; on confirm, runs `liberate_extract`, streaming progress messages from MCP `sendLoggingMessage` (DLA already emits these per `wave-1-dla-inventory.md` ยง4). + - Agent runs `liberate_verify`, surfaces any quality issues. + - Agent calls `liberate_setup` (`delegate: true`), receives the manifest, calls `site_create` with a Studio-friendly blueprint (slug derived from the source domain), then drives the WXR import via `wp_cli import` (or via the `importWxr` blueprint step inlined into `blueprint.studio.json` if the artifact size threshold is exceeded โ€” this is the path DLA already uses to dodge the 120s WP-CLI IPC timeout). + - Agent ends with: "Migration complete. Your new Studio site is at http://example-migrated.localhost:8881/. Open in browser? (y/n)" + +### Why this trade-off set + +We are picking a path that **maximizes integration leverage from DLA's existing shape** and **minimizes blast radius in Studio CLI**. DLA already has: a Claude plugin manifest, a Skill that walks a phased migration workflow, an MCP server with a `delegate` mode designed exactly for hosts like us, working `studio` CLI integration for previews, and a 0-API-key runtime. Asking DLA to change is unnecessary; what we add to Studio is one fetch script, three lines in `agent.ts`, one line in `slash-commands.ts`, one new SKILL.md, and a missing build-config target. Approach B trades that for a fork. Approach C requires DLA to publish (it has no tags or releases yet). Approach D breaks our offline posture. Approach E loses the agent. The only material things we accept: 0โ€“6-week DLA staleness, ~150 MB Playwright Chromium (a sunk cost the moment any user migrates from Wix/Squarespace anyway), and weak permission scoping for DLA's MCP tools (mitigable via `allowed-tools` in the wrapper skill or `canUseTool`). + +--- + +## Open Questions + +1. **`vite.config.prod.ts` plugin-copy gap** (`wave-1-bundling-distribution.md` ยง1, `wave-1-studio-skill-plumbing.md` ยง6 item 3). The Electron-bundled CLI may already be missing the existing `ai/plugin` static-copy target. Needs a 5-minute verification with a real `npm run cli:package` run before this integration ships, since DLA piggybacks on the same target. Owner: implementation phase. +2. **Permission scoping rigor.** With `permissionMode: 'auto'`, do we want DLA's MCP tools (`liberate_extract` writes to disk; `liberate_import` writes to a remote WP) to auto-approve, or do we wrap them in `canUseTool`? Recommended default: auto-approve `_detect`/`_discover`/`_inspect`/`_status`/`_verify` (read-only); ask once for `_extract` (the heavy step); always ask for `_import` if not in `delegate: true` mode. Implementation in `apps/cli/ai/agent.ts` via `canUseTool` callback (`sdk.d.ts:1142-1145`). +3. **DLA's `tsx`-at-runtime decision.** Pre-compile in the fetch script (cleaner) vs. keep `tsx` for parity with DLA's other distribution channels (lower drift). Recommend pre-compile; revisit if DLA evolves to depend on `tsx`-only behaviors (it doesn't today). +4. **Plugin name namespacing across multiple plugins.** `wave-1-claude-plugin-mechanics.md` ยง5 flags as unverified whether MCP tool names from a plugin-bundled MCP server collide with the same-named server passed at `query()` time. We pass `'data-liberation'` from `query()`; DLA's `.claude-plugin/plugin.json#mcp` declares the same. Worth a 10-minute test during implementation to confirm tools surface as `mcp__data-liberation__*` exactly once. +5. **Skill frontmatter spelling.** Studio's existing skills use `user-invokable` (with K) but the SDK reads `user-invocable` (with C). DLA's skills also need to use `user-invocable`. Quick `grep` check in DLA's `skills/*/SKILL.md` during implementation (orthogonal to research). +6. **Distribution mechanic for a private DLA repo.** The fetch script needs auth. Options: GitHub App token (most robust), per-user PAT (worst), public-mirror-of-tagged-releases (asks DLA team to establish a release process โ€” which addresses Approach C blockers as a side effect). Recommend pushing DLA team for tagged public releases so Approach C unblocks long-term and we don't carry a fetch script forever. +7. **DLA's auto-cleanup of orphan Studio sites.** `src/lib/preview/studio.ts:266-279` `rmSync`s a Studio site dir under `defaultStudioRoot()` if it isn't listed by `studio site list`. With Studio Code orchestrating site creation directly, this code path may never fire โ€” but worth confirming we don't end up double-managing the site dir. +8. **Update notifier coverage.** `apps/cli/lib/update-notifier.ts:11` only checks `wp-studio` itself. Bumping DLA bundles a new CLI version, so the notifier's existing nudge ("update to wp-studio@1.x.y") implicitly covers DLA updates โ€” acceptable. + +--- + +## Sources + +- `issues/rsm-1639-dla-integration/findings/wave-1-dla-inventory.md` +- `issues/rsm-1639-dla-integration/findings/wave-1-studio-skill-plumbing.md` +- `issues/rsm-1639-dla-integration/findings/wave-1-claude-plugin-mechanics.md` +- `issues/rsm-1639-dla-integration/findings/wave-1-bundling-distribution.md` +- `issues/rsm-1639-dla-integration/research-plan.md` (running findings log) diff --git a/issues/rsm-3143-dla-pi-research/prior-art/rsm-3139-plan.md b/issues/rsm-3143-dla-pi-research/prior-art/rsm-3139-plan.md new file mode 100644 index 0000000000..f8889423f6 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/prior-art/rsm-3139-plan.md @@ -0,0 +1,256 @@ +# RSM-3139 implementation plan + +## Context the planner had to resolve + +The spec (`spec.md`) and the wave-1 findings were written against an older agent architecture: `@anthropic-ai/claude-agent-sdk` (with `mcpServers`, `plugins`, `canUseTool`, an `apps/cli/ai/agent.ts` entry, and an `apps/cli/ai/plugin/` directory). That stack has been **replaced on trunk** by `@mariozechner/pi-coding-agent` (commit `406b7494`, May 7 2026; see also `6bc92427` "Unify CLI agent on a single pi-based runtime"). On the worktree's `trunk`: + +- `apps/cli/ai/agent.ts` does **not** exist; the SDK call site is `runStudioAgentTurn` in `apps/cli/ai/runtimes/pi/index.ts`. +- The pi-coding-agent `createAgentSession` API has **no `mcpServers`, no `plugins`, no `canUseTool`** โ€” tools are registered as in-process `AgentTool` definitions via `customTools`. +- Skills live at `apps/cli/ai/skills//SKILL.md` (not `apps/cli/ai/plugin/skills/...`). The skill loader (`apps/cli/ai/skills.ts`) only parses `name` and `description` from frontmatter โ€” `user-invocable` / `user-invokable` are both no-ops. +- The vite `viteStaticCopy` target the spec calls "ai/plugin" is actually `ai/skills` in `dev.ts` and `npm.ts`. The latent gap in `vite.config.prod.ts` is real and worth fixing. + +This plan adapts the spec's **intent** (DLA reachable from `studio code` via `/migrate`, github dep, no vendor) to the current architecture. The specific implementation mechanics differ from the spec wherever the SDK API has changed โ€” call-outs are in the affected task. + +The planner did NOT redesign anything not strictly forced by the architectural drift. Anywhere a spec choice still applies verbatim (github dep + pinned SHA, tsx dep, the `delegate: true` handoff contract, the per-tool permission policy, the README and design-doc sections), it carries over unchanged. + +## Ordering and dependencies + +``` +T1 (vite prod fix, independent) T2 (deps) โ”€โ”€โ–บ T3 (DLA tool bridge + agent wiring + readAuthToken lift) + โ”‚ + โ”œโ”€โ”€โ–บ T4 (/migrate slash registration) + โ”œโ”€โ”€โ–บ T5 (migrate SKILL.md) + โ”œโ”€โ”€โ–บ T6 (permission policy) + โ””โ”€โ”€โ–บ T7 (docs) +``` + +- **T1** is independent of DLA and lands first; reviewable on its own. +- **T2** (deps) precedes any code that imports DLA. +- **T3** is the heart of the integration. It cannot meaningfully split further without leaving half-wired changes (the agent wiring, the DLA tool wrappers, and the token-lift in `commands/ai/index.ts` form one coherent change). The spec's ยง1 + ยง2 + ยง3 collapse into T3 here because the bridge layer replaces what was previously a single `mcpServers` config line. +- **T4โ€“T6** are parallel-safe after T3. +- **T7** runs last so it can describe the as-built code. + +## Tasks + +### T1 [code] Fix vite.config.prod.ts skills copy gap + +**What.** `apps/cli/vite.config.prod.ts` is missing the `viteStaticCopy({ targets: [{ src: 'ai/skills', dest: '.' }] })` plugin that `vite.config.dev.ts` and `vite.config.npm.ts` already have. Without it, the Electron-bundled `studio code` does not ship `dist/cli/skills/`, so the Skill tool finds no skills at runtime. The spec describes this gap as "ai/plugin" โ€” the current target name is `ai/skills`; the gap itself is unchanged. Add the copy target. Add a vitest covering the prod config exposes that copy target (or, if testing config is awkward, a small CI-friendly assertion test against the resolved plugins array). + +**Acceptance criteria.** +- `vite.config.prod.ts` adds a `viteStaticCopy` plugin targeting `ai/skills` โ†’ `.`, sitting alongside the existing node_modules copy and prune-php-wasm plugins (still gated on `existsSync(cliNodeModulesPath)` if appropriate, or unconditional if skills must always copy). +- After `npm run cli:build` with the prod config, `dist/cli/skills/` exists and contains the four existing skills (`annotate`, `taxonomist`, `need-for-speed`, `rank-me-up`). +- New vitest passes; existing vitest unchanged. + +**Files likely involved.** +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/vite.config.prod.ts` +- A new test file under `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/tests/` (or alongside the config). + +--- + +### T2 [code] Add data-liberation and tsx dependencies + +**What.** Add to `apps/cli/package.json` `dependencies`: +- `"data-liberation": "github:Automattic/data-liberation-agent#"` โ€” pinned to a concrete commit SHA, not `main` (Studio's install path uses `--no-package-lock` per spec). Implementer picks the SHA at write time from `Automattic/data-liberation-agent`'s default branch. +- `"tsx": "^4.19.0"` โ€” DLA ships TypeScript sources (`src/mcp-server.ts`) with no `prepare`/build script; we spawn it through `tsx` at runtime. + +Run `npm install` and commit the lockfile delta. Confirm `node_modules/data-liberation/` is populated, the postinstall (`playwright install chromium`) completes (or no-ops if Chromium is already present), and `node_modules/tsx/` resolves. No code uses these yet โ€” wiring happens in T3. + +**Acceptance criteria.** +- `apps/cli/package.json` `dependencies` has both entries, SHA is a concrete 40-char commit. +- `npm install` succeeds; `node_modules/data-liberation/package.json` exists; `require.resolve('tsx')` resolves. +- A simple smoke test (vitest) that imports nothing but asserts `createRequire(import.meta.url).resolve('data-liberation/package.json')` does not throw, locking in that DLA stays installable. +- No regressions to existing `npm test`. + +**Files likely involved.** +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/package.json` +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/package-lock.json` +- A new vitest under `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/ai/tests/`. + +--- + +### T3 [code] Bridge DLA MCP tools into the pi runtime and lift readAuthToken + +**What.** This task replaces what spec ยง2 described as a single `mcpServers: { 'data-liberation': { type: 'stdio', ... } }` config entry. pi-coding-agent has no `mcpServers` slot, so we wrap DLA's MCP server with an MCP **client** (using `@modelcontextprotocol/sdk`'s `Client` + `StdioClientTransport`) and surface each DLA tool as an in-process `AgentTool` whose `rawHandler` proxies a CallTool request to the client. + +Add `apps/cli/ai/runtimes/pi/dla-tools.ts` (new file) exporting: + +- `createDlaToolsBridge(opts: { wpcomAccessToken?: string; env: Record; })` โ€” opens a stdio child process running `process.execPath --import /src/mcp-server.ts`. Use `process.execPath` (matches `apps/cli/ai/browser-utils.ts` and `apps/cli/lib/daemon-client.ts` precedent โ€” critical for the Electron-bundled path). Resolve `dlaRoot` via `path.dirname(createRequire(import.meta.url).resolve('data-liberation/package.json'))`. Resolve the tsx loader via `createRequire(import.meta.url).resolve('tsx')`. Forward env: `STUDIO_WPCOM_TOKEN` (from `wpcomAccessToken`), plus `LIBERATION_TOKEN` / `SHOPIFY_ADMIN_TOKEN` etc. from `env`. +- The bridge does a `ListTools` once on startup and returns an `AgentTool[]` where each tool's `rawHandler` forwards to `CallTool` on the child. Name them `data-liberation__` so they read symmetrically with Studio's `mcp__studio__*` references in the SKILL.md (spec frontmatter writes `mcp__data-liberation__liberate_inspect` etc. โ€” pick a naming convention and apply it consistently in T5). +- Provide a `dispose()` returned alongside the tools so the runtime can shut down the child cleanly. + +Wire the bridge into `runStudioAgentTurn` in `apps/cli/ai/runtimes/pi/index.ts`: + +- Build the bridge before `createAgentSession`, prepend its tools to `buildAgentTools(...)`, and call `dispose()` in the existing `finally` cleanup in `runAgentSessionTurn`. +- Skip the bridge entirely when `isRemoteSite` is true โ€” DLA is only meaningful for local-Studio workflows, and the remote-site branch already constrains the tool set. + +Lift `readAuthToken` in `apps/cli/commands/ai/index.ts`: + +- Move the existing `const token = await readAuthToken()` out of the `if (site?.remote)` guard so `wpcomAccessToken` is populated whenever the user is logged in, regardless of active-site type. Add an inline comment citing DLA's need for the WPCOM token even when the active site is local. Pass `wpcomAccessToken` into `runStudioAgentTurn(...)` unconditionally (current call is at `apps/cli/commands/ai/index.ts:453-460` per the worktree at planning time โ€” recheck line numbers before editing). + +Tests (in the same task, no separate test task): + +- **DLA tools bridge unit test** โ€” mock `@modelcontextprotocol/sdk/client` and `StdioClientTransport`, assert the spawned child uses `process.execPath`, the `--import ` argument, the absolute path to `src/mcp-server.ts`, and that `env` includes `STUDIO_WPCOM_TOKEN` when a token is passed. Assert each returned `AgentTool` proxies a `CallTool` with the correct name + arguments and surfaces errors as `isError: true`. +- **`pi-runtime.test.ts` extension** โ€” assert the bridge is invoked once per turn, its tools are appended to the agent's tool list when `!isRemoteSite`, and `dispose()` runs on teardown even on error/abort paths. +- **`commands/ai/index.ts` token-lift test** โ€” extend or add to the existing tests in `apps/cli/tests/commands-ai-index.test.ts` (if present) to assert `wpcomAccessToken` is passed into `runStudioAgentTurn` for both local and remote site shapes. + +**Acceptance criteria.** +- New file `apps/cli/ai/runtimes/pi/dla-tools.ts` exists, exports `createDlaToolsBridge`, is fully typed, and passes lint. +- `runStudioAgentTurn` instantiates the bridge before `createAgentSession`, includes its tools in the agent's tool list for non-remote sites, and disposes the child in the `finally`. +- `readAuthToken` is called outside the `site?.remote` guard in `apps/cli/commands/ai/index.ts` with a clarifying comment; `wpcomAccessToken` is passed to `runStudioAgentTurn` regardless of site type. +- Vitest coverage exists for: bridge spawn args/env, tool name prefixing, error propagation, disposal on abort, token-lift behavior. +- `npm test` is green; `npm run typecheck` is green; modified files pass `npx eslint --fix`. + +**Files likely involved.** +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/ai/runtimes/pi/dla-tools.ts` (new) +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/ai/runtimes/pi/index.ts` +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/commands/ai/index.ts` (around lines 425โ€“460 at planning time) +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/ai/tests/pi-runtime.test.ts` +- New tests under `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/ai/runtimes/pi/tests/` and `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/tests/`. + +--- + +### T4 [code] Register the /migrate slash command + +**What.** Append a new entry to `AI_SKILL_COMMANDS` in `tools/common/ai/slash-commands.ts`: + +```ts +{ name: 'migrate', description: __( 'Migrate a site from a closed platform into Studio' ) }, +``` + +The description goes through `__()` from `@wordpress/i18n` (matches existing entries). Because `AI_CHAT_SLASH_COMMANDS` in `apps/cli/ai/slash-commands.ts` spreads `AI_SKILL_COMMANDS` at line 541, this single change auto-wires the autocomplete provider, the CLI dispatcher, and (via the same shared module) the Electron renderer composer and IPC dispatcher. + +Tests (same task): +- `tools/common/ai/slash-commands.test.ts` (or a new test if no file exists): assert `AI_SKILL_COMMANDS` includes a `migrate` entry with the expected name/description; assert `buildSkillInvocationPrompt('migrate')` returns the literal `Run the /migrate skill using the Skill tool.`. + +**Acceptance criteria.** +- One added entry in `AI_SKILL_COMMANDS`, no other changes. +- New (or updated) vitest passes. +- `/migrate` is selectable in `studio code` autocomplete after `npm run cli:build` (manual smoke; mentioned in the PR description but not a unit-test requirement). + +**Files likely involved.** +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/tools/common/ai/slash-commands.ts` +- A new or updated test under `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/tools/common/ai/` or `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/ai/tests/`. + +--- + +### T5 [code] Create the migrate skill (apps/cli/ai/skills/migrate/SKILL.md) + +**What.** Create `apps/cli/ai/skills/migrate/SKILL.md` (the actual skills directory; not `apps/cli/ai/plugin/skills/` as the spec describes). The file is a YAML-frontmatter Markdown file matching the loader at `apps/cli/ai/skills.ts:10-21`. + +Frontmatter โ€” the spec calls out `user-invocable: true` (with C) over the typo'd `user-invokable: true` (with K) found in existing Studio skills. The current loader **doesn't parse either field**, but the spec asks us to use the canonical `user-invocable` spelling. Honor that. Include: + +- `name: migrate` +- `description: ` +- `argument-hint: ` (informational; the loader doesn't read this either, but include it to match the spec for forward-compat with any future SDK parser). +- `allowed-tools:` โ€” informational at this loader version, but include the spec-listed allow-list verbatim so the model self-restricts (the body should also restate the contract in prose). Names should be: + - `data-liberation__liberate_inspect` + - `data-liberation__liberate_extract` + - `data-liberation__liberate_verify` + - `data-liberation__liberate_setup` + - `data-liberation__liberate_import` + - `mcp__studio__site_create`, `mcp__studio__site_list`, `mcp__studio__site_info`, `mcp__studio__wp_cli` + - `AskUserQuestion` + Use the prefix actually emitted by T3 โ€” if T3 names them `data-liberation__*`, use that; if T3 chose `mcp__data-liberation__*`, use that. **The implementer must mirror T3's choice.** +- Do NOT include `liberate_preview` / `liberate_preview_stop` โ€” Studio creates the site itself. + +Body sections in order, matching spec ยง5: + +1. **On Startup** โ€” short greeting in `taxonomist/SKILL.md`'s voice. +2. **Step 1: Identify the source** โ€” use `argument-hint` URL if present; else `AskUserQuestion`. +3. **Step 2: Inspect** โ€” call `liberate_inspect`, narrate detected platform + counts. +4. **Step 3: Confirm** โ€” `AskUserQuestion`; for Webflow/Shopify, ask for `LIBERATION_TOKEN` / `SHOPIFY_ADMIN_TOKEN` if not set. +5. **Step 4: Extract** โ€” call `liberate_extract`; narrate logging events. +6. **Step 5: Verify** โ€” call `liberate_verify`; surface quality issues; offer retry or proceed. +7. **Step 6: Setup (delegate)** โ€” call `liberate_setup` with `delegate: true`; receive a manifest. +8. **Step 7: Create the Studio site** โ€” derive a slug from the source domain; call `mcp__studio__site_create` with a blueprint inlining the WXR via `importWxr` (cite the DLA-inlines-WXR rationale from `findings/wave-1-dla-inventory.md` ยง9 โ€” the 120s WP-CLI IPC timeout dodge). +9. **Step 8: Import (delegate)** โ€” call `liberate_import` with `delegate: true`; receive `{ wxrFile, outputDir, mediaDir, productsCsv?, redirectMap, importAuthors }`. For products CSV, call `mcp__studio__wp_cli` with `wc product_importer ...`. +10. **Step 9: Wrap up** โ€” `AskUserQuestion`: open in browser? Show URL. + +Footer: explicit deferral of Approach E (`/migrate --headless`) per research. + +Tests (same task): +- `apps/cli/ai/tests/migrate-skill.test.ts` (new): load the skill via `findSkill('migrate')`, assert `name === 'migrate'`, description is non-empty, body contains expected step headers. Assert the file content includes the literal `user-invocable:` (with C) and does NOT include `user-invokable:` (with K) โ€” locks in the spelling per spec. + +**Acceptance criteria.** +- File at `apps/cli/ai/skills/migrate/SKILL.md` parses via the existing loader. +- `loadSkills()` returns a `migrate` entry; the Skill tool registers it in the `Available skills:` index automatically. +- Frontmatter spells `user-invocable` with C; test enforces this. +- Tool name references inside the body match T3's emitted names exactly. + +**Files likely involved.** +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/ai/skills/migrate/SKILL.md` (new) +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/ai/tests/migrate-skill.test.ts` (new) + +--- + +### T6 [code] DLA tool permission policy + +**What.** Spec ยง6 calls for `buildDlaCanUseTool()` wired into the SDK's `query()`. pi-coding-agent has no `canUseTool` callback. The closest hook is each tool's own handler running before the agent sees a result. Implement the policy as a wrapper applied inside the DLA-tools bridge (T3) โ€” the bridge already mediates every DLA tool call. + +Add `apps/cli/ai/runtimes/pi/dla-permissions.ts` exporting `applyDlaPermissionPolicy(rawTools, opts: { askUser?: AskUserHandler })` that returns a new `AgentTool[]` whose `rawHandler`s implement: + +- **Auto-approve (pass through):** `liberate_detect`, `_discover`, `_inspect`, `_status`, `_verify` (read-only). +- **Ask once per session, remember:** `_extract`, `_setup`, `_map_apis`, `_probe`. Use a `Set` captured in the closure for the current `startAiAgent` invocation. If the user denies, the rawHandler returns an `isError: true` content explaining the denial; the agent surfaces it back to the model. +- **Always ask UNLESS `args.delegate === true`:** `_import`. The `delegate: true` short-circuit is safe โ€” DLA returns a manifest; Studio drives the actual import via `wp_cli`. +- **Default deny for unknown DLA tools** (defensive): any DLA tool not on the auto/ask-once/import list returns `isError: true` with a clear "unknown DLA tool โ€” review and add to permission policy" message. +- **If `askUser` plumbing is absent** (e.g. in JSON-mode runs without an interactive UI), the ask-once / always-ask buckets deny with an explicit message: "tool requires confirmation; not wired into the auto classifier. Re-run interactively or pass `delegate: true` for `liberate_import`." + +Wire `applyDlaPermissionPolicy` between `createDlaToolsBridge` and the tool list returned to `buildAgentTools`. Pass `config.onAskUser` (already available โ€” see `apps/cli/ai/runtimes/pi/index.ts:412-414`) into the policy. + +Tests (same task): +- Cover each policy bucket: read-only auto-approve (rawHandler invokes underlying tool, no askUser call), ask-once memoization (second call to same `_extract` tool skips askUser), `delegate: true` short-circuit (no askUser, tool runs), `delegate: false` `_import` (askUser called; denial โ†’ isError), non-DLA passthrough (unaffected), unknown-DLA-tool defensive deny. + +**Acceptance criteria.** +- `dla-permissions.ts` exports `applyDlaPermissionPolicy` with the policy buckets above. +- The bridge in `apps/cli/ai/runtimes/pi/index.ts` calls it before exposing tools to the agent. +- Vitest covers all six policy bucket cases. +- All existing tests still pass. + +**Files likely involved.** +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/ai/runtimes/pi/dla-permissions.ts` (new) +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/ai/runtimes/pi/index.ts` +- New test under `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/ai/runtimes/pi/tests/`. + +--- + +### T7 [docs] Document DLA integration in README and design doc + +**What.** Two docs additions, per spec ยง8: + +**`apps/cli/README.md`** โ€” new "Migrate from a closed platform" section between "Studio Code" (line 83) and "Import and export" (line 101). Cover: +- The eight supported platforms (per `findings/wave-1-dla-inventory.md` ยง1). +- Two invocation forms: `/migrate https://example.com/foo` (inline) and `/migrate` then prompt. +- The detect โ†’ extract โ†’ verify โ†’ site-create โ†’ import flow at a high level. +- Optional `LIBERATION_TOKEN` (Webflow) and `SHOPIFY_ADMIN_TOKEN` (Shopify) env vars. +- One-line "Powered by [Data Liberation Agent](https://github.com/Automattic/data-liberation-agent)" credit. +- Add the new section to the table of contents block at lines 29โ€“30. + +**`docs/design-docs/cli.md`** โ€” new "Data Liberation Agent integration" section describing the as-built architecture: +- `github:` dep declaration with a pinned SHA, npm install path resolves the package as `data-liberation`. +- Plugin path resolution via `createRequire(import.meta.url).resolve('data-liberation/package.json')`. +- DLA's MCP server invoked through `process.execPath --import /src/mcp-server.ts`, wrapped by an in-process MCP client (`@modelcontextprotocol/sdk`'s `Client`) and surfaced as pi-coding-agent `AgentTool` wrappers. Cite the pi-coding-agent migration that motivated the bridge (rather than the spec's `mcpServers` slot, which doesn't exist). +- The `delegate: true` handoff contract: DLA returns artifacts; Studio creates the site and runs the import via `wp_cli`. +- The DLA permission policy (link to `dla-permissions.ts`). +- DLA update mechanics: one-line SHA bump in `apps/cli/package.json`. +- Reference `issues/rsm-3139-dla-github-dep/research-report.md` for the trade-off rationale (Approach A vs C vs E). + +No tests needed for a docs task. + +**Acceptance criteria.** +- Both files updated; TOC in README updated. +- Code snippets compile / would compile against the actual file paths (no fictional paths). +- Cross-references resolve (no dead anchors). + +**Files likely involved.** +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/apps/cli/README.md` +- `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3139-dla-github-dep/docs/design-docs/cli.md` + +--- + +## Out-of-scope reminders (carried from the spec / prompt) + +- No changes to `apps/studio/` (Electron). +- No `/migrate --headless` escape hatch (Approach E โ€” deferred). +- No clean-up of the `user-invokable` typo in existing Studio skills (orthogonal). +- No real-migration E2E test โ€” the human reviewer covers that. +- No DLA-upstream PR to add a `prepare` script (follow-up; we may drop `tsx` once that lands). diff --git a/issues/rsm-3143-dla-pi-research/prior-art/rsm-3139-spec.md b/issues/rsm-3143-dla-pi-research/prior-art/rsm-3139-spec.md new file mode 100644 index 0000000000..0c84bd1a02 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/prior-art/rsm-3139-spec.md @@ -0,0 +1,160 @@ +# RSM-3139 spec โ€” DLA integration via `github:` dep + +## Goal + +Make the Data Liberation Agent (DLA, `Automattic/data-liberation-agent`) available inside the `studio code` AI-agent CLI command as a v1 integration. When the user types `/migrate ` (or `/migrate` then provides a URL when asked), the agent walks through a phased workflow that calls DLA's MCP tools to inspect, extract, and verify content from a closed web platform, then hands artifacts off to Studio's `mcp__studio__site_create` and `mcp__studio__wp_cli` plumbing to produce a fresh local Studio site populated with the migrated content. + +Scope is `apps/cli/` only. Anything Electron-side (`apps/studio/`) is out of scope โ€” flag, don't fix. + +The research, evidence, full tradeoff analysis, and end-to-end UX walk live in `research-report.md` (preserved from RSM-1639). This spec describes only **what changes in this PR** to make Approach C work. + +## Why Approach C (npm dep) and not Approach A (vendor) + +A previous attempt (RSM-1675, PR #3277) shipped Approach A โ€” a build-time fetch script that cloned DLA into `apps/cli/ai/dla/`. That approach was correct given DLA was private at the time. DLA was made public on 2026-05-07, which makes Approach C (declared at research time as "blocked today") strictly simpler: + +- **Deleted from Approach A:** the fetch script, root `postinstall` plumbing for it, `apps/cli/ai/dla/` `.gitignore` entry, Vite `viteStaticCopy` targets for `ai/dla` (in all three configs), the `existsSync(dlaPath)` conditional in `agent.ts` and its tests for the missing-dir branch, the `GH_PAT` warning UX, the `STUDIO_REFRESH_DLA=1` opt-in, and the `fast-xml-parser` + `papaparse` direct deps (npm pulls them as DLA's transitive deps). +- **Kept conceptually from Approach A** (re-derived against the new mechanism โ€” code should be re-implemented from the spec, not copy-pasted from the closed PR): the wrapper `SKILL.md` at `apps/cli/ai/plugin/skills/migrate/SKILL.md`, the `canUseTool` per-tool permission policy in `apps/cli/ai/dla-permissions.ts`, the `/migrate` slash registration in `tools/common/ai/slash-commands.ts`, the README + design-doc additions. +- **Still independently valuable:** the `vite.config.prod.ts` plugin-copy gap fix. It's a pre-existing latent bug unrelated to DLA โ€” Studio's Electron-bundled CLI doesn't ship `dist/cli/plugin/` because `prod` is missing the `viteStaticCopy({ src: 'ai/plugin' })` target that `dev.ts` and `npm.ts` have. Fix it as part of this PR. + +## Concrete v1 design + +### 1. Dependency declaration + +Add to `apps/cli/package.json` `dependencies`: + +```json +"data-liberation": "github:Automattic/data-liberation-agent#", +"tsx": "^4.19.0" +``` + +- `data-liberation` is DLA's package name (per its `package.json`, not `data-liberation-agent`). +- Pin to a concrete commit SHA, **not** `main` โ€” Studio's npm install path uses `--no-package-lock`, so a tracking ref would drift between installs. The implementer picks the SHA at time of writing; this is a one-line bump later. +- `tsx` is needed because DLA ships TypeScript sources (`src/mcp-server.ts`) with no `prepare`/build step and `tsx` only in its `devDependencies`. We spawn its MCP server through `tsx` at runtime. ~5 MB cost; runtime perf is fine (tsx is esbuild-based JIT). +- DLA's `postinstall: playwright install chromium` runs (~150 MB Chromium download). Studio already ships Playwright via existing browser-MCP tools (`Auto-install Playwright Chromium for MCP tools` landed in the upstream tree), so for end users this is largely a sunk cost. Verify the install is idempotent and doesn't double-download. + +### 2. Plugin + MCP wiring in `apps/cli/ai/agent.ts` + +In `startAiAgent`: + +- Resolve the DLA root: `const dlaRoot = path.dirname(createRequire(import.meta.url).resolve('data-liberation/package.json'))`. (Use `node:module`'s `createRequire` because the file is ESM and `require.resolve` isn't directly available.) No `existsSync` conditional โ€” DLA is a hard dependency. +- Extend `mcpServers`: + ```ts + mcpServers: { + studio: ..., + 'data-liberation': { + type: 'stdio', + command: process.execPath, + args: [ + '--import', + createRequire(import.meta.url).resolve('tsx'), + path.join(dlaRoot, 'src/mcp-server.ts'), + ], + env: { + ...resolvedEnv, + STUDIO_WPCOM_TOKEN: wpcomAccessToken ?? '', + // LIBERATION_TOKEN / SHOPIFY_ADMIN_TOKEN forwarded via ...resolvedEnv + }, + }, + } + ``` + Use `process.execPath` (matches `apps/cli/ai/browser-utils.ts` and `apps/cli/lib/daemon-client.ts` precedent โ€” critical for the Electron-bundled path). Use absolute paths in `args` (the `McpStdioServerConfig` type has no `cwd` field; this was confirmed against `node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts` in the RSM-1675 attempt). +- Extend `plugins` to a two-element array: + ```ts + plugins: [ + { type: 'local', path: path.resolve(import.meta.dirname, 'plugin') }, + { type: 'local', path: dlaRoot }, + ], + ``` + +### 3. `readAuthToken` call-site change in `apps/cli/commands/ai/index.ts` + +The current code reads `readAuthToken()` only inside the `site?.remote` guard. DLA's MCP server may need the WPCOM token for tools targeting a remote WP site even when the active Studio site is local. Lift the `readAuthToken()` call outside the `site?.remote` guard so `wpcomAccessToken` is always populated when there's a logged-in user. Pass it into `startAiAgent` regardless of site type. Add a one-line code comment explaining why. + +### 4. `/migrate` slash command + +Append to `AI_SKILL_COMMANDS` in `tools/common/ai/slash-commands.ts`: +```ts +{ name: 'migrate', description: __('Migrate a site from a closed platform into Studio') }, +``` + +The description goes through `__()` from `@wordpress/i18n` (existing pattern in that file). This single change auto-wires the dispatcher in `apps/cli/commands/ai/index.ts`, the autocomplete provider in `apps/cli/ai/ui.ts`, Electron's IPC dispatcher in `apps/studio/src/ipc-handlers.ts` (no app-side change required โ€” the shared list is already consumed there), and the renderer composer. + +### 5. Wrapper SKILL.md + +Create `apps/cli/ai/plugin/skills/migrate/SKILL.md`. Frontmatter MUST use `user-invocable: true` with **C** โ€” not the `user-invokable` (with K) typo that Studio's existing skills have. The SDK's parser reads `user-invocable`; the existing typos only work because the default is `true`. + +Frontmatter fields: +- `name: migrate` +- `description: ...` (one line) +- `argument-hint: ` (lets `/migrate https://example.com/foo` accept inline arg) +- `allowed-tools:` โ€” listed precisely: + - `mcp__data-liberation__liberate_inspect` + - `mcp__data-liberation__liberate_extract` + - `mcp__data-liberation__liberate_verify` + - `mcp__data-liberation__liberate_setup` + - `mcp__data-liberation__liberate_import` + - `mcp__studio__site_create` + - `mcp__studio__site_list` + - `mcp__studio__site_info` + - `mcp__studio__wp_cli` + - `AskUserQuestion` + + Do **not** include `liberate_preview` / `liberate_preview_stop` โ€” Studio creates the site itself, not via DLA's preview path. + +Body sections (in order): +1. **On Startup** โ€” short greeting matching `apps/cli/ai/plugin/skills/taxonomist/SKILL.md`'s voice. +2. **Step 1: Identify the source.** Use `argument-hint` URL if present; otherwise `AskUserQuestion` for the source URL. +3. **Step 2: Inspect.** Call `mcp__data-liberation__liberate_inspect`. Narrate detected platform + content counts. +4. **Step 3: Confirm.** `AskUserQuestion` to confirm; for Webflow/Shopify, ask for `LIBERATION_TOKEN` / `SHOPIFY_ADMIN_TOKEN` if not already set. +5. **Step 4: Extract.** Call `mcp__data-liberation__liberate_extract`. Narrate `sendLoggingMessage` events as agent progress. +6. **Step 5: Verify.** Call `mcp__data-liberation__liberate_verify`. Surface quality issues; offer retry or proceed. +7. **Step 6: Setup (delegate).** Call `mcp__data-liberation__liberate_setup` with `delegate: true`. Receive a manifest of requirements. +8. **Step 7: Create the Studio site.** Derive a slug from the source domain. Call `mcp__studio__site_create` with a blueprint that inlines the WXR via `importWxr` (per `findings/wave-1-dla-inventory.md` ยง9 โ€” DLA inlines WXR into `blueprint.studio.json` to dodge the WP-CLI 120s IPC timeout). Explain the `importWxr` blueprint shape so the model can construct it correctly. +9. **Step 8: Import (delegate).** Call `mcp__data-liberation__liberate_import` with `delegate: true`. Receive `{ wxrFile, outputDir, mediaDir, productsCsv?, redirectMap, importAuthors }`. For products CSV (Shopify only), call `mcp__studio__wp_cli` with `wc product_importer ...`. +10. **Step 9: Wrap up.** `AskUserQuestion`: open in browser? Show URL. + +Footer: explicit deferral of Approach E (`/migrate --headless`) per research. + +### 6. `canUseTool` callback for DLA permission scoping + +Create `apps/cli/ai/dla-permissions.ts` exporting `buildDlaCanUseTool()`. Wire it into `query()` from `agent.ts`. Per-tool policy (research-report Open Question 2): + +- **Auto-approve (read-only):** `mcp__data-liberation__liberate_detect`, `_discover`, `_inspect`, `_status`, `_verify`. +- **Ask once per session, remember:** `mcp__data-liberation__liberate_extract`, `_setup`, `_map_apis`, `_probe`. Use a Set keyed by tool name captured in a closure for this `startAiAgent` invocation. +- **Always ask UNLESS `tool_input.delegate === true`:** `mcp__data-liberation__liberate_import`. The `delegate: true` short-circuit is safe โ€” DLA returns a manifest; Studio drives the actual import via `wp_cli`. +- **Pass through:** non-DLA tools (return `{ behavior: 'allow', updatedInput: input }`). +- **Default deny for unknown DLA tools** (defensive โ€” if DLA ships a new tool we haven't reviewed, don't silently auto-approve). +- **If `onAskUser`-style plumbing isn't available** at the call site for ask-once tools, deny with an explicit message ("tool requires confirmation, not wired into auto classifier โ€” set `permissionMode: 'default'` or pass `delegate: true` for `_import`"). Better than silent auto-approve. + +### 7. `vite.config.prod.ts` plugin-copy gap fix (independent) + +Add `viteStaticCopy({ targets: [{ src: 'ai/plugin', dest: '.' }] })` to `apps/cli/vite.config.prod.ts` โ€” `dev.ts` and `npm.ts` already have this. Without it, the Electron-bundled `studio code` doesn't ship `dist/cli/plugin/`, which means SDK plugin skills don't load in the desktop app. Land as a separate atomic commit so it's reviewable independently of DLA. + +### 8. Documentation + +- `apps/cli/README.md`: new "Migrate from a closed platform" section between "Studio Code" and "Import and export". Mention the eight supported platforms, the inline-URL and prompted-URL invocations, the detectโ†’extractโ†’verifyโ†’site-createโ†’import flow, the optional `LIBERATION_TOKEN` / `SHOPIFY_ADMIN_TOKEN` env vars. Brief "Powered by [Data Liberation Agent]" credit. Add ToC entry. +- `docs/design-docs/cli.md`: new "Data Liberation Agent integration" section describing the as-built architecture: `github:` dep declaration, plugin path resolution via `createRequire(...).resolve(...)`, MCP server invocation through `tsx` + `process.execPath`, the `delegate: true` handoff contract, the `canUseTool` permission policy, and DLA update mechanics (one-line SHA bump in `apps/cli/package.json`). Reference `research-report.md` for trade-off rationale. + +## Tests + +Each `[code]` task includes vitest coverage in the same task (no separate test tasks). Required coverage at minimum: + +- `agent.ts`: DLA MCP server is registered with correct command/args/env; plugins has length 2; `wpcomAccessToken` flows into `STUDIO_WPCOM_TOKEN`. +- `dla-permissions.ts`: each policy bucket (read-only auto-approve, ask-once memoization, `delegate: true` short-circuit, `delegate: false` ask, non-DLA passthrough, unknown-DLA deny). +- `tools/common/ai/slash-commands.ts`: `AI_SKILL_COMMANDS` contains the `migrate` entry; `buildSkillInvocationPrompt('migrate')` returns the expected literal. +- `migrate/SKILL.md`: frontmatter parses; `name === 'migrate'`; `user-invocable === true` (with C); assertion that `user-invokable` (with K) is NOT present (locks in the spelling). + +## Out of scope + +- Anything in `apps/studio/` (Electron desktop). +- A `/migrate --headless` escape hatch (Approach E โ€” explicitly deferred per research). +- Publishing DLA to npm or moving to a proper version-pinned dep (the user said "we can change that afterwards"). +- Cleaning up the `user-invokable` typo in Studio's existing skills (orthogonal cleanup; harmless because default is `true`). +- An end-to-end verification of `/migrate` against a real migration target. The implementer should boot `studio code`, confirm `/migrate` appears in autocomplete and the SKILL.md loads, and call out (in the PR description) that real-migration testing is the human reviewer's job. + +## Pre-merge gates (for the PR description) + +1. Real `cli:package` build verifies that DLA's tree, `node_modules/data-liberation/`, and `node_modules/tsx/` are shipped into the Electron extraResource bundle (`dist/cli/node_modules/`). +2. Real boot of `studio code` confirms `mcp__data-liberation__*` tools surface exactly once (no double-prefixing, no missing tools) โ€” verifies the Open Question 4 in the research report. +3. Bump the `data-liberation` SHA to the latest known-good before merge. +4. Optional: open a DLA-upstream PR adding a `prepare` script so we can drop `tsx` from Studio's runtime deps later. diff --git a/issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-bundling-distribution.md b/issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-bundling-distribution.md new file mode 100644 index 0000000000..a77bf865b4 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-bundling-distribution.md @@ -0,0 +1,193 @@ +--- +task: wave-1-bundling-distribution +wave: 1 +status: complete +--- + +# Wave 1 โ€” Studio CLI bundling and distribution constraints for adding DLA + +## TL;DR + +Studio CLI ships through **two distinct pipelines** from the same source tree: + +1. **npm publish (`wp-studio`)** โ€” a thin tarball containing only the bundled `dist/cli/` (no `node_modules`); deps are installed by user's `npm install`. `viteStaticCopy` plugin copies `apps/cli/ai/plugin/` verbatim into `dist/cli/plugin/`. +2. **Electron desktop bundle** โ€” a fat `dist/cli/` containing `node_modules/` materialized by `npm install --install-links`, used by Electron via `extraResource`. Same `apps/cli/ai/plugin/` static copy applies in dev/npm configs but **not** in `vite.config.prod.ts` (possible bug). + +Both pipelines load the SDK plugin tree from a hardcoded path (`apps/cli/ai/plugin/`) at runtime. The CLI already ships a 200+ MB platform-specific Claude binary via `optionalDependencies`, an in-process MCP server, a postinstall script that fetches third-party AI skills (`scripts/download-agent-skills.ts`), and per-platform binary pruning at packaging. **No documented size budget.** `wp-studio` cadence is **roughly weekly** (1.7.7 โ†’ 1.7.11 โ†’ 1.8.0 across ~6 weeks); Electron releases ride the same release branches. + +--- + +## 1. Build pipeline โ€” three Vite configs + +All three configs extend `vite.config.base.ts`, which: +- Six entry points: `main`, `process-manager-daemon`, `proxy-daemon`, `playground-server-child`, `php-server-child`, `reprint-child` โ€” all output as ESM `[name].mjs` into `dist/cli/` (`vite.config.base.ts:60-77`). +- Externalizes Node builtins and every key in `package.json` `dependencies` (lines 79-91). +- Aliases `cli` โ†’ `apps/cli` and `@studio/common` โ†’ `tools/common` (lines 100-107). +- `write-dist-extras` plugin writes `dist/cli/package.json = { "type": "module" }` and copies `apps/cli/php/`, `wp-files/` (from repo root), `apps/cli/lib/pull/reprint.phar` into `dist/cli/` (lines 36-55). + +| Config | Used by | `__IS_PACKAGED_FOR_NPM__` | Adds entry | Static-copies | Notes | +|---|---|---|---|---|---| +| `vite.config.dev.ts` | `npm run cli:build`, `cli:watch`. Used by Electron dev (`npm start`) and tests | `false` | `eval-runner.ts` (promptfoo) | `ai/plugin` โ†’ `dist/cli/plugin` | None | +| `vite.config.prod.ts` | `npm run cli:package` (called by Electron Forge `prePackage` hook) | inherits base default `false` | base entries only | `node_modules` โ†’ `dist/cli/node_modules` (only if `apps/cli/node_modules` exists), then `prune-php-wasm` plugin removes `@php-wasm/node-*/asyncify/` (~250 MB) | **Electron-bundled build**. Does NOT static-copy `ai/plugin/`. | +| `vite.config.npm.ts` | `npm run prepublishOnly` โ†’ published to npm as `wp-studio` | `true` | base entries only | `ai/plugin` โ†’ `dist/cli/plugin` | Adds `#!/usr/bin/env node` shebang to `main.mjs` | + +**Possible bug (flag, don't fix):** `vite.config.prod.ts` is missing a static-copy target for `ai/plugin`. Either Electron-bundled `studio code` skills don't work in production, or the plugin gets copied via a path I haven't traced. **Worth a 5-minute verification with a real `cli:package` run.** + +### `dist/cli/` layout estimate (configs, not measured here) + +I could not run `cli:build` (sandbox denied). Disk inspection of the hoisted root `node_modules/` and `wp-files/` gives the ~floor. + +**npm-pack layout (`vite.config.npm.ts` output)** โ€” small, no node_modules: +``` +dist/cli/ +โ”œโ”€โ”€ package.json ({ "type": "module" }) +โ”œโ”€โ”€ main.mjs (with #! shebang) +โ”œโ”€โ”€ *.mjs (Vite chunks + side bundles) +โ”œโ”€โ”€ reprint.phar +โ”œโ”€โ”€ php/ (apps/cli/php/) +โ”œโ”€โ”€ wp-files/ (~144 MB on disk) +โ”‚ โ”œโ”€โ”€ latest/ (~81 MB โ€” WordPress + plugins) +โ”‚ โ”œโ”€โ”€ phpmyadmin/ (~52 MB) +โ”‚ โ”œโ”€โ”€ sqlite-command/ (~2.8 MB) +โ”‚ โ”œโ”€โ”€ sqlite-database-integration/ (~868 KB) +โ”‚ โ”œโ”€โ”€ wp-cli/ (~6.8 MB) +โ”‚ โ””โ”€โ”€ skills/ (~212 KB โ€” downloaded from agent-skills repo) +โ””โ”€โ”€ plugin/ (~68 KB โ€” from apps/cli/ai/plugin/) + โ”œโ”€โ”€ .claude-plugin/plugin.json + โ””โ”€โ”€ skills/{annotate,need-for-speed,rank-me-up,site-spec,taxonomist}/SKILL.md +``` + +**Electron-bundled layout (`vite.config.prod.ts` output)** โ€” large, includes node_modules: +``` +dist/cli/ +โ”œโ”€โ”€ (everything above) + +โ””โ”€โ”€ node_modules/ (materialized via --install-links) + โ”œโ”€โ”€ @anthropic-ai/ + โ”‚ โ”œโ”€โ”€ claude-agent-sdk/ (~3.8 MB) + โ”‚ โ””โ”€โ”€ claude-agent-sdk-{platform}-{arch}/ (~200+ MB; the `claude` binary alone is 207 MB) + โ”œโ”€โ”€ @php-wasm/ (~460 MB total; -250 MB after asyncify prune) + โ”œโ”€โ”€ @wp-playground/ (~24 MB) + โ”œโ”€โ”€ playwright/, playwright-core/ (~14 MB) + โ””โ”€โ”€ ...other deps +``` + +After packaging, `forge.config.ts:182-218` further prunes per-platform binaries from `claude-agent-sdk/vendor/` and `koffi/build/koffi/` to keep the final installer smaller. + +**Sizes verified from `du -sh` against hoisted root `node_modules/`:** +- `claude-agent-sdk-darwin-arm64/claude` = **206,534,320 bytes** (exact) +- `node_modules/@anthropic-ai/`: 211 MB +- `node_modules/@php-wasm/`: 460 MB (across all versions; reduced by prune in prod) +- `node_modules/@wp-playground/`: 24 MB +- `node_modules/playwright + playwright-core`: ~14 MB +- repo `wp-files/`: 144 MB + +So bundled CLI inside Electron resources is roughly **~500 MB on disk before prune, ~250 MB after asyncify prune, plus 144 MB of `wp-files/`** โ€” call it **~400 MB total bundled CLI**. + +## 2. `apps/cli/ai/plugin/` bundling rules + +**Source tree** (`apps/cli/ai/plugin/`): +- `.claude-plugin/plugin.json` โ€” Claude Agent SDK plugin manifest +- `skills//SKILL.md` โ€” frontmatter-based skill definition +- `skills//scripts/*.php` โ€” referenced by skill (e.g. taxonomist) + +**Copy rule:** Both `vite.config.dev.ts` (line 11-15) and `vite.config.npm.ts` (line 11-15): +```ts +viteStaticCopy({ targets: [ { src: 'ai/plugin', dest: '.' } ] }) +``` +Copies recursively into `dist/cli/plugin/`. Whole directory is the unit. **Any new file (skill, script, sub-folder) is picked up automatically โ€” no allowlist.** + +**Runtime load:** `apps/cli/ai/agent.ts:149` โ†’ `path.resolve(import.meta.dirname, 'plugin')`. At runtime in `dist/cli/`, that's `dist/cli/plugin`. SDK reads the directory and registers all skills under `skills/`. + +**Slash-command coupling:** New `SKILL.md` files are auto-loaded by the SDK, but they only appear as user-typed `/foo` commands if also added to `tools/common/ai/slash-commands.ts:8-13` `AI_SKILL_COMMANDS`. Evidence: `site-spec` is in the plugin tree but **not** in `AI_SKILL_COMMANDS` (only `annotate`, `taxonomist`, `need-for-speed`, `rank-me-up` are). + +## 3. Delivery-model matrix + +| Aspect | Vendor (copy DLA into `apps/cli/ai/plugin/dla/`) | npm dependency | Runtime install / fetch | +|---|---|---|---| +| **Install size impact (npm `wp-studio`)** | DLA's tree added to npm tarball verbatim. ~10s of KB to a few MB if "Claude plugin + markdown skills + small Node helpers"; several MB to hundreds of MB if MCP server bundles its own node_modules. | Adds DLA as external dep. End-user `npm install -g wp-studio` pulls DLA + transitive deps. | Zero impact on tarball. First-run penalty: must `npm install` or download tarball. | +| **Install size impact (Electron bundle)** | DLA tree included in `apps/cli/dist/cli/plugin/dla/`, ASAR-unpacked. Linear add. | DLA's `node_modules` materialized by `install:bundle --install-links`. Per-platform binary pruning rules apply. | Zero impact on installer. First-run requires network โ€” **regression** for Studio's "just works offline once installed" posture. | +| **Update story** | Updates only when Studio CLI cuts a release. **Stale by 0โ€“6 weeks.** Manual sync or a `download-dla.ts` postinstall script (mirrors `scripts/download-agent-skills.ts`). | Semver-controlled. Ride next CLI release. | DLA can be updated independently โ€” runtime fetches latest on demand or with TTL. **Best decoupling, worst trust/security story.** | +| **Version pinning** | Pinned by SHA/tag in vendoring script. Reproducible. | Pinned by `package.json` semver. **Lockfile not used for npm install path** (`apps/cli/package.json:63` uses `--no-package-lock`), so tilde/caret can drift. | Whatever the runtime decides. | +| **Offline behavior** | Works fully offline. Same posture as `wp-files/latest/` (WordPress is bundled). | Works offline once installed. | Breaks offline first-run. | +| **Mid-migration on reinstall** | User `npm install -g wp-studio@x` mid-migration replaces DLA's bundled tree. Migration runtime state in user-data dirs persists or doesn't depending on layout. | Same as vendor. | Worst risk: aborted partial download. Needs explicit "atomic install + verify" logic. | +| **Complexity** | Lowest if DLA is a static plugin (markdown + scripts). Highest if DLA has compiled JS โ€” needs vendoring/build pipeline mirroring `download-agent-skills.ts`. | Medium. Requires DLA published to a registry. Fits cleanly into `install:bundle`. | High. Network code, retry/cache, version negotiation, security review. | +| **Auth / secrets handoff** | Inherits Studio's resolved env directly (DLA loaded as plugin in same process). | Same. | Subprocess: must pass env explicitly. | +| **Precedents in this repo** | `apps/cli/ai/plugin/skills/*` (existing skills are vendored). | `@anthropic-ai/claude-agent-sdk` itself (~206 MB binary via optionalDependencies). | `scripts/download-agent-skills.ts` fetches `WordPress/agent-skills` repo zip into `wp-files/skills/` at root postinstall; `scripts/download-php-binary.ts`, `download-wp-server-files.ts`, `download-language-packs.ts` โ€” team is comfortable with build-time fetches. | + +## 4. Auth handoff + +**Relevant flow:** +1. `apps/cli/commands/ai/index.ts:429-431` โ†’ `resolveAiEnvironment(currentProvider, { sessionId })` returns env shaped for `ANTHROPIC_*` vars. +2. Same env passed to `startAiAgent({ env, ... })` (lines 464-472), forwarded to `query({ options: { env: resolvedEnv, ... } })` (`agent.ts:67, 130-153`). SDK spawns the `claude` binary with this env. +3. Two providers (`apps/cli/ai/providers.ts:99-163`) shape env differently: + - `wpcom`: `ANTHROPIC_BASE_URL=`, `ANTHROPIC_AUTH_TOKEN=`, custom headers, `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`, `CLAUDE_CODE_MAX_RETRIES=0`. + - `anthropic-api-key`: `ANTHROPIC_API_KEY=`. +4. WPCOM token from `readAuthToken()` (`@studio/common/lib/shared-config`). + +**Cleanest hooks for forwarding to a DLA child plugin/server:** + +| DLA delivery | Anthropic creds | WPCOM creds | +|---|---|---| +| In-process Claude plugin | Inherited by SDK child process automatically | Add `env.STUDIO_WPCOM_TOKEN = (await readAuthToken())?.accessToken` in `providers.ts` `createBaseEnvironment` or per-provider `resolveEnv` | +| Stdio MCP server | Pass `env.ANTHROPIC_*` in `mcpServers.dla.env` (constructed from `resolveAiEnvironment`) | Pass via `mcpServers.dla.env.STUDIO_WPCOM_TOKEN` | +| Child-process CLI | Pass via `child_process.spawn`'s `env` argument | Same | + +**Cross-cutting note (sessions, q11):** `apps/cli/ai/sessions/recorder.ts:112-118` records the raw `SDKMessage` with no per-tool awareness; `apps/cli/ai/sessions/replay.ts:50-53` re-emits via `ui.handleMessage`. Third-party plugin tool calls and results are persisted and replayed verbatim โ€” **no schema changes needed**. The only place tool names are normalized is `apps/cli/ai/eval-runner.ts:27-29` (strips `mcp__studio__` prefix for cleaner eval output). Flag (don't solve): UI's tool-name rendering may need a tweak so unknown plugin tools render gracefully. + +## 5. Cadence and staleness + +**npm `wp-studio` cadence:** + +- 1.7.4 โ†’ 2026-02-17 +- 1.7.5 โ†’ 2026-02-26 / 2026-03-06 +- 1.7.6 โ†’ 2026-03-12 to 2026-03-16 +- 1.7.7 โ†’ 2026-03-24 to 2026-03-27 +- 1.7.8 โ†’ 2026-04-09 to 2026-04-13 +- 1.7.9 npm โ†’ 2026-04-17 +- 1.7.10 npm โ†’ 2026-04-21 +- 1.7.11 npm โ†’ 2026-04-23 +- 1.8.0 โ†’ 2026-04-23 to 2026-04-27 (current `package.json` version) + +**Roughly weekly** for `wp-studio` npm in steady state. 167 commits to `apps/cli/` in the last 6 weeks. + +**Electron Studio cadence:** `release/1.x.y` branch pattern is shared between npm CLI and Electron Studio. Electron picks up the bundled CLI from `apps/cli/dist/cli/` at make-time. Electron shipping speed = CLI shipping speed in worst case. + +**What "stale DLA bundled inside Studio" looks like:** +- **Vendor / npm dep**: typically **1โ€“2 weeks behind** in steady state; up to **6 weeks** if a major release is pending. +- **Runtime install / fetch**: DLA can be at HEAD whenever the user runs `/migrate`. + +**`setupUpdateNotifier` covers plugin/skill updates?** **No.** Per `apps/cli/lib/update-notifier.ts:11` (`NPM_REGISTRY_URL = 'https://registry.npmjs.org/wp-studio/latest'`), the notifier only checks `wp-studio`'s own version. It does not introspect bundled plugins, MCP servers, or skills. + +## 6. Electron-side flags (do not fix; just record) + +1. **Per-platform native binaries must prune cleanly.** `forge.config.ts:182-218` already prunes `@anthropic-ai/claude-agent-sdk/vendor///` and `koffi/build/koffi//`. If DLA brings a new package with platform-specific natives, prune logic needs updating. +2. **Code-signing on Windows refuses non-PE `.node` files.** `scripts/remove-fs-ext-other-platform-binaries.mjs` documents this. +3. **macOS `osxSign` entitlements** configured for `bin/node` only. +4. **ASAR unpacking.** CLI ships via `extraResource` (out of ASAR) at `forge.config.ts:18-22`. Read-write at runtime. +5. **`AutoUnpackNativesPlugin`** handles native-modules-in-ASAR for Electron app, not extraResource. +6. **Linux DEB / Windows MSIX / macOS DMG** all ship the same `extraResource`. Runtime-fetch approach behaves the same on all three but firewall posture varies. +7. **`cli:package` mutates `apps/cli/node_modules/`.** Per `forge.config.ts:172-174`: "may need to rerun `npm ci` from the repo root to reset the dependency tree after packaging." Adding DLA via `install:bundle` participates in this destructive flow. + +## Notes on what I could not verify + +- **`npm run cli:build` denied** by sandbox โ€” no fresh `du -sh apps/cli/dist/cli/` numbers. +- **Published `wp-studio` tarball size** (`dist.unpackedSize`, `dist.fileCount`) โ€” WebFetch denied. +- **`vite.config.prod.ts` not copying `ai/plugin/`** โ€” flagged in section 1; needs a real prod build to confirm. + +## Files referenced + +- `apps/cli/package.json` (lines 12-17 files, 60-67 build scripts, 4 version) +- `apps/cli/vite.config.{base,dev,prod,npm}.ts` +- `apps/cli/scripts/postinstall-npm.mjs` +- `apps/cli/index.ts`, `apps/cli/ai/{agent,providers,auth,eval-runner,slash-commands}.ts` +- `apps/cli/ai/sessions/{recorder,replay}.ts` +- `apps/cli/ai/plugin/.claude-plugin/plugin.json` +- `apps/cli/commands/ai/index.ts:429-472` +- `apps/cli/lib/update-notifier.ts` +- `apps/cli/lib/dependency-management/paths.ts` +- `apps/studio/forge.config.ts:18-22, 25-36, 143, 171-227`, `electron.vite.config.ts` +- `tools/common/ai/slash-commands.ts:8-13` +- `scripts/{download-agent-skills.ts, remove-fs-ext-other-platform-binaries.mjs}` +- `package.json:33` +- `docs/design-docs/cli.md` +- `node_modules/@anthropic-ai/claude-agent-sdk/package.json:57-66` diff --git a/issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-claude-plugin-mechanics.md b/issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-claude-plugin-mechanics.md new file mode 100644 index 0000000000..02564f4734 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-claude-plugin-mechanics.md @@ -0,0 +1,234 @@ +--- +task: wave-1-claude-plugin-mechanics +wave: 1 +status: complete +--- + +# Wave 1 โ€” Claude Agent SDK plugin / MCP / skill loading semantics + +Investigation scope: `@anthropic-ai/claude-agent-sdk@0.2.117` as installed in this worktree's `node_modules`. The wrapper bundles a native Claude Code 2.1.117 binary where the actual plugin/skill/MCP loaders live. + +## 0. Architecture sanity check + +`@anthropic-ai/claude-agent-sdk` is a thin TypeScript IPC wrapper that **spawns the bundled `claude` Mach-O/PE binary as a child process** and exchanges JSON-RPC over stdio. Plugins, skills, MCP, and slash-commands are loaded *inside that subprocess*. + +- `version: "0.2.117"`, `claudeCodeVersion: "2.1.117"` โ€” SDK pin and Claude Code engine pin move in lockstep (`package.json:4,81`). +- Binary lives under platform-specific `optionalDependencies` like `@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.117` (`package.json:57-66`). On this machine: 197 MB Mach-O arm64 executable. +- **Implication: upgrading the SDK upgrades the embedded Claude Code engine.** + +## 1. Type signatures (verbatim) + +### `Options.plugins` +```ts +plugins?: SdkPluginConfig[]; // sdk.d.ts:1442 +``` + +### `SdkPluginConfig` +```ts +// sdk.d.ts:2870-2882 +export declare type SdkPluginConfig = { + type: 'local'; // โ† the only variant supported at query() time + path: string; +}; +``` + +**`type: 'local'` is the only `query()`-time plugin variant.** Marketplace, npm, github, git, pip, and url-source plugins exist (settings schema below) but they're loaded out-of-band via `enabledPlugins` in user/project `settings.json`, not via the `plugins` array passed to `query()`. + +### `Options.mcpServers` +```ts +mcpServers?: Record; // sdk.d.ts:1386 +``` + +### `McpServerConfig` and variants +```ts +// sdk.d.ts:917-922 +export declare type McpServerConfig = McpStdioServerConfig | McpSSEServerConfig | McpHttpServerConfig | McpSdkServerConfigWithInstance; + +// sdk.d.ts:1005-1010 +export declare type McpStdioServerConfig = { + type?: 'stdio'; + command: string; + args?: string[]; + env?: Record; +}; + +// sdk.d.ts:998-1003 +export declare type McpSSEServerConfig = { + type: 'sse'; + url: string; + headers?: Record; + tools?: McpServerToolPolicy[]; +}; + +// sdk.d.ts:897-902 +export declare type McpHttpServerConfig = { + type: 'http'; + url: string; + headers?: Record; + tools?: McpServerToolPolicy[]; +}; + +// sdk.d.ts:904-915 +export declare type McpSdkServerConfigWithInstance = { + type: 'sdk'; + name: string; + instance: McpServer; +}; +``` + +`McpStdioServerConfig.type` is **optional** (so omitting it is valid). Studio's `mcpServers: { studio: createStudioTools(...) }` works because the in-process variant uses `type: 'sdk'` internally. + +### `createSdkMcpServer` and `tool` helper +```ts +// sdk.d.ts:418-424 +export declare function createSdkMcpServer(_options: CreateSdkMcpServerOptions): McpSdkServerConfigWithInstance; + +declare type CreateSdkMcpServerOptions = { + name: string; + version?: string; + tools?: Array>; +}; + +// sdk.d.ts:5036-5040 +export declare function tool(_name: string, _description: string, _inputSchema: Schema, _handler, _extras?: { annotations?, searchHint?, alwaysLoad? }): SdkMcpToolDefinition; +``` + +### `Options.permissionMode` +```ts +// sdk.d.ts:1697 +export declare type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'dontAsk' | 'auto'; +``` +Inline doc (`sdk.d.ts:1694-1697`): `'auto'` โ€” Use a model classifier to approve/deny permission prompts. + +## 2. Plugin layout contract + +The SDK type system doesn't describe the on-disk layout โ€” that's enforced by the bundled `claude` binary. Evidence comes from grepping the binary's strings. + +**Required:** +- `/.claude-plugin/plugin.json` โ€” the plugin manifest. + +**Optional surfaces** (from binary strings): +- `skills//SKILL.md` +- `commands/` +- `agents/` +- `hooks/` +- `prompts/` +- `output-styles/` +- `.mcp.json` โ€” sibling MCP-server config + +A bare `skills/` directory **without `plugin.json` is not enough** at the `query({ plugins: ... })` layer. Studio's plugin ships `apps/cli/ai/plugin/.claude-plugin/plugin.json` containing only `name`, `description`, `version` โ€” those three fields are sufficient in practice. + +**Reload behavior:** `Query.reloadPlugins()` exists (`sdk.d.ts:1977-1983`) โ€” plugin file changes are NOT auto-watched but the host can trigger a reload programmatically. **Studio Code does NOT call `reloadPlugins()`** (`agent.ts:130-153`), so a live DLA update would require either a host-side change or a CLI restart. + +## 3. Skill contract + +### Frontmatter fields (decoded from binary) + +The bundled `claude` binary's skill-frontmatter parser was extracted. Recognized SKILL.md frontmatter keys: + +| Field | Type | Notes | +|---|---|---| +| `name` | string | falls back to directory name | +| `description` | string | **required-ish** โ€” binary error: `is missing required 'description' in frontmatter`; loads with empty metadata if missing | +| `user-invocable` | boolean | default `true` (note kebab spelling, with **C**) | +| `allowed-tools` | string \| string[] | filter | +| `argument-hint` | string | shown in autocomplete | +| `arguments` | parsed list | argument names | +| `when_to_use` | string | natural-language trigger description | +| `version` | string | semver-ish | +| `model` | string | model alias or `'inherit'` | +| `disable-model-invocation` | boolean | hides skill from model but keeps it user-callable | +| `hooks` | object | validated against zod hook schema | +| `context` | `'fork'` | run in forked subagent | +| `agent` | string | agent type | +| `effort` | enum or int | reasoning effort | +| `shell` | string | bash/powershell | +| `paths` | string \| string[] | glob(s) for path-based auto-trigger | +| `created_by` / `improved_by` | string | provenance tag | + +**โš ๏ธ Studio gotcha:** Studio's existing skills use `user-invokable: true` (with **K**). The SDK reads `user-invocable` (with **C**). Default is `true` so the typo is invisible โ€” but if any Studio skill ever needs `user-invocable: false`, it would silently fail. + +### Skill invocation mechanism + +- Skills appear in the system prompt as a listing. +- "Skill" is a **built-in tool of the Claude Code preset**, not something a plugin injects. Implicit when `tools: { type: 'preset', preset: 'claude_code' }` (Studio's setup, `agent.ts:142`). +- Invoked with `{ skill: '' }` argument. Studio's `buildSkillInvocationPrompt(name)` returns the literal text "Run the / skill using the Skill tool." โ€” nudges the model in plain English. +- Skills can also be force-loaded via `Options.agents[].skills: string[]` or `SDKControlInitializeRequest.skills: string[]`. + +### Skill bundling its own MCP / commands + +A skill is just a markdown file in a plugin's `skills//` directory; it can ship sibling assets (e.g. taxonomist's `scripts/*.php`). Skills do NOT have their own MCP-server slot โ€” MCP servers live one level up at the plugin level (`.claude-plugin/plugin.json#mcpServers` or `.mcp.json`). Skills can refer to their own scripts via the runtime-exposed `Base directory for this skill: ` and use `allowed-tools` to whitelist `mcp____*` patterns. + +## 4. MCP transport variants + +| Variant | Type | Discriminator | Example | +|---|---|---|---| +| In-process SDK server | `McpSdkServerConfigWithInstance` | `type: 'sdk'` | `mcpServers: { studio: createSdkMcpServer({ name: 'studio', tools }) }` | +| Local stdio child process | `McpStdioServerConfig` | `type: 'stdio'` (optional) | `mcpServers: { fs: { command: 'node', args: ['./mcp-fs.js'], env: {...} } }` | +| Server-Sent Events | `McpSSEServerConfig` | `type: 'sse'` | `mcpServers: { remote: { type: 'sse', url: '...', headers, tools } }` | +| HTTP | `McpHttpServerConfig` | `type: 'http'` | `mcpServers: { api: { type: 'http', url: '...', headers, tools } }` | + +(A fifth variant `McpClaudeAIProxyServerConfig` exists but is reserved for managed config.) `McpServerToolPolicy` (`sdk.d.ts:975-978`) lets remote-server configs declare per-tool default permissions. + +## 5. Multi-plugin behavior + +### Tool-name namespacing + +MCP tools are exposed as **`mcp____`** (binary regex `"name":"mcp__([^"]+?)__([^"]+)"`; concrete examples: `mcp__playwright__*`, `mcp__slack__slack_read_thread`). + +For plugin-bundled MCP servers: server name is namespaced at MCP-load time. **Exact rule (e.g. `/` vs ``) is unverified** โ€” the SDK Settings type uses `plugin-id@marketplace-id` as plugin identity (`sdk.d.ts:3884`); error messages reference `plugin@marketplace format`. Worth confirming during integration. + +### Multiple plugins + +`Options.plugins: SdkPluginConfig[]` accepts an array โ€” multiple `type: 'local'` plugins can be loaded. Binary string evidence shows the SDK detects cross-source collisions (`has both plugin.json and marketplace manifest entries for ...`). Plugin-vs-plugin name collision behavior is not explicitly documented; needs verification. + +### Merge order for MCP servers + +Top-level config layering (from `PermissionUpdateDestination` at `sdk.d.ts:1767`): +- `userSettings` โ†’ `projectSettings` โ†’ `localSettings` โ†’ `policySettings` โ†’ `plugin` โ†’ `flagSettings` (passed to `query()`). + +For MCP specifically: `enabledMcpjsonServers` / `disabledMcpjsonServers` (`sdk.d.ts:3617-3623`) gate `.mcp.json` (project) servers by name; `allowedMcpServers` / `deniedMcpServers` (`sdk.d.ts:3633-3667`) are an enterprise allowlist/denylist where **denylist wins**. In `query()`, `mcpServers` is the "flagSettings" layer and shallow-merges over file sources. **Conflict on the same key**: safe assumption based on shallow-merge is "host's `mcpServers` argument wins over plugin-declared servers of the same name" โ€” but flag this as unverified. + +## 6. Permissions interaction with `permissionMode: 'auto'` + +Studio runs `permissionMode: 'auto'` (`agent.ts:143`). Each tool call builds an `SDKControlPermissionRequest` (`sdk.d.ts:2503-2523`); a small classifier model gives a per-call yes/no based on tool name/input. Can escalate (require confirmation) on `safetyCheck` (e.g. dangerous `rm`, sensitive paths). Compound bash commands evaluate each sub-command. + +**Implication for a third-party plugin:** A plugin's tools (built-in `Bash`/`Read`/`Write`/`Edit` it inherits, or its own `mcp____` entries) go through the same auto classifier. **There is no per-plugin permission scope: loading a plugin grants its skills/agents access to whatever tool list is in effect for the session.** Plugins can self-restrict via: +- Skill-level `allowed-tools` frontmatter +- `AgentDefinition.tools` / `disallowedTools` if the plugin ships a sub-agent (`sdk.d.ts:44-50`) +- `McpServerToolPolicy.permission_policy: 'always_allow' | 'always_ask' | 'always_deny'` for HTTP/SSE entries + +There is **no host-side mechanism to restrict a plugin to "only its own tools"** other than baking `disallowedTools` into the plugin's agents, or using `canUseTool` callback (`sdk.d.ts:1142-1145`). Studio currently uses neither. + +Auxiliary settings: +- `disableSkillShellExecution` (`sdk.d.ts:3833-3835`) โ€” managed setting that replaces inline `!`-shell blocks. +- `strictPluginOnlyCustomization` (`sdk.d.ts:3863`) โ€” managed setting that *blocks* user/project skills/hooks/MCP and forces customizations to come only from approved plugins. + +## 7. Compatibility risks against Studio's pinned 0.2.117 + +Studio pins `@anthropic-ai/claude-agent-sdk@^0.2.117` (caret = `0.2.x` minor range). + +1. **`type: 'local'` is the only `Options.plugins` variant.** If DLA expects to be loaded by name (`{ type: 'npm', package: '...' }`) at the SDK API layer, won't work. Marketplace/npm/git plugins flow through user/project `settings.json#enabledPlugins`, not the SDK options. + +2. **`PermissionMode: 'auto'` exists in 0.2.117** but DLA's plugin or skills can't override the host's session `permissionMode` from inside; only `acceptEdits`/`plan` etc. can be set per-subagent (`AgentDefinition.permissionMode`). + +3. **Manifest cross-source conflict gate** โ€” binary string: `has both plugin.json and marketplace manifest entries for commands/agents/skills/hooks/outputStyles. This is a conflict.` If DLA ships with both `plugin.json#skills` (declarative listing) and a `skills/` directory, behavior depends on `strict: true` flag. + +4. **Skill frontmatter spelling:** parser reads `user-invocable`. Studio's skills use `user-invokable`. DLA should use `user-invocable` to be safe. + +5. **In-process MCP (`type: 'sdk'`)** requires the host to construct the server with `createSdkMcpServer` from the *same* SDK version. `McpSdkServerConfigWithInstance.instance` is a live object referencing `@modelcontextprotocol/sdk/server/mcp.js`. **If DLA depends on a different SDK version and exports a pre-built `McpSdkServerConfigWithInstance`, DO NOT pass it to Studio's `query()`.** Stdio/HTTP/SSE transports are immune to this because they don't share an in-process object graph. + +6. **Plugin reload is opt-in, not file-watched** โ€” Studio doesn't call `reloadPlugins()`. A user who installs/updates DLA mid-session won't see changes until next `studio code` invocation. + +## Sources + +- `node_modules/@anthropic-ai/claude-agent-sdk/package.json` (lines 4, 30-32, 50-66, 81) +- `node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts` (lines as cited inline) +- `node_modules/@anthropic-ai/claude-agent-sdk/bridge.d.ts`, `manifest.json` +- `node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude` (Mach-O binary; relevant strings extracted) +- `apps/cli/ai/agent.ts:3, 80-84, 130-153` +- `apps/cli/ai/plugin/.claude-plugin/plugin.json` (full) +- `apps/cli/ai/plugin/skills/annotate/SKILL.md:1-5` (production frontmatter with `user-invokable` typo) +- `tools/common/ai/slash-commands.ts:1-17` + +**Not consulted (denied in sandbox):** `docs.claude.com/en/docs/claude-code/{plugins-reference,skills,skills-reference,sdk/sdk-mcp}` and the SDK GitHub repo's CHANGELOG. Items marked "needs verification" should be confirmed there before relying on them. diff --git a/issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-dla-inventory.md b/issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-dla-inventory.md new file mode 100644 index 0000000000..407155a29e --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-dla-inventory.md @@ -0,0 +1,238 @@ +--- +task: wave-1-dla-inventory +wave: 1 +status: complete +--- + +# Wave 1 โ€” DLA Inventory + +## 1. Elevator pitch + +**`Automattic/data-liberation-agent` is a Node/TypeScript toolkit that extracts content from eight closed web platforms (GoDaddy Websites & Marketing, Hostinger, HubSpot, Shopify, Squarespace, Webflow, Weebly, Wix) and produces a WordPress-compatible WXR file plus a media directory, redirect map, and (for e-commerce sites) a WooCommerce-format products CSV.** It targets users who want to leave a closed platform but cannot get a clean export, and bills itself as a companion to WordPress.com's $4/mo Personal plan with MCP write-access. It is *not* an LLM agent itself โ€” the extraction code is deterministic Node โ€” but it is shipped pre-wired as a Claude Code plugin / Codex plugin / Gemini extension / generic MCP server / standalone CLI, plus standalone "prompts" you can paste into any AI assistant. (`README.md:1-31`, `AGENTS.md:1-9`) + +## 2. Surfaces table + +| Surface | Where it's defined | How a host invokes it | Recommended? | +|---|---|---|---| +| **Claude Code plugin / marketplace** | `.claude-plugin/plugin.json` (manifest), `.claude-plugin/marketplace.json` (marketplace), `.mcp.json` (server) | `claude plugin marketplace add Automattic/data-liberation-agent && claude plugin install data-liberation`, or `claude --add-plugin .` from a checkout. Plugin contributes the MCP server (`data-liberation`) and the `skills/` directory. | **Yes โ€” README's first install path** (`README.md:32-46`) | +| **Codex plugin** | `.codex-plugin/plugin.json` โ€” points at `./skills/` and `./.mcp.json` | `cd data-liberation-agent && codex` โ€” the manifests register the MCP server and skills automatically. | Listed equal-rank in README (`README.md:56-63`) | +| **Gemini extension** | `gemini-extension.json` โ€” declares `contextFileName: GEMINI.md`, `skills: ./skills/`, and the MCP server inline (`npx tsx ${extensionPath}${/}src${/}mcp-server.ts`) | `cd data-liberation-agent && gemini extension link .` | Listed equal-rank (`README.md:48-54`) | +| **MCP server (any client)** | `src/mcp-server.ts` (stdio transport, `@modelcontextprotocol/sdk` `Server` + `StdioServerTransport`) | `npx tsx src/mcp-server.ts` or `npm run mcp`. Same binary `.mcp.json` points at โ€” `npx tsx ${CLAUDE_PLUGIN_ROOT}/src/mcp-server.ts`. | "Any MCP client" path in README (`README.md:65-75`) | +| **CLI** | `src/cli.ts` (the real CLI; ~177 lines of arg routing into `ui/*.tsx` Ink screens). `cli.js` at repo root is a separate **interactive bootstrap shell** (browser detection + cookie/profile probing) that ultimately runs the same flows. `start.sh` is the curl-pipe-bash bootstrap. `package.json` `"bin": { "data-liberation": "./dist/cli.js" }` โ€” assumes `npm run build` (tsc) has been run. | `npm run liberate -- `, `npm run inspect -- `, `npm run import -- --site โ€ฆ`, `npm run verify`, `npm run setup`. Subcommand `data-liberation mcp` also boots the MCP server (`src/cli.ts:14-15`). | README's "Quick start" + dedicated `docs/cli.md` | +| **npm package** | `package.json` `name: "data-liberation"`, `version: "0.1.0"`, `private`-by-omission (no `private: true` but no `publishConfig` either) | **Not published.** No `releases` and no `tags` returned by the GitHub API. Install path is `git clone` โ†’ `npm install`. | **Not** a recommended consumption path | +| **Library import** | `src/lib/*` and `src/adapters/*` โ€” TypeScript modules consumed only via `mcp-server.ts` and `cli.ts`. Nothing in `package.json` exposes them as a public API. | Would require referencing the git repo as a dependency and running `tsc`. | **Not advertised** โ€” `AGENTS.md:7` says "Three entry points โ€” MCP server, CLI, and Claude Code plugin โ€ฆ all share `src/lib/` and `src/adapters/`". | + +### Counts + +- `commands/`: 8 files (`adapt.md`, `diagnose.md`, `import.md`, `inspect.md`, `liberate.md`, `qa.md`, `setup.md`, `verify.md`) โ€” Markdown with YAML frontmatter, ~5 lines each, thin pointers into the matching skill or MCP tool. +- `prompts/`: 5 files (`godaddy-wm.md`, `shopify.md`, `squarespace.md`, `webflow.md`, `wix.md`) โ€” long Markdown the user pastes into any AI assistant. +- `skills/`: 4 directories (`adapt`, `diagnose`, `liberate`, `qa`), each with one `SKILL.md` โ€” Markdown with YAML frontmatter (`name`, `description`, optional `allowed-tools`), 8โ€“12 KB each, multi-phase workflow instructions. + +## 3. Manifest contents (verbatim) + +`.claude-plugin/plugin.json`: +```json +{ + "name": "data-liberation", + "description": "Extract content from closed web platforms (GoDaddy Websites & Marketing, Hostinger, HubSpot, Shopify, Squarespace, Webflow, Weebly, Wix) into WordPress-compatible WXR files. Inspect, extract, QA, and import to WordPress.", + "version": "0.1.0", + "author": { "name": "Automattic" }, + "homepage": "https://github.com/Automattic/data-liberation-agent", + "repository": "https://github.com/Automattic/data-liberation-agent", + "license": "GPL-2.0-or-later", + "keywords": [...], + "mcp": { "command": "npx", "args": ["tsx", "src/mcp-server.ts"] } +} +``` + +`.codex-plugin/plugin.json`: +```json +{ + "name": "data-liberation", + "description": "...", + "version": "0.1.0", + ..., + "skills": "./skills/", + "mcpServers": "./.mcp.json" +} +``` + +`.mcp.json`: +```json +{ + "mcpServers": { + "data-liberation": { + "command": "npx", + "args": ["tsx", "${CLAUDE_PLUGIN_ROOT}/src/mcp-server.ts"], + "cwd": "${CLAUDE_PLUGIN_ROOT}" + } + } +} +``` + +`.claude-plugin/marketplace.json` declares the marketplace listing โ€” name `data-liberation`, owner `Automattic`, single plugin pointing at `./` so `claude plugin marketplace add` resolves to this same repo. + +## 4. MCP server + +- **File**: `src/mcp-server.ts` (~620 LOC). Built with `@modelcontextprotocol/sdk@^1.27.0`. Stdio transport (`new StdioServerTransport()`). +- **Spawn**: every manifest spawns it the same way โ€” `npx tsx src/mcp-server.ts` from the repo root, child process / stdio. +- **Tools exposed (13 total** โ€” README's "11 tools" claim at `README.md:75` is stale; preview tools were added in PR #39 on 2026-04-18): + 1. `liberate_detect` โ€” fingerprint platform from a URL. + 2. `liberate_discover` โ€” sitemap + nav inventory; returns `platformFeatures` and (Shopify) `shopDomain`. + 3. `liberate_inspect` โ€” combined detect + sitemap + sample probe + feature flags. + 4. `liberate_extract` โ€” full extraction โ†’ WXR + media + redirect map + (optional) products CSV. Holds an extraction-log lock for the duration. + 5. `liberate_status` โ€” read progress from extraction-log files in an output dir. + 6. `liberate_map_apis` โ€” CDP-based API mapping (used during `/adapt`). + 7. `liberate_probe` โ€” CDP-based browser probe of window globals/cookies/localStorage. + 8. `liberate_qa` โ€” diff WXR against origin, optionally patch fixable issues. + 9. `liberate_verify` โ€” post-extraction health report (stale CDN URLs, failed pages/media, quality scores). + 10. `liberate_setup` โ€” validate WP REST connection. **Has a `delegate: true` mode that returns a manifest of requirements without doing anything** (`src/mcp-server.ts:441-454`). + 11. `liberate_import` โ€” REST-API import of WXR into WordPress (and WooCommerce CSV if WC keys passed). **Also has a `delegate: true` mode that returns a structured manifest of file paths** (`wxrFile`, `outputDir`, `mediaDir`, `productsCsv`, `redirectMap`, `importAuthors`) for hosts that handle the import themselves (`src/mcp-server.ts:467-489`). + 12. `liberate_preview` โ€” spawn a local WP preview of an output dir. Auto-detects `studio` CLI on PATH, otherwise falls back to WordPress Playground. Returns `{ url, port, status, source: 'studio'|'playground', warnings, siteName? }`. + 13. `liberate_preview_stop` โ€” stop a running Playground preview by output dir. + +## 5. CLI (`src/cli.ts`, `cli.js`, `start.sh`) + +- `src/cli.ts` is the real CLI: a thin arg router that dynamically imports Ink-based UIs (`ui/inspect.tsx`, `ui/qa.tsx`, `ui/verify.tsx`, `ui/setup.tsx`, `ui/preview.tsx`, `ui/import.tsx`, `ui/discover.tsx`). The `mcp` subcommand simply re-imports `mcp-server.js` (`src/cli.ts:14-15`). +- `cli.js` at the repo root (~22 KB, JS) is a separate **interactive bootstrap** that detects browsers/cookies and offers to launch Chrome with `--remote-debugging-port` for CDP-driven extraction (`cli.js:9-12,65-120`). It's the one `start.sh` exec's into. +- `start.sh` is a curl-pipe-bash one-liner that checks for Node โ‰ฅ18, clones the repo, runs `npm install`, runs `npx playwright install chromium`, then runs `cli.js` (`start.sh:9-63`). +- **Credentials it requires:** + - **No Anthropic / OpenAI / Claude API key anywhere.** A grep of `src/` and `cli.js` for `ANTHROPIC`, `OPENAI`, `claude` returned nothing. The CLI is a deterministic migration runner, not an AI-agent harness โ€” the AI part lives in the *consumer* (Claude Code, Gemini, etc.) which orchestrates calls to the MCP tools. + - `--token` / `LIBERATION_TOKEN` for platforms requiring auth (Webflow today; conceptually Shopify Tier 2 etc.). + - `--admin-token` / `SHOPIFY_ADMIN_TOKEN` for Shopify Admin GraphQL. + - `--site` / `--username` / `--token` (or `WP_APP_PASSWORD`) for the **import** path only โ€” these are WP Application Passwords, not WordPress.com OAuth. + +## 6. Sample command/prompt/skill (verbatim) + +**Command** (`commands/liberate.md`, full file): +```markdown +--- +name: liberate +description: Extract content from a website into a WordPress-compatible WXR file +--- + +Run the liberate skill to extract content from a closed web platform. +``` +All eight commands follow the same shape โ€” frontmatter + a one-sentence body that points at the matching skill or MCP tool. + +**Prompt** (`prompts/wix.md:1-9`): +```markdown +# Wix to WordPress Migration Prompt + +Copy everything below this line and paste it into your AI assistant (Claude, ChatGPT, Gemini, etc.). + +--- + +I want to migrate my website from Wix to WordPress. My Wix site URL is: **[PASTE YOUR WIX URL HERE]** + +I have (or will create) a WordPress site. Please help me migrate using the playbook at https://github.com/Automattic/data-liberation-agent โ€” read AGENTS.md first for full instructions. +``` + +**Skill** (`skills/liberate/SKILL.md:1-9`): +```markdown +--- +name: liberate +description: Extract content from a closed web platform (GoDaddy Websites & Marketing, Hostinger, HubSpot, Shopify, Squarespace, Webflow, Weebly, Wix) into a WordPress-compatible WXR file +--- + +# Liberate a website + +Help the user extract their content from a closed web platform. + +## Workflow +... +``` +The skills are substantial (~200 lines each for `liberate`, `qa`, `diagnose`, `adapt`) with phased instructions, decision points, and quality gates. + +## 7. Source code + +- **Total**: ~17.6 K LOC across 67 `.ts`/`.tsx` files in `src/` (including colocated `*.test.ts` files; vitest is the test runner). +- **Layout**: + - `src/cli.ts` (177 LOC) โ€” CLI router. + - `src/mcp-server.ts` (~620 LOC) โ€” MCP server. + - `src/types.ts` โ€” shared types (`PlatformAdapter`, etc.). + - `src/adapters/` โ€” 9 files, one per platform plus `shared.ts`. + - `src/lib/`: + - `extraction/` โ€” sitemap, content parser, WXR builder/reader, extraction log, import session, media + media stubs, Shopify GraphQL, adaptive-tuner, platform detect. + - `import/` โ€” WP REST client, WooCommerce REST client, WP-importer driver, Woo CSV reader/writer, site URL resolver. + - `preview/` โ€” `playground-server.ts` (Playground harness), `studio.ts` (Studio CLI driver), `blueprint-builder.ts`, `media-url-map.ts`, `lockfile.ts`, `port-picker.ts`, vendored PHP `scripts/import-wxr.php` and `scripts/import-products.php`. + - `qa/` โ€” `content-differ.ts`, `qa-runner.ts`. + - `setup/`, `verification/`, `features/`, `probe/` โ€” supporting modules. + - `src/ui/` โ€” 9 Ink (React-for-CLI) screens. +- **Major dependencies** (`package.json:27-44`): + - `@modelcontextprotocol/sdk@^1.27.0` โ€” MCP server. + - `@wp-playground/cli@^3.1.20` โ€” **PHP-WASM WordPress Playground (heavy; same engine Studio CLI uses)**. + - `playwright@^1.44.0` + `postinstall: playwright install chromium` โ€” **headless Chromium download is part of `npm install`**. + - `cheerio`, `fast-xml-parser`, `papaparse` โ€” HTML/XML/CSV parsing. + - `ink` + `react@19` + `ink-spinner` โ€” terminal UI. + - `tsx` (dev) โ€” runs TypeScript without a build step. The MCP server, CLI, and plugin manifests all rely on it. +- **No native modules**, no Anthropic/OpenAI SDKs, no `wpcom` / WordPress.com OAuth library. + +## 8. Runtime requirements + +- **Node**: `.nvmrc` says `22`. `package.json` `engines.node: ">=18"`. README's troubleshooting block confirms Node 18+ required (`README.md:138`). +- **Postinstall side-effect**: `playwright install chromium` downloads ~150 MB of browser binaries into Playwright's cache. +- **Subprocesses spawned at runtime**: Playwright Chromium for Wix and Squarespace adapters; `@wp-playground/cli` for Playground previews; `studio` CLI for Studio previews (via `execFile`); `open` / `xdg-open` / `start` to open browser/Studio app on `--open`. +- **Filesystem writes**: + - All extraction artifacts go under `--output` (default `./output//`): `output.wxr`, `media/`, `redirect-map.json`, `extraction-log.jsonl`, `session.json`, `media-stubs.json`, `products.csv`, `products.jsonl`, `.discovery-complete`, `.liberation-lock`. (`docs/cli.md:42-63`, `README.md:107-119`) + - Playground path also writes `/playground/{blueprint.json, blueprint.studio.json, preview.pid, preview.log, .lock}` (`docs/cli.md:213-218`). + - **Studio path stages files into the Studio site directory itself** โ€” `wp-content/uploads/liberation/` for media + WXR, `/.dla-scripts/` for vendored PHP (`src/lib/preview/studio.ts:13-19`). It also `rmSync`'s an orphan Studio site dir before re-creating, but only if the path is under `defaultStudioRoot()` (`src/lib/preview/studio.ts:266-279`). +- **Network calls**: HTTP fetches against the source platform (sitemaps, JSON APIs, media); Shopify GraphQL Admin API (`2025-04` pinned); WordPress REST API (`/wp-json/wp/v2/*`) for non-delegate import; WooCommerce REST when WC keys are passed. +- **Environment vars**: `LIBERATION_TOKEN`, `SHOPIFY_ADMIN_TOKEN`, `WP_APP_PASSWORD`, optional `STUDIO_APP_CMD` (Linux launch fallback). +- **Host-tool dependencies**: skills assume the LLM host provides `Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `AskUserQuestion` (declared in `skills/qa/SKILL.md` `allowed-tools`). The skills also reference an `import-liberated-data` skill *in the host environment* and a `wp_cli` tool โ€” when present, the skill calls `liberate_setup` / `liberate_import` with `delegate: true` and lets the host do the actual import (`skills/liberate/SKILL.md:24,66`). + +## 9. Migration mechanics (end-to-end) + +**Inputs:** +- Source site URL. +- Optional platform tokens (`LIBERATION_TOKEN` for Webflow; `SHOPIFY_ADMIN_TOKEN` + auto-detected `shopDomain` for Shopify Tier 2). +- Optional CDP port (Squarespace admin extraction, advanced Wix workflows). +- For built-in import: a target WP site (domain), username, and Application Password. + +**Pipeline (driven by the `liberate` skill calling MCP tools, or by `npm run liberate`):** +1. `liberate_detect` โ†’ platform identity from URL fingerprinting. +2. `liberate_discover` โ†’ sitemap + navigation + platform features + (Shopify) shop domain. +3. `liberate_extract` โ†’ adapter walks discovered URLs, populates a `WxrBuilder`, downloads media, streams Woo products to `products.jsonl`. Resume-safe via four cooperating files (`extraction-log.jsonl`, `session.json`, `media-stubs.json`, `products.jsonl`) โ€” `AGENTS.md:20-29` is the source of truth on this. +4. `liberate_verify` โ†’ quality + completeness report. +5. **Preview** โ€” `liberate_preview` boots a local WP. **Crucial fact for Studio Code wiring (per `AGENTS.md:51`)**: Studio preview imports the WXR via the `importWxr` blueprint step (LiteralReference, WXR contents inlined into `blueprint.studio.json`) **during** `studio site create`, *not* via `studio wp import` afterward โ€” to avoid Studio's WP-CLI IPC bridge 120s no-activity timeout. Product CSV import does run after, via `studio wp wc product_importer`. +6. `liberate_setup` + `liberate_import` โ†’ either via REST (with credentials) or **`delegate: true`** to hand off a structured manifest to the calling environment. Studio Code is the canonical "calling environment" โ€” the skill explicitly mentions "`import-liberated-data` skill, or `wp_cli` tool" as the delegate target. + +**Outputs (per `README.md:107-119`):** +``` +output// + output.wxr WordPress eXtended RSS file + media/ downloaded images and attachments + redirect-map.json old paths -> new WordPress slugs + extraction-log.jsonl per-URL log + session.json stage + cursors + media-stubs.json per-asset retry state + products.csv WooCommerce-compatible product CSV (if applicable) + products.jsonl raw product stream +``` + +**What the user sees:** Ink-based progress UI (or MCP `sendLoggingMessage` events when called via MCP), then a verification report, then either a preview URL (Studio site at `localhost:` or Playground at `127.0.0.1:9400-9499`) or an import progress stream. + +## 10. Versioning / release cadence + +- **No tags. No releases.** `gh api repos/.../tags` returns `[]`; `gh api repos/.../releases` returns `[]`. Version pinned at `0.1.0` in three places (`package.json`, both plugin manifests). +- **Not published to npm.** Install is by `git clone` or the marketplace path that re-clones it. +- **Cadence**: Repo created **2026-03-31**. Last push **2026-04-28T17:04:21Z** (5 commits that day). The 2026-04 commit history shows bursty work โ€” 10 commits on 2026-04-16 alone, then steady single-digit days, then a cluster on the 28th. Latest 5 PRs are all `discovery:` (Wix-extraction tweaks); slightly older are `feat(detect-platform)`, `fix(preview)`, `feat(preview)` adding the Studio/Playground preview path (PR #39 on 2026-04-18). The two most recent open PRs (#50 add EmDash CMS adapter, #46 desktop+mobile screenshot capture) hint at near-term direction. Stargazers: 29. +- **Recent landing of preview** (PR #39 โ†’ "feat(preview): local WP preview via Studio/Playground before import | BIGR-614") is what makes Studio integration interesting today โ€” DLA already has working `studio site create` + `studio wp eval-file` plumbing. + +## Risks / unknowns for Studio CLI integration + +1. **Tools/load-bearing host assumptions in skills**: `skills/qa/SKILL.md` declares `allowed-tools: Bash, Read, Write, Edit, Glob, Grep, AskUserQuestion`. Studio CLI's `studio code` agent needs to expose a compatible toolset (or skill execution will silently degrade). +2. **DLA already invokes `studio` CLI directly via `execFile`** (`src/lib/preview/studio.ts:71,105,114,133,282`). If Studio Code wants to *embed* DLA rather than have DLA shell out to Studio, the preview path would need a different mode (or to be skipped). +3. **`tsx` at runtime**: `.mcp.json` and both plugin manifests run `npx tsx src/mcp-server.ts` directly โ€” there is no compiled artifact in version control (`dist/` is gitignored). Anyone embedding DLA's MCP server has to ship `tsx` + the TypeScript source, or build first. `package.json bin` points at `./dist/cli.js` which doesn't exist until `npm run build` runs. +4. **Playwright postinstall (Chromium download)** is not behind a flag. Any Studio-side install path that pulls in DLA will pay the ~150 MB browser cost even for users who only migrate from API-only platforms (Hostinger, HubSpot, Shopify Tier 1). +5. **Vendored PHP scripts** (`src/lib/preview/scripts/import-wxr.php`, `import-products.php`) are loaded by absolute path resolved from `import.meta.url` โ€” must be present alongside the JS at runtime. If Studio CLI bundles DLA, the bundle has to preserve that asset layout. +6. **README "11 tools" claim is stale** โ€” the server has 13. Anyone scoping integration off the README's tool list will miss `liberate_preview` / `liberate_preview_stop`. +7. **Private repo + no published artifact** โ€” Studio CLI cannot today depend on DLA via npm. Options: git submodule, `git+ssh:` dep with a deploy key, vendoring, or waiting for a public/published version. +8. **`delegate: true` mode is the natural integration shape** for Studio Code โ€” `liberate_setup`/`liberate_import` already return a structured manifest (`{ wxrFile, outputDir, mediaDir, productsCsv, redirectMap, importAuthors }`) explicitly designed for "local dev tools with direct database/CLI access" (`src/mcp-server.ts:204`). +9. **Single repo, single version** โ€” DLA isn't versioned, so any Studio-side pin will be a git SHA, and breakage from `main` lands the moment it lands upstream. No deprecation contract. +10. **Auto-cleanup of orphan Studio sites**: `startStudioPreview` will `rmSync` a directory under `defaultStudioRoot()` if it exists on disk but isn't listed by `studio site list` (`src/lib/preview/studio.ts:266-279`). + +## Sources + +- DLA repo cloned shallowly via `gh repo clone Automattic/data-liberation-agent --depth=1` (private) and read locally; full read of `README.md`, `AGENTS.md`, `package.json`, `.nvmrc`, all four manifest files (`.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `.codex-plugin/plugin.json`, `gemini-extension.json`, `.mcp.json`); `src/mcp-server.ts` (full); `src/cli.ts` (full); sampled `src/lib/preview/studio.ts`, `src/ui/discover.tsx`; `commands/liberate.md`, `commands/inspect.md`, `commands/import.md`; `prompts/wix.md`; `skills/liberate/SKILL.md`, `skills/qa/SKILL.md`; `docs/cli.md`, `docs/mcp.md`, `docs/skills.md`. +- GitHub API: `gh api repos/Automattic/data-liberation-agent` (metadata), `releases` ([]), `tags` ([]), commits, PRs, languages. diff --git a/issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-studio-skill-plumbing.md b/issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-studio-skill-plumbing.md new file mode 100644 index 0000000000..10a215f9b8 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-studio-skill-plumbing.md @@ -0,0 +1,215 @@ +--- +task: wave-1-studio-skill-plumbing +wave: 1 +status: complete +--- + +# Wave 1 โ€” Studio Code Skill / MCP / Slash-Command Plumbing + +## 1. Lifecycle diagrams (slash command โ†’ execution) + +User input dispatch in `apps/cli/commands/ai/index.ts:684-705`. Two execution paths. + +### Flow A โ€” Handler-based slash command (e.g. `/preview`, `/login`, `/model`) + +``` +user types "/preview" + โ””โ”€> apps/cli/commands/ai/index.ts:688 AI_CHAT_SLASH_COMMANDS.find(name === "preview") + โ””โ”€> apps/cli/commands/ai/index.ts:691 await cmd.handler(prompt, ctx) + โ””โ”€> apps/cli/ai/slash-commands.ts:232-291 preview handler body + โ”œโ”€ reads token via @studio/common/lib/shared-config.readAuthToken + โ”œโ”€ calls captureCommandOutput(...) โ€” apps/cli/ai/tools.ts:225 + โ”‚ (in-process console.log capture, NOT a child-process spawn) + โ””โ”€ calls runCreatePreviewCommand / runUpdatePreviewCommand + (imported from cli/commands/preview/{create,update}) + โ€” these are normal yargs handlers run as plain function calls + [returns 'continue' or 'break' to the chat loop] + the agent (Claude) is never invoked for this command +``` + +### Flow B โ€” Skill-based slash command (e.g. `/annotate`, `/need-for-speed`) + +``` +user types "/need-for-speed" + โ””โ”€> apps/cli/commands/ai/index.ts:688 match in AI_CHAT_SLASH_COMMANDS + (entry comes from spreading AI_SKILL_COMMANDS at line 297 of + apps/cli/ai/slash-commands.ts; it has no `handler`) + โ””โ”€> apps/cli/commands/ai/index.ts:699 + runAgentTurn( buildSkillInvocationPrompt('need-for-speed') ) + โ””โ”€> tools/common/ai/slash-commands.ts:15-17 returns the literal string: + "Run the /need-for-speed skill using the Skill tool." + โ””โ”€> apps/cli/commands/ai/index.ts:464 startAiAgent({ prompt, ... }) + โ””โ”€> apps/cli/ai/agent.ts:130 query({ prompt, options: { + tools: { type: 'preset', preset: 'claude_code' }, // line 142 + plugins: [{ type: 'local', path: /plugin }], // line 149 + mcpServers: { studio: createStudioTools(...) }, // line 80-84 + ... + }}) + โ””โ”€> @anthropic-ai/claude-agent-sdk forks the bundled `claude` native binary + (per-platform vendor binaries, e.g. darwin-arm64 ~206 MB) + โ””โ”€> claude binary discovers the local plugin at `/plugin/`, + reads `.claude-plugin/plugin.json` (apps/cli/ai/plugin/.claude-plugin/plugin.json) + and enumerates `skills//SKILL.md` files + โ””โ”€> Skill tool (a built-in tool from the `claude_code` preset) consumes the + "/need-for-speed" hint and inlines the body of + apps/cli/ai/plugin/skills/need-for-speed/SKILL.md into the agent's context + โ””โ”€> the model follows SKILL.md, e.g. calls `mcp__studio__need_for_speed` โ€” + that tool is the SDK MCP tool registered in apps/cli/ai/tools.ts:852-890, + backed by apps/cli/ai/performance-audit.ts.auditPerformance() +``` + +The "Skill tool" is part of the `claude_code` preset (`tools: { type: 'preset', preset: 'claude_code' }`); Studio code only registers `plugins: [{ type: 'local', path }]` โ€” the SDK does plugin discovery itself. + +## 2. Inventory of existing skills + +Plugin manifest: `apps/cli/ai/plugin/.claude-plugin/plugin.json` โ€” `{ "name": "studio", "description": "WordPress Studio AI skills", "version": "1.0.0" }`. + +| Skill dir | Frontmatter | Listed in `AI_SKILL_COMMANDS`? | Tooling dependencies | +|---|---|---|---| +| `annotate/SKILL.md` | `name: annotate` / `user-invokable: true` | yes (`tools/common/ai/slash-commands.ts:9`) | MCP tools: `open_annotation_browser`, `wait_for_annotations` (`tools.ts:1100-1156`); aux code in `apps/cli/ai/inspector/` (Playwright-driven) | +| `need-for-speed/SKILL.md` | `name: need-for-speed` / `user-invokable: true` | yes (line 11) | MCP tool: `need_for_speed` (`tools.ts:852-890`); aux code in `apps/cli/ai/performance-audit.ts` | +| `rank-me-up/SKILL.md` | `name: rank-me-up` / `user-invokable: true` | yes (line 12) | MCP tool: `rank_me_up`; aux code in `apps/cli/ai/seo-audit.ts`. Skill body also instructs the model to call the built-in `wp_cli` tool. | +| `site-spec/SKILL.md` | `name: site-spec` / `user-invokable: true` | **NO** โ€” referenced by the system prompt (`apps/cli/ai/system-prompt.ts:138`), triggered indirectly during site creation. **Orphan-ish.** | No dedicated MCP tool; calls `site_create` (`tools.ts:295`) and uses the SDK's built-in `AskUserQuestion` (intercepted by `PreToolUse` hook in `agent.ts:106-128`). | +| `taxonomist/SKILL.md` | `name: taxonomist` / `user-invokable: true` | yes (line 10) | MCP tool: `install_taxonomy_scripts` (`tools.ts:824-850`) which copies `apps/cli/ai/plugin/skills/taxonomist/scripts/{apply-changes.php, backup.php, export-posts.php, restore.php}` into the target site. The skill body then drives the agent to call `wp_cli eval-file `. **WP-CLI-invocable PHP, packaged inside the skill directory.** | + +**Orphans / mismatches:** `site-spec` is on disk but absent from `AI_SKILL_COMMANDS`. Skills reach into auxiliary code via SDK MCP tool calls (dominant pattern), via SDK built-in tools (`wp_cli`, `Bash`, `Read`/`Edit`/`Write`), or via packaged sibling files (taxonomist's PHP scripts). + +## 3. MCP plumbing + +### 3a. Wiring (`apps/cli/ai/agent.ts:80-84`) + +```ts +const mcpServers = { + studio: isRemoteSite + ? createRemoteSiteTools( wpcomAccessToken, activeSite.wpcomSiteId! ) + : createStudioTools( { enablePreviewSteering: isForkedByDesktop } ), +}; +``` + +Both factories use `createSdkMcpServer({ name: 'studio', version: '1.0.0', tools })`. Tools surface to the model as `mcp__studio__`. + +### 3b. SDK type signatures (from `node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts`) + +```ts +// line 1386 +mcpServers?: Record; + +// line 920 +McpServerConfig = + McpStdioServerConfig | McpSSEServerConfig | McpHttpServerConfig | McpSdkServerConfigWithInstance; + +// 1005 +type McpStdioServerConfig = { type?: 'stdio'; command: string; args?: string[]; env?: Record }; +// 998 +type McpSSEServerConfig = { type: 'sse'; url: string; headers?: Record; tools?: McpServerToolPolicy[] }; +// 897 +type McpHttpServerConfig = { type: 'http'; url: string; headers?: Record; tools?: McpServerToolPolicy[] }; +// 904 / 913 +type McpSdkServerConfigWithInstance = { type: 'sdk'; name: string; instance: McpServer }; +``` + +JSDoc on `mcpServers` (line 1376) shows the canonical stdio child-process example. + +### 3c. Stdio child-process MCP โ€” supported + +**Yes.** `mcpServers` accepts `{ command: string, args?: string[], env?: Record, type?: 'stdio' }`. **No precedent in this repo of consuming a third-party stdio MCP server** today โ€” the only `mcpServers` entry is the in-process `studio` SDK MCP server. + +### 3d. The MCP stdio server in `apps/cli/ai/mcp-server.ts` and `apps/cli/commands/mcp.ts` + +These exist for the **inverse** direction โ€” they expose Studio's tools to *external* MCP clients (Claude Desktop, Claude Code CLI, Codex, Cursor). Studio Code does not consume any external MCP server today. + +## 4. Bundling story + +### 4a. CLI build matrix + +| Config | Command | Defines | Copies `ai/plugin`? | +|---|---|---|---| +| `apps/cli/vite.config.dev.ts` | `npm run cli:build` | `__IS_PACKAGED_FOR_NPM__: false`, `__ENABLE_CLI_TELEMETRY__: false` | **Yes** โ€” `viteStaticCopy({ targets: [{ src: 'ai/plugin', dest: '.' }] })` (lines 9-17) | +| `apps/cli/vite.config.npm.ts` | `npm run cli:build:npm` (also `prepublishOnly`) | `__IS_PACKAGED_FOR_NPM__: true`, `__ENABLE_CLI_TELEMETRY__: true` | **Yes** โ€” same `viteStaticCopy` (lines 8-17) | +| `apps/cli/vite.config.prod.ts` | `npm run cli:build:prod` (used by `cli:package` for desktop build, `forge.config.ts:174`) | `__ENABLE_CLI_TELEMETRY__: true` | **No** โ€” only copies `node_modules/`. **`ai/plugin` is NOT copied** in the prod build path. | + +History: plugin tree was added to `vite.config.dev.ts` in `853b913c` (2026-03-18); to `vite.config.npm.ts` in `882f27e9` (2026-04-20, "include ai/plugin folder in npm build output"). `vite.config.prod.ts` was not updated. **Possibly a bug.** + +### 4b. Plugin path resolution at runtime + +`apps/cli/ai/agent.ts:149`: `path.resolve(import.meta.dirname, 'plugin')`. The Vite build inlines all CLI sources into `dist/cli/main.mjs`. `import.meta.dirname` is `dist/cli/`. Combined with `viteStaticCopy({ src: 'ai/plugin', dest: '.' })` putting the tree at `dist/cli/plugin/`, the SDK loads `dist/cli/plugin/.claude-plugin/plugin.json` and `dist/cli/plugin/skills/*/SKILL.md` at runtime. + +### 4c. Files shipped with the npm CLI + +`apps/cli/package.json:12-17` `files` array: `assets/`, `dist/cli/`, `patches/`, `scripts/`. With `vite.config.npm.ts` that includes: +- `dist/cli/main.mjs` (+ side-bundles from `vite.config.base.ts:60-67`) +- `dist/cli/package.json` (`{ "type": "module" }`, written by `write-dist-extras` plugin in base) +- `dist/cli/plugin/.claude-plugin/plugin.json` +- `dist/cli/plugin/skills//SKILL.md` (and any sibling files like `scripts/*.php`) +- `dist/cli/php/`, `dist/cli/wp-files/`, `dist/cli/reprint.phar` (from base) + +**No build-config change needed for new files under `ai/plugin/...`** in dev/npm paths โ€” `viteStaticCopy` recursively copies the entire tree. + +### 4d. Electron / desktop runtime layout + +`apps/studio/forge.config.ts:18-22` adds `apps/cli/dist/cli/` as `extraResource`. CLI is forked by `apps/studio/src/modules/ai-agent/run-manager.ts:53-65` (`fork(cliPath, ['code', 'sessions', 'resume', sessionId, prompt, '--json', ...], { execPath: getBundledNodeBinaryPath() })`). + +Because `vite.config.prod.ts` does not copy `ai/plugin`, **the desktop-bundled CLI's `dist/cli/plugin/` directory may be missing unless a separate copy step is added**. (Flag, see section 6.) + +## 5. `/migrate` plug-in points (exhaustive enumeration, no recommendation) + +### 5a. Skill-based slash command (drop a SKILL.md plus optional MCP tool) + +- **Slash registration** โ€” `tools/common/ai/slash-commands.ts:8-13` `AI_SKILL_COMMANDS`. Append `{ name: 'migrate', description: __('โ€ฆ') }`. +- **On-disk skill** โ€” `apps/cli/ai/plugin/skills/migrate/SKILL.md` with YAML frontmatter (`name`, `description`, `user-invokable: true`). +- **No build-config change required** for dev/npm paths. **Build-config change IS required for the prod path** (section 6 item 3). +- The plugin manifest doesn't need to change โ€” directory-based discovery. + +### 5b. New MCP server entry (in-process SDK MCP) + +- **Tool factory** โ€” alongside `createStudioTools`/`createRemoteSiteTools` in `apps/cli/ai/tools.ts:1203-1222`, add e.g. `createMigrateTools()` calling `createSdkMcpServer({ name: 'migrate', tools: [...] })`. +- **Wire in** โ€” `apps/cli/ai/agent.ts:80-84` extend `mcpServers = { studio: ..., migrate: createMigrateTools(...) }`. SDK type at `sdk.d.ts:1386` is `Record`. +- Tools surface as `mcp__migrate__`. + +### 5c. New MCP server entry (stdio child-process MCP) + +- Add entry at `apps/cli/ai/agent.ts:80-84`: + ```ts + mcpServers = { + studio: ..., + migrate: { command: 'node', args: ['/path/to/dla/cli.js', '--mcp'] }, + }; + ``` +- **No precedent in this repo.** Bundling/locating the child-process binary is the caller's responsibility. + +### 5d. Handler-based slash command (no LLM in the loop, like `/preview`) + +- Add handler entry to `AI_CHAT_SLASH_COMMANDS` in `apps/cli/ai/slash-commands.ts:49-298`. Pattern from `/preview`: + ```ts + { name: 'migrate', description: __('โ€ฆ'), handler: async (_prompt, ctx) => { โ€ฆ return 'continue'; } }, + ``` +- Handler context (`SlashCommandContext` at lines 16-29) provides `ui`, `currentModel`, `currentProvider`, `switchProvider`, `prepareProviderSelection`, `clearSession`, `persistSessionContext`. +- Auth available via `readAuthToken` (already imported at line 2); `prepareAiProvider` / `resolveAiEnvironment` from `cli/ai/auth`. +- **Critical:** A handler-only command will **not** be picked up by Electron's IPC dispatcher (`apps/studio/src/ipc-handlers.ts:295-306` only re-routes commands listed in `AI_SKILL_COMMANDS`). It works in CLI; in the desktop app, the command would route to the agent and the Skill tool would fail. (See section 6 item 1.) + +### 5e. Child-process invocation pattern (for spawning DLA's CLI from a slash command) + +- **No precedent for slash commands spawning a child process** today. Closest precedents: + - `apps/cli/ai/browser-utils.ts:50-56` uses `execFile(process.execPath, [cliPath, 'install', 'chromium'], ...)` โ€” Playwright install only. + - `apps/cli/lib/daemon-client.ts:156-164` uses `spawn(process.execPath, [daemonScriptPath], { detached: true, stdio: 'ignore' })` for the process-manager daemon. +- Where it would slot in: inside a handler under `AI_CHAT_SLASH_COMMANDS` (5d) or inside an MCP tool function (5b). + +### 5f. Discoverability cross-reference + +- `apps/cli/ai/ui.ts:566` โ€” autocomplete (`new CombinedAutocompleteProvider(AI_CHAT_SLASH_COMMANDS)`). +- `apps/cli/commands/ai/index.ts:688` โ€” chat-loop dispatcher (Flow A vs B selector). +- `apps/studio/src/ipc-handlers.ts:295-306` โ€” Electron IPC handler that expands `/` only for `AI_SKILL_COMMANDS` entries. +- `apps/ui/src/components/session-view/composer/index.tsx:2,126` โ€” Studio renderer reads `AI_SKILL_COMMANDS` for slash hints. + +## 6. Anything Electron-side โ€” flagged, not investigated + +1. **Electron IPC slash dispatcher reads `AI_SKILL_COMMANDS`** โ€” `apps/studio/src/ipc-handlers.ts:30, 295-306`. Skill entries auto-pick-up; handler-based commands (5d) would NOT be picked up here. +2. **Renderer composer slash hints** โ€” `apps/ui/src/components/session-view/composer/index.tsx:2, 126` reads `AI_SKILL_COMMANDS` to render slash autocomplete. New skill entries appear automatically. +3. **`vite.config.prod.ts` does NOT copy `ai/plugin`** โ€” gap (or compensating step elsewhere I didn't trace). Would mean desktop `studio code` runs without the SDK plugin tree. Needs verification. +4. **CLI is forked from Electron** โ€” `apps/studio/src/modules/ai-agent/run-manager.ts:53-65`. Anything DLA needs at runtime inherits this process model. +5. **MCP install-instructions output** โ€” `apps/cli/commands/mcp.ts:36-58` and `tools/common/lib/mcp-config.ts` only emit `wordpress-studio` install instructions. If `/migrate` adds a separate MCP server other AI hosts should consume, this surface needs extending โ€” but lives in `tools/common/`, not `apps/studio/`. + +## 7. Caveats / unverified + +- Did NOT verify SDK behavior with malformed/missing skill frontmatter; the validation logic lives in the vendored `claude` native binary. +- The Electron-prod-build gap for `ai/plugin` (section 6 item 3) needs cross-check with someone who has a packaged Studio.app to confirm whether plugin loading actually works in production today. diff --git a/issues/rsm-3143-dla-pi-research/prompt.md b/issues/rsm-3143-dla-pi-research/prompt.md new file mode 100644 index 0000000000..b5d1a6326e --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/prompt.md @@ -0,0 +1,33 @@ +# RSM-3143: Re-research DLA integration into `studio code` given pi-coding-agent migration + +- **Ticket:** https://linear.app/a8c/issue/RSM-3143/re-research-dla-integration-into-studio-code-given-pi-coding-agent +- **Branch:** `rsm-3143-dla-pi-research` + +Re-do the DLA integration research now that two things have changed since RSM-1639 closed: + +1. The Data Liberation Agent repo (`Automattic/data-liberation-agent`) was made **public** on 2026-05-07. +2. Studio CLI migrated from `@anthropic-ai/claude-agent-sdk` to `@mariozechner/pi-coding-agent@0.70.2` in PR #3360 (commit `406b7494`, also 2026-05-07). The host-side wiring assumptions in the original research-report โ€” `Options.mcpServers`, `Options.plugins`, `canUseTool`, `apps/cli/ai/agent.ts` โ€” no longer exist on trunk. + +The DLA-side findings from RSM-1639 are still valid (DLA's surfaces, MCP tools, `delegate: true` contract, runtime needs). The host-side findings are stale. Re-use the prior art selectively โ€” see `prior-art/`. + +Open question: how does the `pi-coding-agent` runtime in `apps/cli/ai/runtimes/pi/` accept third-party tool surfaces, and what's the cleanest mechanically-compatible way to land a `/migrate` slash command driven by DLA's tools? + +Candidate shapes to evaluate (non-exhaustive): + +- **Bridge:** spawn DLA's stdio MCP server, wrap each tool as a pi `AgentTool` using `@modelcontextprotocol/sdk` (still in Studio's deps). +- **Vendor as AgentTools:** import `data-liberation/src/lib/...` modules directly and write pi AgentTools that call DLA internals (skips DLA's MCP surface; ties to its internal API). +- **Upstream pi-coding-agent:** push the maintainer to add MCP support so the original research's wiring shape works. +- **Different host entirely:** spawn DLA's CLI as a subprocess from a slash command (Approach E from the original research; was rejected because it loses the agent in the loop โ€” re-evaluate against pi). + +Deliverable: a synthesis report covering the viable options against the pi-coding-agent runtime, their tradeoffs, and a recommended path forward. No code changes. + +## Prior art + +Preserved under `prior-art/` for the research-lead to consult: + +- `prior-art/rsm-1639-research-report.md` โ€” the original research report. DLA-side sections are still authoritative; host-side sections (Approach A, B, C as wired against `claude-agent-sdk`) are stale. +- `prior-art/wave-1-findings/` โ€” the four wave-1 findings from RSM-1639. `wave-1-dla-inventory.md` is still authoritative (modulo the 2-week-newer DLA HEAD). `wave-1-studio-skill-plumbing.md` and `wave-1-claude-plugin-mechanics.md` are stale (describe `claude-agent-sdk`). `wave-1-bundling-distribution.md` is partially stale (build pipeline still applies; SDK-specific bits do not). +- `prior-art/rsm-3139-spec.md` โ€” the (cancelled) Approach C spec written against `claude-agent-sdk`. Useful for the wrapper-skill content and per-tool permission policy, which are reusable regardless of host runtime. +- `prior-art/rsm-3139-plan.md` โ€” the (cancelled) Approach C plan, including the planner's discovery of the pi-coding-agent migration and the bridge proposal. Read the "Ambiguities" section in particular. + +Predecessor tickets: RSM-1639 (research, Done), RSM-1675 (impl Approach A, Cancelled), RSM-3139 (impl Approach C, Cancelled). diff --git a/issues/rsm-3143-dla-pi-research/research-plan.md b/issues/rsm-3143-dla-pi-research/research-plan.md new file mode 100644 index 0000000000..03d7ffecc0 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/research-plan.md @@ -0,0 +1,97 @@ +# RSM-3143 Research Plan โ€” DLA integration into `studio code` (pi-coding-agent runtime) + +**Status:** Wave 1 complete; synthesis written. No wave 2 needed. + +## Context + +The original DLA-integration research (RSM-1639) closed with a recommendation tuned to the `@anthropic-ai/claude-agent-sdk` host runtime. Two upstream changes invalidate the host-side portions of that report: + +1. **DLA went public** (`Automattic/data-liberation-agent`, 2026-05-07). The "private repo โ†’ no `github:` deps" constraint that killed Approach C in RSM-1639 is gone. +2. **Studio CLI migrated to `@mariozechner/pi-coding-agent@0.70.2`** (PR #3360, commit `406b7494`, 2026-05-07). The wiring shapes the original report leaned on โ€” `Options.mcpServers`, `Options.plugins`, `canUseTool`, `apps/cli/ai/agent.ts` โ€” no longer exist on trunk. pi-coding-agent accepts a flat `customTools: ToolDefinition[]` and an opaque `tools: string[]` allowlist; **there is no MCP server slot, no plugin slot, and no `canUseTool` permission hook** in the public SDK. + +The DLA-side findings (its MCP tools, the `delegate: true` contract, skill content, runtime needs) **are still valid** and must not be re-tasked. See `prior-art/wave-1-findings/wave-1-dla-inventory.md` and the DLA-side sections of `prior-art/rsm-1639-research-report.md`. + +The wrapper-skill content and the per-tool permission-policy buckets from `prior-art/rsm-3139-spec.md` are **runtime-agnostic** and reusable โ€” wave-2 researchers should not re-derive them. + +## Revised research question + +> Given the pi-coding-agent runtime currently shipping in `apps/cli/`, what is the cleanest mechanically-compatible way to land a `/migrate` (or equivalent) slash command driven by DLA's tools, and what does that wiring look like end-to-end? + +## Sub-questions + +1. **pi extensibility surface:** What does the public API of `pi-coding-agent@0.70.2` actually let us bolt on? `customTools` and `tools` allowlist are obvious; are there hooks (`beforeToolCall`/`afterToolCall`, extensions, source-info, session events) that aren't surfaced via `createAgentSession` but are reachable through the lower-level `AgentSessionRuntime`/`AgentSessionServices` builders? What's the realistic ceiling on permission gating, prompt injection, and slash-command registration without forking? +2. **MCP-bridge approach:** Mechanically, how does an MCP **client** wrapping DLA's stdio MCP server look against pi? Concretely: spawn the DLA child, `ListTools` at session start, wrap each remote tool as a pi `AgentTool` whose `execute` calls `CallTool`. What goes wrong (lifecycle, startup latency, ListTools failures, abort propagation, output adaptation to pi's `{content, details}` shape)? Is this mechanically equivalent to the RSM-1639 Approach A bridge, just one level lower? +3. **Vendor-as-AgentTools approach:** Could we import `data-liberation/src/lib/...` modules directly and write Studio-owned pi `AgentTool` definitions that call DLA internals? What's DLA's actual internal API shape โ€” is `src/lib/` self-contained enough to vendor, or does it depend on the MCP server's session state, the CLI's UI layer, or other host plumbing? What does the maintenance contract look like (DLA does not expose `src/lib` as a public API)? +4. **Subprocess approach (revisit Approach E):** The original report rejected "spawn the DLA CLI from a slash command" because it took the agent out of the loop. Against pi, can we re-shape this as a single pi `AgentTool` that wraps the DLA CLI as a child process โ€” agent in the loop, DLA as black box, output streamed back? Where does this fall down (interactive prompts, multi-step UX, sub-agent reasoning DLA's MCP path would have provided)? +5. **Upstream-pi approach:** What is the maintainer relationship and release cadence for `@mariozechner/pi-coding-agent`? Is "land MCP support upstream" realistic on the timeline RSM-3143 cares about, and if so what would the upstream contract look like? Is there a maintainer-blessed extension API we should be using instead of `customTools`? +6. **Distribution & bundling:** RSM-1639's `wave-1-bundling-distribution.md` enumerated Studio's CLI build pipeline; what changes given (a) DLA is now an installable public dep (`github:Automattic/data-liberation-agent#` or `npm`/tarball), and (b) DLA spawns via `npx tsx src/mcp-server.ts` at runtime โ€” does this still pass through Studio's `vite build` + electron-forge packaging without surprise? +7. **Wrapper-skill + slash-command integration:** Given Studio's skill loader (`apps/cli/ai/skills.ts`) and the `AI_SKILL_COMMANDS` registry pattern, what is the smallest change that lights up `/migrate`? Reuse RSM-3139's wrapper-skill body (`migrate-site.md`) and per-tool permission policy buckets โ€” confirm those are runtime-agnostic and identify the integration seams (skill load, slash-command registration, tool gating per the policy buckets when pi has no `canUseTool`). +8. **Synthesis & recommendation:** Side-by-side comparison of the surviving approaches against pi, with a recommended path forward. + +## What's still valid from RSM-1639 (DO NOT re-investigate) + +- DLA inventory: surfaces, MCP tools, skill content, manifests, runtime expectations, output shapes (`wave-1-dla-inventory.md`). +- DLA's `delegate: true` handoff contract (covered in `prior-art/rsm-1639-research-report.md`). +- DLA's end-to-end UX walk (liberate โ†’ inspect โ†’ adapt โ†’ import โ†’ verify; covered in `prior-art/rsm-1639-research-report.md`). +- Studio build pipeline at a coarse level (`wave-1-bundling-distribution.md`); SDK-specific bundling claims are stale and will be re-derived in sub-question 6. +- Wrapper-skill content + per-tool permission policy buckets (`rsm-3139-spec.md`), as runtime-agnostic reusable concepts. + +## What's stale (and explicitly out of scope to investigate) + +- `wave-1-studio-skill-plumbing.md` โ€” describes the deleted `claude-agent-sdk` plumbing. +- `wave-1-claude-plugin-mechanics.md` โ€” describes the deleted SDK contract. +- The Approach A/B/C tables in `rsm-1639-research-report.md` (host-side wiring sections only). + +## Wave 1 task plan + +Five parallel briefs cover sub-questions 1โ€“6. Wrapper-skill + slash-command integration (sub-question 7) folds naturally into Brief 1's surface mapping plus Brief 4's vendoring exercise โ€” it does not need a standalone task. Sub-question 8 (synthesis) is the research-lead's job in the final report. + +| Brief | Sub-questions | Title | +|---|---|---| +| `wave-1-pi-extensibility-surface.md` | 1, 7 | Map pi-coding-agent's third-party extensibility ceiling | +| `wave-1-mcp-bridge-feasibility.md` | 2 | Prove out the MCP-stdio-to-AgentTool bridge against pi | +| `wave-1-vendor-as-agenttools.md` | 3 | Evaluate vendoring DLA's `src/lib/` as Studio-owned AgentTools | +| `wave-1-subprocess-revisit.md` | 4 | Re-evaluate the subprocess approach (Approach E) against pi | +| `wave-1-upstream-and-bundling.md` | 5, 6 | Upstream-pi feasibility + DLA bundling/distribution against pi | + +Each brief is a self-contained markdown file under `issues/rsm-3143-dla-pi-research/tasks/`. + +## Wave 1 findings log + +| Brief | Status | Key takeaway | +|---|---|---| +| wave-1-pi-extensibility-surface | complete | `extensionFactories` via `DefaultResourceLoader` IS reachable from public `createAgentSession`. Inline factories load even when `noExtensions: true`. `pi.on('tool_call', handler)` returning `{block: true, reason}` provides clean per-tool permission gating โ€” settles the open `canUseTool` gap. `pi.registerTool` and `pi.registerCommand` reachable too, but for `/migrate` the existing `AI_SKILL_COMMANDS` + Studio `Skill` tool pattern is structurally smaller. MCP confirmed absent in pi 0.70.2 (zero `mcp`/`MCP` matches outside vendored highlight-tables). 0.70.2 is three minors behind npm latest (0.73.1); `extensionFactories` was added in 0.67.2 โ€” recent surface, treat as semver-loose. | +| wave-1-mcp-bridge-feasibility | complete | Bridge mechanically sound. `@modelcontextprotocol/sdk@1.29.0` (not 1.27.1) installed; `Client` + `StdioClientTransport` typed; pi-ai accepts plain JSON Schema in `parameters` (explicit `!hasTypeBoxMetadata && isJsonSchemaObject` branch at `validation.js:253-280`) โ€” schema cast is safe at runtime. DLA emits text-only (no `structuredContent`), simplifying the adapter. Caveats: DLA doesn't honor `notifications/cancelled` (orphans server-side work on abort); permission gating via `canUseTool` is absent but resolved by Brief 1's `tool_call` extension hook; `npx tsx` cold start should be replaced with `node /dist/mcp-server.js` after a build step. End-to-end wiring sketched: ~250 LOC under `tools/dla/` (a sibling workspace package alongside `tools/common/`, consumed from `apps/cli/` as `@studio/dla`; the wave-1 sketch used `apps/cli/ai/dla/` for the same module shape โ€” relocated to `tools/dla/` per owner direction), one new param to `buildAgentTools`, dispose hook in the existing `finally` block at `runtimes/pi/index.ts:222-225`. | +| wave-1-vendor-as-agenttools | complete | Vendoring is mechanically possible and cleaner at runtime (no IPC, no child, no `tsx`). `src/lib/` is genuinely vendor-able โ€” MCP `Server` is a type-only optional import (defensive `?.` usage), no Ink/UI leaks. 7/13 tools are clean pass-throughs; 6/13 need 15-50 LOC of handler-mirroring re-derivation from `src/mcp-server.ts`. `delegate: true` is purely MCP-server-side โ€” Studio wrappers re-implement it as ~15 lines of literal-object construction (**easier** than via bridge). Real blocker is build integration: DLA ships TS only โ€” no `dist/`, no `main`/`exports`, no `prepare` script. Studio's Vite externalizes deps so the bundled CLI would crash importing `.ts`. Three unblockers: (1) upstream `prepare: tsc` PR, (2) Studio runs `tsc` against DLA after install, (3) vendor copy via git submodule. Vendor-via-submodule preferred. Schema/output drift risk is medium-low (13 tools, stable for 14+ days). `liberate_preview` has a recursion-into-Studio hazard via `isStudioAvailable()` requiring a `_noStudio: true` shim. Playwright Chromium ~150 MB postinstall is shared with Bridge. | +| wave-1-subprocess-revisit | complete | Works with caveats โ€” viable as escape-hatch / `--headless` mode, not canonical. Wrapping subprocess spawn in an `AgentTool` keeps the agent in the loop (improving on Approach E's "no-agent" rejection). But: DLA's CLI has no `--json` mode (model consumes ANSI-stripped terminal text); 6 of 13 MCP-only tools (`liberate_detect`, `liberate_discover`, `liberate_map_apis`, `liberate_probe`, `liberate_status`, `liberate_preview_stop`) are unreachable from CLI; `delegate: true` is structurally absent (CLI `import` always REST-imports โ€” defeats most of the value); bare-URL extract bundles detect+discover+extract into one Ink run, agent loses phase observability. Single-tool wrapper ~60 LOC (must use raw `AgentTool` not `defineTool` โ€” Studio's `defineTool` drops `signal`/`onUpdate`). Latency: `tsx`-startup is ~100-300 ms warm โ€” dominated by network for real work. Right shape for a `studio migrate ` non-agent CLI command or a fallback if Bridge fails. | +| wave-1-upstream-and-bundling | complete | Upstream-pi: killed. Maintainer publicly closed issue #563 (Jan 2026) with "we don't need to add an MCP example anymore" โ€” blessed answer is third-party `pi-mcp-adapter`. Repo just renamed to `earendil-works/pi`, mid-refactor, drive-by PRs auto-closed. Realistic timeline 8-16 weeks; slower than in-tree. **Worth a wave-2 note:** `pi-mcp-adapter` itself (npm package, 663 stars, MIT, deps on `@earendil-works/pi-ai`) could potentially be used as a Studio dep to shortcut Bridge's wiring โ€” but it's tied to pi's extension surface and has known bugs (issue #4326). Bundling: `github:Automattic/data-liberation-agent#` deps install cleanly in `npm install --omit=dev`; lockfile resolves to git+ssh URL with HTTPS fallback for public repos; CI/Buildkite/`npm ci` compatible. Bridge's `npx tsx` path BLOCKED as written โ€” `tsx` is in DLA's `devDependencies` (dropped by `--omit=dev`), `npx` not on PATH in packaged Electron CLI. Three fixes: (A) add `tsx` to `apps/cli` deps and spawn ` node_modules/.bin/tsx ...`, (B) build-time `tsc` against DLA, (C) Vite pre-bundle DLA's MCP server. Option A preferred. Playwright Chromium ~150 MB postinstall โ€” mitigate with `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` in build pipeline. Marginal disk: ~200-300 MB in `apps/cli` after Studio's existing prune steps. Licenses clean (MIT/GPL/ISC/BSD/Apache โ€” all GPL-2.0-compatible). | + +## Wave-2 decision + +**No wave 2.** All five briefs returned clean mechanical verdicts. Bridge has a complete end-to-end sketch; Vendor has a complete end-to-end sketch with named unblockers; Subprocess and Upstream are rank-orderable against them with evidence. The "research-complete" criteria are met. + +The one open question worth recording: **`pi-mcp-adapter` (npmjs.com/package/pi-mcp-adapter) was surfaced by the upstream-pi finding but not investigated**. It would route the Bridge through pi's extension API rather than through `customTools`, potentially shrinking Studio-owned code. We choose not to spin a wave-2 task on it because: + +1. The Studio-owned Bridge sketch is already small (~250 LOC) and uses primitives Studio already has โ€” adding an external single-maintainer dep with known bugs (#4326 TUI crash) to save ~100 LOC is a net loss. +2. Brief 1 already proved Studio can reach pi's extension API directly via `DefaultResourceLoader({ extensionFactories })`; if we ever want pi-mcp-adapter's mechanics, we can re-derive them in 1-2 days against pi's documented `ExtensionAPI`. +3. The Bridge sketch is independent of pi's extension API โ€” it lives in `customTools`. Migrating from `customTools` to extension-API later would be a Studio-internal refactor, not a stop-energy redo. + +Filed as an open question in the synthesis report. + +## Research-complete vs wave-2 criteria + +Wave 1 is **sufficient** if all five briefs return with: + +- A clear mechanical verdict ("works" / "works with caveats X, Y" / "blocked by Z") rather than speculation. +- For Bridge and Vendor: an end-to-end wiring sketch concrete enough that a planner could turn it into a spec next turn. +- For Subprocess and Upstream: enough evidence to rank-order them against the leading approach (not necessarily an end-to-end sketch). +- For Bundling: a yes/no on whether the chosen approach packages cleanly through `cli:build` + electron-forge. + +A **wave 2** is needed if any of: + +- Bridge or Vendor turns up ambiguity that blocks a recommendation (e.g. an unresolved abort-signal or lifecycle pitfall that needs a small spike to settle). +- A new approach the prior research didn't surface emerges from Brief 1's API mapping (e.g. an extension hook that wasn't on the candidate list). +- DLA's `src/lib/` turns out to have non-obvious coupling that needs a follow-up vendoring sanity check. +- Permission-gating story for pi can't be settled in Brief 1 alone (e.g. requires testing a `beforeToolCall` hook via the lower-level runtime API). + +The deliverable at the end is a **research report + PR description** (no code changes), per the RSM-1639 deliverable shape. diff --git a/issues/rsm-3143-dla-pi-research/research-report.md b/issues/rsm-3143-dla-pi-research/research-report.md new file mode 100644 index 0000000000..40958b7edd --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/research-report.md @@ -0,0 +1,241 @@ +# RSM-3143: Re-research DLA integration into `studio code` against pi-coding-agent + +> **Note:** The command was renamed from `/migrate` to `/liberate` (and `studio migrate` to `studio liberate`) post-implementation per owner direction (see latest rename commit on this branch). This research artifact preserves the original `/migrate` name as evidence of the design conversation. + +**Status:** research, no code changes +**Scope:** Studio CLI (`apps/cli/`) only. Electron-side touches are flagged, never proposed. +**Supersedes:** RSM-1639 (research, Done), RSM-1675 (impl Approach A, Cancelled), RSM-3139 (impl Approach C, Cancelled), PR #3277 (closed). +**Deliverable framing:** What happens when a user types `/migrate` inside `studio code`, now that the host runtime is `@mariozechner/pi-coding-agent@0.70.2` and DLA is a public Automattic repo? + +--- + +## Executive Summary + +RSM-1639 produced a working recommendation against `@anthropic-ai/claude-agent-sdk`. Two upstream changes invalidate the host-side half of that report: + +1. DLA went public on 2026-05-07 (`Automattic/data-liberation-agent`). The "private repo โ†’ no `github:` deps" constraint that killed Approach C in RSM-1639 is gone โ€” `github:Automattic/data-liberation-agent#` installs cleanly via `npm install --omit=dev` and survives `npm ci` on Studio's CI/Buildkite pipeline (`wave-1-upstream-and-bundling` ยง2.3). +2. Studio CLI migrated to `@mariozechner/pi-coding-agent@0.70.2` on the same day (PR #3360, commit `406b7494`). The wiring surfaces RSM-1639 leaned on โ€” `Options.mcpServers`, `Options.plugins`, `canUseTool`, `apps/cli/ai/agent.ts` โ€” no longer exist on trunk. Pi exposes a flat `customTools: ToolDefinition[]` slot, a `tools` allowlist, and **zero MCP support in core** (grep across `node_modules/@mariozechner/pi-*` returns zero `mcp`/`MCP` matches outside vendored syntax-highlight tables; pi's maintainer publicly closed issue #563 with "we don't need to add an MCP example anymore" and pointed users at the third-party `pi-mcp-adapter`). + +The DLA-side findings from RSM-1639 โ€” its 13 MCP tools, `delegate: true` contract, skill content, manifests, runtime expectations โ€” **remain authoritative**. The wrapper-skill body and per-tool permission-policy buckets from RSM-3139's spec are runtime-agnostic and reusable as-is. + +**Recommended path:** A Studio-owned MCP-client bridge under `tools/dla/` that spawns DLA's stdio MCP server as a child process, calls `ListTools` at session bring-up, and wraps each remote tool as a pi `ToolDefinition` in the existing `customTools` array. This is **mechanically equivalent to RSM-1639's Approach A**, just one level lower: where the Claude SDK accepted `mcpServers: { 'data-liberation': { command, args } }` natively, we re-derive that wiring against pi's `customTools` slot using `@modelcontextprotocol/sdk@1.29.0` (already in Studio's deps). The bridge is ~250 LOC in `tools/dla/` (a sibling workspace package alongside `tools/common/`), one new parameter to `buildAgentTools` (`apps/cli/ai/runtimes/pi/index.ts:403-451`), and a dispose hook in the existing `finally` block that already calls `session.dispose()` (`runtimes/pi/index.ts:222-225`). Per-tool permission gating โ€” the headline open concern at the start of this round โ€” lands via an inline extension factory (`new DefaultResourceLoader({ extensionFactories: [createPolicyExtension()] })` on the same loader Studio passes today) using `pi.on('tool_call', handler)` returning `{block: true, reason}`. Inline extension factories load even when `noExtensions: true` (verified at `resource-loader.js:272-278`), so flipping no other settings is required. + +DLA is bundled as a `github:Automattic/data-liberation-agent#` dependency in `apps/cli/package.json` and consumed via a `node apps/cli/node_modules/.bin/tsx node_modules/data-liberation/src/mcp-server.ts` spawn. The fix to make `tsx` reachable in a packaged CLI is one line: add `tsx` to `apps/cli/package.json` `dependencies` (it currently sits in DLA's `devDependencies` and is dropped by `--omit=dev`). Cold start of the DLA MCP child is paid once at session creation, not per tool call. Per-tool permission policy comes verbatim from RSM-3139's bucket table. `/migrate` lands via the existing `AI_SKILL_COMMANDS` registry pattern (`tools/common/ai/slash-commands.ts:8-13`) plus a bundled `apps/cli/ai/skills/migrate/SKILL.md` wrapping RSM-3139's skill body โ€” no new slash-dispatcher logic, no Studio TUI changes. + +The trade-offs we accept: DLA's MCP server does not honor `notifications/cancelled` (aborts surface to the model but orphan in-flight work upstream โ€” filesystem-bounded by DLA's resume-safe protocol), Studio inherits DLA's ~150 MB Playwright Chromium postinstall (mitigated at build time with `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`; end-user `npm install -g wp-studio` pays the cost), and Studio carries the integration layer rather than waiting for pi-coding-agent to grow first-party MCP support (the maintainer has publicly rejected this direction). The fallback path if Bridge runs into an unforeseen blocker is **Vendor-as-AgentTools** โ€” also mechanically possible, slightly cleaner at runtime, and gated on either an upstream `prepare: tsc` PR or a Studio-owned vendor copy via git submodule. + +--- + +## Approaches Investigated + +We re-evaluate four shapes against pi. Each names *exactly* where it plugs into the Studio CLI, what flows at `/migrate` time, and what we ship. + +### A. MCP-stdio bridge to pi `customTools` (recommended) + +**How it works.** Studio adds a `data-liberation` dep as `github:Automattic/data-liberation-agent#` (commit SHA pin โ€” DLA has zero git tags and no releases per `wave-1-upstream-and-bundling` ยง2.4). A new workspace package under `tools/dla/` (sibling of `tools/common/`) wires the bridge: + +``` +tools/dla/ +โ”œโ”€โ”€ package.json # `name: "@studio/dla"`, declared as a workspace package +โ”œโ”€โ”€ index.ts # `startDlaBridge()` / `DlaBridge.dispose()` +โ”œโ”€โ”€ bridge.ts # MCP client lifecycle: spawn child, connect, listTools, dispose +โ”œโ”€โ”€ agent-tool-adapter.ts # JSON Schema โ†’ pi ToolDefinition shim +โ”œโ”€โ”€ policy.ts # Permission buckets (reuses RSM-3139) +โ””โ”€โ”€ content-adapter.ts # MCP content[] โ†’ pi content[] mapper +``` + +Imported from `apps/cli/` as `@studio/dla` (matching the existing `@studio/common` aliasing pattern in `apps/cli/vite.config.base.ts:100-107`). + +At session bring-up (`apps/cli/ai/runtimes/pi/index.ts`, inside `runAgentSessionTurn`), the bridge spawns `node apps/cli/node_modules/.bin/tsx node_modules/data-liberation/src/mcp-server.ts` (or `node node_modules/data-liberation/dist/mcp-server.js` if we run DLA's `tsc` at build time โ€” see distribution section), calls `client.listTools()` with a 10 s timeout, and adapts each remote tool through `agent-tool-adapter.ts` into a pi `ToolDefinition`. The adapted tools join Studio's existing tool array passed to `createAgentSession({ customTools, tools })` (`apps/cli/ai/runtimes/pi/index.ts:282-285`). Teardown plugs into the existing `finally` block at `runtimes/pi/index.ts:222-225`. + +Per-tool permission gating lands as an inline extension factory on the same `DefaultResourceLoader` Studio already constructs (`apps/cli/ai/runtimes/pi/index.ts:256-267`). The factory subscribes to `pi.on('tool_call', handler)` and returns `{block: true, reason}` for tools in the "destructive" bucket (per `prior-art/rsm-3139-spec.md`'s policy table) โ€” mechanically equivalent to what `canUseTool` was in the Claude SDK. Inline extension factories load even when `noExtensions: true` (`resource-loader.js:272-278`), so no other `DefaultResourceLoader` flag changes. + +`/migrate` registers via the existing `AI_SKILL_COMMANDS` registry in `tools/common/ai/slash-commands.ts:8-13`. A bundled `apps/cli/ai/skills/migrate/SKILL.md` carries the wrapper-skill body verbatim from `prior-art/rsm-3139-spec.md` โ€” runtime-agnostic content, reused as-is. When the user types `/migrate `, Studio's existing slash dispatcher (`apps/cli/commands/ai/index.ts:600-633`) routes through `runAgentTurn(buildSkillInvocationPrompt('migrate'))`, which produces the prompt "Run the /migrate skill using the Skill tool." The model invokes Studio's `Skill` tool (`apps/cli/ai/tools/skill.ts`), which loads the SKILL.md body. The model then walks the workflow, calling `liberate_detect` / `liberate_discover` / `liberate_inspect` / `liberate_extract` / `liberate_qa` / `liberate_verify`, and finally `liberate_setup` and `liberate_import` with `delegate: true`. DLA returns the import manifest as a single text content block (JSON-stringified โ€” DLA's `textResult()` helper at `src/mcp-server.ts:32-34` does not emit `structuredContent`). The skill body instructs the model to hand off to Studio's existing `site_create` / `wp_cli` tools using the manifest paths. + +**Evidence.** +- DLA's MCP tool inventory (13 tools), input schemas, and `delegate: true` contract: `prior-art/wave-1-findings/wave-1-dla-inventory.md` ยง4 โ€” DLA-side facts unchanged from RSM-1639. +- Pi accepts plain JSON Schema in `ToolDefinition.parameters` โ€” explicit `!hasTypeBoxMetadata && isJsonSchemaObject` branch in `node_modules/@mariozechner/pi-ai/dist/utils/validation.js:253-280`. Each provider downstream (`anthropic.js:887-904`, `openai-completions.js:756`, `amazon-bedrock.js:599`, `google-shared.js:273-274`) treats `tool.parameters` as JSON Schema and shape-preservingly hands it to its provider-native input-schema slot. Verified by `wave-1-mcp-bridge-feasibility` ยง2. +- `@modelcontextprotocol/sdk@1.29.0` (the version actually resolved in Studio's lockfile, not the `^1.27.1` floor) ships typed `Client` + `StdioClientTransport` with `signal`-aware `RequestOptions`. Abort propagation is a one-liner: forward pi's `AbortSignal` to `client.callTool(_, _, {signal})` and the SDK emits `notifications/cancelled` to the server. Verified at `dist/esm/shared/protocol.js:677,709-710`. +- Per-tool permission gating reachable via `extensionFactories` on `DefaultResourceLoader`. The hook is `pi.on('tool_call', handler)` returning `{block: true, reason}`. AgentSession's `_installAgentToolHooks` proxies this through agent-core's `beforeToolCall` (`node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js:171-216`). Verified by `wave-1-pi-extensibility-surface` ยง3. +- Slash-command path: existing `AI_SKILL_COMMANDS` registry + Studio's bundled `Skill` tool. Identical to the existing `/annotate`, `/taxonomist`, `/need-for-speed`, `/rank-me-up` shape. Verified at `apps/cli/commands/ai/index.ts:619`. + +**Pros.** +- **No upstream blockers.** Every load-bearing surface (`Client`, `StdioClientTransport`, pi `customTools`, `DefaultResourceLoader.extensionFactories`, `AI_SKILL_COMMANDS`, `Skill` tool) is already shipped in installed dependencies. No PRs to pi, no PRs to DLA. +- **Permission policy is mechanically equivalent to `canUseTool`.** Brief 1's `tool_call` extension hook resolves the headline gap. Per-tool deny with a reason; the model receives an error message and the skill body instructs it to ask the user. +- **`delegate: true` flows naturally.** DLA's manifest comes back as a text content block; the skill body parses it; Studio's existing `site_create` / `wp_cli` tools consume the paths. +- **Sessions / replay / abort wiring carry over.** No tool-name-specific machinery in pi's session manager; aborts plumb through `AbortSignal` end-to-end (with the caveat below about server-side cancellation). +- **Schema cast is safe at runtime.** pi-ai's validation pipeline explicitly handles plain JSON Schema. The TypeScript cast `inputSchema as unknown as TSchema` is the same pattern Studio's own MCP *server* uses for the inverse direction (`apps/cli/ai/mcp-server.ts:27`). +- **Single integration touch-point per release.** DLA is pinned by SHA; bumping is a one-line `package.json` edit. + +**Cons / costs.** +- **DLA does not honor `notifications/cancelled`.** A cancelled `liberate_extract` will keep crawling server-side until the child process exits at session dispose. Filesystem cleanup is bounded by DLA's resume-safe protocol (`extraction-log.jsonl`, `session.json` per `wave-1-dla-inventory.md` ยง5). Mitigation: document the orphan behavior; consider upstreaming a `signal`-honoring patch to DLA. +- **`tsx` is in DLA's `devDependencies`, not `dependencies`.** Bridge's runtime spawn of `npx tsx src/mcp-server.ts` (DLA's documented `.mcp.json`) breaks under `npm install --omit=dev` plus packaged Electron CLI's missing `npx`. Fix: add `tsx` to `apps/cli/package.json` `dependencies` (~10 MB; pulls esbuild transitive) **or** run DLA's `tsc` at Studio build time and spawn `node dist/mcp-server.js`. Option A is the smallest landable change; Option B is faster at runtime (~hundreds-of-ms vs `tsx` startup) but adds a build step we don't otherwise need. +- **Playwright Chromium postinstall.** DLA's `postinstall: playwright install chromium` downloads ~150 MB per architecture into Playwright's user cache. Studio already has the JS `playwright` dep (`apps/cli/package.json:53`) so the browser binary may be cache-shared, but the postinstall *will* run and re-check. Mitigation: set `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` in Studio's build pipeline (Buildkite + GitHub Actions + `apps/cli/install:bundle`); accept the cost at end-user install. +- **0.70.2 is three minor versions behind pi's npm latest (0.73.1) and behind the scope rename to `@earendil-works/pi-coding-agent` (0.74.0).** The two load-bearing surfaces are `extensionFactories` (added 0.67.2) and `tool_call`/`tool_result` extension hooks (post-0.59-0.60 migration). Pi's release cadence is 3-7 patch releases per week; expect to rebase periodically. Treat as semver-loose. +- **Pi has zero MCP support in core; the maintainer has declined to add it.** This is a permanent fixture, not a wait-and-see. Studio carries the integration layer for the life of the dependency. +- **DLA's `liberate_preview` has a recursion-into-Studio hazard.** `lib/preview/playground-server.ts:160` calls `isStudioAvailable()` โ†’ `studio site create`. When Studio CLI hosts the bridge, this means `studio code` spawning `studio site create` as a child. Mitigation lives in the wrapper skill body, which should call `liberate_setup` / `liberate_import` with `delegate: true` (the canonical Studio integration shape per `wave-1-dla-inventory.md` ยง4) and let Studio drive site creation directly via its own `site_create` tool. + +### B. Vendor DLA's `src/lib/` as Studio-owned pi `ToolDefinition`s + +**How it works.** Import `data-liberation/lib/extraction/detect-platform.js`, `data-liberation/lib/preview/playground-server.js`, `data-liberation/adapters/*.js` directly from Studio's process. Each of DLA's 13 MCP tools becomes a Studio-authored pi `ToolDefinition` (via `defineTool`) whose `execute` calls the underlying lib function. Schemas are transcribed from `data-liberation/src/mcp-server.ts:48-234`'s inline JSON-Schema literals into typebox. The `delegate: true` manifest objects (literal constructions in DLA's mcp-server handler) are copied verbatim into Studio's wrappers โ€” `wave-1-vendor-as-agenttools` ยง2 traces all 13 tool-handler bodies to clean lib entry points, with 7 pass-throughs and 6 requiring 15-50 LOC of handler-mirroring per tool. + +**Evidence.** +- DLA's `src/lib/` and `src/adapters/` are self-contained: no Ink/UI imports, no cwd dependence, MCP `Server` is type-only + optional at runtime (defensive `?.` usage at `adapters/shared.ts:368-374` and `adapters/shopify.ts:1028-1031`). Verified by `wave-1-vendor-as-agenttools` ยง1. +- All 13 MCP tools have clean lib entry points. Schema and response-shape duplication is bounded: 13 tools, ~30 schema fields total, 14+ days of stability in DLA's `src/mcp-server.ts`. Verified by `wave-1-vendor-as-agenttools` ยง2-4. +- `delegate: true` is implemented entirely in `src/mcp-server.ts` (lines 472-496 for setup, 498-520 for import) and is not visible to `src/lib/`. Vendored wrappers re-implement the manifest as ~15 lines of literal-object construction with `existsSync` probes โ€” **easier** in this path than via MCP wire-format. Verified by `wave-1-vendor-as-agenttools` ยง5. +- Build-time integration is the blocker: DLA ships TS only โ€” `dist/` is gitignored, `package.json` has no `main`/`exports`/`module`, no `prepare` script. Studio's `vite.config.base.ts:79-91` externalizes anything listed in `apps/cli/package.json` `dependencies`, so the bundled CLI would crash at runtime importing `.ts` files. Three unblockers in `wave-1-vendor-as-agenttools` ยง7. + +**Pros.** +- **No child process, no IPC, no `tsx` runtime dep.** In-process module loads, direct TS exceptions, native abort via in-process `AbortSignal` passing. +- **`delegate: true` is easier than in Bridge.** No wire-format hop; Studio's wrappers construct the manifest as a JS object directly. +- **Schema and behavior are Studio's contract.** No surprise output shape changes when DLA's mcp-server.ts handler reshape happens โ€” Studio's wrappers stay stable until Studio updates them. +- **Lower per-call overhead.** No JSON-RPC framing, no stdio buffering, no JSON serialize/deserialize per tool call. + +**Cons / costs.** +- **Build-time integration is real work, with three unblock options:** + 1. **Upstream `prepare: tsc` + `exports` PR to DLA** โ€” 5-line PR but requires maintainer cooperation. If accepted, this is the cleanest unblocker. + 2. **Studio's `postinstall-npm.mjs` runs `tsc` against DLA** โ€” brittle because DLA's `typescript` is a devDep skipped by `--omit=dev`. Workable but needs careful sequencing. + 3. **Vendor copy via `git submodule add` under `apps/cli/vendor/data-liberation/`** โ€” Studio-owned, sidesteps `npm` mechanics entirely, lets Vite transpile the TS source. Loses SHA-pin convenience but plays to Studio's existing build pipeline. Preferred unblocker if (1) isn't immediately landable. +- **Schema / response-shape transcription is manual.** ~30 schema fields and 6 response-assembly bodies to mirror. Drift risk is medium-low (stable 14+ days, small surface) but real on every DLA release. Mitigation: integration tests that pin a DLA SHA in a fixture and assert response shapes against `JSON.parse(await dlaTool.callTool(args))` for each tool. +- **DLA's `src/lib/*` and `src/adapters/*` are not declared a public API.** `wave-1-dla-inventory.md` ยง2: "Nothing in `package.json` exposes them as a public API." Vendor takes on maintenance cost of tracking DLA's internal API drift โ€” 17 commits to `src/lib/` in the last 60 days, mostly additive but the contract is informal. +- **Same recursion-into-Studio hazard as Bridge.** `lib/preview/playground-server.ts:160` checks `isStudioAvailable()`. Studio wrappers must pass a `_noStudio: true` flag (`playground-server.ts:160` exposes it as an internal option) or call `startStudioPreview` directly via a Studio-owned path. The fix is mechanically smaller in this path because Studio controls the wrapper code, not DLA's mcp-server. +- **Vendored PHP scripts (`lib/preview/scripts/import-wxr.php`, `import-products.php`) must ship alongside compiled JS.** DLA resolves them via `fileURLToPath(import.meta.url)`. Studio's existing `viteStaticCopy` machinery handles this fine, but the bundling target must include the `scripts/` dir. + +**Verdict (B vs A):** Vendor is the lighter ongoing path after the one-time build-integration cost โ€” no child-process lifecycle, no stdio parsing, faster invocations. Bridge wins on time-to-first-PR because there's no upstream PR to wait for and no schema transcription. We recommend **Bridge** as the canonical path with **Vendor** as the fallback if the bridge mechanics turn up an unforeseen blocker. The implementation-phase planner should hold the door open for switching: both approaches share the same wrapper-skill body, the same permission-policy buckets, the same `AI_SKILL_COMMANDS` slash-command registration, and the same `tools/dla/` directory shape. Migrating from Bridge to Vendor later would touch only `agent-tool-adapter.ts` and add a `tsc` step; the rest stays. + +### C. Subprocess (single `dla_run` tool, escape-hatch / fallback) + +**How it works.** Studio wraps DLA's CLI as a single pi `AgentTool` `dla_run({subcommand, target, outputDir?, extraArgs?})`. The tool spawns ` apps/cli/node_modules/.bin/tsx node_modules/data-liberation/src/cli.ts ...` with `NO_COLOR=1 CI=1` to suppress Ink redraws, strips ANSI from stdout/stderr, and returns the text payload as a tool result. Abort and streaming use pi's `signal` and `onUpdate` parameters (which `defineTool` drops, so the wrapper must use a raw `AgentTool` shape โ€” see `wave-1-subprocess-revisit` ยง6). `--non-interactive` is forced on the two subcommands that block (`extract`, `preview`). + +**Evidence.** +- DLA's CLI has 7 useful subcommands (`inspect`, `qa`, `verify`, `setup`, `preview`, `import`, bare-URL extract) โ€” verified at `data-liberation/src/cli.ts:1-176`. Verified by `wave-1-subprocess-revisit` ยง1. +- No `--json` mode โ€” verified via `grep -rn "JSON\.stringify\|--json" src/cli.ts src/ui/` returning zero matches. The model consumes ANSI-stripped terminal text. +- `--non-interactive` honored on `extract` and `preview`; `setup` requires creds up-front. +- Latency: `npx tsx src/cli.ts --version` warm ~100 ms; `inspect http://example.invalid` ~690 ms (network-bound). Real work dominates startup. `wave-1-subprocess-revisit` ยง5. + +**Pros.** +- **Minimum Studio LOC.** ~60 LOC for a single-tool wrapper; ~350 LOC fan-out. +- **DLA upgrades land instantly via SHA pin.** No schema sync, no response-shape mirroring. +- **Black-box delegation: DLA's CLI grammar is the contract.** Stable enough to depend on. +- **No long-lived child to manage.** Each spawn is short-lived; the agent's `dispose` already exits the CLI process at the end of every `/migrate` flow. + +**Cons / costs.** +- **`delegate: true` is structurally unreachable from CLI.** DLA's CLI `import` always REST-imports; `src/cli.ts:138-149` mandates `--site`/`--username`/`--token` and `process.exit(1)` on missing. Studio's wrapper must reconstruct the import manifest from the on-disk output directory โ€” duplicating DLA's manifest contract on the Studio side. This defeats most of the "DLA is a black box" simplicity argument and is the single biggest reason this approach loses to Bridge. +- **6 of 13 MCP tools are unreachable.** `liberate_detect`, `liberate_discover`, `liberate_map_apis`, `liberate_probe`, `liberate_status`, `liberate_preview_stop` have no CLI subcommand equivalents. The bare-URL `` extract bundles `_detect` + `_discover` + `_extract` + `_autoPreview` into one Ink run; the agent cannot observe the phases. +- **Output is messy.** Terminal text with ASCII-art headers, Unicode glyphs, possibly multi-megabyte progress logs for large extracts. ANSI stripping helps; structure cannot be recovered. Brittle against any DLA UI refactor. +- **No phase observability.** The model decides "run extract" then waits for an opaque Ink session to complete. + +**Verdict:** Works with caveats. Not the canonical path โ€” Bridge or Vendor are dominantly better on every UX axis. Subprocess earns its keep as **(i)** a standalone `studio migrate ` non-agent CLI command (full DLA UI fidelity, no agent overhead, useful for users who want the headless path), or **(ii)** a fast MVP / proof-of-concept to land `/migrate` behind a feature flag before committing engineering time to Bridge wiring, or **(iii)** a graceful-degradation fallback if Bridge's child spawn fails at runtime (DLA binary missing, sandbox restriction). + +### D. Wait for pi-coding-agent to grow first-party MCP support (killed) + +**How it works.** Land an `mcpServers: { name: { command, args, env } }` slot upstream in `pi-coding-agent`'s `CreateAgentSessionOptions`. Studio's wiring then mirrors the RSM-1639 Approach A shape โ€” one config object per MCP server, no Studio-side bridge. + +**Evidence.** +- Maintainer publicly rejected this. Issue #563 (`feat(coding-agent): Add MCP extension example`) closed Jan 2026 with: *"Alright, [@nicobailon](https://github.com/nicobailon) made this, so we don't need to add an MCP example anymore. https://www.npmjs.com/package/pi-mcp-adapter"*. The blessed answer is the third-party `pi-mcp-adapter`, built on pi's already-public extension API. `wave-1-upstream-and-bundling` ยง1.2. +- Maintainer philosophy (`CONTRIBUTING.md`): *"pi's core is minimal. If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected."* `wave-1-upstream-and-bundling` ยง1.3. +- Drive-by PRs are auto-closed under a Contribution Gate (PR #3774 was auto-closed within seconds, 2026-04-26). New contributors require an `lgtm` from a maintainer to even submit. +- Repo is mid-scope-rename (`badlogic/pi-mono` โ†’ `earendil-works/pi`, `@mariozechner/*` โ†’ `@earendil-works/*`) as of 0.74.0 (2026-05-07). Project velocity is 3-7 patch releases per week, with frequent breaking changes. Studio's pin lags by 3+ minor versions already. + +**Pros.** None mechanically relevant under current maintainer signals. + +**Cons.** +- Timeline 8-16 weeks realistic, "never" non-negligible. +- Even an accepted PR has to wait through the scope-rename refactor to settle. +- Slower than in-tree in every scenario. + +**Verdict:** Killed. Studio carries the integration layer. The one open thread is whether `pi-mcp-adapter` itself is a viable Studio dep (it's MIT-licensed, 663 stars, recent activity, built on pi's public `ExtensionFactory` API). We chose not to evaluate it for this round โ€” see Open Questions. + +--- + +## Comparison + +| Dimension | A. Bridge (recommended) | B. Vendor | C. Subprocess (escape-hatch) | D. Upstream pi (killed) | +|---|---|---|---|---| +| Mechanical verdict | works with caveats | works with caveats | works with caveats | blocked | +| Time to first PR | 2-4 weeks | 1-3 weeks after upstream PR or vendor copy | 1 week | 8-16 weeks | +| Studio-owned LOC | ~250 | ~600+ | ~60 | ~10 (config only, if it landed) | +| Tool coverage | 13/13 | 13/13 | 7/13 | 13/13 | +| `delegate: true` available | yes | yes (easier than Bridge) | **no** (structural) | yes | +| Permission gating | `pi.on('tool_call', โ€ฆ)` extension hook | per-tool wrapper handler | per-`subcommand` string parser | `canUseTool`-equivalent (would-be) | +| Per-call latency | JSON-RPC + stdio (~10 ms) | in-process function call | `tsx` spawn ~100-300 ms | in-process | +| Per-session latency | one DLA child spawn at bring-up | zero | per-tool-call spawn | zero | +| Agent phase observability | high (13 discrete tools) | high (13 discrete tools) | low (bare-URL extract bundles 4 phases) | high | +| Output shape | structured MCP `content[]` | Studio-defined JS objects | ANSI-stripped terminal text | structured MCP `content[]` | +| Abort propagation (client โ†’ tool) | `signal` โ†’ `notifications/cancelled` (sent but ignored server-side) | native `AbortSignal` passthrough | `killProcessTree` from `pi-coding-agent/dist/utils/shell.js` | same as A | +| Schema drift risk | low (auto-synced via `ListTools` at startup) | medium-low (13 tools manually transcribed) | low (no schema sync; uses CLI grammar) | n/a | +| Bundling blocker | `tsx` in DLA devDeps; `npx` not in packaged CLI (fix: add `tsx` to Studio deps) | no `dist/`, no `exports`, no `prepare` script (fix: upstream PR or vendor copy) | same as A | n/a | +| Recursion-into-Studio hazard (`liberate_preview`) | mitigate in skill body (`delegate: true` route) | mitigate in wrapper (`_noStudio` flag) | bypassed (CLI `preview` is its own subcommand) | mitigate in skill body | +| Playwright Chromium postinstall | inherits ~150 MB | inherits ~150 MB (unless vendor-copy + lazy import) | inherits ~150 MB | inherits ~150 MB | +| External maintainer dependency | DLA (Automattic), pi (single maintainer) | DLA internal `src/lib/` API (informal contract) | DLA CLI grammar (documented) | pi maintainer cooperation | +| Survives pi 0.71+ breaking changes | low risk (uses `customTools` + `extensionFactories`, both stable surfaces) | same | same | n/a | +| Migration cost to swap in B later | low (only `agent-tool-adapter.ts` changes; rest of `tools/dla/` shape stays) | n/a | high (different wrapper shape) | n/a | + +--- + +## Recommendation + +**Land Bridge (Approach A) for the canonical `/migrate` UX, behind a feature flag, with Subprocess (Approach C) as a `studio migrate ` standalone CLI command for the non-agent / headless flow.** Defer Vendor (Approach B) as a documented fallback if Bridge runs into an unforeseen blocker during implementation. Kill Upstream pi (Approach D) โ€” the maintainer has publicly rejected this direction and the timeline is worse than in-tree. + +**Concrete next steps for the implementation phase:** + +1. **Add DLA as a `github:` dep** at `apps/cli/package.json` `dependencies` (SHA pin; recommend `17219c42b0420267302b138bf402930508006e0e`, the HEAD audited by `wave-1-vendor-as-agenttools`). Bump cadence: weekly to start (matches DLA's commit velocity), monthly once stable. +2. **Add `tsx` to `apps/cli/package.json` `dependencies`** so it survives `--omit=dev`. Spawn via ` apps/cli/node_modules/.bin/tsx ...` rather than `npx tsx`. Trade-off: ~10 MB plus esbuild transitives. Faster alternative (replace post-MVP): run DLA's `tsc` at Studio build time and spawn `node dist/mcp-server.js` โ€” needs DLA tsconfig adjustments because DLA's TS source assumes loose ESM resolution. +3. **Scaffold `tools/dla/` as a workspace package** (sibling of `tools/common/`) with `name: "@studio/dla"` and consume it from `apps/cli/` as `@studio/dla`. The file layout (sketched in `wave-1-mcp-bridge-feasibility` ยง6 under the now-superseded `apps/cli/ai/dla/` path) carries over verbatim. One new param to `buildAgentTools(config, isForkedByDesktop, remoteSession, dlaBridge?)`. Bridge bring-up in `runAgentSessionTurn`; teardown in the existing `finally` block at `runtimes/pi/index.ts:222-225`. `signal` plumbing forwards pi's `AbortSignal` to `client.callTool(_, _, {signal})`. Add `@studio/dla` to `tsconfig.base.json` path aliases and `apps/cli/vite.config.base.ts` resolve aliases (mirror the existing `@studio/common` entry). +4. **Wire the permission extension factory.** Construct `new DefaultResourceLoader({ ...currentOptions, extensionFactories: [createDlaPolicyFactory(buckets)] })` at the same construction site (`apps/cli/ai/runtimes/pi/index.ts:256-267`). The factory subscribes to `pi.on('tool_call', ...)` and returns `{block: true, reason}` for `destructive`-bucket tools. **Reuse RSM-3139's bucket table verbatim** (`prior-art/rsm-3139-spec.md`'s policy section is runtime-agnostic). The "advisory + adapter-throw" defense-in-depth pattern from `wave-1-mcp-bridge-feasibility` ยง5 is the recommended layering: system-prompt language โ†’ skill-body discipline โ†’ adapter-layer hard-stop on `delegate:true` for `liberate_import`. +5. **Ship the `/migrate` skill** at `apps/cli/ai/skills/migrate/SKILL.md`. **Reuse RSM-3139's `migrate-site.md` body verbatim** (`prior-art/rsm-3139-spec.md`'s skill content is runtime-agnostic). Skill loader (`apps/cli/ai/skills.ts:27-51`) discovers it on startup. Note that `apps/cli/ai/skills` is not currently copied by `vite.config.prod.ts` (the same gap RSM-1639 flagged for the old `ai/plugin` path) โ€” confirm or fix as part of this work. +6. **Register the slash command** in `tools/common/ai/slash-commands.ts:8-13`: `{ name: 'migrate', description: __('Migrate a site from a closed web platform to WordPress') }`. The existing `AI_SKILL_COMMANDS` dispatcher routes through `runAgentTurn(buildSkillInvocationPrompt('migrate'))`; the model calls the `Skill` tool. +7. **Set `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`** in Studio's Buildkite + GitHub Actions + `apps/cli/install:bundle` to skip Chromium download at build time. End-user `npm install -g wp-studio` pays the 150 MB cost on first install; Wix/Squarespace adapters that need Chromium will fail at runtime without it (the right place to fail; DLA's `cli.js:9-12,65-120` already bootstraps a Chromium check). +8. **Ship Subprocess as a separate `studio migrate ` CLI command** (not a `studio code` slash) per `wave-1-subprocess-revisit` ยง8. ~60 LOC; gives users a non-agent headless path. Zero conflict with the agent-side `/migrate`. +9. **Document the orphan-work behavior** for cancelled tool calls (DLA doesn't honor `notifications/cancelled`). File an upstream issue on DLA to wire `signal` / `progressToken` into its tool handlers. + +**Why Bridge over Vendor for the canonical path:** Bridge ships sooner (no upstream PR or vendor-copy decision needed before code can land), its schema/output contract auto-syncs via `ListTools` at session bring-up (no schema transcription), and the migration cost from Bridge โ†’ Vendor later is small (only `agent-tool-adapter.ts` changes; the rest of `tools/dla/` shape stays). The trade โ€” child-process lifecycle plus stdio framing โ€” is bounded by the existing `session.dispose()` plumbing and one line of `signal`-forwarding. + +**What's reusable from prior art (do not re-derive):** +- **Wrapper-skill body** for `/migrate` โ€” `prior-art/rsm-3139-spec.md`'s `migrate-site.md` content. Runtime-agnostic. +- **Per-tool permission-policy buckets** โ€” `prior-art/rsm-3139-spec.md`'s policy table. Runtime-agnostic. Reuse the bucket assignments (`liberate_import` = destructive, `liberate_extract` with non-dry-run = fs-write, etc.) verbatim. +- **DLA tool inventory, surfaces, `delegate: true` contract, runtime expectations** โ€” `prior-art/wave-1-findings/wave-1-dla-inventory.md`. DLA-side facts unchanged from RSM-1639. +- **Studio build pipeline overview** โ€” `prior-art/wave-1-findings/wave-1-bundling-distribution.md`. The SDK-specific bits are stale (re-derived in `wave-1-upstream-and-bundling`), but the pipeline shape (Vite โ†’ ASAR โ†’ electron-forge โ†’ `extraResource`) is unchanged. + +--- + +## Open Questions + +1. **Is `pi-mcp-adapter` viable as a Studio dependency?** The upstream-pi finding surfaced `pi-mcp-adapter` (npmjs.com/package/pi-mcp-adapter, 663 stars, MIT, author Nico Bailon, deps on `@earendil-works/pi-ai`) โ€” pi's maintainer-blessed answer for MCP support. It's built on pi's public `ExtensionFactory` API, which Brief 1 confirmed Studio can reach via `DefaultResourceLoader({ extensionFactories })`. If adopted, it could turn Bridge into a thin "configure pi-mcp-adapter + register DLA's server" rather than a Studio-owned MCP-client wrapper. We chose not to investigate it for this round because (a) the Studio-owned Bridge sketch is already small (~250 LOC) using primitives already in Studio's deps, (b) adopting a single-maintainer npm dep with known bugs (issue #4326 โ€” TUI crash on non-string tool descriptions) trades ~100 LOC for non-trivial supply-chain risk, and (c) Bridge lives in pi's `customTools` slot rather than the extension API, so migrating to pi-mcp-adapter later is a Studio-internal refactor, not a stop-energy redo. Worth a 30-minute spike before the implementation phase commits to Studio-owned wiring โ€” if pi-mcp-adapter's design is materially nicer than the wave-1 sketch, the implementation phase can pivot. + +2. **`tsx` runtime overhead vs. `dist/mcp-server.js` build step.** Both unblockers work; nobody has timed them on a packaged Studio install. Time `npx tsx src/mcp-server.ts` startup and `node dist/mcp-server.js` startup against current DLA HEAD on a cold cache (macOS + Windows + Linux); if `tsx` is < 500 ms warm, accept the ~10 MB dep cost. If it's > 1 s, run DLA's `tsc` at Studio build time. + +3. **Should DLA wire `signal` / `progressToken` into its MCP tool handlers?** Currently it doesn't, so cancelled `liberate_*` calls orphan in-flight work upstream. Filesystem-bounded by DLA's resume-safe protocol, but worth fixing. Upstream issue + PR to DLA โ€” out of RSM-3143's scope; file before implementation lands. + +4. **Will `apps/cli/ai/skills` be copied by `vite.config.prod.ts`?** RSM-1639 flagged the same gap for the old `ai/plugin` directory. `wave-1-upstream-and-bundling` ยง3.1 confirms: prod build does not copy `ai/skills/`; runtime degrades silently to "no skills" via the `console.warn` at `apps/cli/ai/skills.ts:33-39`. Confirm whether this is a real prod-build gap or an `import.meta.dirname` resolution quirk; fix as part of the `/migrate` skill landing. **Out-of-scope flag for the Electron-side owner if the fix touches `apps/studio/`.** + +5. **DLA's `liberate_preview` recursion-into-Studio.** When DLA detects `studio` on PATH it shells out to `studio site create`. The wrapper skill body should drive this via `liberate_setup` / `liberate_import` `delegate: true` (returns the manifest, Studio's `site_create` tool does the work). But the agent might call `liberate_preview` directly โ€” Studio must either (a) override `_noStudio: true` for all `liberate_preview` calls from the bridge, or (b) trust the skill body to never invoke `liberate_preview` when Studio is host. Option (a) is safer; (b) is mechanically smaller. + +6. **Permission policy: `extensionFactories` is recent (0.67.2, 2026-04-14).** Brief 1 rated the `tool_call` extension hook as medium-risk โ€” recent regression fixes in 0.70.x. If pi 0.71+ tightens semantics, Studio's `createDlaPolicyFactory` may need touch-up. Bookmarked for the pi version-bump cycle; not a blocker. + +--- + +## Appendix: Wave 1 brief outcomes + +| Brief | Verdict | Confidence | Key finding | +|---|---|---|---| +| `wave-1-pi-extensibility-surface` | Resolves permission-gate ambiguity | High | `extensionFactories` reachable, `tool_call` hook = `canUseTool` equivalent | +| `wave-1-mcp-bridge-feasibility` | works-with-caveats | High | ~250 LOC sketch, JSON Schema cast safe, abort-server-side caveat documented | +| `wave-1-vendor-as-agenttools` | works-with-caveats | Medium-high | Build integration is real blocker; 3 unblockers spelled out | +| `wave-1-subprocess-revisit` | works-with-caveats / escape-hatch only | Medium | `delegate:true` unreachable; 6/13 tools unreachable; right shape for `studio migrate ` standalone | +| `wave-1-upstream-and-bundling` | upstream killed; bundling works with caveats | High | Maintainer rejected MCP-in-core; `tsx` install path needs fixing; ~200-300 MB marginal disk after prune | + +All five briefs returned clean mechanical verdicts. No wave 2 required. Implementation-phase planner has enough evidence to write a spec. + +--- + +## Sources + +This report synthesizes evidence from five wave-1 research briefs in `issues/rsm-3143-dla-pi-research/wave-1-findings/`, plus preserved prior art under `issues/rsm-3143-dla-pi-research/prior-art/`. All primary evidence is sourced in the wave-1 findings themselves โ€” see their "Sources" sections for verbatim file paths, line ranges, and command outputs. + +Cross-cutting references: + +- `prior-art/rsm-1639-research-report.md` โ€” original DLA-integration research (Done). DLA-side sections still authoritative. Host-side (Approach A/B/C wired against `claude-agent-sdk`) is stale. +- `prior-art/wave-1-findings/wave-1-dla-inventory.md` โ€” DLA inventory (MCP tools, surfaces, `delegate: true`, skill bodies). Still authoritative. +- `prior-art/rsm-3139-spec.md` โ€” cancelled Approach C spec. The wrapper-skill body and permission-policy buckets are runtime-agnostic and reused as-is in this report's recommendation. +- `prior-art/rsm-3139-plan.md` โ€” cancelled Approach C plan. Useful for the "Ambiguities" section which discovered the pi-coding-agent migration mid-flight. diff --git a/issues/rsm-3143-dla-pi-research/review-1.md b/issues/rsm-3143-dla-pi-research/review-1.md new file mode 100644 index 0000000000..7bbe796cee --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/review-1.md @@ -0,0 +1,93 @@ +# Review 1 + +## Verdict: rejected + +## Summary + +The 9 implementation commits (T1โ€“T9) land a clean, well-tested workspace package at `tools/dla/`, a pi-runtime wiring that's properly feature-flagged, a non-MCP standalone `studio migrate` command, and CI Playwright skip wiring. Code quality, test coverage, type safety, lint, scope (no `apps/studio/` touches), and translation wrapping are all in good shape. All 1721 unit tests pass, the CLI builds cleanly, and both `studio code` and `studio migrate` boot. + +However, the headline behavior of the integration โ€” the DLA bridge actually loading DLA tools into the agent โ€” does not work at runtime. Booting `studio code` with `STUDIO_DLA_ENABLED=1` reproduces the implementer-flagged `tsx/dist/cli.mjs` resolution bug: `tsx`'s `exports` map does not expose that subpath, so `require.resolve('tsx/dist/cli.mjs')` throws `ERR_PACKAGE_PATH_NOT_EXPORTED`. The bridge's graceful-degradation path catches the throw and continues without DLA tools โ€” the agent still answers, but the `liberate_*` tools are never registered. This contradicts T6's acceptance criterion ("`liberate_detect` shows up in the tool list"). The unit tests miss the bug because they bypass the production transport via the (otherwise sound) `BridgeTransportProvider` injection. + +The fix is a one-liner (`tsx/cli` instead of `tsx/dist/cli.mjs`) โ€” the standalone migrate command's `resolveTsxCli` (`apps/cli/commands/migrate/resolvers.ts`) already uses the correct form. + +## Checks + +| Check | Command | Result | +| ------------- | -------------------------------------------------------------------------------------- | -------------------------------------------- | +| Unit tests | `npm test` (root, all workspaces) | โœ… 188 files / 1721 tests passed | +| Type check | `npm run typecheck` | โœ… All workspaces pass (incl. `@studio/dla`) | +| Lint | `npx eslint tools/dla apps/cli/ai/runtimes/pi/index.ts apps/cli/commands/migrate ...` | โœ… 0 errors, 1 ignored-file warning on .md | +| CLI build | `npm run cli:build` | โœ… Vite emits all bundles | +| Scope | `git diff --stat 46d83870..43a7d920 -- 'apps/studio/'` | โœ… No changes to `apps/studio/` | +| Flag gating | grep `STUDIO_DLA_ENABLED` across runtime โ€” both bridge spawn + policy factory | โœ… Both early-return when flag is unset | + +## CLI Verification + +Evidence saved to `issues/rsm-3143-dla-pi-research/verification/`: + +### Command Runs + +| Command invoked | Expected behavior | Observed behavior | Pass? | +| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ----- | +| `node apps/cli/dist/cli/main.mjs code --help` | Prints `studio code` usage | Usage printed; exit 0 | yes | +| `node apps/cli/dist/cli/main.mjs migrate --help` | Prints `migrate ` usage with `--output` / `--non-interactive` | Usage printed; exit 0 | yes | +| `LANG=es node apps/cli/dist/cli/main.mjs migrate --help` | yargs chrome translated, new strings unchanged (no Spanish .jed yet) | yargs translates "Show help", "Positionals:", etc.; new `__()`-wrapped strings stay in English (expected) | yes | +| `node --input-type=module 'require.resolve("tsx/dist/cli.mjs")'` against root `node_modules` | Resolve path | FAILS with `ERR_PACKAGE_PATH_NOT_EXPORTED` (tsx 4.21.0 exports map has no `./dist/cli.mjs` key) | no | +| `STUDIO_DLA_ENABLED=1 ... node apps/cli/dist/cli/main.mjs code --json "hello world"` | DLA bridge boots, `liberate_*` tools registered as customTools | Bridge spawn fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`; degraded path takes over; 0 `liberate_*` tools seen | **no** | + +### Output Evidence + +| Run | Evidence file | +| -------------------------------------------- | ------------------------------------------------------------------- | +| `studio code --help` | `verification/review-1-code-help-stdout.txt` | +| `studio migrate --help` | `verification/review-1-migrate-help-stdout.txt` | +| `studio migrate --help` (Spanish) | `verification/review-1-migrate-help-i18n-stdout.txt` | +| `tsx`/DLA resolution check | `verification/review-1-tsx-resolution.txt` | +| `STUDIO_DLA_ENABLED=1 studio code` transcript | `verification/review-1-dla-enabled-stdout.txt` | +| Bridge-degraded summary + root cause | `verification/review-1-dla-bridge-degraded.txt` | + +## Issues + +### Issue 1: DLA bridge spawn fails at runtime โ€” `tsx/dist/cli.mjs` is not an exported subpath of the `tsx` package + +**What's wrong:** `require.resolve('tsx/dist/cli.mjs')` throws `ERR_PACKAGE_PATH_NOT_EXPORTED` because `tsx@4.21.0`'s `exports` map only exposes `./cli` โ†’ `./dist/cli.mjs`; the literal `./dist/cli.mjs` subpath is intentionally not exported. The implementer flagged this in the prompt as a likely must-fix; I reproduced the failure end-to-end. With `STUDIO_DLA_ENABLED=1`, the bridge log emits + +``` +[@studio/dla] failed to spawn DLA MCP server (Package subpath './dist/cli.mjs' is not defined by "exports" in /โ€ฆ/node_modules/tsx/package.json); continuing without DLA tools. +[studio code] DLA bridge degraded; continuing without DLA tools (...). +``` + +and no `liberate_*` tool ever shows up in the agent transcript. T6's acceptance criterion ("`liberate_detect` shows up in the tool list") therefore is not satisfied for any real session โ€” every invocation falls into the degraded path. The graceful-degradation guardrail is correct and prevents a crash, but the bridge itself is non-functional today. + +The standalone `studio migrate` command (added in T8) already uses the canonical form (`apps/cli/commands/migrate/resolvers.ts:30` โ†’ `require.resolve('tsx/cli')`) which resolves cleanly to the same file. The bridge needs the same spelling. + +**Where:** `tools/dla/bridge.ts:267` โ€” `const tsxCli = require.resolve( 'tsx/dist/cli.mjs' );` + +**Expected:** `const tsxCli = require.resolve( 'tsx/cli' );` (or follow the existing pattern in `apps/cli/commands/migrate/resolvers.ts:30`). Add a unit test that exercises the production `defaultTransportProvider` resolution (not just the swappable `BridgeTransportProvider`) so this regression cannot recur silently โ€” e.g. a vitest that calls `require.resolve('tsx/cli')` and `require.resolve('data-liberation/src/mcp-server.ts')` and asserts both succeed. + +**Severity:** must-fix + +### Issue 2 (informational, not a blocker): `tools/dla/tsconfig.json` `outDir: dist` collides with built artifacts + +The package's `tsconfig.json` sets `outDir: "dist"` and `declaration: true`, and a `tools/dla/dist/` directory is present in the worktree (from a `tsc` build run). The directory is not in `.gitignore` (no `.gitignore` at `tools/dla/` and the root one does not cover it) so a future `npm run -w @studio/dla build` could commit emitted `.d.ts` files. `tools/common/` has the same shape, so this is a pre-existing pattern rather than a T1 regression โ€” flagging only for awareness. + +**Severity:** should-fix (pre-existing pattern; not blocking this PR) + +## Implementer-flagged items โ€” disposition + +The prompt asked me to confirm a list of flagged items. Disposition: + +1. **T3 `tsx` path bug** โ€” Confirmed must-fix; root cause of Issue 1 above. End-to-end reproducer captured. +2. **T9 Playwright env-var inertness** โ€” Confirmed: T9 ships the env var in `.buildkite/pipeline.yml`, `.buildkite/release-build-and-distribute.yml`, `.buildkite/release-pipelines/code-freeze.yml`, `.github/workflows/publish-npm-package.yml`, and `apps/cli/package.json`'s `install:bundle` script. Whether the env var actually saves the ~150 MB depends on whether the active `data-liberation` SHA's postinstall path reads it โ€” that empirical check is out of scope here. The defensive ship-it is the right call and matches the planner direction. Not a must-fix. +3. **T3 deviations from sketch:** all sound. + - Using pi-coding-agent `ToolDefinition` rather than pi-agent-core `AgentTool` is consistent with how `apps/cli/ai/runtimes/pi/index.ts` already feeds `customTools` (it converts its native `AgentTool` values to `ToolDefinition` via `toToolDefinition`). The bridge skips that conversion by emitting `ToolDefinition` directly โ€” cleaner. + - Test path `tools/dla/tests/` mirrors `tools/common/lib/tests/`. Fine. + - `BridgeTransportProvider` injection is a small, well-documented extension over the spec; tests use it appropriately. It is also the reason the tsx-resolution bug evaded automated coverage โ€” see Issue 1's recommendation to add a real-resolution test. + - `degraded`/`degradationReason` are useful and consumed in `apps/cli/ai/runtimes/pi/index.ts:267-273` for a warning log. +4. **T6 remote-site branch comment** โ€” Present at `apps/cli/ai/runtimes/pi/index.ts:494-497` (JSDoc on `buildAgentTools`). Clear. +5. **T5 frontmatter** โ€” Confirmed: only `name` + `description`. The skill loader (`apps/cli/ai/skills.ts`) consumes those two fields; omitting `user-invocable`/`argument-hint`/`allowed-tools` is the right call since they are no-ops. Skill body wires `liberate_*` (bare names) and steers callers toward `delegate: true` for `liberate_import` โ€” matches the bridge contract. +6. **`STUDIO_DLA_ENABLED` feature flag** โ€” Confirmed both paths gate on it (`apps/cli/ai/runtimes/pi/index.ts:260` for bridge spawn, line 468 for policy factory). Both early-return when the flag is unset, and the runtime-policy and runtime-bridge tests exercise the unset / `=0` / `=1` cases for each. With the flag off, the runtime is structurally identical to pre-T6. + +## Next steps if rejected + +The fix is mechanical and small (one line in `tools/dla/bridge.ts`, plus a test that exercises real `require.resolve` for the two entry points). Once the bridge spawn works, re-run `STUDIO_DLA_ENABLED=1 studio code` and confirm `liberate_*` tools show up in the customTools list to satisfy T6's acceptance criterion. After that, this PR is in good shape and should approve cleanly. diff --git a/issues/rsm-3143-dla-pi-research/review-2.md b/issues/rsm-3143-dla-pi-research/review-2.md new file mode 100644 index 0000000000..9736ae57ef --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/review-2.md @@ -0,0 +1,51 @@ +# Review 2 + +## Verdict: approved + +## Summary + +Re-review of commit `65ce8848` ("Fix tsx resolution in DLA bridge"), the must-fix from review-1. Scope is exactly the two files touched by that commit (`tools/dla/bridge.ts` and `tools/dla/tests/bridge.test.ts`); nothing else from T1โ€“T9 moved. The fix is the expected one-liner โ€” `require.resolve('tsx/cli')` instead of `require.resolve('tsx/dist/cli.mjs')` โ€” and the comment block around it now correctly explains why the deep `dist/` subpath fails (no exports key). The new test coverage is solid: three tests in a new `defaultTransportProvider โ€” real require.resolve paths` block exercise the production code path (not the injected `BridgeTransportProvider` stub), including a regression guard that fires if upstream `tsx` ever re-exposes the deep subpath. I confirmed the suite actually catches the bug by temporarily reverting the one-line fix and re-running the bridge tests โ€” the e2e `connect()` test fails fast with the expected `ERR_PACKAGE_PATH_NOT_EXPORTED` assertion. With the fix restored, all gates pass. + +## Checks + +| Check | Command | Result | +| -------------- | ------------------------------------------------------------- | ------------------------------------- | +| DLA tests | `npx vitest run tools/dla/` | 4 files / 45 tests passed | +| Bridge tests | `npx vitest run tools/dla/tests/bridge.test.ts --reporter verbose` | 14 tests passed (3 new in real-resolve block) | +| CLI tests | `npx vitest run --project cli` | 61 files / 632 tests passed | +| Type check | `npm run typecheck` | All workspaces pass (incl. `@studio/dla`) | +| Lint | `npx eslint tools/dla/bridge.ts tools/dla/tests/bridge.test.ts` | 0 errors | +| CLI build | `npm run cli:build` | Vite emits all bundles | +| Scope | `git diff --stat 43a7d920..65ce8848 -- tools/` | Only `bridge.ts` (+8/-2) and `bridge.test.ts` (+96/-1) | +| Regression catch | Revert one-line fix locally, re-run `tools/dla/tests/bridge.test.ts` | e2e `connect()` test fails with `ERR_PACKAGE_PATH_NOT_EXPORTED` โ€” fix restored | + +## CLI Verification + +Evidence saved to `issues/rsm-3143-dla-pi-research/verification/`: + +### Command Runs + +| Command invoked | Expected | Observed | Pass? | +| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----- | +| `STUDIO_DLA_ENABLED=1 node apps/cli/dist/cli/main.mjs code --help` | Prints usage; exit 0 | Usage printed; exit 0 | yes | +| `require.resolve('tsx/cli')` anchored to built `apps/cli/dist/cli/main.mjs` | Resolves to `node_modules/tsx/dist/cli.mjs` | Resolved to `โ€ฆ/node_modules/tsx/dist/cli.mjs` | yes | +| `require.resolve('data-liberation/src/mcp-server.ts')` anchored to the built CLI | Resolves to the DLA `mcp-server.ts` | Resolved to `โ€ฆ/node_modules/data-liberation/src/mcp-server.ts` | yes | +| Real-bridge e2e test (`defaultTransportProvider.connect()`) with fix applied | Connects, no `ERR_PACKAGE_PATH_NOT_EXPORTED` | Connects in ~350 ms, no resolution error | yes | +| Same e2e test with fix reverted (`tsx/dist/cli.mjs`) | Fails with `ERR_PACKAGE_PATH_NOT_EXPORTED` | Fails: `expected 'ERR_PACKAGE_PATH_NOT_EXPORTED' not to be 'ERR_PACKAGE_PATH_NOT_EXPORTED'` | yes (test catches the bug) | + +### Output Evidence + +| Run | Evidence file | +| ------------------------------------------------ | ---------------------------------------------------------------------- | +| `STUDIO_DLA_ENABLED=1 studio code --help` stdout | `verification/review-2-code-help-stdout.txt` | +| Same run, stderr | `verification/review-2-code-help-stderr.txt` | +| Built-CLI `require.resolve` probe | `verification/review-2-resolution-check.txt` | + +## Issues + +None. Both review-1 must-fix items are resolved: + +1. The one-line spelling change (`tsx/cli`) is in place at `tools/dla/bridge.ts:269`, and the surrounding JSDoc now documents why the deep `dist/` subpath cannot be used โ€” future maintainers won't be tempted to revert. +2. The new tests in `tools/dla/tests/bridge.test.ts` cover the production resolution path that the previous mock-injected coverage missed: (a) positive resolution of `tsx/cli` and `data-liberation/src/mcp-server.ts` through the bridge's own `createRequire` anchor; (b) a regression guard asserting `tsx/dist/cli.mjs` is not exported; (c) an end-to-end check that `defaultTransportProvider.connect()` does not throw a path-resolution error, with the only acceptable failure mode being non-resolution errors (DLA missing creds, etc.). Test (c) is the one that actually catches the regression โ€” verified empirically by reverting and re-running. + +Other items from review-1 (Issue 2 about `tools/dla/dist/` `outDir` collision) remain unchanged and were correctly flagged as informational only. diff --git a/issues/rsm-3143-dla-pi-research/tasks/wave-1-mcp-bridge-feasibility.md b/issues/rsm-3143-dla-pi-research/tasks/wave-1-mcp-bridge-feasibility.md new file mode 100644 index 0000000000..003eef46ad --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/tasks/wave-1-mcp-bridge-feasibility.md @@ -0,0 +1,63 @@ +--- +id: wave-1-mcp-bridge-feasibility +wave: 1 +title: Prove out the MCP-stdio-to-AgentTool bridge against pi +--- + +# Wave 1 โ€” MCP-stdio-to-AgentTool bridge feasibility + +## Goal + +Determine whether a Studio-owned shim that spawns DLA's stdio MCP server, queries its `ListTools`, and wraps each remote tool as a pi `AgentTool` is mechanically sound against the pi runtime โ€” and what the gotchas are (lifecycle, abort propagation, output-shape adaptation, startup latency, missing/partial tools, error semantics). + +This is the leading candidate per the prior-research planner (see `prior-art/rsm-3139-plan.md`'s "Ambiguities" section). The job here is to validate or kill it concretely, not to ratify the planner's intuition. + +## Questions + +1. **Client-side MCP API.** `@modelcontextprotocol/sdk` is already in Studio's deps (used by `apps/cli/ai/mcp-server.ts` to *expose* a server). Confirm the package also ships a client with `StdioClientTransport` + the `ListTools`/`CallTool` flows. Document the concrete client API (constructor, `connect`, `request` / `callTool` helpers, types, error semantics) at the installed version. +2. **Tool wrapping shape.** Each remote MCP tool returns `{name, description, inputSchema (JSON Schema)}`. pi's `AgentTool` wants `{name, description, label, parameters (TSchema from typebox), execute, prepareArguments?, executionMode?}`. What's the smallest correct shim? Specifically: + - Can a remote JSON Schema be cast as a typebox TSchema for `parameters`, or does pi do anything with `parameters` (e.g. validate, statically introspect) that would break? Inspect how `parameters` flows through the agent loop โ€” is it forwarded to the LLM as-is, or validated? + - What's `prepareArguments` for, and do we need it for remote tools (since the schema isn't typebox-native)? + - Tool result shape: pi expects `{content: (TextContent | ImageContent)[], details, terminate?}`; MCP returns `{content: ContentBlock[], isError?, structuredContent?}`. What are the divergences and how do we adapt them โ€” and what happens to `structuredContent` (which DLA uses for inspect/diagnose output)? +3. **Lifecycle.** Where does the DLA child process live? Per-session (spawn on `runStudioAgentTurn`, kill on dispose), per-CLI-process (singleton, refcount), or lazily-on-first-call? What does `session.dispose()` need to tear down? What if `ListTools` fails or hangs โ€” does the agent session fail to start, or does it start without DLA tools and surface a warning? +4. **Startup latency.** DLA's MCP server boots via `npx tsx src/mcp-server.ts`. The original report measured ~few-second cold start. Re-confirm against current DLA HEAD and against Studio's CLI startup budget. Is `npx tsx` acceptable in production, or do we want a built JS entry point (`npm run build` on DLA, then `node dist/mcp-server.js`)? +5. **Abort propagation.** pi tools take an `AbortSignal`. MCP's `callTool` accepts a `signal` or a `progressToken` for cancellation. What's the right plumbing โ€” `signal.addEventListener('abort', () => client.cancelToolCall(...))`, or simpler? What does DLA's MCP server do on cancel today (does it actually cancel work, or just orphan the result)? +6. **Permission gating.** Without `beforeToolCall` at the public-API level (confirm via Brief 1), how does Studio enforce per-tool permission buckets from `prior-art/rsm-3139-spec.md`? Options to evaluate: + - Gate inside the wrapper's `execute` (check a policy table; throw to deny โ†’ pi treats as error). + - Wrap each tool with a Studio-owned `ask-user-question` round-trip before the real `execute`. + - Punt to a wrapper-skill that instructs the model what to ask before what. + Pick the cleanest and explain. +7. **Where does the bridge live in `apps/cli/`?** Sketch the file layout: a new module like `apps/cli/ai/dla/` containing `bridge.ts` (MCP client), `agent-tool-adapter.ts` (JSON Schema โ†’ AgentTool shim), `policy.ts` (permission buckets), `index.ts` (entrypoint returning `AgentTool[]` for the runtime to splice into `buildAgentTools`). Confirm this is the minimal seam. +8. **Slash-command + wrapper-skill integration.** `/migrate` should not just dump a generic prompt; it should load a wrapper skill (RSM-3139's `migrate-site.md`, reusable as-is) that orchestrates DLA's tools in the right order. Confirm the pattern: register `migrate` in `AI_SKILL_COMMANDS`, drop the skill at `apps/cli/ai/skills/migrate-site/SKILL.md`, and the existing Skill tool + `buildSkillInvocationPrompt` does the rest. Identify any gaps. +9. **`canUseTool` replacement.** RSM-1639 leaned on `canUseTool` for the permission flow. pi has none at the public API. Decide: does this kill the per-tool permission story, or does Brief 1's hook inventory provide a substitute? If neither, the recommendation should explicitly call out that per-tool permissions become advisory (system-prompt language + wrapper-skill discipline) rather than enforced. + +## Suggested approach + +- Read `apps/cli/ai/mcp-server.ts` end-to-end to see how Studio uses the MCP SDK today. +- `ls node_modules/@modelcontextprotocol/sdk/dist/` and read the client-side typings (`client/index.d.ts`, `client/stdio.d.ts` if present). +- Walk `apps/cli/ai/runtimes/pi/index.ts` (especially `buildAgentTools` and `toToolDefinition`) and figure out where exactly DLA tools would splice in. The existing `wpcom_request` tool is the structural analog. +- Walk DLA's `src/mcp-server.ts` (via the prior-art `wave-1-dla-inventory.md`, sections 4 and 5) for the request/response shapes โ€” particularly the `structuredContent` returned by `liberate_inspect`, `liberate_diagnose`, etc. +- Sketch one concrete tool round-trip end-to-end: `liberate_detect` (simplest) and `liberate_inspect` (most structured), showing the JSON Schema โ†’ TSchema cast, the `execute` body, the `CallTool` invocation, and the result adaptation. Sketches in TypeScript-flavored pseudocode are fine. +- Test-build the cast mechanically if it's cheap โ€” but no need to write a full prototype; the goal is a yes/no plus a concrete sketch. + +## Deliverable + +A markdown file at `issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-mcp-bridge-feasibility.md` with frontmatter and sections: + +1. **MCP SDK client API at installed version** (with code references). +2. **Tool wrapping shim** โ€” concrete TypeScript sketch of the adapter, with notes on schema cast, result adaptation, and `structuredContent` handling. +3. **Lifecycle & startup latency** โ€” decision and rationale. +4. **Abort propagation** โ€” wiring sketch. +5. **Permission gating without `canUseTool`** โ€” chosen approach + rationale. +6. **File layout & integration seams** โ€” proposed module tree and the diff against `buildAgentTools`. +7. **Slash-command + wrapper-skill plumbing** โ€” concrete wiring against `AI_SKILL_COMMANDS` + Skill tool. +8. **Gotchas & open questions** โ€” anything that needs a wave-2 spike. +9. **Verdict** โ€” works / works-with-caveats / blocked, and a recommendation strength (strong / acceptable / dispreferred). + +## Out of scope + +- Implementing the bridge (no code changes โ€” sketches only). +- Re-investigating DLA's tool list. Use `prior-art/wave-1-findings/wave-1-dla-inventory.md`. +- Permission policy bucket *content* (which DLA tool goes in which bucket) โ€” that's reused as-is from `prior-art/rsm-3139-spec.md`. Just confirm the mechanism Studio uses to enforce it (or note that it's advisory). +- Bundling/distribution โ€” that's Brief 5. +- Vendoring DLA's `src/lib` โ€” that's Brief 3. diff --git a/issues/rsm-3143-dla-pi-research/tasks/wave-1-pi-extensibility-surface.md b/issues/rsm-3143-dla-pi-research/tasks/wave-1-pi-extensibility-surface.md new file mode 100644 index 0000000000..508ee46020 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/tasks/wave-1-pi-extensibility-surface.md @@ -0,0 +1,51 @@ +--- +id: wave-1-pi-extensibility-surface +wave: 1 +title: Map pi-coding-agent's third-party extensibility ceiling +--- + +# Wave 1 โ€” pi-coding-agent extensibility surface + +## Goal + +Establish, with evidence from the installed `@mariozechner/pi-coding-agent@0.70.2` and `@mariozechner/pi-agent-core` packages, exactly what extension points pi exposes to third-party tool surfaces โ€” both via the public `createAgentSession` API used by `apps/cli/ai/runtimes/pi/index.ts`, and via the lower-level `AgentSessionRuntime`/`AgentSessionServices`/agent-loop APIs that Studio currently does not use. + +This is the foundation every other wave-1 brief leans on. Get it right and the other briefs collapse to focused mechanical questions; get it wrong and they will speculate. + +## Questions + +1. **Public-API ceiling.** What can a caller of `createAgentSession(options)` actually plug in? Confirm the shapes of `customTools`, `tools` allowlist, `noTools`, `resourceLoader`, `settingsManager`, `sessionStartEvent`, `scopedModels`, `authStorage`, `modelRegistry`. Are there hooks I'm missing โ€” extensions, plugins, sources, slash-command registration, MCP-anything? +2. **Lower-level ceiling.** What does `AgentSessionRuntime` / `AgentSessionServices` / `createAgentSessionFromServices` / `createAgentSessionRuntime` add on top of the public path? Is there a way to register an `extension` programmatically (`ExtensionFactory`, `ExtensionRunner`, `discoverAndLoadExtensions`, `wrapRegisteredTools`, `wrapRegisteredTool`, `defineTool` from extensions/index), and what does that buy us โ€” slash commands, source-info wiring, runtime API, anything else? +3. **Permission gating.** Does any public or lower-level surface expose `beforeToolCall` / `afterToolCall` (the hooks in `AgentLoopConfig` from `pi-agent-core/dist/types.d.ts`)? If not at `createAgentSession`, is there a path through `createAgentSessionFromServices` or the runtime builder? If neither, what's the realistic shape of per-tool permission policy in Studio (today the runtime has none โ€” verify that)? +4. **Slash commands & skills.** Pi has its own slash-command + skill loader (`@mariozechner/pi-coding-agent`'s `core/skills.ts`, `core/extensions/...`); Studio currently disables them via `noSkills: true` on `DefaultResourceLoader` and runs its own skill loader (`apps/cli/ai/skills.ts`) + slash-command registry (`apps/cli/ai/slash-commands.ts`). Confirm this is still the case on trunk and document **why** Studio overrode pi's own surfaces. Note any constraints this puts on a `/migrate` slash command (i.e. the existing pattern is the `AI_SKILL_COMMANDS` registry from `tools/common/ai/slash-commands.ts` that triggers the Skill tool via `buildSkillInvocationPrompt`). +5. **MCP support.** Search the entire pi package surface (`grep -r "[mM][cC][pP]"`) and confirm there is no MCP client/server slot โ€” neither as a tool source, nor as an extension type, nor as a session-start input. Document the absence concretely. (We expect "no", but state the bound explicitly.) +6. **Sources, system prompt, context.** What does `resourceLoader` actually load, and what does `noSkills`/`noExtensions`/`noPromptTemplates`/`noThemes`/`noContextFiles` (all currently set to `true` in `apps/cli/ai/runtimes/pi/index.ts`) suppress? If we wanted a wrapper-skill or DLA's `AGENTS.md`/`GEMINI.md` content injected into the system prompt, what's the right surface โ€” `systemPrompt` override, `resourceLoader`, or a Studio-side prompt-builder change? +7. **Versioning & churn risk.** Capture the exact installed version, and note any features that look unstable or marked experimental in the typings (search for `@deprecated`, `experimental`, `internal`, `unstable`). The bridge / vendor approach we end up choosing rides on these surfaces โ€” knowing which are load-bearing matters. + +## Suggested approach + +- Start from `apps/cli/ai/runtimes/pi/index.ts` and audit every pi symbol it imports. +- Walk every `*.d.ts` in `node_modules/@mariozechner/pi-coding-agent/dist/` and `node_modules/@mariozechner/pi-agent-core/dist/`. The public re-exports live in `pi-coding-agent/dist/index.d.ts`; the deeper hooks live in `pi-agent-core/dist/types.d.ts` (especially `AgentLoopConfig`) and `pi-coding-agent/dist/core/`. +- For each candidate hook, trace whether it's actually reachable from `createAgentSession` or only from the lower-level builders (`createAgentSessionRuntime`, `createAgentSessionFromServices`). +- Confirm or refute MCP support with `grep -rn "[mM][cC][pP]\|McpServer\|MCPServer\|ModelContextProtocol" node_modules/@mariozechner/pi-coding-agent/dist/ node_modules/@mariozechner/pi-agent-core/dist/`. +- Cross-check against Studio's current usage in `apps/cli/ai/runtimes/pi/index.ts`, `apps/cli/ai/mcp-server.ts` (note: that's Studio *exposing* its tools as MCP โ€” not consuming MCP), `apps/cli/ai/skills.ts`, `apps/cli/ai/slash-commands.ts`, `tools/common/ai/slash-commands.ts`, and `apps/cli/ai/tools/skill.ts`. +- Skim the pi GitHub repo (or npm changelog if no public repo) for the 0.70.2 changelog if you can find it โ€” confirms whether MCP support is on the roadmap or has been deferred. + +## Deliverable + +A markdown file at `issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-pi-extensibility-surface.md` with frontmatter (`task`, `wave`, `status`) and sections: + +1. **Extensibility map (public API)** โ€” table of every extension point exposed by `createAgentSession`, with its type signature, what it accepts, and what it lets you do. +2. **Extensibility map (lower-level)** โ€” same, but for `createAgentSessionRuntime`, `createAgentSessionFromServices`, and any related builders. +3. **Hook inventory** โ€” `beforeToolCall`, `afterToolCall`, session/turn events, extension events: which exist, which are reachable from where, with a verdict on whether each can be used for Studio per-tool permission policy. +4. **Slash-commands & skills mechanism** โ€” what pi ships natively, what Studio currently overrides, and what seams a `/migrate` slash command should plug into (the `AI_SKILL_COMMANDS` registry pattern). +5. **MCP support** โ€” a one-paragraph "confirmed: no MCP surface in pi 0.70.2" with the search evidence. +6. **Suppressed surfaces** โ€” table of which `Default*Loader` flags suppress what, and which would have to flip to bring DLA content into pi's prompt/context (vs. being injected via a Studio-side prompt builder). +7. **Versioning & churn risk** โ€” installed version, anything deprecated/experimental that's load-bearing. + +## Out of scope + +- Investigating DLA's surfaces. Use `prior-art/wave-1-findings/wave-1-dla-inventory.md`. +- Picking an approach. That's the research-lead's job after all five briefs land. +- Modifying any code. This is a survey. +- Following pi-coding-agent's interactive-mode-only surfaces (`InteractiveMode`, `RpcClient`, etc.) unless they unexpectedly expose tool-extension hooks. diff --git a/issues/rsm-3143-dla-pi-research/tasks/wave-1-subprocess-revisit.md b/issues/rsm-3143-dla-pi-research/tasks/wave-1-subprocess-revisit.md new file mode 100644 index 0000000000..f07557c859 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/tasks/wave-1-subprocess-revisit.md @@ -0,0 +1,57 @@ +--- +id: wave-1-subprocess-revisit +wave: 1 +title: Re-evaluate the subprocess approach (Approach E) against pi +--- + +# Wave 1 โ€” Subprocess approach revisited + +## Goal + +The original RSM-1639 research rejected "spawn DLA's CLI as a subprocess from a slash command" (Approach E) because it took the agent out of the loop โ€” the user would `/migrate https://...` and DLA's Ink-based interactive CLI would take over until done, with the agent contributing nothing. + +Against pi, the picture is different: we can wrap the DLA CLI as a single pi `AgentTool` whose `execute` spawns the child, streams output back, and returns a structured result. The agent stays in the loop and can reason over the result; DLA is a black box. Re-evaluate this approach against pi's runtime and decide whether it deserves to be a contender. + +## Questions + +1. **CLI subcommand surface.** Per `prior-art/wave-1-findings/wave-1-dla-inventory.md` sec 2, DLA exposes `data-liberation liberate|inspect|adapt|import|verify|qa|diagnose|setup|mcp`. For a single-tool wrapper, which subcommand(s) would the wrapper call โ€” one tool that calls the high-level `liberate` (which orchestrates internally), or one tool per subcommand? +2. **Single vs. fan-out.** Tradeoff: + - **Single tool** (`run_dla_cli(subcommand, args, url)`): simple wrapping, agent supplies subcommand + args, agent loses fine-grained reasoning per phase. + - **Multiple tools** (`dla_inspect(url)`, `dla_liberate(...)`, etc.): tools mirror the MCP surface 1:1 in semantics but each wraps a CLI call โ€” eliminates `npx tsx` overhead per call (or magnifies it, depending on whether each subcommand pays a startup cost). + Evaluate both. The single-tool variant is what makes this distinct from Bridge/Vendor; the multi-tool variant collapses back toward Bridge-with-different-IPC. +3. **Interactive prompts.** DLA's CLI uses Ink screens (`src/ui/*.tsx`) โ€” does it ever block on stdin for user input? If yes, which subcommands, and what's the wrapping strategy โ€” preflight check (require `--non-interactive` flag if DLA supports it) or capture the prompt and surface it via Studio's `createAskUserQuestionTool`? +4. **Output capture.** DLA's CLI outputs Ink-rendered tty output. Capturing that into a tool result is lossy (ANSI codes, terminal widths, redraws). Options: + - Pipe stdout/stderr, strip ANSI, return raw. + - Force a non-Ink mode (if DLA supports a `--json` flag, use that). + - If neither works, this is a structural blocker โ€” note it. +5. **Sub-agent reasoning.** DLA's MCP `delegate: true` lets a sub-agent reason inside DLA's process. A subprocess approach loses that โ€” does the wrapper-skill make up for it (instructing the host agent to do the reasoning between subprocess calls), or does this fundamentally cripple the workflow? +6. **Process lifecycle & resource consumption.** Each DLA call pays `npx tsx` startup (a few seconds). For the multi-tool variant, this happens N times per `/migrate` flow. Quantify against Studio's UX budget. For the single-tool variant, it happens once but the agent is blocked the whole time without progress reporting unless we wire streaming via `onUpdate`. +7. **Slash-command + wrapper-skill plumbing.** Same as the other briefs โ€” `/migrate` triggers a skill that drives the wrapper tool(s). Confirm this still works and identify divergences from the Bridge/Vendor plumbing. +8. **Permission gating.** Subprocess approach naturally inherits whatever Studio's tool-level policy enforces in the wrapper's `execute` โ€” the gating is at Studio's seam, not inside DLA. Confirm this and note any tools (e.g. `import` writing to WordPress) that need per-call confirmation. + +## Suggested approach + +- Read DLA's `src/cli.ts` (referenced in `wave-1-dla-inventory.md` sec 2) to understand the subcommand routing and which screens block on input. +- Check DLA's package.json scripts for any `--json` / `--non-interactive` modes. +- Sketch the single-tool wrapper concretely โ€” `execute` invokes `child_process.spawn('npx', ['tsx', 'src/cli.ts', subcommand, ...args])`, pipes stdout/stderr, awaits exit, returns `{content: [{type: 'text', text: capturedOutput}]}`. Note signal handling for abort. +- Compare against Bridge/Vendor on agent-in-the-loop, latency, maintenance, and UX richness. + +## Deliverable + +A markdown file at `issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-subprocess-revisit.md` with frontmatter and sections: + +1. **CLI subcommand inventory** โ€” what's spawnable. +2. **Single vs. fan-out tradeoff** โ€” table, with recommended choice. +3. **Interactivity & output capture** โ€” feasibility check + risks. +4. **`delegate: true` impact** โ€” does losing DLA's sub-agent reasoning matter for `/migrate`? +5. **Latency & resource notes** โ€” concrete numbers if you can get them, otherwise a careful estimate. +6. **Concrete wrapper sketch** โ€” single-tool implementation pseudo-code. +7. **Comparison vs. Bridge & Vendor** โ€” short table on agent-in-the-loop, latency, maintenance, UX. +8. **Verdict** โ€” works / works-with-caveats / blocked + recommendation strength. + +## Out of scope + +- Implementing the wrapper. +- Re-investigating DLA's tools/surfaces. +- Permission policy bucket content. +- Bundling/distribution. diff --git a/issues/rsm-3143-dla-pi-research/tasks/wave-1-upstream-and-bundling.md b/issues/rsm-3143-dla-pi-research/tasks/wave-1-upstream-and-bundling.md new file mode 100644 index 0000000000..cdb0e192d4 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/tasks/wave-1-upstream-and-bundling.md @@ -0,0 +1,77 @@ +--- +id: wave-1-upstream-and-bundling +wave: 1 +title: Upstream-pi feasibility + DLA bundling/distribution against pi +--- + +# Wave 1 โ€” Upstream-pi feasibility + DLA bundling/distribution + +## Goal + +Two compact sub-investigations: + +- **Upstream-pi:** Can we plausibly land MCP support in `@mariozechner/pi-coding-agent` upstream, on a timeline RSM-3143 cares about? If yes, this becomes a fifth approach (and dominates Bridge by removing the Studio-owned shim). If no, kill it explicitly. +- **Bundling & distribution:** Given Bridge and Vendor each have different DLA install/distribution profiles (DLA-as-runtime-dependency-with-`npx tsx` for Bridge; DLA-as-npm-dep-imported-at-build-time for Vendor), what survives Studio's `cli:build` + electron-forge packaging? RSM-1639's `wave-1-bundling-distribution.md` covers the pipeline; this brief confirms or refutes specifically for DLA. + +## Questions โ€” upstream-pi + +1. **Maintainer & release cadence.** Who maintains `@mariozechner/pi-coding-agent`? Is it a one-person open-source project, an Automattic-internal package vendored under a personal scope, something else? What's the release cadence (from npm `versions` timestamps)? +2. **Issue tracker.** Is there a public repo / issue tracker? Have MCP-support requests been filed? If yes, what's the maintainer's response โ€” accepted, deferred, rejected? +3. **Reasonable contribution shape.** If we contributed MCP support upstream, what would the API look like โ€” a new `mcpServers` slot on `CreateAgentSessionOptions` that pi internally resolves to `customTools`? An extension factory pi already supports (depends on Brief 1's findings)? Sketch the upstream API at a coarse level. +4. **Timeline plausibility.** Order-of-magnitude estimate: how long would landing this upstream realistically take, and how does that compare with shipping Bridge or Vendor in-tree? (If upstream is 6+ months on the optimistic end, it's not on the RSM-3143 timeline regardless of merit.) +5. **Risk of going alone.** If we land Bridge as Studio-owned, what's the cost of *not* upstreaming โ€” vendor lock-in to pi 0.70.x, brittleness against pi releases, divergence from how other pi-based agents handle MCP if upstream lands it independently? + +## Questions โ€” bundling & distribution + +1. **Install path for Bridge.** Bridge runs DLA's stdio MCP server via `npx tsx src/mcp-server.ts` at runtime. For that to work in a packaged Studio CLI: + - DLA must be installed somewhere reachable from the packaged binary. + - `npx` and `tsx` must be available at runtime, or we use a built JS entry. + - Confirm whether Studio's electron-forge packaging includes `node_modules` for the CLI (ASAR layout per `wave-1-bundling-distribution.md`) and whether `npx tsx` is resolvable from a packaged install. +2. **Install path for Vendor.** Vendor imports `data-liberation/src/lib/...` at Studio's build time. For that to work: + - DLA must be in Studio's `package.json` dependencies (probably `apps/cli/package.json`). + - Studio's Vite build must transpile or pre-build DLA's TS sources, or DLA must ship `dist/` we can import. + - Confirm whether Vite picks DLA up transparently (and what tarball-vs-`github:` does to the lockfile). +3. **`github:` deps post-public-DLA.** RSM-1639 rejected this when DLA was private. Now that DLA is public, confirm `"data-liberation": "github:Automattic/data-liberation-agent#"` works in `npm install` against Studio's CI/dev/install pipelines (CI has GitHub access; users running `npm install` to dev Studio will need GitHub access too โ€” verify that's already an assumption Studio makes). +4. **Pinning strategy.** Commit SHA vs. semver tag vs. branch โ€” recommendation, given DLA does not publish to npm and may not tag releases reliably (per `wave-1-dla-inventory.md` sec 2). +5. **Lockfile impact.** `npm install` against a `github:` dep populates the lockfile with a SHA. What does this do to bot-driven `npm update`, dependabot, Renovate, etc.? Anything Studio-specific to worry about? +6. **DLA's own dependencies.** DLA pulls in `@modelcontextprotocol/sdk`, `tsx`, Ink, etc. For Vendor, those leak into Studio's node_modules. Quantify (rough byte count, license check) โ€” anything that would make legal/distribution nervous? +7. **Native deps / postinstall scripts.** Does DLA have any native deps (browser drivers, headless chromium, etc.) that would complicate Studio's packaging? Check `package.json` and `postinstall` scripts. + +## Suggested approach + +- For upstream-pi: + - `npm view @mariozechner/pi-coding-agent` for maintainer + recent versions. + - Look at the package's repository URL (in `package.json`) โ€” if a public GitHub repo, scan issues for "mcp". + - Estimate timeline qualitatively. Don't pretend to know what you don't. +- For bundling & distribution: + - Reuse `prior-art/wave-1-findings/wave-1-bundling-distribution.md` for the pipeline-level facts. + - Read `apps/cli/package.json`, `apps/cli/vite.config.dev.ts` (or wherever the CLI build config lives), `apps/studio/forge.config.ts` for the packaging shape. + - Check DLA's `package.json` for `dependencies`, `scripts.postinstall`, and any binaries. + - For Bridge specifically: trace whether `node_modules/data-liberation` would be packaged or stripped by Studio's bundler โ€” `cli:build` config will tell you. + +## Deliverable + +A markdown file at `issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-upstream-and-bundling.md` with frontmatter and two top-level sections: + +### Upstream-pi +1. Maintainer & release cadence. +2. Issue-tracker scan (MCP requests). +3. Plausible upstream API shape. +4. Timeline estimate. +5. Risk of *not* upstreaming. +6. Verdict. + +### Bundling & distribution +1. Bridge install path โ€” works in packaged CLI? `npx tsx` available? Pinning? +2. Vendor install path โ€” Vite transpilation? `dist/` vs. source? +3. `github:` dep mechanics & lockfile. +4. DLA's transitive deps โ€” byte/license check. +5. Postinstall / native-dep hazards. +6. Verdict per approach. + +## Out of scope + +- Implementing anything. +- Re-investigating DLA's surfaces or pi's extensibility (those are Briefs 1โ€“4). +- Permission policy bucket content. +- Detailed runtime perf benchmarking โ€” back-of-envelope is fine. diff --git a/issues/rsm-3143-dla-pi-research/tasks/wave-1-vendor-as-agenttools.md b/issues/rsm-3143-dla-pi-research/tasks/wave-1-vendor-as-agenttools.md new file mode 100644 index 0000000000..eed623f13a --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/tasks/wave-1-vendor-as-agenttools.md @@ -0,0 +1,60 @@ +--- +id: wave-1-vendor-as-agenttools +wave: 1 +title: Evaluate vendoring DLA's src/lib as Studio-owned AgentTools +--- + +# Wave 1 โ€” Vendor DLA's `src/lib/` as Studio-owned pi AgentTools + +## Goal + +Determine whether Studio can skip DLA's MCP surface entirely and instead consume DLA at the library level โ€” importing `data-liberation/src/lib/...` (and `src/adapters/...`) directly and writing Studio-owned pi `AgentTool` definitions that call those internals. + +This skips the IPC overhead and the JSON-Schema-to-typebox dance, but pays maintenance cost: DLA's `AGENTS.md` explicitly notes the three entry points (MCP, CLI, plugin) all share `src/lib/` โ€” but `src/lib/` is **not advertised** as a public API, and the package is not published on npm. + +## Questions + +1. **What's in `src/lib/`?** Walk the directory and enumerate the public-shaped exports. For each platform adapter (Wix, Shopify, Squarespace, Webflow, Weebly, Hostinger, GoDaddy, HubSpot) and each cross-cutting lib (WXR generator, media downloader, redirect mapper, content normalizer, etc.), identify: + - Function/class signatures. + - Whether they're called *only* from `src/mcp-server.ts` and `src/cli.ts`, or also from `src/adapters/...` / each other (i.e. is the lib genuinely standalone, or is it scaffolding around the MCP server's session state?). + - Side effects: do they write to disk, spawn child processes, open browsers, hit the network, mutate global state? Anything that's tightly bound to the CLI's `ui/*.tsx` Ink screens or to the MCP server's session/cwd is a coupling smell. +2. **Coverage of the MCP tool surface.** Map each of DLA's 13 MCP tools (from `prior-art/wave-1-findings/wave-1-dla-inventory.md`, section 4) to the `src/lib` entry points that back it. Are all 13 cleanly reachable as library calls, or do some of them live inside the MCP server's request handlers without a clean lib equivalent? +3. **Schema reuse.** DLA's MCP server defines input schemas inline for each tool (probably as zod or JSON Schema). When wrapping the lib as Studio AgentTools, would we copy those schemas into typebox manually (drift risk), import them from DLA (if exported), or re-derive them from each lib function's TypeScript signature? +4. **Output adaptation.** DLA's MCP tools return `{content: [...], structuredContent: {...}}`. The underlying `src/lib` functions return raw TypeScript values. Confirm โ€” does the structure live in `src/lib` or only in `src/mcp-server.ts`'s response shaping? If the latter, Studio's Studio-side tool wrappers reimplement DLA's response shaping in TypeScript (drift risk). +5. **Sub-agent / delegation.** DLA's MCP `delegate: true` contract (per the prior research) lets DLA's MCP server delegate to a sub-agent. If we vendor at the lib level, do we lose this โ€” and does that matter for the `/migrate` workflow? Concretely: which DLA workflows depend on `delegate: true`, and what would Studio have to reimplement to compensate? +6. **Maintenance contract.** DLA's `AGENTS.md` notes `src/lib/` is shared scaffolding, not a public API. The risk: DLA's maintainers may rename / restructure / break it without semver. Quantify the risk โ€” how active is DLA development, how often does `src/lib/` change, and what's the cost of pinning to a specific commit vs. tracking HEAD? (Use the recent commit log from DLA.) +7. **Build-time integration.** DLA is written in TypeScript and built with `tsc` (per `wave-1-dla-inventory.md`, sec 2). To import its `src/lib/` source into Studio's `vite build` pipeline, what's required โ€” let DLA build itself first (`npm run build` in `node_modules/data-liberation`) and import from `dist/`, or have Vite transpile the source on-the-fly? Are there transpilation hazards (decorators, top-level await, dynamic import, etc.)? Tarball vs. `github:` dep tradeoffs. +8. **Slash-command + wrapper-skill plumbing.** Same as Bridge brief, sub-question 8: confirm `/migrate` integrates via the `AI_SKILL_COMMANDS` registry pattern. The wrapper-skill itself is identical between Bridge and Vendor โ€” the only thing that changes is which tools the skill instructs the agent to call. +9. **Permission gating.** Same as Bridge brief, sub-question 6: how does Studio enforce per-tool permission buckets when pi has no `canUseTool`? Vendoring puts policy *inside Studio's `execute`* โ€” does that simplify the story, or does it just shift the same code from a wrapper to a wrapper-with-imports? + +## Suggested approach + +- `git clone https://github.com/Automattic/data-liberation-agent` into a scratch directory (or read its tree via the GitHub API / `gh repo view`). +- For each `src/lib/.ts`, read the file and note its exports + dependencies. +- Cross-reference `src/mcp-server.ts`'s tool handlers โ€” which lib functions does each call? +- Cross-reference the prior-art `wave-1-dla-inventory.md` for the full MCP tool list. +- Sketch one concrete `liberate_detect` AgentTool implementation using lib imports (the simplest tool), and one `liberate_inspect` (one of the most structured) โ€” TypeScript-flavored pseudocode is fine. +- Check DLA's recent commit log for `src/lib/` churn (a `git log --oneline -- src/lib/` for the last 30โ€“60 days is informative). +- Document the install path: `"data-liberation": "github:Automattic/data-liberation-agent#"` in `apps/cli/package.json` is the obvious starting point; quantify what that means for `npm install`, cache pinning, and Studio's lockfile. + +## Deliverable + +A markdown file at `issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-vendor-as-agenttools.md` with frontmatter and sections: + +1. **`src/lib/` inventory** โ€” table of exports per module, coupling notes. +2. **MCP tool โ†’ lib mapping** โ€” table mapping each of DLA's 13 MCP tools to its lib entry point(s); flag any tools that don't have clean lib equivalents. +3. **Schema reuse strategy** โ€” chosen approach + drift risk. +4. **Output adaptation strategy** โ€” chosen approach + drift risk. +5. **`delegate: true` impact** โ€” what's lost and whether it matters. +6. **Maintenance contract risk** โ€” DLA churn rate, pinning strategy, sustainability. +7. **Build-time integration** โ€” concrete `package.json` install path + Vite transpilation notes. +8. **Concrete sketches** โ€” one simple tool + one structured tool, end-to-end. +9. **Verdict** โ€” works / works-with-caveats / blocked + recommendation strength. + +## Out of scope + +- Implementing the vendoring (no code changes โ€” sketches only). +- Re-investigating DLA's tool list. Use `prior-art/wave-1-findings/wave-1-dla-inventory.md`. +- Bundling/distribution โ€” that's Brief 5 (this brief covers the `package.json` install path only). +- Permission policy bucket content โ€” reused as-is from `prior-art/rsm-3139-spec.md`. +- Mechanically wrapping every DLA tool. One simple + one structured sketch is enough to evaluate the shape. diff --git a/issues/rsm-3143-dla-pi-research/verification/review-1-code-help-stdout.txt b/issues/rsm-3143-dla-pi-research/verification/review-1-code-help-stdout.txt new file mode 100644 index 0000000000..fa09e20525 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/review-1-code-help-stdout.txt @@ -0,0 +1,15 @@ +studio code + +AI agent for building WordPress sites + +Commands: + studio code [message] AI agent for building WordPress [default] + studio code sessions Manage code sessions + +Positionals: + message Initial message to send to the AI agent [string] + +Options: + --help Show help [boolean] + --json Output events as NDJSON to stdout (headless mode) + [boolean] [default: false] diff --git a/issues/rsm-3143-dla-pi-research/verification/review-1-dla-bridge-degraded.txt b/issues/rsm-3143-dla-pi-research/verification/review-1-dla-bridge-degraded.txt new file mode 100644 index 0000000000..0539fe3660 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/review-1-dla-bridge-degraded.txt @@ -0,0 +1,17 @@ +$ STUDIO_DLA_ENABLED=1 OPENAI_API_KEY=sk-test-fake OPENAI_BASE_URL=https://example.invalid/v1 node apps/cli/dist/cli/main.mjs code --json "hello world" + +Captured stderr: +[@studio/dla] failed to spawn DLA MCP server (Package subpath './dist/cli.mjs' is not defined by "exports" in /Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3143-dla-pi-research/node_modules/tsx/package.json); continuing without DLA tools. +[studio code] DLA bridge degraded; continuing without DLA tools (Package subpath './dist/cli.mjs' is not defined by "exports" in /Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3143-dla-pi-research/node_modules/tsx/package.json). + +Outcome: +- Agent boots and answers normally (graceful degradation works). +- BUT no `liberate_*` tools are ever registered. +- T6 acceptance criterion "liberate_detect shows up in the tool list" is NOT satisfied. + +Root cause: +- tools/dla/bridge.ts line 267 calls require.resolve('tsx/dist/cli.mjs'). +- The tsx package's exports map only exposes "./cli" โ†’ "./dist/cli.mjs". +- "./dist/cli.mjs" is not an exported subpath, so resolution throws ERR_PACKAGE_PATH_NOT_EXPORTED. +- The standalone migrate command (apps/cli/commands/migrate/resolvers.ts) already + uses the correct "tsx/cli" form โ€” the bridge needs the same fix. diff --git a/issues/rsm-3143-dla-pi-research/verification/review-1-dla-enabled-stdout.txt b/issues/rsm-3143-dla-pi-research/verification/review-1-dla-enabled-stdout.txt new file mode 100644 index 0000000000..29fdf7b921 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/review-1-dla-enabled-stdout.txt @@ -0,0 +1,19 @@ +{"type":"turn.started","timestamp":"2026-05-13T23:11:05.686Z"} +{"type":"message","timestamp":"2026-05-13T23:11:05.691Z","message":{"type":"agent_start"}} +{"type":"message","timestamp":"2026-05-13T23:11:05.691Z","message":{"type":"turn_start"}} +{"type":"message","timestamp":"2026-05-13T23:11:05.691Z","message":{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"hello world"}],"timestamp":1778713865691}}} +{"type":"message","timestamp":"2026-05-13T23:11:05.692Z","message":{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"hello world"}],"timestamp":1778713865691}}} +{"type":"message","timestamp":"2026-05-13T23:11:07.668Z","message":{"type":"message_start","message":{"role":"assistant","content":[],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692}}} +{"type":"message","timestamp":"2026-05-13T23:11:09.279Z","message":{"type":"message_update","assistantMessageEvent":{"type":"text_start","contentIndex":0,"partial":{"role":"assistant","content":[{"type":"text","text":"","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}},"message":{"role":"assistant","content":[{"type":"text","text":"","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}}} +{"type":"message","timestamp":"2026-05-13T23:11:09.280Z","message":{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello","partial":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}},"message":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}}} +{"type":"message","timestamp":"2026-05-13T23:11:09.280Z","message":{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"! I'm **WordPress Studio Code**, your AI agent for building","partial":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create**","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}},"message":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create**","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}}} +{"type":"message","timestamp":"2026-05-13T23:11:09.280Z","message":{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create**","partial":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}},"message":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}}} +{"type":"message","timestamp":"2026-05-13T23:11:09.280Z","message":{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using","partial":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}},"message":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}}} +{"type":"message","timestamp":"2026-05-13T23:11:10.354Z","message":{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview","partial":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}},"message":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}}} +{"type":"message","timestamp":"2026-05-13T23:11:10.355Z","message":{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/","partial":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/pull** to WordPress.com or Pressable\n\nWhat would you like to build today?","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}},"message":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/pull** to WordPress.com or Pressable\n\nWhat would you like to build today?","index":0}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}}} +{"type":"message","timestamp":"2026-05-13T23:11:10.355Z","message":{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"pull** to WordPress.com or Pressable\n\nWhat would you like to build today?","partial":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/pull** to WordPress.com or Pressable\n\nWhat would you like to build today?"}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}},"message":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/pull** to WordPress.com or Pressable\n\nWhat would you like to build today?"}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":1,"cacheRead":0,"cacheWrite":11495,"totalTokens":11499,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}}} +{"type":"message","timestamp":"2026-05-13T23:11:10.355Z","message":{"type":"message_update","assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/pull** to WordPress.com or Pressable\n\nWhat would you like to build today?","partial":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/pull** to WordPress.com or Pressable\n\nWhat would you like to build today?"}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":117,"cacheRead":0,"cacheWrite":11495,"totalTokens":11615,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}},"message":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/pull** to WordPress.com or Pressable\n\nWhat would you like to build today?"}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":117,"cacheRead":0,"cacheWrite":11495,"totalTokens":11615,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}}} +{"type":"message","timestamp":"2026-05-13T23:11:10.356Z","message":{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/pull** to WordPress.com or Pressable\n\nWhat would you like to build today?"}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":117,"cacheRead":0,"cacheWrite":11495,"totalTokens":11615,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}}} +{"type":"message","timestamp":"2026-05-13T23:11:10.357Z","message":{"type":"turn_end","message":{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/pull** to WordPress.com or Pressable\n\nWhat would you like to build today?"}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":117,"cacheRead":0,"cacheWrite":11495,"totalTokens":11615,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"},"toolResults":[]}} +{"type":"message","timestamp":"2026-05-13T23:11:10.358Z","message":{"type":"agent_end","messages":[{"role":"user","content":[{"type":"text","text":"hello world"}],"timestamp":1778713865691},{"role":"assistant","content":[{"type":"text","text":"Hello! I'm **WordPress Studio Code**, your AI agent for building and managing local WordPress sites.\n\nHere's what I can help you with:\n\n- **Create** a new WordPress site with a custom theme\n- **Design** beautiful, memorable pages using block themes\n- **Configure** plugins, content, menus, and settings\n- **Preview** your site on WordPress.com\n- **Audit** performance and SEO\n- **Push/pull** to WordPress.com or Pressable\n\nWhat would you like to build today?"}],"api":"anthropic-messages","provider":"studio-wpcom-anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":117,"cacheRead":0,"cacheWrite":11495,"totalTokens":11615,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1778713865692,"responseId":"msg_01CCWKnogiwSNyw14vjkGMz9"}]}} +{"type":"turn.completed","timestamp":"2026-05-13T23:11:10.358Z","sessionId":"019e239b-add5-7714-9476-ad96292cf51f","status":"success"} diff --git a/issues/rsm-3143-dla-pi-research/verification/review-1-migrate-help-i18n-stdout.txt b/issues/rsm-3143-dla-pi-research/verification/review-1-migrate-help-i18n-stdout.txt new file mode 100644 index 0000000000..76db97d8f0 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/review-1-migrate-help-i18n-stdout.txt @@ -0,0 +1,15 @@ +studio migrate + +Migrate a site from a closed platform using Data Liberation Agent + +Positionals: + url URL of the site to migrate [cadena de caracteres] [requerido] + +Opciones: + --help Muestra ayuda [booleano] + --version Muestra nรบmero de versiรณn [booleano] + --path Ruta a los archivos de WordPress + [cadena de caracteres] [defecto: Directorio actual] + --output Output directory for extracted content (default: ./output) + [cadena de caracteres] + --non-interactive Skip the post-extraction import prompt [booleano] diff --git a/issues/rsm-3143-dla-pi-research/verification/review-1-migrate-help-stdout.txt b/issues/rsm-3143-dla-pi-research/verification/review-1-migrate-help-stdout.txt new file mode 100644 index 0000000000..d4dbb96e68 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/review-1-migrate-help-stdout.txt @@ -0,0 +1,15 @@ +studio migrate + +Migrate a site from a closed platform using Data Liberation Agent + +Positionals: + url URL of the site to migrate [string] [required] + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --path Path to the WordPress files + [string] [default: Current directory] + --output Output directory for extracted content (default: ./output) + [string] + --non-interactive Skip the post-extraction import prompt [boolean] diff --git a/issues/rsm-3143-dla-pi-research/verification/review-1-tsx-resolution.txt b/issues/rsm-3143-dla-pi-research/verification/review-1-tsx-resolution.txt new file mode 100644 index 0000000000..c24a376829 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/review-1-tsx-resolution.txt @@ -0,0 +1,2 @@ +FAILED tsx/dist/cli.mjs: ERR_PACKAGE_PATH_NOT_EXPORTED +data-liberation/src/mcp-server.ts: /Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3143-dla-pi-research/node_modules/data-liberation/src/mcp-server.ts diff --git a/issues/rsm-3143-dla-pi-research/verification/review-2-code-help-stderr.txt b/issues/rsm-3143-dla-pi-research/verification/review-2-code-help-stderr.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/issues/rsm-3143-dla-pi-research/verification/review-2-code-help-stdout.txt b/issues/rsm-3143-dla-pi-research/verification/review-2-code-help-stdout.txt new file mode 100644 index 0000000000..fa09e20525 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/review-2-code-help-stdout.txt @@ -0,0 +1,15 @@ +studio code + +AI agent for building WordPress sites + +Commands: + studio code [message] AI agent for building WordPress [default] + studio code sessions Manage code sessions + +Positionals: + message Initial message to send to the AI agent [string] + +Options: + --help Show help [boolean] + --json Output events as NDJSON to stdout (headless mode) + [boolean] [default: false] diff --git a/issues/rsm-3143-dla-pi-research/verification/review-2-resolution-check.txt b/issues/rsm-3143-dla-pi-research/verification/review-2-resolution-check.txt new file mode 100644 index 0000000000..2e85e47114 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/review-2-resolution-check.txt @@ -0,0 +1,2 @@ +tsx/cli resolved at: /Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3143-dla-pi-research/node_modules/tsx/dist/cli.mjs +mcp-server resolved at: /Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3143-dla-pi-research/node_modules/data-liberation/src/mcp-server.ts diff --git a/issues/rsm-3143-dla-pi-research/verification/t5-migrate-skill-test-stdout.txt b/issues/rsm-3143-dla-pi-research/verification/t5-migrate-skill-test-stdout.txt new file mode 100644 index 0000000000..40c8094481 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/t5-migrate-skill-test-stdout.txt @@ -0,0 +1,13 @@ + +> test +> vitest run ai/tests/migrate-skill.test.ts + + + RUN v4.1.5 /Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3143-dla-pi-research + + + Test Files 1 passed (1) + Tests 7 passed (7) + Start at 00:41:29 + Duration 342ms (transform 14ms, setup 19ms, import 7ms, tests 4ms, environment 245ms) + diff --git a/issues/rsm-3143-dla-pi-research/verification/t5-prod-build-skill-listing.txt b/issues/rsm-3143-dla-pi-research/verification/t5-prod-build-skill-listing.txt new file mode 100644 index 0000000000..e44f142104 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/t5-prod-build-skill-listing.txt @@ -0,0 +1,7 @@ +-rw-r--r-- 1 iamposti staff 7963 May 14 00:40 apps/cli/dist/cli/skills/migrate/SKILL.md +annotate +migrate +need-for-speed +rank-me-up +site-spec +taxonomist diff --git a/issues/rsm-3143-dla-pi-research/verification/t8-migrate-help-stdout.txt b/issues/rsm-3143-dla-pi-research/verification/t8-migrate-help-stdout.txt new file mode 100644 index 0000000000..d4dbb96e68 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/t8-migrate-help-stdout.txt @@ -0,0 +1,15 @@ +studio migrate + +Migrate a site from a closed platform using Data Liberation Agent + +Positionals: + url URL of the site to migrate [string] [required] + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --path Path to the WordPress files + [string] [default: Current directory] + --output Output directory for extracted content (default: ./output) + [string] + --non-interactive Skip the post-extraction import prompt [boolean] diff --git a/issues/rsm-3143-dla-pi-research/verification/t8-studio-help-stdout.txt b/issues/rsm-3143-dla-pi-research/verification/t8-studio-help-stdout.txt new file mode 100644 index 0000000000..e545e15063 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/t8-studio-help-stdout.txt @@ -0,0 +1,20 @@ +WordPress Studio CLI + +Commands: + studio auth Manage authentication + studio code AI agent for building WordPress sites + studio export [export-file] Export site to a backup file + studio import Import a backup file to site + studio mcp MCP server for AI agents + studio migrate Migrate a site from a closed platform using Data + Liberation Agent + studio preview Manage preview sites + studio pull Pull a WordPress.com site to your local site + studio push Push your local site to a WordPress.com site + studio site Manage sites + studio wp WP-CLI + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --path Path to the WordPress files [string] [default: Current directory] diff --git a/issues/rsm-3143-dla-pi-research/verification/t8-vitest-migrate.txt b/issues/rsm-3143-dla-pi-research/verification/t8-vitest-migrate.txt new file mode 100644 index 0000000000..3065f7121b --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/verification/t8-vitest-migrate.txt @@ -0,0 +1,9 @@ + + RUN v4.1.5 /Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3143-dla-pi-research + + + Test Files 1 passed (1) + Tests 10 passed (10) + Start at 00:51:41 + Duration 369ms (transform 21ms, setup 19ms, import 12ms, tests 34ms, environment 236ms) + diff --git a/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-mcp-bridge-feasibility.md b/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-mcp-bridge-feasibility.md new file mode 100644 index 0000000000..be5be55b17 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-mcp-bridge-feasibility.md @@ -0,0 +1,664 @@ +--- +task: wave-1-mcp-bridge-feasibility +wave: 1 +status: complete +verdict: works-with-caveats +recommendation: acceptable +--- + +# Wave 1 โ€” MCP-stdio-to-AgentTool bridge feasibility + +## TL;DR + +The bridge works mechanically. `@modelcontextprotocol/sdk@1.29.0` (Studio's installed version; brief mentioned 1.27.1) ships a fully-typed `Client` + `StdioClientTransport`, and pi's tool registration path accepts plain JSON Schema objects in `parameters` (no TypeBox metadata required) โ€” so the JSON Schema โ†’ pi.AgentTool adapter is a thin wrapper, not a re-derivation. The integration touches a single splice point in `buildAgentTools` and a single new module tree under `apps/cli/ai/dla/`. + +Three real caveats (not blockers): (1) DLA's MCP server does **not** honor `notifications/cancelled` โ€” aborts orphan in-flight work server-side; (2) `npx tsx src/mcp-server.ts` adds a ~few-second cold start that Studio should swap for `node dist/mcp-server.js` (DLA already has a `build: tsc` script); (3) per-tool permission gating cannot be enforced via the public `createAgentSession()` API โ€” it has no `beforeToolCall` hook (the hook exists at the lower `agent-loop.js` `config.beforeToolCall` layer; Brief 1 should confirm whether the lower-level `createAgentSessionFromServices` exposes it). Until that's resolved, gating is advisory: permission policy is encoded in the wrapper skill body + system prompt language, and the per-tool `execute` wrapper can do simple bucket-based throws-to-deny for the destructive ones. + +One correction to the brief: it claims `liberate_inspect` and `liberate_diagnose` return `structuredContent`. They don't โ€” DLA's `textResult()` helper at `src/mcp-server.ts:32-34` JSON-stringifies the entire structured payload into a single `{type:'text', text}` content block. `structuredContent` handling is not needed for the v1 bridge. + +## 1. MCP SDK client API at installed version + +**Version:** `@modelcontextprotocol/sdk@1.29.0` (`node_modules/@modelcontextprotocol/sdk/package.json:3`). The brief mentioned 1.27.1 from `package.json`'s `^1.27.1`, but the resolved lockfile picked up 1.29 โ€” same client API (1.x semver compatibility), just newer minor. + +**Imports:** + +```ts +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +``` + +Both are subpath exports (verified in `package.json`'s `"./client"` exports field). + +**`StdioClientTransport`** (`dist/esm/client/stdio.d.ts:46-76`): + +```ts +export type StdioServerParameters = { + command: string; + args?: string[]; + env?: Record; + stderr?: IOType | Stream | number; // default "inherit" + cwd?: string; +}; + +export declare class StdioClientTransport implements Transport { + constructor(server: StdioServerParameters); + start(): Promise; + get stderr(): Stream | null; + get pid(): number | null; + close(): Promise; + send(message: JSONRPCMessage): Promise; +} +``` + +The transport spawns the child process when `client.connect(transport)` is called (the `connect` method calls `transport.start()` internally โ€” `dist/esm/client/index.d.ts:155`). `stderr` defaults to "inherit"; for an embedded bridge we want `stderr: 'pipe'` so we can capture warnings without polluting Studio's TUI. + +**`Client`** (`dist/esm/client/index.d.ts:110-590`): + +```ts +constructor(_clientInfo: Implementation, options?: ClientOptions); +connect(transport: Transport, options?: RequestOptions): Promise; +listTools(params?: ListToolsRequest['params'], options?: RequestOptions): Promise<{ + tools: { + inputSchema: { type: "object"; properties?: Record; required?: string[]; [x: string]: unknown }; + name: string; + description?: string; + outputSchema?: { type: "object"; properties?: Record; required?: string[]; [x: string]: unknown }; + annotations?: { title?: string; readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean }; + // ... + execution, _meta, icons, title + }[]; + nextCursor?: string; +}>; +callTool(params: CallToolRequest['params'], resultSchema?, options?: RequestOptions): Promise<{ + content: (TextContent | ImageContent | AudioContent | ResourceContent | ResourceLinkContent)[]; + structuredContent?: Record; + isError?: boolean; + _meta?: { progressToken?, "io.modelcontextprotocol/related-task"? }; +}>; +``` + +`CallToolRequest['params']` is `{name: string, arguments?: Record}` โ€” matches MCP `tools/call` spec. + +**`RequestOptions`** (`dist/esm/shared/protocol.d.ts:61-98`): + +```ts +export type RequestOptions = { + onprogress?: ProgressCallback; + signal?: AbortSignal; // <-- this is the abort handle + timeout?: number; // default 60_000ms + resetTimeoutOnProgress?: boolean; + maxTotalTimeout?: number; + task?: TaskCreationParams; + relatedTask?: RelatedTaskMetadata; +} & TransportSendOptions; +``` + +When `signal` aborts, `Protocol.request` triggers a `notifications/cancelled` to the server and rejects the in-flight promise with an `AbortError`/`McpError` (`dist/esm/shared/protocol.js:709-710`: `options?.signal?.addEventListener('abort', () => cancel(...))`, and `notifications/cancelled` is sent at line 677). So forwarding pi's `AbortSignal` to `callTool` is the entire abort plumbing on the client side; whether the *server* honors it is a separate concern (see ยง4). + +**Error semantics:** +- Transport-level failures (process crash, malformed JSON-RPC) reject the `callTool` promise with `McpError`. +- Tool-level errors set `isError: true` on the returned `CallToolResult` but resolve the promise normally โ€” caller must check. +- Schema validation: with `jsonSchemaValidator` provided in `ClientOptions`, the SDK validates `structuredContent` against the tool's declared `outputSchema`. We don't need this for v1 (DLA doesn't declare `outputSchema`). + +**No `request` helper is required:** `client.callTool()` and `client.listTools()` are typed convenience wrappers; we don't need the lower-level `Protocol.request` API. + +## 2. Tool wrapping shim + +### Schema cast: JSON Schema โ†’ TSchema + +This was the headline risk and it's safe. pi-ai's `validateToolArguments` (`node_modules/@mariozechner/pi-ai/dist/utils/validation.js:253-280`) explicitly handles plain JSON Schema: + +```js +function hasTypeBoxMetadata(schema) { + return isRecord(schema) && Object.getOwnPropertySymbols(schema).includes(TYPEBOX_KIND); +} + +export function validateToolArguments(tool, toolCall) { + const args = structuredClone(toolCall.arguments); + Value.Convert(tool.parameters, args); + const validator = getValidator(tool.parameters); // Compile() from typebox/compile + if (!hasTypeBoxMetadata(tool.parameters) && isJsonSchemaObject(tool.parameters)) { + const coerced = coerceWithJsonSchema(args, tool.parameters); // JSON-Schema-aware coercion path + // ... + } + if (validator.Check(args)) return args; + // throw with formatted errors +} +``` + +Two things matter: + +1. `typebox`'s `Compile()` accepts plain JSON Schema objects (it doesn't require the `Symbol.for("TypeBox.Kind")` brand). The compiled validator runs against the raw schema. +2. There's an explicit `!hasTypeBoxMetadata(...) && isJsonSchemaObject(...)` branch that runs `coerceWithJsonSchema` โ€” type coercion (stringโ†’number, etc.) tailored for plain JSON Schema, separate from `Value.Convert` (which is TypeBox's own coercer). + +**Downstream pi-ai providers also take `tool.parameters` as JSON Schema, no transformation:** +- Anthropic (`anthropic.js:887-904`): pulls `schema.properties` and `schema.required` and passes them to Claude's `input_schema`. +- OpenAI Completions (`openai-completions.js:756`): `parameters: tool.parameters // TypeBox already generates JSON Schema`. +- Bedrock (`amazon-bedrock.js:599`): `inputSchema: { json: tool.parameters }`. +- Google (`google-shared.js:273-274`): two paths, both treat `tool.parameters` as JSON Schema. +- Mistral (`mistral.js:376`): `stripSymbolKeys(tool.parameters)`. + +A remote MCP `inputSchema` (object with `type: 'object', properties, required`) **drops in unchanged** as pi's `parameters`. No conversion required. + +The TypeScript cast is the only friction. `ToolDefinition.parameters` is typed `TParams extends TSchema` (`pi-coding-agent/dist/core/extensions/types.d.ts:323`), and a plain JSON-Schema object isn't a `TSchema`. We get away with the same pattern Studio's MCP *server* uses for the inverse direction (`apps/cli/ai/mcp-server.ts:27`): + +```ts +inputSchema: tool.parameters as unknown as Record +``` + +For the bridge, the cast is `inputSchema as unknown as TSchema`. Runtime behavior is correct (the compile/check/coerce path handles it); the cast just sidesteps TypeScript not knowing that. + +### `prepareArguments` + +We don't need it for remote tools. `prepareArguments` is for tools that want to massage raw model arguments **before** TypeBox validation (e.g. legacy aliases, splitting a combined field). MCP's `inputSchema` is the source of truth for what arguments look like; the model emits arguments shaped by that schema; we forward them as-is. + +If a remote tool has known argument quirks we want to paper over, we can plug `prepareArguments` per-tool โ€” but it's optional and we'll skip it in v1. + +### Result adaptation: MCP `CallToolResult` โ†’ pi `AgentToolResult` + +pi's `AgentToolResult` (`pi-agent-core/dist/types.d.ts:259-269`): + +```ts +export interface AgentToolResult { + content: (TextContent | ImageContent)[]; + details: T; + terminate?: boolean; +} +``` + +MCP's `CallToolResult` (`mcp/sdk dist/esm/client/index.d.ts:431-512`): + +```ts +{ + content: (TextContent | ImageContent | AudioContent | ResourceContent | ResourceLinkContent)[]; + structuredContent?: Record; + isError?: boolean; + _meta?: {...}; +} +``` + +Divergences: + +| MCP | pi | Adaptation | +|---|---|---| +| `content` may include `audio`/`resource`/`resource_link` | only `text`/`image` | Filter: keep `text`/`image` as-is; flatten `resource` (text variant) into `text`; serialize `resource_link` to a `text` block with the URI/description; drop `audio` with a warning. | +| `isError: true` (resolved promise) | thrown error โ†’ pi's loop sets `isError: true` automatically | If `result.isError === true`, throw an `Error` with the first text content as the message. pi's `executePreparedToolCall` (`pi-agent-core/dist/agent-loop.js:378-384`) catches and produces the right `ToolResult`. | +| `structuredContent` | `details` | Map directly: `details = result.structuredContent ?? undefined`. (DLA doesn't actually emit `structuredContent` โ€” see correction at top. But the adapter should still support it for forward-compat with other servers.) | +| `_meta` | โ€” | Ignore for v1. Could surface `progressToken`/`task` later if we ever turn on long-running task support. | + +The brief mentioned `structuredContent` as DLA's mechanism for `liberate_inspect`/`liberate_diagnose`. **This is incorrect.** DLA's `textResult()` (`data-liberation-agent/src/mcp-server.ts:32-34`) does: + +```ts +function textResult(data: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; +} +``` + +All DLA tool results are a single `text` content block containing JSON-stringified data. The MCP `structuredContent` field is unused. This simplifies the adapter โ€” the model gets the JSON-as-text and parses it inline. If DLA ever migrates to `structuredContent`, the adapter handles it automatically via the `details` mapping. + +### Concrete sketch โ€” `liberate_detect` (simplest) + +```ts +// apps/cli/ai/dla/agent-tool-adapter.ts +import type { AgentTool, AgentToolResult } from '@mariozechner/pi-agent-core'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { TSchema } from 'typebox'; + +interface RemoteMcpTool { + name: string; + description?: string; + inputSchema: { type: 'object'; properties?: Record; required?: string[]; [k: string]: unknown }; +} + +export function adaptRemoteTool( + client: Client, + remote: RemoteMcpTool, + policy: PermissionPolicy, +): AgentTool { + return { + name: remote.name, + label: remote.name, + description: remote.description ?? '', + parameters: remote.inputSchema as unknown as TSchema, // see ยง2: safe at runtime + execute: async (toolCallId, args, signal): Promise | undefined>> => { + policy.assert(remote.name, args); // throws on deny (see ยง5) + const result = await client.callTool( + { name: remote.name, arguments: args as Record }, + undefined, // default CallToolResultSchema + { signal }, + ); + if (result.isError) { + const errText = result.content.find((c): c is { type: 'text'; text: string } => c.type === 'text')?.text + ?? `Remote tool ${remote.name} reported isError without a text payload`; + throw new Error(errText); + } + return { + content: result.content.flatMap(mcpContentToPiContent), + details: result.structuredContent, // undefined for DLA today + }; + }, + }; +} + +function mcpContentToPiContent(c: { type: string; [k: string]: unknown }): ({ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string })[] { + if (c.type === 'text') return [{ type: 'text', text: c.text as string }]; + if (c.type === 'image') return [{ type: 'image', data: c.data as string, mimeType: c.mimeType as string }]; + if (c.type === 'resource' && (c as any).resource?.text) { + return [{ type: 'text', text: `[resource ${(c as any).resource.uri}]\n${(c as any).resource.text}` }]; + } + if (c.type === 'resource_link') { + return [{ type: 'text', text: `[resource_link ${(c as any).uri}${(c as any).description ? ' โ€” ' + (c as any).description : ''}]` }]; + } + return []; // drop audio + unknown types; could log a warning +} +``` + +**Round-trip for `liberate_detect`:** + +1. ListTools returns `{name: 'liberate_detect', description: '...', inputSchema: {type: 'object', properties: {url: {type: 'string', description: 'The URL of the website to detect'}}, required: ['url']}}` (`data-liberation-agent/src/mcp-server.ts:50-60`). +2. Adapter wraps it. `parameters` is the JSON Schema object as-is. +3. Model emits `{tool_call: {name: 'liberate_detect', args: {url: 'https://example.com'}}}`. +4. pi's `validateToolArguments` calls `Compile(schema)` โ†’ validates โ†’ calls `execute`. +5. `execute` calls `client.callTool({name, arguments: {url}}, undefined, {signal})`. +6. Client sends MCP JSON-RPC `tools/call` over stdin to DLA child. +7. DLA returns `{content: [{type: 'text', text: '{"platform": "wix", "confidence": "high", ...}'}]}`. +8. Adapter returns `{content: [{type: 'text', text: ''}], details: undefined}`. +9. pi delivers it to the model as a `toolResult` message. + +### Concrete sketch โ€” `liberate_inspect` (most "structured") + +Same adapter; the only difference is that the JSON payload returned in the single text block is richer: + +```jsonc +// DLA result (line 273-310 of data-liberation-agent/src/mcp-server.ts) +{ + content: [{ + type: 'text', + text: `{ + "url": "...", + "platform": "wix", + "confidence": "high", + "signals": [...], + "sitemapFound": true, + "urlCount": 142, + "counts": {"page": 8, "post": 134}, + "probeResults": [...], + "authRequired": false, + "extractionFeasibility": "ready", + "platformFeatures": {...} + }` + }] +} +``` + +The adapter is identical. The model parses the JSON-in-text and reasons about it. **No special-casing for `structuredContent` needed in v1**, because DLA doesn't emit it. + +If DLA later moves to `structuredContent` (i.e. `return { content: [...], structuredContent: result }`), the adapter passes the structured object through `details` automatically. pi's `details` field is opaque from the model's perspective (used for UI rendering and post-call extensions) โ€” the model still sees the `content` blocks. So a DLA migration to `structuredContent` would need them to *also* emit a useful text block, or pi-coding-agent would have to learn to surface `details` to the LLM. That's out of scope. + +## 3. Lifecycle & startup latency + +### Recommendation: per-CLI-process singleton, lazy-spawned, started during agent session setup, torn down on session dispose. + +**Decision:** Spawn the DLA MCP child the **first time the agent session is constructed** (in `createStudioAgentSession`, after the tools are needed). Cache the spawned `{client, transport}` on the session lifetime. Tear down in the `finally` block that already calls `session.dispose()` at `apps/cli/ai/runtimes/pi/index.ts:222-225`. + +```ts +// apps/cli/ai/runtimes/pi/index.ts (sketch of the diff) +async function runAgentSessionTurn(config, controller, setActiveSession) { + // ... + let session, unsubscribe, dlaBridge; + try { + dlaBridge = await maybeStartDlaBridge(config); // <-- new; returns undefined if disabled + session = await createStudioAgentSession(config, family, resolved.creds, dlaBridge); + // ... + } finally { + unsubscribe?.(); + session?.dispose(); + await dlaBridge?.dispose(); // <-- new; closes MCP client + kills child + setActiveSession(undefined); + } +} +``` + +Why not per-CLI-process (long-lived across multiple turns)? + +- Studio `code` runs in two modes: interactive (multiple turns) and one-shot. A per-process singleton would leak a Node child process for the duration of the CLI; for one-shot, it's wasted lifecycle; for interactive, gating by `/migrate` invocation is cleaner than spawning DLA on every CLI startup. +- Per-turn spawn is wasteful too (multiple DLA tool calls in one turn would re-pay the cold-start). +- Per-session is the middle ground โ€” and the existing `dispose()` finally block makes teardown trivial. + +Why not lazily-on-first-call (spawn DLA the first time the model invokes `liberate_*`)? + +- The first call would block on `npx tsx`'s cold start (several seconds), looking like a model hang to the user. +- `ListTools` must run *before* we can register the tools with pi (we need their schemas to build `customTools`). So we have to spawn before session creation anyway. + +If we want to make DLA opt-in (only spawn when `/migrate` is used), the cleanest gate is: register a placeholder `liberate_migrate` slash command that **first** spawns the bridge, **then** runs `buildSkillInvocationPrompt('migrate-site')`. That bumps the cold-start to the slash-command invocation rather than chat startup. See ยง7. + +**`ListTools` failure / hang handling:** + +- Wrap the bridge bring-up in `Promise.race` with a 10s timeout and `signal: AbortSignal.timeout(10_000)` to `listTools`. +- On failure: log a warning and start the session **without** DLA tools. The Skill body for `/migrate` already references DLA tools by name; if they're missing, the model should fail gracefully ("the migration tools didn't load โ€” please check..."). The Skill could also include a `### Preflight` step that asks the model to check tool availability via `Skill` tool index introspection. +- Surfacing the warning into the TUI: easiest is a synthetic system message in the transcript before the session prompts. Alternatively, an extension event โ€” but that's wave-2 territory. + +### Startup latency + +**Verdict: avoid `npx tsx` in production. Use `node dist/mcp-server.js`.** + +DLA already has `"build": "tsc"` and points its `bin` at `./dist/cli.js` (`data-liberation-agent/package.json:6-23`). Studio's install path should: + +1. Run `npm install` in DLA's directory (already covered by `package.json` dep resolution). +2. Run `npm run build` (or invoke `tsc` directly via the local `typescript` dep). +3. Spawn `node /dist/mcp-server.js` instead of `npx tsx src/mcp-server.ts`. + +Numbers: +- The brief cites "few-second cold start" for `npx tsx`. I didn't re-time it โ€” call this **needs re-verification** in Brief 5 (bundling) before locking in expectations. +- `node dist/mcp-server.js` cold start should be ~hundreds of ms (rough estimate from model knowledge of typical Node startup with a real MCP server's transitive import graph โ€” not verified for DLA specifically; should be measured). + +Distribution mechanics (subscribing the build step into Studio's `cli:build` pipeline) are Brief 5's problem. For this brief: **a built JS entry point is the right answer if it packages cleanly**; if not, `tsx` is acceptable with the latency hit documented. + +### Lifecycle to tear down + +`StdioClientTransport.close()` ([`stdio.d.ts:74`](node_modules/@modelcontextprotocol/sdk/dist/esm/client/stdio.d.ts:74)) closes the streams; the child process exit follows from receiving EOF on stdin. Belt-and-braces: also send SIGTERM via the cached `pid` after a short grace period. + +```ts +async dispose() { + try { + await this.client.close(); // calls transport.close() + } finally { + if (this.transport.pid) { + setTimeout(() => { try { process.kill(this.transport.pid!, 'SIGKILL'); } catch {} }, 2000); + } + } +} +``` + +## 4. Abort propagation + +**Wiring:** Forward pi's `AbortSignal` into `client.callTool`'s `RequestOptions.signal`. That's it on the client side. + +```ts +execute: async (toolCallId, args, signal) => + client.callTool({ name: remote.name, arguments: args }, undefined, { signal }) +``` + +Why this is sufficient on the **client** side: +- `Protocol.request` (`dist/esm/shared/protocol.js:677, 709-710`) registers `signal.addEventListener('abort', () => cancel(...))`. +- On abort, the SDK sends `notifications/cancelled` over JSON-RPC to the server with the in-flight request id. +- The promise rejects with `McpError(ConnectionClosed, 'Request was cancelled')` (line 334) or an `AbortError`-shaped error from the cleanup path. +- pi's `executePreparedToolCall` (`pi-agent-core/dist/agent-loop.js:378-384`) catches that and emits `isError: true` with the message. + +**But:** DLA's MCP server does **not** wire `notifications/cancelled` into its tool handlers. A grep for `signal|aborted|cancelled` in `data-liberation-agent/src/mcp-server.ts` (line 279) returns only `detection.signals` (an unrelated field โ€” platform-fingerprint signals like CSS classes). DLA's handlers run to completion regardless of the cancel notification. + +**Implications:** +- The model sees a `cancelled` tool result promptly โ€” good. +- The DLA child keeps working โ€” bad. A cancelled `liberate_extract` will keep crawling. A cancelled `liberate_preview` will leave a `studio site create` running in a sub-spawn. +- Filesystem cleanup of half-finished extractions is DLA's resume-safe protocol (`extraction-log.jsonl`, `session.json` per `prior-art/wave-1-findings/wave-1-dla-inventory.md:195-198`). So restarting recovers. + +**Mitigations to consider (not blocking):** +- Document: "Cancelling a DLA tool call surfaces the cancel to the model immediately but DLA continues server-side. If you Ctrl-C the session, the child is killed via `dispose()`." +- Wave-2 spike: upstream a fix to DLA to honor `signal`/`progressToken` in its handlers. + +## 5. Permission gating without `canUseTool` + +**Confirmed:** `CreateAgentSessionOptions` (`pi-coding-agent/dist/core/sdk.d.ts:11-55`) exposes no `beforeToolCall` hook. The hook **does exist** at the lower `agent-loop.js` layer (`pi-agent-core/dist/agent-loop.js:333-347`): + +```js +if (config.beforeToolCall) { + const beforeResult = await config.beforeToolCall({ assistantMessage, toolCall, args: validatedArgs, context: currentContext }, signal); + if (beforeResult?.block) { + return { kind: "immediate", result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"), isError: true }; + } +} +``` + +Whether `createAgentSessionFromServices` / `createAgentSessionRuntime` / the lower-level `AgentSessionServices` exposes a way to plumb a `beforeToolCall` config is **Brief 1's question**. This brief assumes no for purposes of recommendation; if Brief 1 finds a way through, the bridge should use it. + +### Chosen approach: defense-in-depth, all three layers + +1. **System-prompt language** (advisory). The system prompt should warn about destructive DLA tools (`liberate_extract` writes files; `liberate_import` writes to a live WP site). +2. **Wrapper-skill discipline** (advisory, primary). The `/migrate` skill body explicitly orders the tools and tells the model when to call `AskUserQuestion` before destructive steps. RSM-3139's skill body (per `prior-art/rsm-3139-spec.md`) already does this for `liberate_setup` / `liberate_import`. +3. **Adapter-layer policy check** (advisory, enforced). The adapter's `execute` runs `policy.assert(toolName, args)` first. For "destructive" buckets (`liberate_import`, `liberate_extract` with non-dry-run, `liberate_preview` writing to a Studio site) the policy can short-circuit to an `AskUserQuestion` invocation โ€” but `AskUserQuestion` is itself a model-invoked tool, so the adapter can't *call* it. The cleanest in-adapter pattern is to throw with a deny-reason that includes the bucket name; the model will see the error and is instructed by the skill to ask the user. + +The **cleanest** of these (single recommendation): keep gating in the **wrapper skill body** and have the **adapter throw on deny** for the worst tools. Skip "wrap each tool with an AskUserQuestion round-trip" โ€” it bloats the adapter and the agent can call AskUserQuestion itself when instructed. + +```ts +// apps/cli/ai/dla/policy.ts (sketch) +export type PermissionBucket = 'safe' | 'fs-write' | 'destructive'; + +export const DLA_TOOL_POLICY: Record = { + liberate_detect: 'safe', + liberate_discover: 'safe', + liberate_inspect: 'safe', + liberate_status: 'safe', + liberate_extract: 'fs-write', // writes WXR + media + liberate_qa: 'fs-write', // may patch fixable issues + liberate_verify: 'safe', + liberate_setup: 'safe', // delegate:true returns manifest only + liberate_import: 'destructive', // writes to WP โ€” must be delegated to Studio + liberate_preview: 'fs-write', // spawns a Studio site + liberate_preview_stop: 'safe', + liberate_map_apis: 'safe', + liberate_probe: 'safe', +}; + +export function createPolicy(opts: { allowDestructive: boolean }) { + return { + assert(name: string, args: unknown) { + const bucket = DLA_TOOL_POLICY[name] ?? 'safe'; + if (bucket === 'destructive' && !opts.allowDestructive) { + // RSM-3139 spec says: liberate_import MUST be invoked with delegate:true + // when Studio drives. Enforce that. + if (name === 'liberate_import' && (args as any)?.delegate !== true) { + throw new Error( + 'Studio enforces delegate:true for liberate_import. Re-invoke with delegate:true to receive a manifest, then use Studio\'s wp-cli tool to do the actual import.' + ); + } + } + }, + }; +} +``` + +Per the brief: bucket *content* (which tool is destructive) is reused from `prior-art/rsm-3139-spec.md`. This sketch just shows the *mechanism* โ€” runtime policy in adapter + skill-body discipline + system-prompt language. Wave-2 can tune buckets. + +**Recommendation strength:** strong. Per-tool permissions become advisory-with-teeth: the skill body steers the model, and the adapter hard-stops the worst-case (forcing `delegate:true` on `liberate_import`). + +## 6. File layout & integration seams + +``` +apps/cli/ai/dla/ +โ”œโ”€โ”€ index.ts # Public entry: `buildDlaAgentTools(): Promise` and `disposeDlaBridge()` +โ”œโ”€โ”€ bridge.ts # MCP client lifecycle: spawn child, connect, listTools, dispose +โ”œโ”€โ”€ agent-tool-adapter.ts # JSON Schema โ†’ AgentTool shim (ยง2) +โ”œโ”€โ”€ policy.ts # Permission buckets (ยง5) +โ””โ”€โ”€ content-adapter.ts # MCP content[] โ†’ pi content[] mapper (ยง2 sketch) +``` + +**Splice into `buildAgentTools` (`apps/cli/ai/runtimes/pi/index.ts:403-451`):** the existing `wpcom_request` tool is the structural analog โ€” it's a runtime-dependent tool added to `buildAgentTools`'s return. DLA tools follow the same pattern, but they're async to construct (need `await client.listTools()`), so the seam moves slightly upstream: + +```diff + async function createStudioAgentSession( + config: ResolvedStudioAgentTurnConfig, + family: AiModelFamily, + creds: ResolvedCredentials, ++ dlaBridge: DlaBridge | undefined, // <-- new + ): Promise { + // ... +- const tools = buildAgentTools( config, isForkedByDesktop, remoteSession ); ++ const tools = buildAgentTools( config, isForkedByDesktop, remoteSession, dlaBridge ); + const toolDefinitions = tools.map( toToolDefinition ); + // ... + } + + function buildAgentTools( + config: ResolvedStudioAgentTurnConfig, + enablePreviewSteering: boolean, + remoteSession: boolean, ++ dlaBridge: DlaBridge | undefined, + ): AgentToolAny[] { + // ... ++ const dlaTools = dlaBridge?.tools ?? []; + return [ + ...resolveStudioToolDefinitions( { enablePreviewSteering, remoteSession } ), ++ ...dlaTools, + ...askUserTool, + ...skillTool, + ...piTools, + ]; + } +``` + +And `runAgentSessionTurn` gains a bridge bring-up/tear-down (sketched in ยง3). + +`apps/cli/ai/dla/index.ts`: + +```ts +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import type { AgentTool } from '@mariozechner/pi-agent-core'; +import { adaptRemoteTool } from './agent-tool-adapter'; +import { createPolicy } from './policy'; + +export interface DlaBridge { + tools: AgentTool[]; + dispose(): Promise; +} + +export async function startDlaBridge(opts: { + dlaRoot: string; // resolved path to data-liberation-agent install + env?: Record; + allowDestructive?: boolean; +}): Promise { + const transport = new StdioClientTransport({ + command: 'node', // or 'npx tsx' fallback + args: [`${opts.dlaRoot}/dist/mcp-server.js`], + cwd: opts.dlaRoot, + env: opts.env, + stderr: 'pipe', + }); + const client = new Client( + { name: 'studio-cli', version: '1.0.0' }, + { capabilities: { tools: {} } } + ); + await client.connect(transport); + const { tools: remoteTools } = await client.listTools(undefined, { signal: AbortSignal.timeout(10_000) }); + const policy = createPolicy({ allowDestructive: !!opts.allowDestructive }); + const tools = remoteTools.map(t => adaptRemoteTool(client, t, policy)); + return { + tools, + async dispose() { + try { await client.close(); } finally { + if (transport.pid) { + setTimeout(() => { try { process.kill(transport.pid!, 'SIGKILL'); } catch {} }, 2000); + } + } + }, + }; +} +``` + +That's the **minimal seam**. The bridge is gated on a configuration flag (e.g. `STUDIO_DLA_ENABLED` env var, or detection of an installed DLA path) โ€” when off, the bridge isn't started, `dlaTools` is `[]`, and nothing changes. + +## 7. Slash-command + wrapper-skill plumbing + +The pattern Studio already uses for `/annotate`, `/taxonomist`, `/need-for-speed`, `/rank-me-up` is exactly what `/migrate` needs: + +1. **Drop the skill** at `apps/cli/ai/skills/migrate-site/SKILL.md`. The body is the runtime-agnostic content from `prior-art/rsm-3139-spec.md`'s `migrate-site.md`. The skill loader (`apps/cli/ai/skills.ts:27-51`) discovers it on startup, no other registration needed. +2. **Register the slash command** in `tools/common/ai/slash-commands.ts:8`: + ```ts + export const AI_SKILL_COMMANDS: SkillSlashCommand[] = [ + // ... + { name: 'migrate', description: __( 'Migrate a website from another platform to WordPress' ) }, + ]; + ``` + Note: skill directory name must match the command name (`migrate-site/` vs `migrate`) โ€” the `buildSkillInvocationPrompt` emits `"Run the /migrate skill using the Skill tool."` and `findSkill('migrate')` must match. So **either** rename the skill dir to `migrate/` **or** point the command at `migrate-site`. The first is cleaner. +3. **The rest is automatic.** `apps/cli/commands/ai/index.ts:619` routes any `AI_SKILL_COMMANDS` entry through `runAgentTurn(buildSkillInvocationPrompt(cmd.name))`. The agent then calls the `Skill` tool (`apps/cli/ai/tools/skill.ts`), which loads and returns the skill body. + +**Optional refinement โ€” gate the DLA bridge to `/migrate`:** + +To avoid spawning DLA's child on every chat session, register a custom slash-command handler for `migrate` that starts the bridge before triggering the skill. This means demoting `migrate` from `AI_SKILL_COMMANDS` to its own entry in `slash-commands.ts` (a `handler` is provided, mirroring the `swag` example at line 528-535). Sketch: + +```ts +{ + name: 'migrate', + description: __( 'Migrate a website from another platform to WordPress' ), + handler: async (prompt, ctx) => { + await ensureDlaBridgeStarted(); // idempotent + await ctx.runAgentTurn(buildSkillInvocationPrompt('migrate')); + return 'continue'; + }, +}, +``` + +The downside is the agent restart pattern doesn't cleanly carry the bridge across sessions โ€” for v1, **register `migrate` in `AI_SKILL_COMMANDS` and pay the bridge cost on every CLI startup**. Bridge gating is a wave-2 optimization. + +**Gaps identified:** +- The skill body in `prior-art/rsm-3139-spec.md` references DLA tool names verbatim. If a remote tool isn't loaded (bridge failed to start), the model will get confused. Add a preflight line to the skill: "If `liberate_detect` is not available, stop and report that the migration tools failed to load." +- DLA's skill bodies (in DLA's `skills/liberate/SKILL.md`) declare `allowed-tools: Bash, Read, Write, Edit, Glob, Grep, AskUserQuestion`. Studio CLI exposes all of these (per `buildAgentTools` at lines 437-444). Compatible. + +## 8. Gotchas & open questions + +1. **`structuredContent` not used by DLA.** Brief assumption was wrong. v1 adapter handles it for forward-compat (`details = result.structuredContent`); no special handling needed today. +2. **Abort doesn't propagate to DLA's server-side work.** DLA doesn't honor `notifications/cancelled`. See ยง4. Mitigation: kill the child on session dispose; document the orphan-work behavior; consider upstream PR to DLA. +3. **`beforeToolCall` not on the public API.** Brief 1 must confirm whether `createAgentSessionRuntime`/`AgentSessionServices` expose a way to wire one. If yes, the bridge can replace the in-adapter `policy.assert` throws with a centralized hook. If no, advisory + adapter-throw is the recommendation (ยง5). +4. **Cold start of `npx tsx`.** Needs re-measurement against current DLA HEAD. Brief 5 should benchmark both `npx tsx src/mcp-server.ts` and `node dist/mcp-server.js` on a cold cache. +5. **DLA dependency on Playwright Chromium (~150 MB postinstall).** Not the bridge's problem per se, but if Studio bundles DLA the install footprint balloons. Brief 5. +6. **DLA spawns `studio` CLI itself** (`data-liberation-agent/src/lib/preview/studio.ts:71+` per the prior-art file). If Studio CLI is the host and DLA tries to spawn `studio site create`, we get a nested CLI invocation โ€” works but ugly. The `delegate: true` path on `liberate_preview` should avoid this; verify the skill body uses it. +7. **TypeScript cast `inputSchema as unknown as TSchema`.** Safe at runtime (ยง2), but it's a `// eslint-disable-next-line @typescript-eslint/no-explicit-any`-shaped concession. Acceptable; same pattern Studio's MCP *server* uses. +8. **DLA's `outputSchema` (if any) goes unused.** SDK supports `jsonSchemaValidator` to validate `structuredContent`. We skip this in v1 since DLA doesn't emit `outputSchema`. Not a real concern. +9. **Multiple DLA tool calls in flight in one turn.** pi's `executionMode: 'parallel'` is the default. Each `callTool` is a separate JSON-RPC request; the MCP SDK handles concurrent requests fine. Should work; benchmark recommended. +10. **DLA's `_meta`/progress notifications.** DLA's mcp-server calls `sendLoggingMessage` for progress (per inventory). pi's `onUpdate` partial-result callback could surface these. Out of scope for v1; nice wave-2. +11. **Error messages from MCP errors are JSON-RPC-formatted.** When `client.callTool` rejects (transport error, server crash), the message includes JSON-RPC framing. Wrap it in a friendlier error before re-throwing from `execute`. + +## 9. Verdict + +**Verdict:** works with caveats. + +**Recommendation strength:** acceptable โ€” leans strong. + +The bridge mechanically works: +- `@modelcontextprotocol/sdk@1.29.0` ships a typed `Client` + `StdioClientTransport`. The API for spawning a stdio-MCP child, calling `listTools`, and calling `callTool(name, args, {signal})` is exactly what we need (ยง1). +- pi accepts plain JSON Schema objects in `parameters` โ€” confirmed by `validateToolArguments`'s explicit `!hasTypeBoxMetadata && isJsonSchemaObject` branch. No schema translation required (ยง2). +- The result shape adapts cleanly: filter audio/resource_link content; map `structuredContent` โ†’ `details`; throw on `isError: true` and let pi's loop handle it (ยง2). +- Lifecycle plugs into the existing `dispose()` finally block (ยง3). +- Abort propagation is one line on the client (ยง4); the server-side gap is a documented caveat, not a blocker. +- File layout slot is `apps/cli/ai/dla/`; integration is one new parameter to `buildAgentTools` and one bring-up/tear-down in `runAgentSessionTurn` (ยง6). +- Slash-command + skill plumbing is the existing `AI_SKILL_COMMANDS` pattern (ยง7). + +The caveats are real but bounded: +- Permission gating becomes advisory unless Brief 1 surfaces a `beforeToolCall` hook through the lower-level API. +- Abort doesn't cancel DLA-side work; restartable extractions limit the damage. +- `npx tsx` cold start should be replaced with a built JS entry point โ€” DLA already has the build script. + +The bridge approach should win the synthesis unless Brief 3 (vendor-as-AgentTools) shows DLA's `src/lib/` is cleanly self-contained, or Brief 4 (subprocess) finds a way to give the agent enough visibility into DLA without 13 round-tripped tool definitions. Neither seems likely on first read โ€” the MCP surface is the right abstraction, just embedded in-process via stdio. + +## Sources + +- `node_modules/@modelcontextprotocol/sdk/package.json` (version 1.29.0) +- `node_modules/@modelcontextprotocol/sdk/dist/esm/client/index.d.ts` lines 22-590 โ€” Client / ClientOptions / listTools / callTool signatures +- `node_modules/@modelcontextprotocol/sdk/dist/esm/client/stdio.d.ts` lines 5-76 โ€” StdioServerParameters / StdioClientTransport +- `node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.d.ts` lines 61-98 โ€” RequestOptions (signal/timeout) +- `node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.js` lines 677, 709-710 โ€” signal-to-cancel wiring +- `node_modules/@modelcontextprotocol/sdk/dist/esm/types.d.ts` lines 2495-2520, 8089 โ€” CallToolResultSchema +- `node_modules/@mariozechner/pi-agent-core/dist/types.d.ts` lines 259-291 โ€” AgentToolResult / AgentTool / Tool +- `node_modules/@mariozechner/pi-agent-core/dist/agent-loop.js` lines 308-401 โ€” prepareToolCall / executePreparedToolCall / beforeToolCall hook +- `node_modules/@mariozechner/pi-ai/dist/types.d.ts` lines 153-168 โ€” Tool / ToolResultMessage +- `node_modules/@mariozechner/pi-ai/dist/utils/validation.js` lines 1-280 โ€” validateToolArguments, hasTypeBoxMetadata, coerceWithJsonSchema, getValidator (the load-bearing evidence that pi accepts plain JSON Schema) +- `node_modules/@mariozechner/pi-ai/dist/providers/anthropic.js` lines 887-904 โ€” convertTools (proof parameters โ†’ input_schema is shape-preserving) +- `node_modules/@mariozechner/pi-ai/dist/providers/openai-completions.js` line 756 (same, with comment "TypeBox already generates JSON Schema") +- `node_modules/@mariozechner/pi-ai/dist/providers/amazon-bedrock.js` line 599 (same) +- `node_modules/@mariozechner/pi-coding-agent/dist/core/extensions/types.d.ts` lines 323-354 โ€” ToolDefinition +- `node_modules/@mariozechner/pi-coding-agent/dist/core/sdk.d.ts` lines 11-106 โ€” CreateAgentSessionOptions (confirming no beforeToolCall) +- `node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.d.ts` line 244 โ€” `dispose()` +- `apps/cli/ai/mcp-server.ts` lines 1-58 โ€” Studio's existing MCP *server* usage (the brief's structural analog) +- `apps/cli/ai/runtimes/pi/index.ts` lines 75-451 โ€” buildAgentTools, toToolDefinition, createStudioAgentSession, runAgentSessionTurn (the splice point) +- `apps/cli/ai/tools/define-tool.ts` lines 1-57 โ€” Studio's tool-defining convention +- `apps/cli/ai/tools/wpcom-request.ts` lines 1-143 โ€” the brief-suggested structural analog +- `apps/cli/ai/tools/skill.ts` lines 1-32 โ€” Skill tool that loads SKILL.md files +- `apps/cli/ai/skills.ts` lines 1-55 โ€” skill loader (path resolution at line 30) +- `apps/cli/ai/tools/index.ts` lines 1-79 โ€” resolveStudioToolDefinitions +- `apps/cli/ai/slash-commands.ts` lines 528-542 โ€” slash-command registry, AI_SKILL_COMMANDS splat +- `apps/cli/commands/ai/index.ts` lines 600-633 โ€” slash-command โ†’ skill invocation routing +- `tools/common/ai/slash-commands.ts` lines 1-17 โ€” AI_SKILL_COMMANDS / buildSkillInvocationPrompt +- `/Users/iamposti/Automattic/repos/data-liberation-agent/src/mcp-server.ts` lines 32-310 โ€” DLA tool list, inputSchemas, textResult helper (the key finding: structuredContent unused) +- `/Users/iamposti/Automattic/repos/data-liberation-agent/package.json` lines 1-23 โ€” DLA has `"build": "tsc"`; `bin` points at `./dist/cli.js` +- `issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-dla-inventory.md` โ€” DLA tool count (13), surfaces, delegate:true semantics, skill bodies +- `issues/rsm-3143-dla-pi-research/tasks/wave-1-mcp-bridge-feasibility.md` โ€” task brief +- `issues/rsm-3143-dla-pi-research/research-plan.md` โ€” wave 1 plan diff --git a/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-pi-extensibility-surface.md b/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-pi-extensibility-surface.md new file mode 100644 index 0000000000..fd883e618a --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-pi-extensibility-surface.md @@ -0,0 +1,367 @@ +--- +task: wave-1-pi-extensibility-surface +wave: 1 +status: complete +--- + +# Wave 1 โ€” pi-coding-agent extensibility surface + +## TL;DR + +- Studio's `createAgentSession({ customTools, tools, sessionStartEvent, resourceLoader, โ€ฆ })` call exposes the **full** session creation contract; `createAgentSessionFromServices`/`createAgentSessionRuntime` add **no new tool/extension hooks** โ€” they are runtime/cwd-rebinding helpers that ultimately delegate to `createAgentSession`. +- pi has a real, programmatic **inline extension API** reachable from `createAgentSession`: `new DefaultResourceLoader({ extensionFactories: [...] })`. This is the *only* documented seam that gives Studio access to `tool_call` (block/mutate args), `tool_result` (override content/details/isError), `before_agent_start` (rewrite system prompt for one turn), `pi.registerCommand` (named slash commands routed through `session.prompt('/name โ€ฆ')`), `pi.registerTool` (dynamic LLM-callable tools), and the rest of the `ExtensionAPI` event family. +- `beforeToolCall`/`afterToolCall` on `AgentLoopConfig` exist on agent-core โ€” but pi's `AgentSession` **already claims them in its constructor** (`_installAgentToolHooks`) and proxies them into the extension runner's `tool_call`/`tool_result` handlers. The hooks are not exposed independently on `createAgentSession` and are not on the `Agent` instance anywhere a Studio host can reach. The realistic Studio permission-gate seam is therefore an **inline extension factory** that registers a `tool_call` handler returning `{ block: true, reason }`. +- pi 0.70.2 has **zero MCP support** โ€” no client, no server, no transport, no roadmap entry in the bundled CHANGELOG. Concretely: `grep -rn "[Mm]cp\b\|MCP\b\|ModelContextProtocol\|modelcontextprotocol"` against `node_modules/@mariozechner/pi-coding-agent/dist/` and `node_modules/@mariozechner/pi-agent-core/dist/` matches **nothing** outside vendored syntax-highlight tables (`export-html/vendor/highlight.min.js`). +- The five `noExtensions / noSkills / noPromptTemplates / noThemes / noContextFiles` flags on `DefaultResourceLoader` only suppress **filesystem discovery**. `extensionFactories: []` is *always* loaded โ€” `noExtensions: true` does not gate inline factories. Studio is free to inject inline extensions today without flipping any of these toggles. +- 0.70.2 is **three minor versions behind** npm latest (0.73.1). Every minor release in 2026 ships a "Breaking Changes" section. The extension API is comparatively settled (last reshape was the `beforeToolCall`/`afterToolCall` migration at 0.59.0-ish per CHANGELOG line 873), but anything Studio leans on should be treated as semver-loose. + +--- + +## 1. Extensibility map (public API) + +`createAgentSession(options: CreateAgentSessionOptions): Promise` โ€” declared in `node_modules/@mariozechner/pi-coding-agent/dist/core/sdk.d.ts:11-106`, implemented in `node_modules/@mariozechner/pi-coding-agent/dist/core/sdk.js:75-270`. + +| Option | Type (verbatim from `sdk.d.ts`) | What it accepts / what it gives you | +|---|---|---| +| `cwd` | `string` | Project root for project-local discovery (skills, AGENTS.md). Studio passes `STUDIO_SITES_ROOT`. | +| `agentDir` | `string` | Global config dir. Studio also points this at `STUDIO_SITES_ROOT`. | +| `authStorage` | `AuthStorage` | Stores per-provider OAuth/API-key credentials. Studio uses `AuthStorage.inMemory()` (`apps/cli/ai/runtimes/pi/index.ts:327`). | +| `modelRegistry` | `ModelRegistry` | Lets you register providers (with `streamSimple`, OAuth, custom models). Studio registers a synthetic wpcom provider on this (`apps/cli/ai/runtimes/pi/index.ts:330-334`). | +| `model` | `Model` | The active model for the session. | +| `thinkingLevel` | `ThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh"`. | +| `scopedModels` | `Array<{ model: Model; thinkingLevel?: ThinkingLevel }>` | Cycle list for Ctrl+P. Studio passes nothing โ€” only relevant in interactive mode. | +| `noTools` | `"all" \| "builtin"` | `"all"`: start with empty allowlist; `"builtin"`: drop pi's `read/bash/edit/write` but keep extension/custom tools. Per CHANGELOG 0.70.0, this option's semantics changed; the version Studio ships is post-fix. | +| `tools` | `string[]` | Explicit allowlist of tool names. Anything not in this set is hidden from the model. Studio passes `toolDefinitions.map(t => t.name)`. | +| `customTools` | `ToolDefinition[]` | The primary tool-injection seam. Each `ToolDefinition` declares `name`, `label`, `description`, `parameters` (TypeBox), optional `promptSnippet`/`promptGuidelines`, `prepareArguments`, `executionMode` (`"sequential" \| "parallel"`), and `execute(toolCallId, params, signal, onUpdate, ctx)`. Full shape in `node_modules/@mariozechner/pi-coding-agent/dist/core/extensions/types.d.ts:323-354`. | +| `resourceLoader` | `ResourceLoader` | Optional override. When omitted, pi uses `new DefaultResourceLoader({ cwd, agentDir, settingsManager })` (`sdk.js:87`). Studio constructs its own `DefaultResourceLoader` with `noExtensions/noSkills/noPromptTemplates/noThemes/noContextFiles: true` and a precomputed `systemPrompt` string (`apps/cli/ai/runtimes/pi/index.ts:256-267`). | +| `sessionManager` | `SessionManager` | Persistence layer for messages/branch summaries. Studio passes its own `SessionManager` from `cli/ai/types`. | +| `settingsManager` | `SettingsManager` | Reads `defaultThinkingLevel`, compaction settings, image policies, transport, etc. Studio uses `SettingsManager.inMemory({ defaultThinkingLevel: 'high', compaction: { โ€ฆ } })`. | +| `sessionStartEvent` | `SessionStartEvent` | `{ type: "session_start"; reason: "startup" \| "reload" \| "new" \| "resume" \| "fork"; previousSessionFile?: string }`. Forwarded to extensions on bind. Studio sends `{ type: 'session_start', reason: 'startup' }`. | + +**Hooks NOT surfaced on `createAgentSession`:** + +- No `beforeToolCall`/`afterToolCall` parameter. (Pi's own `AgentSession._installAgentToolHooks` at `node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js:171-216` claims those agent-core hooks for itself.) +- No `plugins` / `mcpServers` / `canUseTool` parameter (these existed on `claude-agent-sdk` but **do not exist** in pi). +- No `extensions: ExtensionFactory[]` parameter directly on `CreateAgentSessionOptions`. The seam is one level deeper: `resourceLoader: new DefaultResourceLoader({ extensionFactories: [...] })`. See section 2. +- No `streamFn`/`convertToLlm`/`onPayload`/`transformContext` parameter. These exist on the `Agent` constructor but `createAgentSession` constructs the `Agent` itself with a fixed wiring (`sdk.js:179-235`). Replacing them would require not using `createAgentSession` at all (i.e., constructing `Agent` + `AgentSession` directly, which means owning their stability contract). + +**`CreateAgentSessionResult`** (`sdk.d.ts:57-64`): + +```ts +export interface CreateAgentSessionResult { + session: AgentSession; + extensionsResult: LoadExtensionsResult; + modelFallbackMessage?: string; +} +``` + +Studio currently discards `extensionsResult`. That's the handle to `runtime.flagValues`, `errors`, and the loaded `Extension[]` โ€” useful for diagnostics if Studio starts injecting extensions. + +--- + +## 2. Extensibility map (lower-level) + +### `createAgentSessionRuntime` / `AgentSessionRuntime` + +Declared in `node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-runtime.d.ts:43-115`. + +```ts +export declare function createAgentSessionRuntime( + createRuntime: CreateAgentSessionRuntimeFactory, + options: { cwd: string; agentDir: string; sessionManager: SessionManager; sessionStartEvent?: SessionStartEvent; } +): Promise; +``` + +`AgentSessionRuntime` wraps a session and exposes `switchSession()`, `newSession()`, `fork()`, `importFromJsonl()`, plus `setRebindSession()` and `setBeforeSessionInvalidate()` hooks. **None of these add tool-extension or permission-hook surfaces**; they are concerned with rebinding services when the user switches/forks a session in interactive mode. The factory callback gets to recreate cwd-bound services on each switch, and that's the only added power. + +Studio runs one-shot RPC turns and recreates the session each turn (`apps/cli/ai/runtimes/pi/index.ts:204-227`), so `AgentSessionRuntime` is not buying anything we need. + +### `createAgentSessionFromServices` / `createAgentSessionServices` + +Declared in `node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-services.d.ts:28-85`. The split decouples "build services" from "create session". + +- `createAgentSessionServices(options: CreateAgentSessionServicesOptions): Promise` โ€” builds `{ cwd, agentDir, authStorage, settingsManager, modelRegistry, resourceLoader, diagnostics }`. Accepts `resourceLoaderOptions: Omit` โ€” so you reach `extensionFactories`, the `*Override` callbacks, and the `no*` toggles here. +- `createAgentSessionFromServices(options: CreateAgentSessionFromServicesOptions): Promise` โ€” implementation (`agent-session-services.js:99-116`) just delegates to `createAgentSession(...)` and forwards the prepared services. **No additional hooks.** + +```ts +// agent-session-services.js:99-116 (verbatim) +export async function createAgentSessionFromServices(options) { + return createAgentSession({ + cwd: options.services.cwd, + agentDir: options.services.agentDir, + authStorage: options.services.authStorage, + settingsManager: options.services.settingsManager, + modelRegistry: options.services.modelRegistry, + resourceLoader: options.services.resourceLoader, + sessionManager: options.sessionManager, + model: options.model, + thinkingLevel: options.thinkingLevel, + scopedModels: options.scopedModels, + tools: options.tools, + noTools: options.noTools, + customTools: options.customTools, + sessionStartEvent: options.sessionStartEvent, + }); +} +``` + +**Verdict:** the lower-level entry points add **session-switching** mechanics, not tool/extension power. The only Studio-relevant route to deeper hooks is through `DefaultResourceLoader` and its `extensionFactories` option โ€” and that's already reachable from the public `createAgentSession({ resourceLoader: new DefaultResourceLoader({ extensionFactories: [โ€ฆ] }) })` path Studio uses today. + +### `DefaultResourceLoader` (the actual lower-level seam) + +Declared in `node_modules/@mariozechner/pi-coding-agent/dist/core/resource-loader.d.ts:56-108`. The options matter: + +| Option | Type | Studio currently | What it does | +|---|---|---|---| +| `additionalExtensionPaths` | `string[]` | unset | Append filesystem extension paths to the discovered set. | +| `additionalSkillPaths` | `string[]` | unset | Append filesystem skill dirs. | +| `additionalPromptTemplatePaths` | `string[]` | unset | Same for prompt templates. | +| `additionalThemePaths` | `string[]` | unset | Same for themes. | +| **`extensionFactories`** | **`ExtensionFactory[]`** | **unset** | **Inline extension factories that are loaded unconditionally, even when `noExtensions: true`.** Each factory is `(pi: ExtensionAPI) => void \| Promise` and gets the full ExtensionAPI surface (Section 3). See `resource-loader.js:272-278`. | +| `noExtensions` | `boolean` | `true` | Suppress filesystem-discovered extensions โ€” NOT inline factories. | +| `noSkills` | `boolean` | `true` | Suppress filesystem skill discovery (`apps/cli/ai/skills/...` is Studio's own; pi's loader is bypassed). | +| `noPromptTemplates` | `boolean` | `true` | Suppress `.pi/prompts/` discovery. | +| `noThemes` | `boolean` | `true` | Suppress theme discovery (Studio renders its own UI). | +| `noContextFiles` | `boolean` | `true` | Suppress AGENTS.md/CLAUDE.md discovery. Filenames hard-coded at `resource-loader.js:31`: `const candidates = ["AGENTS.md", "CLAUDE.md"];` โ€” `GEMINI.md` is **not** discovered automatically. | +| `systemPrompt` | `string` | preassembled Studio prompt | Replaces pi's default system prompt entirely. See `system-prompt.js:19-41` for the `customPrompt`-branch behavior. | +| `appendSystemPrompt` | `string[]` | unset | Joined with `\n\n` and appended to the (custom or default) prompt. | +| `extensionsOverride` / `skillsOverride` / `promptsOverride` / `themesOverride` / `agentsFilesOverride` / `systemPromptOverride` / `appendSystemPromptOverride` | callbacks | unset | Post-load transforms. `agentsFilesOverride` is a clean seam for synthesizing AGENTS.md content into the prompt without writing files. | + +**`extensionFactories` is the load-bearing finding.** It's the programmatic way to register `tool_call`/`tool_result`/`before_agent_start`/`registerTool`/`registerCommand` from inside Studio's process โ€” no filesystem extension required. + +--- + +## 3. Hook inventory + +`AgentLoopConfig` on agent-core (`node_modules/@mariozechner/pi-agent-core/dist/types.d.ts:84-197`) declares the low-level hooks: + +```ts +beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise; +afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise; +``` + +with `BeforeToolCallResult = { block?: boolean; reason?: string }` (line 32-35) and `AfterToolCallResult = { content?, details?, isError?, terminate? }` (line 48-57). + +The block path is wired in `node_modules/@mariozechner/pi-agent-core/dist/agent-loop.js:333-347`: + +```js +if (config.beforeToolCall) { + const beforeResult = await config.beforeToolCall({ assistantMessage, toolCall, args: validatedArgs, context: currentContext }, signal); + if (beforeResult?.block) { + return { kind: "immediate", result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"), isError: true }; + } +} +``` + +These are **claimed by pi's `AgentSession` in its constructor**, verbatim from `agent-session.js:171-216`: + +```js +_installAgentToolHooks() { + this.agent.beforeToolCall = async ({ toolCall, args }) => { + const runner = this._extensionRunner; + if (!runner.hasHandlers("tool_call")) { return undefined; } + await this._agentEventQueue; + try { + return await runner.emitToolCall({ type: "tool_call", toolName: toolCall.name, toolCallId: toolCall.id, input: args }); + } catch (err) { โ€ฆ } + }; + this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => { + const runner = this._extensionRunner; + if (!runner.hasHandlers("tool_result")) { return undefined; } + const hookResult = await runner.emitToolResult({ type: "tool_result", toolName: toolCall.name, toolCallId: toolCall.id, input: args, content: result.content, details: result.details, isError }); + if (!hookResult) { return undefined; } + return { content: hookResult.content, details: hookResult.details, isError: hookResult.isError ?? isError }; + }; +} +``` + +So `beforeToolCall`/`afterToolCall` are **structurally unreachable** as direct parameters on `createAgentSession` or anywhere else in the public API. The pi-blessed substitute is the extension event surface, which `AgentSession` proxies through `_installAgentToolHooks`. + +### Reachable hooks via inline `extensionFactories` + +The `ExtensionAPI` (`node_modules/@mariozechner/pi-coding-agent/dist/core/extensions/types.d.ts:768-921`) exposes: + +| Event / API | Signature | Permission-gate? | +|---|---|---| +| `pi.on("tool_call", handler)` | `ExtensionHandler` โ€” return `{ block: true, reason }` to refuse; mutate `event.input` to patch args (no re-validation). | **Yes โ€” primary permission gate.** Goes through `runner.emitToolCall` โ†’ `AgentSession.beforeToolCall` โ†’ agent-core `block`. | +| `pi.on("tool_result", handler)` | `ExtensionHandler` โ€” replace `content` / `details` / `isError`. | Post-execution mutation. Useful for redaction. | +| `pi.on("before_agent_start", handler)` | Returns `{ message?, systemPrompt? }` โ€” `systemPrompt` chains across handlers and is applied for this turn only. | Per-turn prompt rewrite. Closest pi has to dynamic prompt injection. | +| `pi.on("agent_start" / "agent_end" / "turn_start" / "turn_end" / "message_start" / "message_update" / "message_end")` | Read-only telemetry events. | Observability. | +| `pi.on("tool_execution_start" / "_update" / "_end")` | Read-only; emitted by `Agent`, not by the extension `tool_call` proxy. | Observability โ€” separate stream from `tool_call`/`tool_result`. | +| `pi.on("session_start" / "session_shutdown" / "session_before_*")` | Lifecycle events. `session_before_switch/fork/compact/tree` accept a `{ cancel: true }` return to veto. | Session lifecycle, not relevant to per-tool gating. | +| `pi.on("input", handler)` | Returns `{ action: "continue" \| "transform" \| "handled" }`. Intercepts user prompt before agent processing. | **Reachable** when `source` is `"interactive"`, `"rpc"`, or `"extension"`. Studio sets `source: 'rpc'` (`apps/cli/ai/runtimes/pi/index.ts:217`). | +| `pi.on("resources_discover", handler)` | Returns `{ skillPaths?, promptPaths?, themePaths? }`. Lets an extension inject paths into the loader at session start. | Resource injection at runtime. | +| `pi.registerTool(tool: ToolDefinition)` | Registers an LLM-callable tool with full `ToolDefinition` shape (same as `customTools`). | **Alternative tool-injection seam.** Equivalent power to `customTools` but lives inside the extension. | +| `pi.registerCommand(name, options)` | Registers a slash command. Routed by `AgentSession.prompt()` when `text.startsWith('/')` and the name matches (`agent-session.js:681-688`). | **Reachable from RPC** because `prompt('/migrate โ€ฆ', { source: 'rpc' })` still flows through `_tryExecuteExtensionCommand`. Studio's CLI loop currently consumes slashes *before* calling `runAgentTurn`, so the pi-side `/migrate` would never fire unless Studio's slash dispatcher forwards to `agent.prompt('/migrate โ€ฆ')` instead of intercepting. See section 4. | +| `pi.registerProvider(name, config: ProviderConfig)` / `pi.unregisterProvider(name)` | Add/replace LLM providers (incl. OAuth, custom `streamSimple`). | Studio already does this via `ModelRegistry.registerProvider` directly โ€” alternative path. | +| `pi.registerMessageRenderer(customType, renderer)` | Interactive-mode TUI hook. | Irrelevant โ€” Studio renders its own UI. | +| `pi.registerShortcut(keyId, options)` / `pi.registerFlag(name, options)` | Interactive-only. | Irrelevant for headless. | +| `pi.sendMessage` / `pi.sendUserMessage` / `pi.appendEntry` | Push messages/entries into the session from inside the extension. | Useful for the migrate tool to emit progress as `CustomMessage`s. | +| `pi.exec(command, args, options)` | Run shell commands with abort support. | Useful for the bridge if it spawns DLA. | +| `pi.events` | `EventBus` for inter-extension messaging. | Irrelevant for single-tenant Studio extension. | + +**Verdict on permission policy:** Studio can implement per-tool permission buckets today by registering one inline extension factory that subscribes to `tool_call` and returns `{ block: true, reason }` from a Studio-side policy function. No fork, no upstream PR, no agent-core direct access required. Mechanically equivalent to what `canUseTool` was in `claude-agent-sdk`. + +--- + +## 4. Slash-commands & skills mechanism + +### What pi ships natively + +- `BUILTIN_SLASH_COMMANDS` (`node_modules/@mariozechner/pi-coding-agent/dist/core/slash-commands.js:1-25`): `/settings`, `/model`, `/scoped-models`, `/export`, `/import`, `/share`, `/copy`, `/name`, `/session`, `/changelog`, `/hotkeys`, `/fork`, `/clone`, `/tree`, `/login`, `/logout`, `/new`, `/compact`, `/resume`, `/reload`, `/quit`. **Interactive mode only** โ€” handled by `InteractiveMode`, not `AgentSession`. +- `pi.registerCommand(name, { handler, description, getArgumentCompletions })` lets extensions add commands. These are routed by `AgentSession.prompt()` at `agent-session.js:679-688`: if `text` starts with `/` and matches a registered command, the handler runs immediately and the prompt is consumed โ€” no LLM call. +- File-based prompt templates (`.pi/prompts/.md`) expand via `expandPromptTemplate()` in `prompt()` at `agent-session.js:706-707` when `expandPromptTemplates: true`. Studio explicitly passes `expandPromptTemplates: false` (`apps/cli/ai/runtimes/pi/index.ts:217`), so template expansion is off. +- Skill expansion (`/skill:name args`) handled by `_expandSkillCommand` (`agent-session.js:706`). Skills come from `resourceLoader.getSkills()`. With `noSkills: true` Studio receives an empty list and pi's skill expansion never fires. + +### What Studio currently overrides + +- **Slash commands**: Studio's REPL dispatches slashes in `apps/cli/commands/ai/index.ts:602-625` by matching against `getActiveSlashCommands()` from `apps/cli/ai/slash-commands.ts:71`. Built-in commands (`/browser`, `/clear`, `/model`, `/provider`, `/login`, `/preview`, `/remote-session`, `/swag`, `/exit`, `/api-key`, `/logout`) have handlers; skill commands from `tools/common/ai/slash-commands.ts` are handler-less and the loop falls through to `runAgentTurn(buildSkillInvocationPrompt(cmd.name))` (`apps/cli/commands/ai/index.ts:619`), which produces the literal prompt `Run the /${name} skill using the Skill tool.`. Slashes pi doesn't know about never reach `agent.prompt()`. +- **Skills**: Studio loads `apps/cli/ai/skills//SKILL.md` files in `apps/cli/ai/skills.ts:27-51` and surfaces them through a Studio-owned `Skill` tool (`apps/cli/ai/tools/skill.ts:9-32`). The tool's parameters list every discovered skill name as a `Type.Enum` and returns the SKILL body as a text content block. **Pi's own skill loader is bypassed** via `noSkills: true`. + +### Why Studio overrode pi's surfaces + +Three reasons, gleaned from the runtime wiring and prior-art: + +1. **Skill UX**: Studio's skills are AI-callable workflows (e.g. `annotate`, `taxonomist`, `need-for-speed`, `rank-me-up`). They run via a tool call, not a slash expansion, so the agent can decide *when* to load them mid-turn. Pi's `_expandSkillCommand` only fires when the user types `/skill:name`, which doesn't fit the "Run the /X skill using the Skill tool" pattern. +2. **Slash UI control**: Studio renders its own UI (`AiChatUI`), so it needs slashes to flow through *its* dispatcher (for `askUser`, browser opens, daemon control, error formatting). Letting pi handle `/login` would route around `runLoginCommand` and Studio's auth model. +3. **Bundled skills**: Skill content ships inside Studio's bundle (`apps/cli/ai/skills/**`), not the user's home dir. Pi's loader expects `.pi/skills/` and similar โ€” Studio's bundled paths don't fit pi's discovery model. + +### Integration seams for `/migrate` + +The smallest change that lights up `/migrate` follows the existing `AI_SKILL_COMMANDS` registry pattern: + +1. **Add the command** to `tools/common/ai/slash-commands.ts:8-13` โ€” `{ name: 'migrate', description: __('Migrate a site into Studio') }`. +2. **Ship the wrapper-skill body** at `apps/cli/ai/skills/migrate-site/SKILL.md` so `loadSkills()` discovers it and the existing `Skill` tool exposes `migrate-site` as a valid `name` enum value. The wrapper-skill body from `prior-art/rsm-3139-spec.md` is reusable as-is โ€” `buildSkillInvocationPrompt` produces `Run the /migrate skill using the Skill tool.` and the LLM resolves that to a `Skill({ name: 'migrate-site' })` call. +3. **Inject DLA tools** as `customTools` on `createAgentSession` (the same array Studio passes today for `Read`/`Write`/`Edit`/etc). Each `ToolDefinition` would wrap the DLA-side handler (MCP bridge, vendored function, or subprocess โ€” wave-1 briefs 2-4 settle the bridge mechanics). +4. **Permission gating** (per RSM-3139's policy buckets) lands as an inline `extensionFactories: [createPolicyExtension()]` on the same `DefaultResourceLoader` instance, using `pi.on('tool_call', ...)` to return `{ block: true, reason }` from a Studio-side policy. + +Notably, `pi.registerCommand('migrate', { handler })` is **also** reachable from an inline extension, but it would not fire because Studio's REPL intercepts `/migrate` before calling `agent.prompt('/migrate')`. To use pi's native command routing instead, Studio would have to forward unmatched slashes to `agent.prompt(text, { source: 'rpc' })`. The existing `AI_SKILL_COMMANDS` registry route is mechanically smaller and keeps slash-command UX consistent with the rest of Studio's commands. + +--- + +## 5. MCP support + +**Confirmed: no MCP surface in pi 0.70.2.** Search command: + +```bash +grep -rn "[Mm]cp\b\|MCP\b\|ModelContextProtocol\|modelcontextprotocol" \ + node_modules/@mariozechner/pi-coding-agent/dist/ \ + node_modules/@mariozechner/pi-agent-core/dist/ \ + node_modules/@mariozechner/pi-ai/dist/ \ + node_modules/@mariozechner/pi-tui/dist/ \ + | grep -v "export-html/vendor\|highlight\|.js.map\|.d.ts.map" +``` + +returns **zero matches**. The only superficial hits on a broader pattern (`[mM][cC][pP]`) come from `dist/core/export-html/vendor/highlight.min.js:444,483` โ€” these are vendored C++ syntax-highlight token tables that include `make_pair`/`memcpy`/etc., not MCP code. + +The bundled `CHANGELOG.md` (1700+ lines, covering 0.49 โ†’ 0.70.2) contains **no MCP mentions**. pi-coding-agent has no `mcpServers` option, no `McpServer` type, no MCP transport, no MCP client/server. There is no roadmap signal in the changelog either way โ€” the absence is total. + +Implication: any MCP-stdio-to-`AgentTool` bridge is **Studio-owned plumbing** and must live entirely in `apps/cli/`. There is no upstream slot to fit it into. + +Note also that `apps/cli/ai/mcp-server.ts` is Studio **exposing** its tools as an MCP server (for external clients to call), not Studio **consuming** an MCP server. It's unrelated. + +--- + +## 6. Suppressed surfaces + +`DefaultResourceLoader` flags Studio currently sets to `true` (`apps/cli/ai/runtimes/pi/index.ts:260-264`) and what each suppresses: + +| Flag | Currently | Suppresses | Effect on prompt / runtime | Toggle if we wantโ€ฆ | +|---|---|---|---|---| +| `noExtensions` | `true` | Filesystem extension discovery (CLI `--extension` paths, `additionalExtensionPaths`, package-manager extensions, project `.pi/extensions/`). **Does not suppress `extensionFactories`.** | No third-party extensions get loaded from disk. | Stay `true`. Inline factories load regardless and that's all we want. | +| `noSkills` | `true` | Filesystem skill discovery from `~/.pi/skills/`, project `.pi/skills/`, and (when `noExtensions: false`) extension-bundled skills. | Pi's `_expandSkillCommand` finds nothing; the system prompt's skill section (`formatSkillsForPrompt`) is empty. | Stay `true`. Studio's skill model uses its own loader + `Skill` tool. Flipping this would surface pi's skills section in the prompt and `/skill:name` expansion โ€” both redundant with Studio's wiring. | +| `noPromptTemplates` | `true` | `.pi/prompts/*.md` template discovery for `/template-name` expansion. | `expandPromptTemplate()` is a no-op. | Stay `true`. Studio passes `expandPromptTemplates: false` anyway. | +| `noThemes` | `true` | Theme discovery for the interactive TUI. | None โ€” Studio doesn't use pi's TUI. | Stay `true`. Cosmetic only. | +| `noContextFiles` | `true` | Auto-discovery of `AGENTS.md` / `CLAUDE.md` walking up from `cwd` and from `agentDir`. Filenames hard-coded at `resource-loader.js:31`. | `loadProjectContextFiles` returns `[]`; pi's `buildSystemPrompt` skips the `# Project Context` section. | **Flip to `false`** if Studio wants pi to auto-discover an `AGENTS.md` placed at `STUDIO_SITES_ROOT//AGENTS.md`. Caveat: `GEMINI.md` is *not* in the candidate list, and DLA's `AGENTS.md`/`CLAUDE.md`/`GEMINI.md` all carry the same wrapper content (per DLA inventory). The cleanest route to inject DLA's wrapper into the prompt is either (a) `systemPrompt` rewrite in Studio's `buildSystemPrompt`, or (b) `agentsFilesOverride: base => ([...base, { path: '', content: WRAPPER_SKILL_BODY }])`. | + +**Where DLA content should land**: + +- **Wrapper-skill body** (`migrate-site/SKILL.md` per `prior-art/rsm-3139-spec.md`): goes into Studio's bundled skills (`apps/cli/ai/skills/migrate-site/SKILL.md`) and reaches the LLM via the `Skill` tool โ€” *not* via pi's system prompt. This is the established pattern. +- **AGENTS.md-style guidance** that should always be present: rewrite `buildSystemPrompt` in `apps/cli/ai/system-prompt.ts` to include a `## DLA / migration guidance` section. This is more reliable than `agentsFilesOverride` โ€” it doesn't depend on a synthetic filename being acceptable to pi's prompt builder. +- **DLA tool-specific descriptions**: each migrate tool's `ToolDefinition.description` already feeds the model. Optionally add `promptSnippet` so the tool name appears in pi's "Available tools" list โ€” but since Studio's `customPrompt` replaces pi's default prompt entirely (`system-prompt.js:19-41`), the snippet is currently unused. Adding it would require Studio's `buildSystemPrompt` to render an "Available tools" section itself if we want the snippets visible. + +--- + +## 7. Versioning & churn risk + +**Installed version:** `@mariozechner/pi-coding-agent@0.70.2` (`package.json`). Pinned via Studio's `package.json`. + +**npm latest:** `0.73.1` (verified via `npm view @mariozechner/pi-coding-agent dist-tags`). 0.70.2 is **three minor versions behind** as of 2026-04-29. + +**Stability signals:** + +- Only one `@internal` marker in the `*.d.ts` files (`core/model-resolver.d.ts:37` โ€” exported for testing). **No `@deprecated`, `@experimental`, `@beta`, `@alpha`, or `@unstable` annotations** anywhere we'd lean on. +- Every minor release in 2026 (0.65 โ†’ 0.70) ships a `Breaking Changes` section. From the bundled `CHANGELOG.md`: + - **0.70.0** (2026-04-23): Disabled OSC 9;4 terminal progress by default; `--no-builtin-tools` / `createAgentSession({ noTools: "builtin" })` semantics were corrected. + - **0.67.2** (2026-04-14): Added `extensionFactories` to `main()` (the seam we'd rely on). This is **recent** โ€” anyone on < 0.67.2 doesn't have it. + - Around 0.59-0.60-ish: extension tool interception migrated from wrapper-based to `beforeToolCall`/`afterToolCall` on agent-core (CHANGELOG line 873). The current `_installAgentToolHooks` model is the result of that migration; this is the relevant subsystem for Studio's permission gating. + - 0.70.1: Added `retry.provider.{timeoutMs,maxRetries,maxRetryDelayMs}`. + - 0.70.2: Bugfix for provider retry undefined-value forwarding (CHANGELOG.md lines 3-9). +- Repo: `git+https://github.com/badlogic/pi-mono.git` directory `packages/coding-agent` (verified via `npm view @mariozechner/pi-coding-agent@0.73.1 repository`). + +**Load-bearing surfaces for Studio if we proceed with the inline-extension approach:** + +| Surface | First introduced | Risk | +|---|---|---| +| `createAgentSession({ resourceLoader, customTools, tools, sessionStartEvent })` | 0.49.x onward | Low. Public SDK surface, kept stable across many minor releases. | +| `DefaultResourceLoader({ extensionFactories, systemPrompt, no* })` | `extensionFactories` at 0.67.2 (2026-04-14); `no*` flags older. | Low-medium. Recent addition; semantics could be tightened in future releases. | +| `ExtensionAPI.on("tool_call", h)` block/mutate | Post the wrapperโ†’hook migration (~0.59-0.60). | Medium. The CHANGELOG documents two related fixes (0.70.x for `isError` forwarding, earlier for stale `sessionManager` state). Treat the contract as still maturing. | +| `ExtensionAPI.on("tool_result", h)` content/details/isError override | Same era. | Medium โ€” recent regression fixed in 0.70.x (CHANGELOG.md:216). | +| `ExtensionAPI.registerTool(...)` | 0.49.x (predates split). | Low โ€” stable. | +| `ExtensionAPI.registerCommand(...)` | 0.49.x. | Low. | +| `AgentSession.prompt(text, { expandPromptTemplates, source })` | Public from 0.49.x. | Low. | +| `AgentSession.subscribe / .abort / .dispose / .prompt` | Public. | Low. | + +**Migration cost between 0.70.2 โ†’ 0.73.1**: not investigated here (out of scope โ€” that's wave-1 brief 5's territory). Worth bookmarking: if Studio commits to `extensionFactories` + `tool_call` policy, the version-jump survey should validate those two surfaces specifically. + +--- + +## Appendix โ€” files audited + +Primary: + +- `apps/cli/ai/runtimes/pi/index.ts:1-488` โ€” Studio's pi runtime; every imported pi symbol traced. +- `apps/cli/ai/slash-commands.ts:1-543` โ€” Studio's slash-command dispatcher. +- `apps/cli/commands/ai/index.ts:600-633` โ€” REPL dispatch loop showing `/skill` โ†’ `buildSkillInvocationPrompt` โ†’ `runAgentTurn`. +- `apps/cli/ai/skills.ts:1-56` โ€” Studio skill loader. +- `apps/cli/ai/tools/skill.ts:1-32` โ€” Studio `Skill` tool. +- `apps/cli/ai/tools/define-tool.ts:1-56` โ€” `defineTool` adapter Studio wraps each AgentTool with. +- `apps/cli/ai/system-prompt.ts:1-50` โ€” Studio's `buildSystemPrompt`. +- `tools/common/ai/slash-commands.ts:1-17` โ€” `AI_SKILL_COMMANDS` + `buildSkillInvocationPrompt`. + +Pi packages (every `.d.ts` audited): + +- `node_modules/@mariozechner/pi-coding-agent/dist/index.d.ts:1-28` (top-level re-exports) +- `node_modules/@mariozechner/pi-coding-agent/dist/core/sdk.d.ts:1-107` (`createAgentSession`) +- `node_modules/@mariozechner/pi-coding-agent/dist/core/sdk.js:1-270` (implementation) +- `node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.d.ts:1-592` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js:160-216,625-660,1783-1912` (hook install, tool registry, runtime build) +- `node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-runtime.d.ts:1-117` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-services.d.ts:1-86` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-services.js:1-117` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/extensions/index.d.ts:1-12` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/extensions/types.d.ts:1-1151` (full `ExtensionAPI`, all event types, `ToolDefinition`) +- `node_modules/@mariozechner/pi-coding-agent/dist/core/extensions/loader.d.ts:1-25` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/extensions/loader.js:1-352` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/extensions/runner.d.ts:1-157` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/extensions/wrapper.d.ts:1-20` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/resource-loader.d.ts:1-194` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/resource-loader.js:25-76,250-330,595-610` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/slash-commands.d.ts:1-14` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/slash-commands.js:1-25` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/source-info.d.ts:1-18` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/system-prompt.d.ts:1-28` +- `node_modules/@mariozechner/pi-coding-agent/dist/core/system-prompt.js:1-120` +- `node_modules/@mariozechner/pi-agent-core/dist/index.d.ts:1-5` +- `node_modules/@mariozechner/pi-agent-core/dist/types.d.ts:1-347` (`AgentLoopConfig`, `beforeToolCall`/`afterToolCall`, `AgentTool`) +- `node_modules/@mariozechner/pi-agent-core/dist/agent.d.ts:1-117` +- `node_modules/@mariozechner/pi-agent-core/dist/agent-loop.d.ts:1-24` +- `node_modules/@mariozechner/pi-agent-core/dist/agent-loop.js:328-362` (block path) +- `node_modules/@mariozechner/pi-coding-agent/CHANGELOG.md` (full file, ~1700 lines) + +Commands run: + +- `grep -rn "[Mm]cp\b\|MCP\b\|ModelContextProtocol\|modelcontextprotocol" node_modules/@mariozechner/pi-coding-agent/dist/ node_modules/@mariozechner/pi-agent-core/dist/ node_modules/@mariozechner/pi-ai/dist/ node_modules/@mariozechner/pi-tui/dist/` โ€” zero matches outside vendored highlight tables. +- `grep -rn "@deprecated\|@experimental\|@internal\|@unstable\|@alpha\|@beta" node_modules/@mariozechner/pi-*/dist/` โ€” one `@internal` in `core/model-resolver.d.ts:37`. +- `npm view @mariozechner/pi-coding-agent dist-tags --json` โ†’ `{ latest: '0.73.1' }`. +- `npm view @mariozechner/pi-coding-agent versions --json` โ†’ 0.70.2 is three minors behind (0.71.0, 0.71.1, 0.72.0, 0.72.1, 0.73.0, 0.73.1 released since). diff --git a/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-subprocess-revisit.md b/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-subprocess-revisit.md new file mode 100644 index 0000000000..2c61afea22 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-subprocess-revisit.md @@ -0,0 +1,350 @@ +--- +task: wave-1-subprocess-revisit +wave: 1 +status: complete +verdict: works-with-caveats โ€” viable as an escape-hatch / minimum-effort path; weaker than Bridge for the canonical `/migrate` UX +--- + +# Wave 1 โ€” Subprocess approach revisited (Approach E vs. pi) + +## TL;DR + +Against pi's runtime, "spawn DLA's CLI as a subprocess from a wrapper `AgentTool`" **is mechanically feasible** and meaningfully different from the original RSM-1639 Approach E rejection โ€” wrapping the spawn in a tool keeps the agent in the loop. But the gains are smaller than they look: + +- **The agent stays in the conversational loop, yes.** What it loses is anything *inside* DLA โ€” the skill's phased reasoning, the `delegate: true` handoff, the per-tool observability the model gets from MCP `_detect` / `_discover` / `_inspect` / `_extract` events. +- **Output is messy.** DLA's CLI is Ink-only; there is **no `--json` mode** (verified by reading `src/cli.ts` and `package.json`). Non-TTY fallback is parse-able but still contains ASCII-art headers and Unicode glyphs; the model would consume an entire 1โ€“10 KB "screenshot of a terminal" per tool call. +- **Single-tool wrapper collapses agent reasoning to the user's URL prompt + a final import/QA dialogue.** Multi-tool wrapper gives back fine-grained agency but pays `npx tsx` startup per call (~0.7 s warm per Ink subcommand on an M-series Mac) **and** still has no `delegate: true` for import. +- **Recommendation: keep as the escape-hatch / fallback shape** โ€” exposed via a single tool `dla_run` and a *separate* slash command `/migrate --headless` โ€” and let Bridge or Vendor own the canonical `/migrate` UX. Subprocess wins on "minimum code in Studio" and "DLA upgrades land instantly via SHA pin" but loses on every UX axis that matters for the user-facing migration assistant. + +--- + +## 1. CLI subcommand inventory โ€” what's spawnable + +The task brief listed `data-liberation liberate|inspect|adapt|import|verify|qa|diagnose|setup|mcp`. That list is loose โ€” `adapt` and `diagnose` are skills/MCP tools, **not CLI subcommands**. Reading `src/cli.ts` (verbatim from `Automattic/data-liberation-agent@main`, file is 177 LOC): + +| Subcommand | Args | UI? | Blocks on stdin? | `--non-interactive` honored? | +|---|---|---|---|---| +| `` (the "liberate"/extract path) | URL, `--output`, `--dry-run`, `--limit N`, `--resume`, `--token`, `--admin-token`, `--shop-domain`, `--cdp-port`, `--non-interactive`, `--verbose`, `--delay` | `runDiscover` โ†’ Ink `` then `autoPreview` then `ask('Ready to import?')` | **Yes** โ€” final `ask()` prompts via `readline.question` (`src/ui/discover.tsx:443`), and `autoPreview` boots a Playground/Studio site and prints a URL | **Yes** โ€” `if (props.nonInteractive) return;` before `ask()` (`src/ui/discover.tsx:442`) | +| `inspect ` | URL, `--token` | `runInspect` โ†’ Ink `` | No (`useInput`/readline grep negative on `src/ui/inspect.tsx`) | n/a โ€” does not prompt | +| `qa ` | path, `--fix` | `runQaUi` โ†’ Ink `` | No | n/a | +| `verify ` | path | `runVerify` โ†’ Ink `` | No | n/a | +| `setup` | `--site`, `--username`, `--token` | `runSetup` โ†’ Ink `` | **Yes** โ€” `await ask(...)` for missing site/username/token via `readline` (`src/ui/setup.tsx:118-126`) | **No** flag; only env-var / arg presence skips the prompt | +| `preview ` | `--open`, `--port N`, `--non-interactive` | `runCliPreview` โ†’ Ink + optional readline | Spinner + readline post-prompt | **Yes** โ€” `--non-interactive` flag and TTY check (`src/cli.ts:116`) | +| `import ` | `--site`, `--username`, `--token`, `--dry-run`, `--delay`, `--verbose`, `--only`, `--import-authors` | `runImport` โ†’ Ink `` | No (all required args validated up-front in `cli.ts:138-149`) | n/a | +| `mcp` | none | re-imports `mcp-server.js` (stdio MCP server, not user-facing CLI) | Stays alive on stdin | n/a โ€” irrelevant | + +**Adapters / diagnose / qa-fix-up workflows aren't CLI subcommands** โ€” they live in `src/lib/` (consumed by the MCP server) and in `skills/{adapt,diagnose}/SKILL.md`. The MCP server exposes 13 tools; the CLI exposes 7 useful subcommands (`inspect`, `qa`, `verify`, `setup`, `preview`, `import`, bare URL/extract). That asymmetry is important โ€” **a subprocess approach cannot reach MCP-only capabilities** like `liberate_detect`, `liberate_discover`, `liberate_extract`, `liberate_status`, `liberate_map_apis`, `liberate_probe`, `liberate_qa`, `liberate_setup`, `liberate_import` (each as standalone calls), `liberate_preview_stop`. The bare-URL CLI path bundles detect+discover+extract+autoPreview into one Ink run, but the agent can't observe the phases. + +### Output capture realism + +Run with `NO_COLOR=1 CI=1` and non-TTY (pipe) stdout, Ink degrades to a one-shot final render โ€” no spinner re-draws โ€” but the output still contains: + +- ASCII-art header (~8 lines, ~80 cols), Unicode block glyphs, `data-liberation v0.1.0` banner. +- Unicode status glyphs (`โœ“`, `โœ—`, `โš `, `โž”`, `โ—‹`). +- Bullet lines like ` โœ“ Platform: Unknown โ—‹ low` and ` โš  Sitemap: 0 URLs found`. + +Verbatim sample from `npx tsx src/cli.ts inspect http://example.invalid`, non-TTY mode (8 lines of header omitted): + +``` + โœ“ Platform: Unknown โ—‹ low + โš  Sitemap: 0 URLs found + + โš  Extraction: limited (unknown platform) +``` + +Capturable โ€” strip ANSI (no `--json` flag confirmed; grep across `src/cli.ts` and `src/ui/` returns zero hits for `--json`/`JSON.stringify`-as-output), discard the header, return the rest. The agent will reason fine over `โœ“ Platform: Unknown` lines, but the surface is fragile โ€” any DLA UI change re-shapes the model's input. + +--- + +## 2. Single vs. fan-out tradeoff + +| Dimension | Single tool `dla_run(subcommand, args, url?)` | Multi-tool fan-out (`dla_extract`, `dla_inspect`, `dla_import`, `dla_qa`, `dla_verify`, `dla_setup`, `dla_preview`) | +|---|---|---| +| Wrapper LOC | ~50 LOC, one `AgentTool` def | ~50 LOC ร— 7 = ~350 LOC, plus per-tool schemas | +| Per-call startup cost | One `tsx` spawn per agent invocation (~0.7 s warm Ink subcommand, 0.1 s cold for `--version`-class) | One spawn per phase the agent decides to run โ€” magnifies startup. End-to-end `/migrate` of a Wix site would be inspectโ†’extractโ†’previewโ†’qaโ†’import = 5 spawns = ~3.5 s startup + each phase's real work | +| Agent phase observability | **None** โ€” agent picks subcommand then gets terminal output back. No way to interleave reasoning between detect/discover/extract phases of the bare-URL flow. | **High** โ€” agent runs `dla_inspect` first, reasons, decides whether to proceed, runs `dla_extract`, etc. Mirrors MCP-bridge UX but loses `_detect`/`_discover` granularity (those are MCP-only) | +| `delegate: true` (import handoff to Studio) | **No** โ€” DLA's CLI `import` always hits REST (`src/cli.ts:138-149` requires `--site`/`--username`/`--token`). Studio can't intercept the WXR for in-Playground import. | **Same โ€” no** | +| Distinguishing shape vs. Bridge/Vendor | **Yes** โ€” black-box delegation, agent supplies subcommand strings | **Collapses toward Bridge with worse IPC**: same per-tool surface as MCP-bridge but pays `npx tsx` startup per call instead of a single warm MCP child | +| Schema for the model | Untyped โ€” agent learns valid subcommand grammar from the tool description (failure modes: typos, made-up flags). Likely needs a Zod-validated whitelist in the wrapper. | Strictly-typed per tool, model can't pass garbage flags | +| Permission gating | One policy entry per tool name ร— subcommand string โ€” needs a parser in the policy layer | Maps cleanly onto the per-tool policy buckets RSM-3139 already defined (`dla_extract` = write, `dla_import` = network-write, etc.) | +| Failure mode if DLA changes | Tool keeps working as long as DLA's subcommand grammar is stable. New subcommands are *automatically* available. | Each new DLA subcommand requires a new wrapper tool definition + schema + permission bucket | + +**Recommended choice (if subprocess is adopted at all): single tool `dla_run`**, with a tightly enumerated `subcommand` string parameter (Typebox `Type.Union([Type.Literal('inspect'), Type.Literal('extract'), ...])`). Reasons: + +1. The distinguishing property of subprocess vs. Bridge is "DLA is a black box." Fan-out re-derives MCP-bridge with worse IPC, no `delegate: true`, and 7ร— the wrapper code. If you've already paid the cost of mapping subcommands 1:1 you should be running MCP, not `tsx`. +2. The single-tool wrapper is what makes this the cheap escape-hatch. ~50 LOC, instant feature-flag toggle, no maintenance. +3. Loss of phase-by-phase reasoning is real but largely mitigated by the bare-URL "liberate" subcommand bundling detect+discover+extract+autoPreview internally โ€” the agent's job between calls is "interpret the result of this Ink run and decide what to do next." + +The fan-out variant has one advantage: it lets the agent **start with `dla_inspect`** (cheap, no writes) before committing to `dla_extract`. With a single tool, the agent still does this โ€” it just calls `dla_run('inspect', ...)` first โ€” but the tool surface is less discoverable. Hybrid: single `dla_run` *plus* a separate `dla_inspect` (because inspection is the natural decision point) is a reasonable middle ground. + +--- + +## 3. Interactivity & output capture โ€” feasibility check + risks + +### Interactivity + +| Subcommand | Blocks on stdin? | Wrapping strategy | +|---|---|---| +| `inspect`, `qa`, `verify`, `import` | No | Just pipe stdout/stderr | +| `setup` | Yes (missing-arg prompt) | Wrapper must require `--site`/`--username`/`--token` as tool params before spawn; never let DLA reach the `readline.question` path | +| `preview` | Yes (post-preview readline) | Pass `--non-interactive` always | +| `` (extract) | Yes (post-extract "Ready to import?") | Pass `--non-interactive` always | + +**`--non-interactive` is honored by `` (extract) and `preview`** (`src/cli.ts:172,116`). It is **not** a parameter on `setup`, which means `setup` must always have all three creds supplied or it deadlocks. **`setup` and `preview` and `` are the only stdin-touching subcommands**, and all are gated. No use of the wider `useInput` Ink hook anywhere โ€” terminal raw mode is only set during Ink rendering (not for input). + +Studio's existing `createAskUserQuestionTool` (`apps/cli/ai/tools/ask-user-question.ts`) is the right tool for "I need credentials" โ€” the wrapper-skill that drives `dla_run` should call `AskUserQuestion` *before* the subprocess spawn, never as a recovery from DLA blocking. If the wrapper detects the child has been running > N seconds with no output, the safe behavior is to abort and re-raise to the agent with "ask the user" guidance. + +### Output capture + +- **No `--json` mode.** Confirmed by `grep -rn "JSON\\.stringify" src/ui/ src/cli.ts` (0 hits) and `grep -rn "\\-\\-json" src/cli.ts` (0 hits). The output is whatever Ink renders. +- **Non-TTY degradation is clean enough.** With `NO_COLOR=1 CI=1`, Ink prints a single final render โ€” no animated spinners, no redraws. ANSI codes are still present (color escapes); the wrapper must strip them. The ASCII-art `Header` component prints unconditionally (`src/ui/header.tsx`). +- **ANSI stripping** โ€” small npm dep (`strip-ansi`, ~5 LOC) or hand-rolled regex `/\x1b\[[0-9;]*m/g`. Trivial. +- **Truncation** โ€” pi's bash tool already implements the canonical pattern: roll a buffer up to N KB, write overflow to a temp file, return the tail + temp-file path (`@mariozechner/pi-coding-agent/dist/core/tools/bash.js:194-308`). We should reuse `truncateTail`/`createWriteStream` shape rather than re-deriving. +- **Multi-megabyte extraction logs** โ€” large `extract` runs print per-URL progress lines via Ink. In non-TTY mode they accumulate into a long static report (no in-place redraws). The wrapper must stream via `onUpdate` so the model sees progress without OOM'ing the result payload. pi's bash tool is the precedent. + +**Output is structurally capturable.** It is *not* structurally parseable โ€” every tool call returns "what a terminal would have shown." Acceptable for an LLM (which is good at reading text); annoying for assertions, telemetry, or downstream parsing. + +--- + +## 4. `delegate: true` impact โ€” does losing DLA's sub-agent reasoning matter? + +**Yes, materially.** + +The `delegate: true` mode on `liberate_setup` and `liberate_import` (`src/mcp-server.ts:441-489`) was explicitly designed for hosts that "handle the import themselves" โ€” i.e. Studio Code. In MCP, `liberate_import {delegate: true, ...}` returns a structured manifest: + +```json +{ + "wxrFile": "/abs/path/output.wxr", + "outputDir": "/abs/path/", + "mediaDir": "/abs/path/media/", + "productsCsv": "/abs/path/products.csv", + "redirectMap": "/abs/path/redirect-map.json", + "importAuthors": false +} +``` + +โ€ฆand Studio's agent then drives a Studio-side import path (Playground blueprint, `studio site create`, or `studio wp eval-file`) instead of going through DLA's WP REST client. This is the integration shape `AGENTS.md:51` and the DLA inventory `wave-1-dla-inventory.md:198` call out as canonical for Studio. + +**Subprocess loses this entirely.** DLA's CLI `import` subcommand has no `--delegate` flag โ€” `src/cli.ts:138-149` mandates `--site`, `--username`, `--token`, and on missing any of them `process.exit(1)`. There is no way through the CLI to ask for the manifest without performing the REST import. Subprocess users must either: + +1. **Run extract only**, never call `dla_run('import', ...)`, and have the wrapper-skill reconstruct the manifest from the on-disk output directory (`output.wxr`, `media/`, `redirect-map.json`, `products.csv`). This works โ€” the file layout is documented and stable per `AGENTS.md:20-29` โ€” but it duplicates DLA's import logic on the Studio side. +2. **Spawn DLA's MCP server** instead of the CLI for the import phase specifically โ€” at which point you're running a one-call MCP bridge for a single tool. If you're doing that, just run the full Bridge approach. + +The phased reasoning DLA's MCP server enables (separate `_detect`, `_discover`, `_inspect`, `_extract`, `_qa`, `_verify`, `_preview`, `_import` tool calls with delegate handoff) is what the wrapper-skill in RSM-3139 was written around. Subprocess collapses that into 2โ€“3 black-box invocations. The model can still narrate, ask, and decide between calls โ€” but it has zero visibility into *which* of DLA's 13 internal capabilities are actually running. + +**Verdict on this dimension: subprocess loses ~60% of the per-phase intelligence Bridge would have provided.** The remaining 40% (URL collection, credential gathering, post-run interpretation, error escalation) is enough for a serviceable `/migrate` but not a polished one. + +--- + +## 5. Latency & resource notes + +Measured on an Apple M-series Mac (warm npm cache, `tsx` already in `node_modules`): + +| Invocation | Wall time | Notes | +|---|---|---| +| `npx tsx src/cli.ts --version` (warm) | 100 ms | tsx loader + minimal cli.ts parse, no Ink | +| `npx tsx src/cli.ts --version` (cold-ish, second run after machine boot) | 280 ms | one-time disk read | +| `npx tsx src/cli.ts --help` | 100 ms | just prints | +| `npx tsx src/cli.ts inspect http://example.invalid` (network-bound but URL fails fast) | 690 ms | Includes Ink render, adapter scan, sitemap fetch (~500 ms = network + adapter init) | + +(Methodology: `/usr/bin/time -p` ร— 3 runs; ranges reported. Source repo cloned at `/tmp/dla-research`, npm install completed once before timing.) + +**The "few seconds startup" anchor in the brief is conservative.** On a developer Mac, `tsx`-startup is ~100โ€“300 ms; the rest is DLA doing actual work (HTTP fetches, sitemap parsing, content extraction). For *real* work โ€” sitemap of a real Wix site, extraction of N pages โ€” the dominant cost is network, not startup. A bare `` extraction of a 50-page Wix site is dominated by `--delay 500` ร— 50 = 25 s minimum, plus media downloads. The `tsx` cost is in the noise. + +**For the multi-tool variant**, a 5-call `/migrate` flow pays 5 ร— ~200 ms = 1 s of `tsx`-startup overhead. Negligible relative to extraction time, but the *user-visible* latency between agent decisions (each tool call blocks the chat) is felt โ€” Studio's existing tools resolve in ~50โ€“500 ms; a 700 ms tool call is noticeably slower. + +**Memory**: each spawn loads the DLA module tree (~17 K LOC plus Ink + React + tsx) โ€” ~80โ€“150 MB resident per spawn. For multi-tool flows this is freed between calls (subprocess exits). For single-tool flows the spawn is short-lived. **No long-lived child to keep alive** โ€” this is one structural advantage over Bridge (no warm child to manage across the agent session). + +**Playwright Chromium dependency**: DLA's `postinstall` runs `playwright install chromium` โ€” adds ~150 MB to `node_modules`. This pays once at Studio install time regardless of approach. + +--- + +## 6. Concrete wrapper sketch โ€” single-tool + +Pseudo-code following pi's bash-tool conventions (`@mariozechner/pi-coding-agent/dist/core/tools/bash.js`). This is a **raw `AgentTool`**, not Studio's `defineTool` (which today drops `signal` and `onUpdate` โ€” see `apps/cli/ai/tools/define-tool.ts:51`): + +```ts +// apps/cli/ai/tools/dla.ts +import { spawn } from 'child_process'; +import { Type } from 'typebox'; +import { killProcessTree } from '@mariozechner/pi-coding-agent/dist/utils/shell.js'; +import type { AgentTool } from '@mariozechner/pi-agent-core'; +import path from 'path'; + +const DLA_ROOT = path.join(__dirname, '..', 'dla'); // vendored DLA tree +const STRIP_ANSI = /\x1b\[[0-9;]*m/g; +const MAX_OUTPUT_BYTES = 64 * 1024; + +const dlaRunSchema = Type.Object({ + subcommand: Type.Union([ + Type.Literal('extract'), // bare-URL liberate path; arg = url + Type.Literal('inspect'), + Type.Literal('qa'), + Type.Literal('verify'), + Type.Literal('preview'), + // 'setup' and 'import' need creds; surfaced as their own tools + ]), + target: Type.String({ description: 'URL (extract/inspect), WXR file (qa), output dir (verify/preview)' }), + outputDir: Type.Optional(Type.String()), + extraArgs: Type.Optional(Type.Array(Type.String())), +}); + +export const dlaRunTool: AgentTool = { + name: 'dla_run', + label: 'dla_run', + description: 'Run a Data Liberation Agent CLI subcommand. Returns terminal output. ' + + 'For extract/preview the call is always --non-interactive.', + parameters: dlaRunSchema, + async execute(_toolCallId, params, signal, onUpdate) { + const { subcommand, target, outputDir, extraArgs = [] } = params; + + // Build argv, force non-interactive for stdin-blocking subcommands. + const argv = [ + 'tsx', + path.join(DLA_ROOT, 'src/cli.ts'), + ...(subcommand === 'extract' ? [target] : [subcommand, target]), + ...(outputDir ? ['--output', outputDir] : []), + ...(subcommand === 'extract' || subcommand === 'preview' ? ['--non-interactive'] : []), + ...extraArgs, + ]; + + const child = spawn('npx', argv, { + cwd: DLA_ROOT, + env: { ...process.env, NO_COLOR: '1', CI: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const chunks: Buffer[] = []; + let totalBytes = 0; + + const handleData = (data: Buffer) => { + chunks.push(data); + totalBytes += data.length; + // Stream partial output to the UI; tail-truncate at MAX_OUTPUT_BYTES. + if (onUpdate) { + const text = Buffer.concat(chunks).toString('utf-8').replace(STRIP_ANSI, ''); + const tail = text.length > MAX_OUTPUT_BYTES ? text.slice(-MAX_OUTPUT_BYTES) : text; + onUpdate({ content: [{ type: 'text', text: tail }], details: undefined }); + } + }; + child.stdout?.on('data', handleData); + child.stderr?.on('data', handleData); + + // Abort handling โ€” kill the process tree, not just the parent (DLA may + // have spawned Playwright Chromium or Playground subprocesses). + const onAbort = () => { + if (child.pid) killProcessTree(child.pid); + }; + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + } + + return new Promise((resolve, reject) => { + child.on('error', reject); + child.on('exit', (code) => { + if (signal) signal.removeEventListener('abort', onAbort); + const fullText = Buffer.concat(chunks).toString('utf-8').replace(STRIP_ANSI, ''); + const text = fullText.length > MAX_OUTPUT_BYTES + ? `[...truncated to last ${MAX_OUTPUT_BYTES} bytes...]\n${fullText.slice(-MAX_OUTPUT_BYTES)}` + : fullText; + if (signal?.aborted) { + reject(new Error(text + '\n\nDLA aborted')); + } else if (code !== 0) { + reject(new Error(text + `\n\nDLA exited with code ${code}`)); + } else { + resolve({ + content: [{ type: 'text', text: text || '(no output)' }], + details: { subcommand, exitCode: code }, + }); + } + }); + }); + }, +}; +``` + +Registration site: `apps/cli/ai/runtimes/pi/index.ts:252` (the `buildAgentTools` call site). Add `dlaRunTool` to the returned tools array. `customTools`/`tools` are already passed through to `createAgentSession`. + +**Notes on the sketch:** + +- Studio's `defineTool` (`apps/cli/ai/tools/define-tool.ts:51`) ignores the `signal` and `onUpdate` parameters. The DLA wrapper **cannot use `defineTool`** โ€” it needs the raw `AgentTool` shape because abort + streaming are load-bearing for a long-running subprocess. +- `killProcessTree` from pi's internal `utils/shell.js` is the right tool โ€” DLA fans out to Playwright Chromium for Wix/Squarespace, so `child.kill()` alone leaks browsers. (Import via deep path or vendor a copy; the symbol is not re-exported from pi's public surface.) +- `NO_COLOR=1` + `CI=1` strips ANSI redraws; the regex strip handles residual color codes. +- `setup` and `import` need separate wrapper tools because they have non-trivial schemas (creds) that should surface to the model and to Studio's permission policy. `dla_setup` and `dla_import` are dla_run-shaped but with typed params; mostly cut-and-paste. + +--- + +## 7. Comparison vs. Bridge & Vendor + +| Dimension | Subprocess (single-tool `dla_run`) | Bridge (MCP-stdio-to-AgentTool) | Vendor (DLA `src/lib` as Studio-owned AgentTools) | +|---|---|---|---| +| Agent-in-the-loop | **Yes (between tool calls)** โ€” model picks subcommand, reads terminal output, decides next call | **Yes (per-tool)** โ€” model gets 13 distinct MCP tools with structured I/O | **Yes (per-tool)** โ€” model gets N Studio-owned tools wrapping DLA internals | +| Per-phase observability for the agent | **Low** โ€” bare-URL extract bundles detect+discover+extract+autoPreview into one Ink run, no observability | **High** โ€” each MCP tool is a discrete call with structured result | **High** โ€” same as Bridge but Studio owns the schemas | +| Tool-result quality (model input) | **Terminal text + Unicode glyphs, ANSI-stripped, possibly truncated**; lossy | **Structured `{content, details}` per MCP spec** โ€” model gets JSON-shaped data via `details` and human text via `content` | **Same as Bridge** (Studio author controls shape) | +| `delegate: true` (Studio handles import via Playground/Studio site) | **Lost** โ€” CLI `import` always REST-imports, no manifest mode | **Available** โ€” `liberate_setup`/`liberate_import` with `{delegate: true}` return the manifest | **Available** (Studio's wrapper synthesizes the manifest from `src/lib` calls) | +| Startup overhead per call | ~100โ€“300 ms `tsx` + first-import (~500 ms) per spawn | One spawn for the entire session (a few hundred ms once); MCP `CallTool` is sub-10 ms | Zero โ€” in-process module loads | +| Maintenance burden | Lowest โ€” ~50 LOC of wrapper, no schema sync with DLA | Medium โ€” `ListTools` at startup auto-syncs surface; mapping layer is ~150 LOC | Highest โ€” Studio re-derives DLA's tool surface; drift risk on every DLA release | +| Bundling shape | DLA tree + `tsx` + DLA deps (Ink/React/Playwright/Playground) | Same | DLA `src/lib` + transitive deps; Ink/React droppable | +| UX richness | Low โ€” terminal output, ASCII-art header, lossy compared to MCP | High โ€” structured progress events, native-feeling tool calls | Highest โ€” Studio owns the UI affordances | +| Failure granularity | Subprocess exit code + stderr blob | Per-tool `isError: true` with structured `content` | Native JS errors with Studio-shaped catch handling | +| Permission gating | Per-tool (`dla_run` ร— subcommand string) โ€” needs a parser layer | Per-MCP-tool โ€” maps 1:1 to RSM-3139 policy buckets | Per-Studio-tool โ€” maps 1:1 to RSM-3139 policy buckets | +| Survives DLA renaming/refactoring internals | **Yes** (as long as CLI subcommand grammar holds) | Mostly (MCP tool names are public surface; renames break) | **No** โ€” direct module imports break on every internal rename | +| Minimum Studio LOC to ship `/migrate` | ~60 LOC (wrapper) + skill (already exists from RSM-3139) + slash-command registration | ~250 LOC (bridge + lifecycle + per-tool adaptation) + skill + slash-command | ~600+ LOC (re-derive 13 tools) + skill + slash-command | + +--- + +## 8. Verdict โ€” works-with-caveats; escape-hatch role only + +**Verdict: works-with-caveats. Recommend keeping subprocess as the documented fallback / escape-hatch path, not as the canonical `/migrate` implementation.** + +**Why it works** (against the original Approach E rejection): +- The wrapper-in-`AgentTool` shape keeps the agent in the conversational loop โ€” the model decides which DLA subcommand to run, gathers creds from the user via `AskUserQuestion`, and reasons over each subprocess result. +- pi's `AgentTool.execute` signature supports `signal` + `onUpdate` exactly the way the bash tool uses them, so abort and streaming work out of the box. The single-tool wrapper is ~60 LOC. +- `--non-interactive` exists on the only subcommands that block (`extract`, `preview`); `setup` only blocks if creds are missing, which the wrapper enforces up-front. No deadlock risk if the wrapper is disciplined. +- DLA's CLI subcommands are stable enough to depend on โ€” they map onto README documentation and `docs/cli.md`; renaming them would be a documented breaking change. + +**The caveats** (why it loses to Bridge for the canonical path): +- **`delegate: true` is structurally unreachable.** DLA's CLI `import` has no manifest mode. The wrapper-skill must reconstruct the import manifest from the on-disk output dir if Studio wants to handle import via Playground โ€” which means re-implementing DLA's manifest contract on the Studio side. That defeats most of the "DLA is a black box" simplicity argument. +- **Output is terminal text, not structured data.** The model can read it, but every DLA UI refactor changes what the model sees. Brittle in a way Bridge isn't. +- **Bundled into one bare-URL call, the agent loses observability of detect/discover/extract phases.** It can re-gain it by calling `inspect` first, but the bundled `extract` path is still opaque. +- **MCP-only tools (`liberate_detect`, `liberate_discover`, `liberate_map_apis`, `liberate_probe`, `liberate_status`, `liberate_preview_stop`) are unreachable from the CLI.** That's 6 of DLA's 13 tools the agent can never touch on the subprocess path. + +**Where subprocess is genuinely the right choice:** + +- **As an escape-hatch / `--headless` mode.** A second slash command `/migrate --headless` (or `studio migrate ` outside `studio code`) that spawns `npx tsx src/cli.ts ` with full terminal fidelity, for users who want DLA's UI and don't want the agent overhead. Original Approach E's "handler-only slash" framing โ€” but mounted as a non-agent CLI command rather than an agent slash. Zero conflict with agent-side `/migrate`. +- **As a fallback when Bridge has a problem.** If MCP spawn fails for any reason (DLA binary missing, sandbox restrictions), the subprocess wrapper is a graceful degradation โ€” same DLA, less observability. +- **For a fast MVP / proof-of-concept** before committing engineering time to Bridge wiring. The wrapper is ~60 LOC; you can land `/migrate` behind a feature flag in one PR and prove the UX before the Bridge spec. + +**Strength of recommendation: medium against using subprocess as primary; strong for keeping it as the fallback shape.** Bridge will deliver the polished `/migrate` UX. Subprocess will deliver a working-but-rough `/migrate` faster and is the right shape for `--headless` or escape-hatch flavors. + +--- + +## Sources + +- DLA repo `Automattic/data-liberation-agent` (now public per `gh repo view`), shallow-cloned to `/tmp/dla-research` from `https://github.com/Automattic/data-liberation-agent.git` (default branch `main`); full read of: + - `src/cli.ts` (177 LOC, all subcommand routing) + - `src/ui/discover.tsx:85-460` (interactive prompts + `--non-interactive` handling) + - `src/ui/setup.tsx` (full โ€” `readline` ask path) + - `src/ui/preview.tsx:140-294` (Ink + stdin race, `nonInteractive` plumbing) + - `package.json` (no `--json`, no compile required for spawn) +- DLA inventory `prior-art/wave-1-findings/wave-1-dla-inventory.md` (full); MCP tool list ยง4; `delegate: true` references ยง4 #10โ€“11; output layout ยง9. +- Prior-art `prior-art/rsm-1639-research-report.md:91-96, 100-115, 141-155` โ€” Approach E rejection, comparison table, why "no agent" was the killer. +- pi-coding-agent `@mariozechner/pi-coding-agent@0.70.2`: + - `dist/core/tools/bash.d.ts` (full) โ€” canonical AgentTool subprocess shape + - `dist/core/tools/bash.js:190-310` โ€” `execute(toolCallId, params, signal, onUpdate)` pattern, `killProcessTree`, stream-and-truncate buffer logic + - `@mariozechner/pi-agent-core/dist/types.d.ts:259-291` โ€” `AgentToolResult`, `AgentToolUpdateCallback`, `AgentTool.execute` signature +- Studio CLI: + - `apps/cli/ai/runtimes/pi/index.ts:1-282` โ€” `runStudioAgentTurn`, `customTools` registration site, abort wiring + - `apps/cli/ai/tools/define-tool.ts` โ€” Studio's `defineTool` helper (note: drops `signal`/`onUpdate`, so DLA wrapper must skip it and use raw `AgentTool`) + - `apps/cli/ai/tools/ask-user-question.ts` (full) โ€” credential-collection precedent + - `apps/cli/ai/tools/create-site.ts:1-60` โ€” example of a long-running tool that wraps a Studio child command +- Hands-on benchmarks (commands run in `/tmp/dla-research` on Apple Silicon Mac, npm-installed dependencies): + - `npm install` (one-shot, no errors) + - `/usr/bin/time -p npx tsx src/cli.ts --version` ร— 3 โ†’ 100โ€“280 ms + - `/usr/bin/time -p npx tsx src/cli.ts --help` โ†’ 100 ms + - `/usr/bin/time -p npx tsx src/cli.ts inspect http://example.invalid` โ†’ 690 ms (network-bound) + - `NO_COLOR=1 CI=1 npx tsx src/cli.ts inspect http://example.invalid` โ€” Ink non-TTY output captured; ANSI present, refresh-redraws absent + - `grep -rn "\\-\\-json\\|JSON\\.stringify" src/cli.ts src/ui/` โ†’ 0 hits (no JSON output mode) + - `grep -n "args\\[0\\] === '" src/cli.ts` โ†’ enumerates real subcommand set +- `gh repo view Automattic/data-liberation-agent --json visibility,isPrivate` โ†’ `PUBLIC, isPrivate=false` (confirms repo is now public) diff --git a/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-upstream-and-bundling.md b/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-upstream-and-bundling.md new file mode 100644 index 0000000000..63b00d398f --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-upstream-and-bundling.md @@ -0,0 +1,332 @@ +--- +task: wave-1-upstream-and-bundling +wave: 1 +status: complete +--- + +# Wave 1 โ€” Upstream-pi feasibility + DLA bundling/distribution against pi + +## TL;DR + +- **Upstream-pi: do not bet RSM-3143 on it.** The package is a single-maintainer side project (`Mario Zechner`, `badlogic`) shipping **3โ€“7 patch releases per week**. The maintainer already declined to land a first-party MCP example in pi-coding-agent itself: issue #563 was closed Jan 2026 with "we don't need to add an MCP example anymore" โ€” the blessed answer is the third-party `pi-mcp-adapter` (Nico Bailon), which is built on pi's **public extension API** (`ExtensionFactory`/`registerTool`/`session_start`). Studio's `apps/cli/ai/runtimes/pi/index.ts` explicitly *opts out* of that extension surface via `noExtensions: true` and goes through `createAgentSession({ customTools, tools })`. A separate upstream PR to add an `mcpServers` slot to `CreateAgentSessionOptions` is plausible in shape but very unlikely to land soon: (a) the project is in the middle of a public refactor + package-scope rename (`@mariozechner/*` โ†’ `@earendil-works/*`, in progress since 0.73.x), (b) contributions from new contributors are auto-closed by default, (c) the maintainer's stated philosophy is "if it doesn't belong in core it should be an extension." Treat Upstream-pi as **not on the critical path** โ€” Studio still gets the same effect via Studio-owned wiring around `customTools`. +- **Bundling, Bridge profile (`npx tsx src/mcp-server.ts`):** **Blocked as written, fixable with work.** Studio's packaged Electron CLI bundles its own `node` binary (`scripts/download-node-binary.ts`) but does **not** ship `npm` or `npx`. DLA's `tsx` is in DLA's `devDependencies`, so Studio's `install:bundle` (`npm install --omit=dev โ€ฆ`) drops it. Spawning `npx tsx ...` at runtime in a packaged install would fail unless Studio either (a) adds `tsx` to `apps/cli/package.json` `dependencies`, (b) runs DLA's `tsc` at Studio build time and spawns the built `dist/mcp-server.js`, or (c) pre-bundles DLA's MCP server entry through Vite. All three are workable; none is free. +- **Bundling, Vendor profile (`import 'data-liberation/src/lib/...'` at build time):** **Blocked as written, harder to fix.** Studio CLI runs plain Node ESM at runtime (no `tsx`, no ts-node). DLA ships TypeScript source only โ€” `dist/` is gitignored and `npm install` from `github:` does **not** trigger DLA's `build` script (it's not in `scripts.postinstall`; only `playwright install chromium` is). Vite's externalization rule keeps `data-liberation/...` imports external, so they hit `node_modules/data-liberation/...` at runtime โ€” which is `.ts`. Vendor needs either (i) a Studio-build-time bundling step that pulls DLA's sources through Vite/rollup (gives up the "wrap DLA's `src/lib` directly" simplicity), or (ii) a Studio-owned `npm run build` step against DLA after install (a `tsc` invocation). Either is real work. +- **`github:` dep:** Mechanically works against current trunk. `npm install --omit=dev` against `data-liberation: github:Automattic/data-liberation-agent#` succeeded in a clean test (373 packages added). Lockfile resolves the SHA to `git+ssh://git@github.com/...#`; npm/git fall back to HTTPS for public repos, so CI runners without an SSH key are fine. **Pin by commit SHA**, not branch or tag โ€” DLA has no tags or releases. Studio CI is `npm ci` + lockfile, dependabot is positive-allowlist, so a `github:` dep won't auto-update; explicit bumps required. +- **Marginal size cost for Vendor:** A fresh DLA install in isolation = **~633 MB / 260 packages / 373 nodes total**. Most overlaps with Studio's tree (226 of 260 top-level dep names already present in Studio's hoisted `node_modules`); the marginal cost during workspace install is much smaller, but during `apps/cli`'s standalone `install:bundle` (which uses `--no-workspaces`), DLA does duplicate `@php-wasm/*` and `@wp-playground/*` trees. Licenses are clean (241 MIT, 22 GPL-2.0-or-later โ€” Studio-compatible). +- **Native deps / postinstall:** DLA's `postinstall: playwright install chromium` will download **~150 MB of headless Chromium** into Playwright's per-user cache on every `npm install` โ€” even for users who only migrate from API-only platforms. Studio CLI already depends on `playwright@^1.52.0`, so the Chromium browser binary itself may already be on disk (Playwright shares cache by version), but the postinstall *will* still run and re-check. + +--- + +## 1. Upstream-pi feasibility + +### 1.1 Maintainer & release cadence + +**Package identity.** `@mariozechner/pi-coding-agent` is the published name; Studio pins `0.70.2` (2026-04-24). Metadata from `npm view @mariozechner/pi-coding-agent`: + +- `author: Mario Zechner` +- `maintainers: badlogic , mitsuhiko ` (Armin Ronacher / mitsuhiko of Flask fame appears as co-maintainer). +- `repository.url: git+https://github.com/badlogic/pi-mono.git` โ€” **this URL now 301-redirects** to `https://github.com/earendil-works/pi`. The repo was renamed/relocated as part of a package-scope migration (CHANGELOG: "0.74.0 โ€” Updated repository links and package references for the move to `earendil-works/pi-mono` and `@earendil-works/*` package scopes"). +- `license: MIT`. +- Discord-driven community at `discord.com/invite/3cU7Bz4UPx`. + +**Repo signals (`earendil-works/pi`):** 49 000 stars, 5 828 forks, 36 open issues, 4 000+ closed issues โ€” the project has explosive growth and active development. + +**Release cadence** โ€” extremely high. Last 25 versions span **2026-04-13 โ†’ 2026-05-07 (24 days)**, so roughly **one patch release per day** in steady state, with multiple same-day releases on busy days. Studio's pinned `0.70.2` is already **3+ minor versions behind** (`0.71`/`0.72`/`0.73`/`0.74` shipped in the 13 days after). + +| Version | Released | Note | +|---|---|---| +| `0.70.2` | 2026-04-24 | Studio's pinned version | +| `0.70.6` | 2026-04-28 | | +| `0.71.0` | 2026-04-30 | | +| `0.72.0` | 2026-05-01 | | +| `0.73.0` | 2026-05-04 | | +| `0.73.1` | 2026-05-07 | Adds `pi update --self` rename support | +| `0.74.0` | 2026-05-07 | **Last `@mariozechner/*` release** โ€” followed by first `@earendil-works/pi-coding-agent@0.74.0` (2026-05-07T15:15) | + +**Bottom line:** one-/two-person open-source project, not Automattic-internal. Velocity is high; breaking-changes risk is non-trivial โ€” Studio's `0.70.2` pin is already lagging by an entire scope-rename cycle. + +### 1.2 Issue-tracker scan (MCP requests) + +The public issue tracker is at `github.com/earendil-works/pi` (formerly `badlogic/pi-mono`). Two definitive data points: + +**Issue #563 โ€” `feat(coding-agent): Add MCP extension example`** (closed 2026-01-29, opened by the maintainer himself). +The maintainer's closing comment: + +> "Alright, [@nicobailon](https://github.com/nicobailon) made this, so we don't need to add an MCP example anymore. +> https://www.npmjs.com/package/pi-mcp-adapter" + +This is the blessed upstream answer: pi-coding-agent will *not* add an in-tree MCP example or built-in MCP slot. Pi's official position is "use the third-party `pi-mcp-adapter` extension." + +**PR #3774 โ€” `feat(mcp): add MCP extension with stdio/SSE transport support`** (auto-closed 2026-04-26, never merged). +A drive-by PR adding `.pi/extensions/mcp/` was auto-closed within seconds โ€” the project's `CONTRIBUTING.md` documents an explicit "Contribution Gate" that auto-closes all PRs from new contributors, requiring an `lgtm` from a maintainer to even submit. The PR was AI-written ("๐Ÿค– Generated with Claude Code"), so it would have been rejected anyway. + +**Other relevant tracker signals:** + +- Issue #4326 (closed-because-refactor, 2026-05-08) โ€” `pi-mcp-adapter` itself causes a TUI crash on non-string tool descriptions. Indicates the MCP adapter ecosystem is still finding edge cases. +- Issue #4085 (closed, 2026-05-02) โ€” request to add `pi.unregisterTool` / `pi.replaceTools` for hot-swappable MCP tool catalogs. Also tied back to `pi-mcp-adapter`. **The maintainer's `closed-because-refactor` label dominates** mid-2026 issues โ€” a "big refactor" is in progress. + +**Conclusion:** the maintainer's position has been publicly stated and acted on. MCP support in pi-coding-agent core is **not coming** as a built-in `mcpServers` slot on `createAgentSession`. The blessed answer is the extension API, which is *already public* in pi-coding-agent (`ExtensionAPI`, `registerTool`, lifecycle hooks; see `node_modules/@mariozechner/pi-coding-agent/dist/index.d.ts` and `docs/extensions.md`). + +### 1.3 Plausible upstream API shape โ€” and why it's the wrong question + +If we *did* contribute upstream, the realistic shapes are: + +1. **`customMcpServers` slot on `CreateAgentSessionOptions`.** Internally, `createAgentSession` would resolve each `{ command, args, env }` to a stdio MCP client, call `ListTools`, wrap each as a pi `ToolDefinition` (mirroring the `customTools` path), and pass through to the underlying `AgentSessionRuntime`. This is the smallest, most-host-friendly shape. Mario has already rejected it once (#563), so getting it past review would require selling him on a use case beyond what `pi-mcp-adapter` provides. + +2. **Bless a sanctioned "factory" pattern.** Pi already exports `createAgentSessionFromServices`, `createAgentSessionServices`, and `createAgentSessionRuntime` (see `index.d.ts` exports above), which Studio doesn't currently use. We could land a small `createMcpToolDefinitions(servers): Promise` helper in `pi-coding-agent` proper that returns the same shape Studio's wrapper would build in-tree, then expose it from `index.ts`. Lower bar to acceptance because it's just a utility, not a runtime change. + +3. **Promote the extension API as the contract.** The contribution would be docs + types only โ€” recommend that hosts wanting MCP use the existing `registerTool` + `session_start` extension surface. This is the **shape Mario has effectively chosen**. Studio could adopt `pi-mcp-adapter` as a dependency or vendor its logic into a Studio-owned extension. Both still mean Studio owns the runtime wiring, just at a different layer. + +**Reading the tea leaves:** option 3 is what Mario will accept. Options 1 and 2 are pipe dreams given the maintainer's stated philosophy ("pi's core is minimal. If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected." โ€” `CONTRIBUTING.md`). + +### 1.4 Timeline estimate + +Order-of-magnitude only โ€” single-maintainer projects are bursty. + +- **Optimistic (PR accepted):** 4โ€“8 weeks. Requires (a) a maintainer relationship (`lgtmi` โ†’ `lgtm` in pi's contribution gate; humans on Discord can shortcut this), (b) waiting through the in-progress `@earendil-works/*` migration to settle, (c) shaping the PR exactly as Mario wants (likely "extension API docs only" per ยง1.3.3). After merge, wait for a Studio-targeted release. +- **Realistic:** 8โ€“16 weeks, accounting for back-and-forth on contract shape and the fact that pi's velocity will likely produce a fresh breaking change (e.g. another scope rename or refactor) mid-cycle that we have to chase. +- **Pessimistic / never:** Mario closes the PR with the same "use the extension API / pi-mcp-adapter" answer he already gave #563. Probability not negligible. + +**Compare with shipping in-tree:** Bridge or Vendor inside Studio's repo is unblocked the moment we decide on a shape โ€” call it 2โ€“4 weeks of focused work for an MVP, plus the package/wiring it inherits from RSM-1639's wave-2 plan. + +**Verdict on timeline:** Upstream-pi is **slower than in-tree** in every scenario and *much* slower in the realistic one. The cost-of-waiting on Studio's `/migrate` slash command isn't worth the upstream cleanliness. + +### 1.5 Risk of *not* upstreaming + +If we ship Bridge or Vendor as Studio-owned: + +| Risk | Severity | Mitigation | +|---|---|---| +| **Pin to pi 0.70.x indefinitely.** Studio's wiring uses `customTools` + `tools` allowlist; pi's extension API is the maintainer's preferred direction. If pi changes either signature in 0.71+, Studio rebases. | Medium โ€” pi's `createAgentSession` API has been the supported shape for the last 70+ minor versions, so it likely stays stable, but pi's velocity means churn is constant. | Track pi releases; budget 2โ€“4 hrs of catch-up per Studio release. | +| **Divergence from `pi-mcp-adapter`.** If pi-mcp-adapter becomes the de-facto MCP-bridge layer and grows features Studio doesn't have (token-efficient proxy tool, lazy connections, hot-reload), Studio's hand-rolled bridge looks dated. | Low/Medium โ€” Studio's bridge can mature in parallel; nothing forces feature parity. | Re-evaluate at RSM-3143's first post-ship checkpoint. | +| **Maintainer relationship debt.** If pi or pi-mcp-adapter introduces a breaking change in their wire contract and Studio is *not* in the conversation, we find out at user-install time. | Low โ€” Studio pins versions; breakage is bounded by upgrade decisions. | Pin tightly; Renovate-style alerts on `@mariozechner/pi-coding-agent` (which dependabot doesn't watch today, so manual). | +| **Brittleness against pi releases.** `pi-tui` already needs a Studio-owned patch (`patches/@mariozechner+pi-tui+*.patch` per `apps/cli/scripts/postinstall-npm.mjs:23`). Bridge or Vendor could similarly accumulate patches. | Medium โ€” Studio already accepts this cost for `pi-tui`. | Accept; the patch-package workflow is documented. | + +**Conclusion:** the risk of going alone is **bounded and acceptable**. Pi's maintainer has signalled that upstream MCP support is not coming via `createAgentSession` โ€” there's no future in which Studio doesn't own *some* host-side MCP plumbing. + +### 1.6 Verdict (upstream-pi) + +**Kill it as an approach.** Land MCP wiring Studio-owned (Bridge or Vendor). Optionally contribute documentation upstream once we know our shape โ€” that's a goodwill gesture, not a critical-path item. + +Concretely: +- Do not block RSM-3143 on a pi upstream PR. +- Treat `pi-mcp-adapter` as **prior art / library candidate** for the Bridge approach: it has 663 stars, recent activity (2026-05-13 push), an MIT license, and a stated design ("token-efficient MCP adapter") that aligns with Studio's "one proxy tool, not 13 tool descriptions" needs. Worth a 30-minute spike to see if it's reusable as-is or its design is adoptable; that lives in wave-2 not wave-1. + +--- + +## 2. Bundling & distribution + +This section confirms or refutes the pipeline-level facts from `prior-art/wave-1-findings/wave-1-bundling-distribution.md` against current trunk (commit `47be387a`, post-pi migration). The Studio CLI build/packaging shape is materially unchanged from RSM-1639's writeup; what changed is the static-copy target name (`ai/skills` not `ai/plugin`) and the fact that DLA is now public (so `github:` deps are an option). + +### 2.1 Bridge install path + +Bridge needs: a working `node_modules/data-liberation/` reachable from the packaged binary, plus a way to spawn DLA's MCP server. + +**What works:** + +- **`npm install --omit=dev` against `data-liberation: github:Automattic/data-liberation-agent#` succeeds.** Verified in a clean temp directory: 373 packages added, no error. The github tarball includes everything DLA tracks in git (no `.npmignore`, so `.gitignore` rules apply โ€” `dist/` excluded but `src/`, `commands/`, `skills/`, `cli.js`, `start.sh` all present). +- **DLA's `src/mcp-server.ts` and the resolution-relative manifests (`.mcp.json`, `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, `gemini-extension.json`) land in `node_modules/data-liberation/`.** No surprises. +- **Studio's `vite.config.prod.ts` viteStaticCopy plugin copies `apps/cli/node_modules/` โ†’ `apps/cli/dist/cli/node_modules/` recursively** (`apps/cli/vite.config.prod.ts:17-24`). DLA's tree would survive. `forge.config.ts:22` then picks the whole `apps/cli/dist/cli/` up via `extraResource`, so it lands at `Studio.app/Contents/Resources/cli/node_modules/data-liberation/` (macOS) or equivalent on Win/Linux. **No ASAR rewrite needed; the CLI ships out-of-ASAR.** + +**What breaks:** + +1. **`tsx` is in DLA's `devDependencies`, not `dependencies`.** `cat node_modules/data-liberation/package.json` after `npm install --omit=dev`: `tsx: ^4.19.0` is in `devDependencies`. `--omit=dev` drops it. `node_modules/.bin/tsx` is **absent**. Result: `npx tsx src/mcp-server.ts` would fail with "tsx: not found" on a packaged Studio install. + +2. **`npx` is not on PATH in packaged Electron CLI.** Studio bundles its own `node` binary (`scripts/download-node-binary.ts:14` โ€” `LTS_FALLBACK = 'v24.13.1'`) but not `npm`. The `studio` shim launches the bundled node directly against `dist/cli/main.mjs` (`bin/studio-cli-launcher.js:23-39`, `bin/studio-cli.sh`, `bin/studio-cli.bat`). `PATH` resolution falls back to user shell `npx`, which on a clean machine without Node installed system-wide is unreliable. **Bridge cannot assume `npx` works at runtime.** + +3. **DLA's `package.json bin: "data-liberation": "./dist/cli.js"` points to a non-existent file.** DLA's `dist/` is gitignored; no postinstall builds it. `npm install` resolves `bin` symlinks pointing to nothing โ€” most npm versions warn but proceed. Studio cannot use DLA's bin directly. + +**What fixes Bridge:** + +| Option | Effort | Cost | +|---|---|---| +| **A. Add `tsx` to `apps/cli/package.json` `dependencies`.** Then spawn ` apps/cli/node_modules/.bin/tsx node_modules/data-liberation/src/mcp-server.ts`. | Smallest โ€” one-line dep. | `tsx` is ~10 MB; Studio doesn't otherwise need it. Brings in `esbuild` transitive deps. | +| **B. Run DLA's `tsc` at Studio build time.** Add a script that `cd apps/cli/node_modules/data-liberation && npx tsc` after `install:bundle`. Then spawn ` node_modules/data-liberation/dist/mcp-server.js`. | Medium โ€” needs a Studio-owned wrapper script. Adds `typescript` to `apps/cli` devDeps if not already. | DLA's TS sources may rely on `tsx`'s loose ESM resolution (no `.js` extensions on imports). A real `tsc` build with `moduleResolution: NodeNext` is likely to fail on the unmodified DLA tree without `tsconfig` adjustments. **Pre-spike required.** | +| **C. Pre-bundle DLA's MCP server through Vite/rollup at Studio build.** Add a new Vite entry that imports DLA's MCP server and produces `dist/cli/dla-mcp-server.mjs`. | Largest โ€” net-new build wiring. | Removes runtime dependency on `tsx` entirely. Best for size and reproducibility. But couples Studio's release to DLA's source compatibility. Same risk as Vendor with respect to DLA's import surface. | + +**Recommendation for Bridge:** start with Option A. It's the least invasive and matches DLA's manifest contract (`.mcp.json` says `npx tsx src/mcp-server.ts` โ€” Studio just substitutes "``" for `npx tsx`). If `tsx`'s overhead becomes a problem at Studio's size budget (no documented budget exists per prior-art ยง1), revisit Option C. + +### 2.2 Vendor install path + +Vendor needs: `import { extractToWxr, ... } from 'data-liberation/src/lib/extraction/...'` to resolve at Studio's build/runtime. + +**Studio's externalization rule (`vite.config.base.ts:79-91`):** + +```ts +external: ( id ) => { + if ( id.includes( 'blueprint-schema-validator' ) ) return false; + if ( nodeBuiltinExternals.some( ( pattern ) => pattern.test( id ) ) ) return true; + return packageJsonDependencies.some( ( dep ) => id === dep || id.startsWith( dep + '/' ) ); +}, +``` + +If `data-liberation` is added to `apps/cli/package.json` `dependencies`, **Vite externalizes both `data-liberation` and `data-liberation/...` deep imports**. They survive bundling as-is; Node resolves them at runtime against `node_modules/data-liberation/`. + +**What breaks:** at runtime, Node tries to resolve `data-liberation/src/lib/extraction/wxr-builder` and hits a `.ts` file. Studio CLI is plain Node ESM โ€” no `tsx`, no `ts-node`, no `--experimental-loader`. The import fails. + +**What fixes Vendor:** + +| Option | Effort | Cost | +|---|---|---| +| **V1. Compile DLA at Studio build time.** Add a Studio script that runs `tsc` against DLA's `src/` after `install:bundle`, then imports the compiled `dist/`. Same as Bridge Option B but for build-time bundling. | Medium. | Same `tsconfig` risk as Bridge B. Compiled output goes to `node_modules/data-liberation/dist/`. Imports become `data-liberation/dist/lib/extraction/wxr-builder.js`. | +| **V2. Forcibly include DLA in Vite's bundle.** Override Vite's `external` rule for `data-liberation/...` ids and let rollup pull DLA's `.ts` sources through a TS plugin. | Large โ€” new Vite plugin, new TS pipeline, gives up the externalization shape. | Brittle; pi-coding-agent and pi-agent-core would be the only deps not externalized โ€” code smell. | +| **V3. Vendor the bytes.** Copy DLA's `src/lib/*` modules into `apps/cli/ai/` directly, transcribe imports, run Studio's existing tsc + vite. Same shape as current `apps/cli/ai/skills/`. | Per-file effort; the migration is manual. | Loses upstream-update story unless we automate the copy. RSM-1639 ยง3 "Vendor (copy DLA into apps/cli/...)" was rated lowest-complexity for static plugins (markdown), but DLA's modules are real TS with intra-package imports โ€” vendoring requires fixing every import path. | + +**RSM-1639 made the same call against the old SDK:** copying DLA in worked for skills/manifests but stumbled on compiled JS. Same story here. Option V1 is the cleanest for staying in sync with upstream DLA; Option V3 is the cleanest for stability. + +**Note on the import-export contract:** DLA's `package.json` does **not** define an `exports` field. That means `data-liberation/src/lib/...` deep imports are *allowed* by Node's resolution (no encapsulation), but DLA's maintainers haven't signed up to keep them stable. Per `wave-1-dla-inventory.md` ยง2: "`src/lib/*` and `src/adapters/*` โ€” TypeScript modules consumed only via `mcp-server.ts` and `cli.ts`. Nothing in `package.json` exposes them as a public API." Vendor takes on the maintenance cost of tracking DLA's internal API drift. + +### 2.3 `github:` dep mechanics & lockfile + +**`npm install` against `"data-liberation": "github:Automattic/data-liberation-agent#"` works against the public DLA repo.** Verified in clean dir: 373 packages added in 14s; no auth prompt, no error. + +**Lockfile shape** (`package-lock.json` excerpt after install): + +```json +"node_modules/data-liberation": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/Automattic/data-liberation-agent.git#17219c42b0420267302b138bf402930508006e0e", + "integrity": "sha512-FCZEig2n/...", + "hasInstallScript": true, + "dependencies": { ... } +}, +``` + +Important details: + +1. **`integrity` is computed against the tree, not the tarball.** npm rebuilds the integrity hash per install โ€” `npm warn skipping integrity check for git dependency` is the visible signal. Lockfile reproducibility for `github:` deps is the SHA, not a cryptographic hash. Bumping the SHA is the only way to change the dep. +2. **`resolved` rewrites `github:` โ†’ `git+ssh://git@github.com/...`.** This is npm's default behavior even when the source spec was HTTPS. For public repos, npm/git fall back to HTTPS transparently โ€” verified by `npm ci` in a clean directory completing successfully without any SSH key configured. For *private* repos this would need an SSH key or `url."https://...".insteadOf "ssh://..."` git config. DLA is public, so this is moot. +3. **CI compatibility.** Studio's `.buildkite/commands/install-node-dependencies.sh` runs `npm ci --unsafe-perm --prefer-offline --no-audit --no-progress`. Adding a `github:` dep with a SHA pin works inside `npm ci`. Studio's GitHub Actions (`publish-npm-package.yml:27` `npm ci`) similarly work. +4. **Dependabot impact.** Studio's `.github/dependabot.yml` uses a **positive allowlist** (`@automattic/*`, `@electron/*`, `@wordpress/*`, etc.). DLA is not on the allowlist. **Dependabot will not auto-bump the SHA.** Manual bumps required. This is probably the desired behavior given DLA's pre-1.0 / unstable status. + +### 2.4 Pinning strategy + +Three options: + +- **Commit SHA** โ€” `github:Automattic/data-liberation-agent#17219c42b0420267302b138bf402930508006e0e`. Pinpoint-reproducible. **Recommended.** +- **Tag** โ€” DLA has **zero git tags** (`gh api repos/Automattic/data-liberation-agent/tags` returned `[]` per `wave-1-dla-inventory.md` ยง10). **Not an option** until DLA starts tagging. +- **Branch** โ€” `github:Automattic/data-liberation-agent#main`. **Anti-pattern.** Every install/CI run would resolve to whatever HEAD is, eating reproducibility. Lockfile would freeze the SHA at install time, but the spec drifts. + +**Recommendation: commit SHA.** Add a Studio-owned bump script (`scripts/bump-dla.ts`) that hits `gh api repos/Automattic/data-liberation-agent/branches/main` for the latest SHA and rewrites `apps/cli/package.json`. Cadence: weekly (matches DLA's commit cadence per `wave-1-dla-inventory.md` ยง10 โ€” 5 commits/day on busy days, 10 in one day on 2026-04-16) or per-release. + +### 2.5 DLA's transitive deps โ€” byte & license check + +**Size measurement** (clean `npm install --omit=dev --ignore-scripts` of `data-liberation: github:Automattic/data-liberation-agent#17219c42`): + +| Subtree | Size | +|---|---| +| `node_modules/@php-wasm` (all PHP versions 5.2 โ†’ 8.5) | **459 MB** | +| `node_modules/@octokit` | 61 MB | +| `node_modules/@wp-playground` | 15 MB | +| `node_modules/playwright-core` | 12 MB | +| `node_modules/es-toolkit` | 12 MB | +| `node_modules/zod` | 6.3 MB | +| `node_modules/@modelcontextprotocol` | 5.8 MB | +| `node_modules/playwright` | 4.8 MB | +| `node_modules/isomorphic-git` | 4.7 MB | +| Total | **~633 MB / 260 top-level packages** | + +**Crucially:** 226 of the 260 top-level dep names already exist in Studio's hoisted root `node_modules` (Studio depends on `@php-wasm/*`, `@wp-playground/*`, `playwright` directly). At workspace-install time the trees dedupe. **The marginal cost of adding DLA to Studio's root install is small** โ€” most of it comes from version mismatches (DLA pins `@wp-playground/cli@^3.1.20`, Studio pins `3.1.28`, so a *new* `@wp-playground/cli@3.1.20` tree may install alongside Studio's `3.1.28`). + +**`install:bundle` cost** (`npm install --no-workspaces --omit=dev --install-links`): inside `apps/cli/`, the dedupe doesn't apply โ€” DLA's `@php-wasm/*` and `@wp-playground/*` trees materialize alongside Studio's own. The 459 MB of `@php-wasm` gets duplicated. This shows up in `apps/cli/dist/cli/node_modules` post-build; then `vite.config.prod.ts`'s `prune-php-wasm` plugin (lines 30-44) strips `@php-wasm/node-*/asyncify/` directories, reclaiming ~250 MB (the prior-art doc has the math). **Net post-prune additional disk footprint of bundling DLA: ~200-300 MB**, depending on overlap. + +**Per-platform binary handling.** `forge.config.ts:212-277` already prunes platform-specific binaries from `koffi` and `fs-ext-extra-prebuilt`. DLA pulls in `playwright`/`playwright-core` which already exist in Studio's tree โ€” no new prune target needed unless DLA's version brings native binaries Studio's doesn't. + +**License survey** (260 top-level deps, sampling `package.json` `license` fields): + +| License | Count | +|---|---| +| MIT | 241 | +| GPL-2.0-or-later | 22 | +| ISC | 19 | +| BSD-2-Clause | 10 | +| Apache-2.0 | 7 | +| BSD-3-Clause | 5 | +| Compound (e.g. `(MIT OR Apache-2.0)`) | 7 | +| BlueOak-1.0.0, CC0-1.0, Zlib | 3 | +| Unknown / missing | 3 | + +Notable directly-imported licenses: +- `@modelcontextprotocol/sdk`: MIT +- `@wp-playground/cli`: GPL-2.0-or-later (matches Studio) +- `playwright`: Apache-2.0 +- `ink`, `react`, `cheerio`, `papaparse`, `fast-xml-parser`, `undici`: MIT + +**Compatibility:** Studio is `GPL-2.0-or-later`; MIT/ISC/BSD/Apache-2.0 are all compatible. **No legal-flag-raising licenses found.** The 3 "unknown" entries are typically internal `@types/*` packages or odd manifests; not a real risk. + +### 2.6 Postinstall / native-dep hazards + +**DLA's `package.json scripts`:** + +```json +"postinstall": "playwright install chromium" +``` + +This is the dominant risk for the install pipeline: + +1. **Downloads ~150 MB of headless Chromium** per architecture into Playwright's cache (`~/Library/Caches/ms-playwright/`, `%LOCALAPPDATA%\ms-playwright\`, etc.). +2. **Runs on every `npm install`** including CI and end-user `npm install -g wp-studio`. Studio's existing `playwright` dep does *not* postinstall Chromium โ€” Studio uses Playwright for tests only (`npm run e2e: npx playwright install && npx playwright test`). DLA pulls Chromium in transparently. +3. **Honors `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` env var** โ€” Studio's CI/build could set this to skip the download if Chromium isn't needed at build time. But at *user* install time (`npm install -g wp-studio`), the env var isn't set, and Chromium downloads. +4. **`forge.config.ts` doesn't prune Playwright browsers** โ€” the Chromium binary lives in `~/Library/Caches/`, not under Studio's bundle, so packaging isn't affected. **But Studio's `install:bundle` in `cli:package`** does run DLA's postinstall (downloads Chromium into the build machine's user cache, not the bundle). One-time cost per build machine. +5. **`PLAYWRIGHT_BROWSERS_PATH=0` is the recommended override** to bundle the browser into `node_modules` rather than user cache, if Studio ever wants to ship Chromium. **It does not** today. + +**Other hazards โ€” none found:** + +- **No other postinstall scripts.** Verified via DLA's `package.json scripts`. +- **No `gyp` / native modules.** DLA's deps don't include node-gyp builds (no `binding.gyp`, no `node-addon-api`). +- **No prebuilt binaries beyond Playwright's.** `koffi` and `fs-ext-extra-prebuilt` (which `forge.config.ts` already handles) aren't DLA dependencies. +- **`tsx`'s esbuild has its own platform-specific binaries**, but `tsx` is in DLA's devDependencies โ€” `--omit=dev` skips them. + +**Recommendation:** if Studio adds DLA, set `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` in the build pipeline (Buildkite + GitHub Actions + `apps/cli/install:bundle`). The Wix/Squarespace adapters that need Chromium will fail at *runtime* without it, which is the right place to fail (and DLA's CLI bootstraps `cli.js` already detects this โ€” `cli.js:9-12,65-120`). For end users running `npm install -g wp-studio`, accept the 150 MB download; it's an acceptable cost for a migration tool that uses headless browsers when needed. + +### 2.7 Verdict per approach + +| Approach | Mechanically possible? | Effort to land | Critical blockers | Recommended? | +|---|---|---|---|---| +| **Bridge** (`npx tsx src/mcp-server.ts` at runtime via wrapped MCP client) | Yes, with caveats. Needs Studio-owned tsx-launch or pre-built JS. | 2-4 days for Option A (add `tsx` dep, hand-rolled spawn). 1-2 weeks for Option C (pre-bundle). | **`tsx` not present in `--omit=dev` install. `npx` not on PATH in packaged CLI.** Both fixable in-tree. | **Recommended path** for RSM-3143. Matches DLA's manifest contract. | +| **Vendor** (`import { โ€ฆ } from 'data-liberation/src/lib/...'` at Studio build time) | Yes, with significant work. Needs build-time `tsc` of DLA or manual vendoring. | 1-3 weeks for build-time tsc (V1). Comparable for vendoring (V3). | **DLA ships TS only, no `dist/`, no `exports` map.** Studio runs plain Node. Build-time compile required. | **Second choice.** Higher coupling to DLA internals (no public API contract). Worth comparing in wave-2 once Bridge has a spec. | +| **`github:` dep, SHA-pinned** | Yes, fully. | Minutes once chosen. | None. Mechanically works in `npm install`/`npm ci`/CI/Buildkite. | **Recommended pinning strategy** for either Bridge or Vendor. | +| **DLA via npm publish** | No โ€” DLA is not published to npm and has no tags/releases. Not on the roadmap (per `wave-1-dla-inventory.md` ยง10). | N/A. | Out of Studio's control. | **Not available.** | +| **Runtime fetch** (download DLA on first `/migrate` invocation) | Yes, conceptually. Studio's `scripts/download-agent-skills.ts` is precedent. | 1-2 weeks of network-code, retry, cache, integrity-check work. | Breaks Studio's "works offline once installed" posture. Adds attack surface. Same security review as RSM-1639 dismissed. | **Not recommended** unless Bridge/Vendor both fail. | + +--- + +## 3. Cross-cutting notes + +### 3.1 Static-copy regression flag (cross-reference to prior-art ยง "Possible bug") + +Prior-art's `wave-1-bundling-distribution.md` ยง1 flagged: "`vite.config.prod.ts` is missing a static-copy target for `ai/plugin`." Post-pi-migration, the directory is `apps/cli/ai/skills/` and **the same gap exists**: `vite.config.dev.ts` and `vite.config.npm.ts` both `viteStaticCopy({ src: 'ai/skills' })`, but `vite.config.prod.ts` only copies `node_modules` and applies the asyncify prune. **`ai/skills/` is not copied in prod builds.** + +At runtime, `apps/cli/ai/skills.ts:30` resolves skills from `import.meta.dirname + '/skills'`. After Vite build that's `dist/cli/skills`. The `loadSkills()` function has a defensive `console.warn` when the directory is missing (lines 33-39), so it would degrade silently to "no skills" rather than crash. + +**This is unchanged by adding DLA**; flagging here only because any DLA-as-skill strategy (versus DLA-as-MCP-server) inherits this latent issue. Bridge/Vendor are unaffected โ€” they ship via `node_modules`, not `ai/skills/`. + +### 3.2 Patch-package precedent + +`patches/@mariozechner+pi-tui+*.patch` already exists per `apps/cli/scripts/postinstall-npm.mjs:23`. If Studio needs to patch DLA upstream (e.g. to make `src/mcp-server.ts` runnable without `tsx`, or to coerce DLA's `Server` to a Studio-provided stdio transport), the precedent is in place. Adds maintenance debt; manageable. + +### 3.3 Studio CLI's existing MCP server + +`apps/cli/ai/mcp-server.ts` already exists โ€” Studio runs its own in-process MCP server for `studio code` tools. If Bridge proceeds, Studio would end up with **two MCP servers**: its own (in-process) and DLA's (child process). That's mechanically fine โ€” MCP servers compose โ€” but it's worth documenting in the eventual spec. + +--- + +## Sources + +- `npm view @mariozechner/pi-coding-agent` (full JSON dump, 270 versions, 2025-11-12 โ†’ 2026-05-07). +- `npm view @earendil-works/pi-coding-agent` (1 version, 2026-05-07, scope-rename target). +- `npm view pi-mcp-adapter` (31 versions, MIT, author Nico Bailon, deps include `@earendil-works/pi-ai`). +- `npm view wp-studio@1.8.0` (current published Studio CLI deps). +- GitHub API: `gh api repos/earendil-works/pi` (49 000 stars, 36 open issues, 4 000+ closed), `gh api repos/Automattic/data-liberation-agent` (public, 29 stars, 954 KB), `gh api repos/nicobailon/pi-mcp-adapter` (663 stars, MIT). +- `gh search issues "mcp" --repo=earendil-works/pi`: 31 closed issues + 13 closed PRs found. +- Issue #563 (`feat(coding-agent): Add MCP extension example`) full body + 7 comments โ€” maintainer's verbatim "we don't need to add an MCP example anymore" closing. +- PR #3774, PR #1221, Issue #4326, Issue #4085 โ€” extension-API + MCP-related closures, mostly auto-closed under `closed-because-bigrefactor`. +- `earendil-works/pi/README.md` (full) โ€” confirms `@earendil-works/*` is the new scope; project description "AI agent toolkit: coding agent CLI, unified LLM API, TUI & web UI libraries, Slack bot, vLLM pods". +- `earendil-works/pi/CONTRIBUTING.md` (full) โ€” documents Contribution Gate (auto-close from new contributors), maintainer philosophy ("pi's core is minimal"). +- `earendil-works/pi/packages/coding-agent/CHANGELOG.md` 0.72โ€“0.74 entries. +- Local: `apps/cli/package.json`, `apps/cli/vite.config.{base,dev,prod,npm}.ts`, `apps/cli/scripts/postinstall-npm.mjs`, `apps/cli/ai/runtimes/pi/index.ts:1-285`, `apps/cli/ai/skills.ts:1-60`, `apps/studio/forge.config.ts:1-310`, `apps/studio/bin/studio-cli-launcher.js`, `scripts/download-node-binary.ts`, `.github/workflows/publish-npm-package.yml`, `.github/dependabot.yml`, `.buildkite/commands/install-node-dependencies.sh`, root `package.json`. +- `node_modules/@mariozechner/pi-coding-agent@0.70.2/dist/index.d.ts` (extension/runtime exports), `docs/extensions.md` (full). +- DLA: `gh api repos/Automattic/data-liberation-agent/contents/package.json`, `gh api repos/Automattic/data-liberation-agent/contents/cli.js`, `gh api repos/Automattic/data-liberation-agent/branches/main` (HEAD SHA `17219c42b0420267302b138bf402930508006e0e`). +- Hands-on: clean `npm install --omit=dev --ignore-scripts data-liberation@github:Automattic/data-liberation-agent#17219c42โ€ฆ` in a temp directory โ€” 373 packages added in 14s, 633 MB node_modules. Followed by `npm ci` against the generated lockfile in a second temp directory โ€” also succeeded (5s, "skipping integrity check for git dependency" warning observed). +- License survey: parsed `package.json` `license` field from all 260 top-level installed deps. +- Prior art: `prior-art/wave-1-findings/wave-1-bundling-distribution.md` (RSM-1639), `prior-art/wave-1-findings/wave-1-dla-inventory.md` (DLA inventory, still valid). diff --git a/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-vendor-as-agenttools.md b/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-vendor-as-agenttools.md new file mode 100644 index 0000000000..a4de4a22d4 --- /dev/null +++ b/issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-vendor-as-agenttools.md @@ -0,0 +1,488 @@ +--- +task: wave-1-vendor-as-agenttools +wave: 1 +status: complete +dla_head_sha: 17219c42b0420267302b138bf402930508006e0e +dla_head_date: 2026-05-07 +--- + +# Wave 1 โ€” Vendor DLA's `src/lib/` as Studio-owned pi AgentTools + +## Verdict (TL;DR) + +**Works with caveats โ€” recommended as the primary integration path, with the unblockers spelled out below.** + +DLA's `src/lib/` and `src/adapters/` are technically vendor-able: no Ink/UI imports leak in, the MCP `Server` only appears as a **type-only** import used for an optional `sendLoggingMessage` callback (already coded defensively with `?.`), and the `delegate: true` contract is implemented entirely inside `src/mcp-server.ts` โ€” the underlying library functions know nothing about it, so re-implementing the delegate manifest in Studio's wrappers is straightforward. + +However, three concrete blockers must be solved before this lands: + +1. **DLA has no build output and no `prepare` script.** `dist/` is gitignored, `package.json` has no `main`/`exports`/`module`, and there is no `prepare`/`prepack` hook to run `tsc` on install. A `github:Automattic/data-liberation-agent#` dependency would deliver only `.ts` files. Node cannot import them directly, and Studio's `vite.config.base.ts` externalizes anything listed in `apps/cli/package.json` `dependencies` โ€” so Vite will not transpile them either. **Unblocker:** PR upstream a `"prepare": "tsc"` script (or use `tarball` URLs of pre-built artifacts). +2. **Schema duplication.** DLA's tool schemas live inline as plain JSON-Schema objects inside `src/mcp-server.ts:48-234`. They are not exported. Studio's `defineTool` consumes typebox โ€” the schemas have to be re-authored. Drift risk is real but bounded; the surface is small (13 tools). +3. **Output shape duplication.** Several tool responses (`liberate_inspect`, `liberate_extract`, `liberate_setup`/`liberate_import` with `delegate: true`, `liberate_discover`) are assembled inside `src/mcp-server.ts`'s request handler โ€” not inside `src/lib`. Studio's wrappers re-implement that assembly, with the same drift risk. + +Once those are addressed, vendoring is meaningfully cheaper than the MCP-bridge path: no IPC, no child process, no `tsx` runtime dependency, no Playwright postinstall surprise (Studio already depends on `playwright@^1.52.0` in `apps/cli/package.json:53`), and direct access to types like `DetectionResult`, `StartPreviewResult`, `VerificationReport`. + +Strength of recommendation: **medium-high.** If the upstream `prepare`-script PR is acceptable to DLA's maintainers, vendor-as-AgentTools is the clean answer. If not, the MCP-bridge path (Brief 2) is the fallback. + +## 1. `src/lib/` inventory + +Module-by-module export catalog with coupling notes, all paths relative to DLA's repo root at SHA `17219c4`. + +### `src/lib/extraction/` + +| File | Key exports | Touches | Coupling notes | +|---|---|---|---| +| `detect-platform.ts` (193 LOC) | `detect(url): Promise`, `detectFromUrl`, `detectFromHttp`, `PATH_PROBES` (test-injection only), types `DetectionResult` / `FullDetectionResult` | `fetch()` (10s + 15s timeouts) | **Clean.** Pure function + HTTP fetch. No filesystem, no globals, no MCP types. Safe to vendor as-is. | +| `sitemap.ts` (154 LOC) | `parseSitemapXml`, `classifyUrl(url): UrlType`, `fetchSitemap(baseUrl): Promise` | `fetch()` (15s timeout) | **Clean.** Pure functions over strings + HTTP. SSRF-guarded by same-origin check. | +| `extraction-log.ts` (164 LOC) | `class ExtractionLog`, types `ProcessedEntry` / `FailedEntry` / `LogSummary` | `fs.appendFileSync`, `readFileSync`, lockfile via `process.kill(pid, 0)` | **Stateful, filesystem-bound.** Writes `extraction-log.jsonl` and `.liberation-lock` under `outputDir`. The lockfile uses PID-pinning โ€” owned by whichever process holds it. Constructor takes the outputDir; safe to use from a Studio process, but the lock semantics need care if pi runs multiple tool calls concurrently. | +| `import-session.ts` (171 LOC) | `class ImportSession`, types `ImportStage`, `EntityProgress` | `fs` writes under outputDir | Stateful per-extraction. Used only inside adapters' extract paths. Not called from MCP handlers directly. | +| `media.ts` (130 LOC) | `safeFilename`, `resolveMediaPath`, `deriveFilenameFromUrl`, `extensionFromContentType`, `downloadMedia` | `fetch`, `fs.writeFile` | **Clean.** Pure helpers + HTTP fetch + media file write. | +| `media-stubs.ts` (130 LOC) | `class MediaStubStore`, `MediaStatus`, `MediaStub` | `fs` (per-output JSONL) | Stateful per-output dir; same pattern as `ExtractionLog`. | +| `wxr-builder.ts` (791 LOC) | `class WxrBuilder` + ~20 interface exports (`SiteMeta`, `Author`, `Category`, `Tag`, `MediaItem`, `PageItem`, `PostItem`, `MenuItem`, `Redirect`, `Comment`, `Term`, `ValidationResult`, `WxrItem`) | `fs.writeFileSync` (only inside `.serialize()`) | **Clean.** In-memory builder; serializes XML on demand. No MCP/CLI coupling. | +| `wxr-reader.ts` (326 LOC) | `readWxr(path): WxrData`, type `WxrData` | `fs.readFileSync` | **Clean.** Pure XML parser. | +| `content-parser.ts` (~) | `parseContent(html, scopeToContent?)`, type `ContentModel` | cheerio | **Clean.** Pure. Used by qa-runner. | +| `adaptive-tuner.ts` (~) | `class AdaptiveTuner`, `TUNER_DEFAULTS`, types `AdaptiveTunerConfig` / `TunerState` / `TunerDecision` | none | **Clean.** Pure state machine. | +| `shopify-graphql.ts` (277 LOC) | (Shopify-specific helpers, exports unaudited โ€” only used inside the Shopify adapter) | `fetch` against Shopify Admin GraphQL API | Internal to the shopify adapter. Not on the MCP-tool path directly. | + +### `src/lib/preview/` + +| File | Key exports | Touches | Coupling notes | +|---|---|---|---| +| `playground-server.ts` (342 LOC) | `startPreview(opts): Promise`, `stopPreview`, `playgroundDir`, `pidFilePath`, `logFilePath`, `blueprintFilePath`, `lockFilePath`, `ensurePlaygroundDir`, `writePidFile`, `readPidFile`, `deletePidFile`, `isPidAlive` | `spawn('npx', ['wp-playground-cli', 'server', ...])`; PID file management; calls `isStudioAvailable()` and falls through to `startStudioPreview()` if `studio` binary is on PATH | **Spawns child processes.** Crucially, when `isStudioAvailable()` is true it shells out to `studio site create`. Embedding DLA inside Studio CLI would cause **recursion** (studio code โ†’ DLA โ†’ `studio site create`). Studio wrappers should either pass `_noStudio: true` (an internal flag the function exposes, `playground-server.ts:160`) or skip the Studio branch by calling `startStudioPreview` directly with a Studio-side site-orchestration variant. | +| `studio.ts` (377 LOC) | `startStudioPreview(opts)`, `isStudioAvailable`, `makeStudioSiteName`, `toVfsPath`, `stageArtifacts`, `StudioSite` | `execFileSync('studio', ['--version'])`, `execFileAsync('studio', [...])`, `fs.cpSync` / `copyFileSync` / `rmSync`, `process.env.STUDIO_SITES_DIR`, `homedir()` | **Tightly coupled to the `studio` CLI binary on PATH** โ€” the very binary we'd be running inside. Same recursion concern as above. The vendored PHP scripts at `src/lib/preview/scripts/import-wxr.php` and `import-products.php` are resolved via `fileURLToPath(import.meta.url)` (`studio.ts:36-52`) โ€” Studio's Vite bundling must preserve them adjacent to the compiled JS at runtime. | +| `blueprint-builder.ts` (124 LOC) | `buildBlueprint`, `persistBlueprint`, `BlueprintMode`, `Blueprint`, `BlueprintStep`, `VFS_MOUNT_DIR`, `IMPORT_COMPLETE_MARKER`, `BuildBlueprintOpts` | `fs.writeFileSync` | **Clean.** Pure builder + one filesystem write. | +| `media-url-map.ts` (69 LOC) | `buildMediaUrlMap`, `rewriteWxrAttachmentUrls`, `MediaUrlMap` | `fs` reads/writes | **Clean.** Pure with one in-place WXR rewrite. | +| `lockfile.ts` (97 LOC) | `acquireLock(path, opts)`, `LockTimeoutError` | `fs.openSync` (O_EXCL), polling | **Clean.** Self-contained advisory lock. | +| `port-picker.ts` (41 LOC) | `pickFreePort(range)`, `DEFAULT_PORT_RANGE`, `PortRangeExhaustedError` | `net.createServer` | **Clean.** | +| `types.ts` | `PreviewPhase`, `PreviewPidRecord`, `StartPreviewOpts`, `StartPreviewResult`, `StopPreviewResult`, `PreviewSource` | none | Pure types. | +| `boot-spinner.tsx` | (Ink/React component) | Ink, React | **Ink-bound.** This is the only `lib/` file that imports React/Ink โ€” it's referenced exclusively by `src/ui/preview.tsx`. A Studio vendor would not import it. | + +### `src/lib/import/` + +| File | Key exports | Touches | Coupling notes | +|---|---|---|---| +| `wp-importer.ts` (794 LOC) | `importToWordPress(opts): Promise`, types `ImportOptions`/`ImportResult` | `fetch` against WP REST | **Clean.** Pure REST-import orchestrator; takes an `onProgress` callback for streaming progress. | +| `wp-rest-client.ts` (212 LOC) | `class WpRestClient`, `WpRestClientOptions` | `fetch` | **Clean.** | +| `resolve-site-url.ts` (45 LOC) | `resolveSiteUrl(site)`, `resolveSiteUrlSync(site)` | `fetch` for redirect probes | **Clean.** | +| `http-client.ts` (95 LOC) | (internal HTTP helpers) | `fetch` | **Clean.** | +| `woo-csv-reader.ts` (114 LOC) | (WooCommerce CSV reader) | `fs` | **Clean.** | +| `woo-product-csv.ts` (304 LOC) | `class WooProductCsvBuilder`, `WooProduct` types | `fs` | **Clean.** | +| `woo-rest-client.ts` (148 LOC) | (WC REST client) | `fetch` | **Clean.** | + +### `src/lib/qa/`, `setup/`, `verification/`, `features/`, `probe/` + +| File | Key exports | Touches | Coupling notes | +|---|---|---|---| +| `qa/qa-runner.ts` (257 LOC) | `runQa(opts): Promise`, types `QaOptions`/`PageResult`/`QaResult` | `fs` (writes `qa-log.jsonl`), `fetch` (with 429 retry) | **Clean.** Optional `onProgress` callback for streaming. | +| `qa/content-differ.ts` (135 LOC) | `diffContent`, type `ContentDiff` | none | **Clean.** Pure diff. | +| `setup/wp-setup.ts` (128 LOC) | `validateWpConnection(input): Promise`, types `WpSetupInput`/`WpSetupReport` | `fetch` + Basic Auth (10s timeout) | **Clean.** | +| `verification/verify.ts` (116 LOC) | `verifyExtraction(outputDir): Promise`, type `VerificationReport` | `fs.readFileSync`, `readdirSync` | **Clean.** Pure post-hoc filesystem scan. | +| `features/detect-features.ts` (156 LOC) | `detectFeatures(platform, urls, ...)`, type `PlatformFeature` | none | **Clean.** Pure URL pattern matcher. | +| `probe/browser-probe.ts` (201 LOC) | `probeBrowser(cdpPort, siteUrl?): Promise`, type `ProbeResult` | Playwright via `getPlaywright()` from `adapters/shared.ts` | **Clean,** but **requires Playwright** at runtime โ€” see `getPlaywright()` lazy-import in `adapters/shared.ts:108-117`. Studio already depends on `playwright`. | +| `probe/map-apis.ts` (344 LOC) | `mapApis(opts): Promise`, types `ApiEndpoint`/`ApiMapResult` | Playwright | Same. | + +### `src/adapters/` + +The shape every adapter implements is `PlatformAdapter` from `src/types.ts:5-16`: + +```ts +export interface PlatformAdapter { + id: string; + detect(url: string): boolean; + discover(url: string, opts: Record): Promise; + extract( + inventory: unknown, + wxr: WxrBuilder, + opts: Record, + context: { log: ExtractionLog; server: Server } + ): Promise; + probe?(url: string, urls: string[], opts: Record): Promise; +} +``` + +**Critical: `context.server` is typed as `Server` from `@modelcontextprotocol/sdk/server/index.js`**, but it's used only optionally via `server?.sendLoggingMessage?.(โ€ฆ)` โ€” see `src/adapters/shared.ts:368-374` and `src/adapters/shopify.ts:1028-1031`. Every adapter imports the type, but every call-site protects against undefined. **Vendor implication:** pass either `undefined` or a small shim object `{ sendLoggingMessage: (msg) => โ€ฆforwardToStudioProgress }`, and the adapters work. The MCP SDK is still on Studio's dep tree (`apps/cli/package.json:33`), so the type import resolves; no shim needed for types. + +Adapter sizes: `wix.ts` 1041 LOC, `shopify.ts` 1305 LOC, `squarespace.ts` 817 LOC, `hubspot.ts` 769 LOC, `godaddy-wm.ts` 744 LOC, `weebly.ts` 574 LOC, `hostinger.ts` 532 LOC, `webflow.ts` 337 LOC, `shared.ts` 734 LOC. None import Ink/React or UI-layer code. + +## 2. MCP tool โ†’ lib mapping + +Each of DLA's 13 MCP tools, with the `src/lib` (or `src/adapters`) call(s) that back it and whether response assembly lives in lib or in the MCP handler. + +| MCP tool | Library entry points | Response built in lib? | Notes | +|---|---|---|---| +| `liberate_detect` | `detect(url)` from `lib/extraction/detect-platform.ts` | **Yes** (returns `FullDetectionResult`) | One-liner. `src/mcp-server.ts:242-245` is a pass-through. | +| `liberate_discover` | `detect(url)` + `findAdapter(detection.platform).discover(url, opts)` + `detectFeatures(...)` from `lib/features/detect-features.ts` | **No โ€” handler assembles** `{ ...inventory, platformFeatures }` from two calls (`src/mcp-server.ts:247-271`) | Drift risk. | +| `liberate_inspect` | `detect(url)` + `fetchSitemap(url)` + `classifyUrl()` (per-URL count) + `adapter.probe?(url, urls.slice(0,3), opts)` + `detectFeatures(...)` | **No โ€” handler assembles** a 9-field result object in `src/mcp-server.ts:273-311` | Substantial structured assembly; this is the canonical "structured" tool to sketch. | +| `liberate_extract` | `detect` + `adapter.discover` + `new WxrBuilder()` + `readWxr` (on resume) + `adapter.extract(inventory, wxr, opts, {log, server})` + `wxr.serialize()` + `wxr.validate()` + `log.getSummary()` | **No โ€” handler assembles** a multi-section summary (`pagesExtracted`, `postsExtracted`, โ€ฆ, `qualityScores`, `failures`, `wxrValidation`) in `src/mcp-server.ts:313-429` | Plus owns the extraction-log lock around the whole call. Most complex shape; partially feasible to vendor but heavy. | +| `liberate_status` | `new ExtractionLog(outputDir).isLockActive()` + `.getSummary()` | **No โ€” handler assembles** a 7-field status record (`src/mcp-server.ts:553-569`) | Trivial reassembly. | +| `liberate_map_apis` | `mapApis(opts)` from `lib/probe/map-apis.ts` | **Yes** (handler is pass-through, `src/mcp-server.ts:446-455`) | | +| `liberate_probe` | `probeBrowser(cdpPort, url)` from `lib/probe/browser-probe.ts` | **Yes** (pass-through, `src/mcp-server.ts:457-464`) | | +| `liberate_qa` | `runQa({wxrFile, fix, onProgress})` from `lib/qa/qa-runner.ts` | **Yes** (pass-through, `src/mcp-server.ts:431-444`) | Progress callback wires `server.sendLoggingMessage`; for Studio, route to Studio's own progress channel. | +| `liberate_verify` | `verifyExtraction(outputDir)` from `lib/verification/verify.ts` | **Yes** (pass-through, `src/mcp-server.ts:466-470`) | | +| `liberate_setup` | If `delegate`: handler returns a static manifest object. Else: `validateWpConnection({site, username, token})` from `lib/setup/wp-setup.ts` | **No โ€” both branches assembled in handler** (`src/mcp-server.ts:472-496`) | `delegate` shape is fixed text + 3-bullet requirement list; trivial to copy. | +| `liberate_import` | If `delegate`: handler synthesizes `{wxrFile, outputDir, mediaDir, productsCsv, redirectMap, importAuthors}` via `existsSync()` probes. Else: `resolveSiteUrl` + `importToWordPress(opts)` from `lib/import/wp-importer.ts` (+`resolve-site-url.ts`) | **No โ€” both branches assembled in handler** (`src/mcp-server.ts:498-551`) | `delegate` is ~15 lines of `existsSync(...) ? path : null` checks โ€” also trivial to copy. | +| `liberate_preview` | `startPreview(opts)` from `lib/preview/playground-server.ts` + a 30-line `--open` branch that `spawn`s `open`/`xdg-open`/`Studio` app (`src/mcp-server.ts:579-621`) | **Mostly pass-through** but the `--open` browser/Studio-app launch lives in the handler | Studio CLI will likely not want the `Studio.app` auto-launch branch (it IS Studio). Wrapper should hard-skip it. Also: `startPreview` itself prefers `studio site create` if `isStudioAvailable()` (lib/preview/playground-server.ts:160). When Studio CLI calls this, the result is the **studio code agent** invoking the studio CLI as a child process โ€” recursion. The vendor wrapper must thread a `_noStudio: true` flag or call `startStudioPreview` directly via a Studio-owned site-orchestration path that bypasses spawning a sibling `studio` binary. | +| `liberate_preview_stop` | `stopPreview({outputDir})` from `lib/preview/playground-server.ts` | **Yes** (pass-through, `src/mcp-server.ts:625-629`) | | + +**Conclusion:** All 13 MCP tools have clean lib entry points. **None** of them live entirely inside the MCP server's request handlers โ€” but **6 of 13** (`liberate_discover`, `liberate_inspect`, `liberate_extract`, `liberate_status`, `liberate_setup`, `liberate_import`) require Studio to re-implement the response-assembly logic, and 1 (`liberate_preview`) carries an environment-bound side-branch (`--open` browser/Studio-app launch + the recursive-`studio` fallback inside `startPreview`). + +## 3. Schema reuse strategy + +DLA's tool input schemas are inline JSON-Schema objects inside `src/mcp-server.ts:48-234` โ€” plain TypeScript object literals like `{ type: 'object', properties: { url: { type: 'string' } }, required: ['url'] }`. They are not exported. There is no zod, no typebox, no JSON-Schema file on disk. + +Studio's `defineTool` (`apps/cli/ai/tools/define-tool.ts:37-56`) expects a typebox `TProperties` object. The schemas have to be transcribed. + +**Chosen approach: manual transcription, organized by tool, with one source-of-truth comment per field pointing at the DLA line range.** + +```ts +// apps/cli/ai/tools/liberation/liberate-detect.ts +export const liberateDetectTool = defineTool( + 'liberate_detect', + // Description verbatim from data-liberation src/mcp-server.ts:52 + 'Detect the platform of a website (...)', + { + url: Type.String({ description: 'The URL of the website to detect' }), + }, + async ({ url }) => { โ€ฆ } +); +``` + +**Drift risk: bounded.** 13 tools, ~30 fields total. The brief flagged "drift risk" โ€” I rate it medium-low because (a) the schema shape is small, (b) DLA's schemas have been stable in the last 30 days of commits (one tool added two months ago, no field changes in the last 14 days per the `src/mcp-server.ts` git log), and (c) Studio's wrappers run integration tests that catch tool-call shape mismatches early. + +**Rejected alternatives:** + +- **Importing schemas from DLA** โ€” they aren't exported. Would need an upstream PR to re-export them; even then, the JSON-Schemaโ†’typebox conversion is non-trivial (typebox doesn't accept plain JSON-Schema; you'd parse it at runtime). +- **Re-deriving from TypeScript signatures** โ€” the lib functions take loose `Record` opts (see `PlatformAdapter.discover`, `extract` signatures in `src/types.ts:5-16`). The MCP tools have tighter, well-described schemas than the lib does. The MCP schema is the source of truth, not the TS signature. + +## 4. Output adaptation strategy + +DLA's MCP tools return `{content: [{type: 'text', text: JSON.stringify(data, null, 2)}]}` via the `textResult()` helper at `src/mcp-server.ts:32-34` โ€” meaning the actual JSON shape lives in `data`, and the MCP wrapper just stringifies it. + +Studio's `defineTool` wraps the user handler in an `execute` that returns `{content: result.content, details: undefined}` (`define-tool.ts:51-54`). The handler must return `{content: [{type: 'text', text: โ€ฆ}]}`. The shape is identical to MCP's โ€” Studio can directly reuse the `textResult` convention. + +**For pass-through tools (7 of 13):** Wrappers call the lib function and stringify the result. + +```ts +async ({ url }) => textResult( JSON.stringify( await detect( url ), null, 2 ) ) +``` + +**For handler-assembled tools (6 of 13):** Wrappers re-implement the assembly from `src/mcp-server.ts` directly. The drift risk is the same surface as schema drift โ€” comment each block with `// Mirrored from data-liberation/src/mcp-server.ts:273-311 (liberate_inspect handler)`. + +**For `delegate: true` modes (`liberate_setup`, `liberate_import`):** These don't even touch DLA's lib โ€” the manifest objects are pure literals in the handler. Studio copies them verbatim. Since `delegate: true` is the canonical Studio integration shape (per `wave-1-dla-inventory.md` risks #8 โ€” `delegate: true` mode is designed for "local dev tools with direct database/CLI access"), Studio will likely **only** support `delegate: true` for setup/import and skip the REST-import branches entirely โ€” Studio has its own site management, its own WP-CLI bridge, and shouldn't import through the source site's REST API. + +**Drift risk: same as schema (medium-low).** Mitigation: integration tests that run each Studio wrapper and assert the response shape matches `JSON.parse(await dlaTool.callTool(args))` from a DLA HEAD pinned in a fixture. + +## 5. `delegate: true` impact + +**No loss in the vendored path.** The `delegate: true` contract is implemented **entirely inside `src/mcp-server.ts`** (lines 472-496 for setup, 498-520 for import). The underlying lib functions (`validateWpConnection`, `importToWordPress`) know nothing about it โ€” `grep delegate src/lib src/adapters` returns no matches. Studio's wrappers re-implement the delegate manifest as 10-20 lines of literal-object construction, with the same `existsSync`-on-known-paths probes the MCP server does today. + +**What `delegate: true` is actually doing:** It's a "give me the file paths, don't do the work" mode where DLA returns `{wxrFile, outputDir, mediaDir, productsCsv, redirectMap, importAuthors}` and the host environment (Studio, in our case) handles the import via its own site-management primitives โ€” `studio site create --blueprint โ€ฆ` or `studio wp eval-file โ€ฆ`. + +**Sub-agent delegation that the brief mentions ("delegate to a sub-agent"):** I see no evidence of this in DLA's code. `delegate: true` is purely a structured-manifest-return mode, not a sub-agent handoff. The `/migrate` workflow doesn't depend on DLA hosting its own sub-agent โ€” it depends on the **calling agent** (in our case, Studio CLI's pi agent) orchestrating tool calls. Vendoring preserves that exactly because it puts the tools in the agent's `customTools` array. + +**Verdict:** `delegate: true` is *easier* in the vendored path than in the MCP bridge path. The structured manifest is one less wire-format hop. + +## 6. Maintenance contract risk + +### Commit churn (60-day window, `src/lib/` only, measured at SHA `17219c4`) + +- **17 commits** touch `src/lib/` in the last 60 days; **1 commit** in the last 14 days (a WP.com auth-fix in `lib/setup/wp-setup.ts`). +- Aggregate churn: **46 file-touches, 3,920 insertions, 151 deletions** in the last 30 days. +- Breakdown by subdir over 60 days: `extraction/` 13 commits, `import/` 4, `preview/` 2, `qa/` 2. +- Most of the bulk came from adapter onboarding (PRs #21, #23, #28 = Hostinger, Weebly, HubSpot, all April), the preview PR (#39, April 18), and the AIMD adaptive tuner (#38, April 16). The repo was created **2026-03-31** (6 weeks old at SHA `17219c4`), so almost all the lib code is brand-new โ€” there's no long-term stability track record. + +### Pinning strategy + +- **DLA has no tags and no releases** (`gh api repos/Automattic/data-liberation-agent/{tags,releases}` both return `[]`). Pin must be by commit SHA, not by version. +- Recommended `apps/cli/package.json` entry: `"data-liberation": "github:Automattic/data-liberation-agent#17219c42b0420267302b138bf402930508006e0e"` (full SHA). npm 6+ resolves this to a `npm:` git URL with a tarball cached under `~/.npm/_cacache`; the SHA is recorded in `package-lock.json`. +- Lockfile implications: `package-lock.json` will gain a `node_modules/data-liberation` entry with `resolved: "git+ssh://git@github.com/Automattic/data-liberation-agent.git#17219โ€ฆ"` and an `integrity` hash. `npm ci` reproduces deterministically. +- Studio's `apps/cli/scripts/postinstall-npm.mjs` runs at install โ€” should NOT need changes for DLA itself, but DLA's own `postinstall: playwright install chromium` will fire. **This is the second-largest practical concern after the no-build issue:** every `npm install` of Studio CLI will download ~150 MB of Chromium even for users who never run the migration command. +- Mitigation for the playwright download: either (a) PR upstream to gate `postinstall` behind an env var like `DLA_SKIP_PLAYWRIGHT=1`, (b) ship a Studio-owned fork with that gate, or (c) accept the cost. + +### Sustainability rating + +| Risk | Severity | Notes | +|---|---|---| +| `src/lib/` API breakage | Medium | No public-API contract is documented in `AGENTS.md` beyond "shared scaffolding". But the 17 lib commits over 60 days were mostly *additive* โ€” new adapters, new tuner, new preview โ€” not signature changes. The two function-signature changes I noticed were both additive optional params. Worst-case impact is bounded by Studio's wrapper layer: any breakage surfaces at the wrapper, not deep in user code. | +| Tool-list churn | Medium | 11โ†’13 tools in last 90 days. Studio's wrapper list isn't auto-derived, so new tools require manual wrapper authoring โ€” but never silent breakage. | +| Schema changes | Low | Schemas in `src/mcp-server.ts` have been stable for 14+ days. No deprecations observed. | +| Breaking adapter changes | Low | The `PlatformAdapter` interface in `src/types.ts:5-16` has not changed since the adapter system was introduced (PR #6). | +| DLA project abandonment | Low | Repo is actively developed (29 stars, weekly commits, two recent feature PRs #46 and #50 in flight). | + +**Recommendation: track HEAD with SHA pinning, update on cadence (weekly to start, monthly once stable).** Set up a CI job that does `npm update data-liberation` weekly and runs the wrapper integration tests; if green, open a PR auto-bumping the pin. This is much cheaper than bridging via MCP because the wrapper integration tests give you a much tighter feedback loop than MCP wire-format tests would. + +## 7. Build-time integration + +### Install path + +Recommended `apps/cli/package.json` addition: + +```json +"dependencies": { + โ€ฆ, + "data-liberation": "github:Automattic/data-liberation-agent#17219c42b0420267302b138bf402930508006e0e" +} +``` + +### Blockers + +**Blocker A: DLA has no compiled output and no `prepare` script.** Evidence: + +- `dist/` is gitignored (`.gitignore:5`). +- `package.json` has no `main`, no `module`, no `exports`, no `files` whitelist. `bin` points at `./dist/cli.js` which doesn't exist on a fresh clone. +- No `prepare` or `prepack` script in `package.json:9-23`. `postinstall` runs Playwright only. + +The `github:Automattic/data-liberation-agent#` install would deliver the unmodified repo (since no `files` whitelist exists, it ships everything not gitignored). The consumer receives `src/lib/extraction/detect-platform.ts` but no `.js`. Node cannot import `.ts` directly. **Studio's `vite build` cannot transpile it either, because `vite.config.base.ts:79-91` externalizes every entry in `apps/cli/package.json` `dependencies`** โ€” including `data-liberation`. The bundled CLI would crash at runtime with `Cannot find module 'data-liberation/lib/extraction/detect-platform.js'`. + +**Three unblock options:** + +1. **(Recommended) Upstream a `"prepare": "tsc"` script.** With `"prepare"`, npm runs `tsc` after the dependency tree is installed, so `dist/` is materialized. Also add `"main": "./dist/cli.js"`, `"exports": { "./lib/*": "./dist/lib/*.js", "./adapters/*": "./dist/adapters/*.js" }`, and `"files": ["dist/", "scripts/"]` to publish a clean surface. Risk: requires DLA-maintainer cooperation, but it's a 5-line PR and benefits all three of DLA's integration paths. +2. **Studio runs DLA's build itself.** Studio's `apps/cli/scripts/postinstall-npm.mjs` (referenced at `apps/cli/package.json:71`) already runs custom install logic โ€” it could `cd node_modules/data-liberation && npm install && npx tsc`. Cost: Studio CLI's npm install grows a `tsc` step (Studio doesn't currently depend on TypeScript at runtime โ€” it's devDep-only). Also brittle because DLA's own deps include `typescript@^5.7.0` in devDependencies, which `--omit=dev` install will skip. +3. **Vendor copy.** Drop the `github:` dep and instead `git submodule add` (or `git subtree` import) DLA's `src/lib/` and `src/adapters/` under `apps/cli/vendor/data-liberation/`. Add `vendor` to Vite's `resolve.alias` and let Vite transpile the TS source on Studio's build. **No external dep, no postinstall surprise, no Playwright auto-install.** Cost: vendoring is a one-time copy + per-update manual sync; the SHA-pinning convenience is lost. + +**Option 3 is my preferred unblocker** if (1) isn't immediately landable upstream. It plays to Studio's existing build pipeline strengths (Vite already transpiles TS) and sidesteps the Playwright postinstall problem entirely (Studio can lazy-import Playwright only when adapters need it, which they already do via `lib/adapters/shared.ts:108`). + +**Blocker B: Recursion in `liberate_preview`.** As noted in section 2: `lib/preview/playground-server.ts:160` calls `isStudioAvailable()` โ†’ if the `studio` binary is on PATH, it shells out to `studio site create`. When DLA is embedded inside Studio CLI's `studio code` agent, this means Studio is spawning Studio. The brief is "out of scope" for full implementation, but the wrapper sketch in section 8 must pass `_noStudio: true` or thread a `previewMode: 'studio-in-process' | 'playground'` flag the wrapper sets explicitly. + +**Blocker C: Playwright postinstall.** DLA's `postinstall: playwright install chromium` runs on every `npm install`. Studio already depends on `playwright@^1.52.0` (`apps/cli/package.json:53`) so the **JS dep** is shared, but the **Chromium browser binary** is downloaded per-package. Either upstream a `DLA_SKIP_PLAYWRIGHT` env-gate, vendor DLA into the build (option 3 above), or accept the cost. + +### Vite transpilation hazards + +DLA's TS source is straightforward โ€” no decorators, no top-level await (besides `src/cli.ts:14-15` which is a CLI-only branch), uses dynamic `import()` extensively for lazy loading (e.g. `src/mcp-server.ts:265, 306, 432, 447, 458, 467, 489, 504, 524, 525, 572, 626`). Dynamic imports are well-supported by Vite, but they need the targets to be resolvable in the bundle. Under option 3 (vendor copy), Vite would handle this transparently. Under option 1 (DLA ships `dist/`), Vite externalizes everything so the issue doesn't arise. + +### Asset preservation + +`lib/preview/studio.ts:36-52` resolves vendored PHP scripts via `fileURLToPath(import.meta.url)` to `lib/preview/scripts/import-wxr.php` and `import-products.php`. **Any bundling strategy must preserve these PHP files alongside the compiled JS.** Under option 3 (vendor copy), Studio's existing `vite.config.base.ts:45-54` `writeBundle` hook can be extended to copy `vendor/data-liberation/lib/preview/scripts/` into `dist/cli/lib/preview/scripts/`. Under option 1, npm installs all files in `data-liberation/dist/` so the assets ship with the package. + +### `tsx` runtime requirement + +DLA's MCP server and CLI both run via `tsx` (`.mcp.json: ["tsx", "src/mcp-server.ts"]`, `package.json: "mcp": "tsx src/mcp-server.ts"`). **Vendoring sidesteps this entirely** โ€” Studio's wrappers call the lib functions directly via Node's native ESM loader. The only places `tsx` shows up in DLA are the unused MCP-spawn paths and `package.json scripts`. None of these matter for the vendored AgentTool path. + +## 8. Concrete sketches + +### Simple sketch: `liberate_detect` + +```ts +// apps/cli/ai/tools/liberation/liberate-detect.ts +import { Type } from 'typebox'; +import { defineTool } from '../define-tool'; +import { textResult } from '../utils'; +// Vendor copy at apps/cli/vendor/data-liberation/lib/extraction/detect-platform.ts +// (or `data-liberation/dist/lib/extraction/detect-platform.js` if upstream ships dist/) +import { detect } from 'data-liberation/lib/extraction/detect-platform.js'; + +export const liberateDetectTool = defineTool( + 'liberate_detect', + // Description verbatim from data-liberation src/mcp-server.ts:52 + 'Detect the platform of a website (GoDaddy Websites & Marketing, Hostinger, HubSpot, ' + + 'Shopify, Squarespace, Webflow, Weebly, Wix, or unknown)', + { + url: Type.String( { description: 'The URL of the website to detect' } ), + }, + async ( { url } ) => { + const result = await detect( url ); + return textResult( JSON.stringify( result, null, 2 ) ); + } +); +``` + +That's the whole tool. 11 lines including the schema. The `detect()` function takes one string and returns `FullDetectionResult` โ€” there's nothing Studio-specific to inject. + +### Structured sketch: `liberate_inspect` + +Mirrors `src/mcp-server.ts:273-311`: + +```ts +// apps/cli/ai/tools/liberation/liberate-inspect.ts +import { Type } from 'typebox'; +import { defineTool } from '../define-tool'; +import { textResult } from '../utils'; +import { detect } from 'data-liberation/lib/extraction/detect-platform.js'; +import { fetchSitemap, classifyUrl } from 'data-liberation/lib/extraction/sitemap.js'; +import { detectFeatures } from 'data-liberation/lib/features/detect-features.js'; +// Static adapter import (alphabetical, matches src/mcp-server.ts:17-26) +import { godaddyWmAdapter } from 'data-liberation/adapters/godaddy-wm.js'; +import { hostingerAdapter } from 'data-liberation/adapters/hostinger.js'; +import { hubspotAdapter } from 'data-liberation/adapters/hubspot.js'; +import { shopifyAdapter } from 'data-liberation/adapters/shopify.js'; +import { squarespaceAdapter} from 'data-liberation/adapters/squarespace.js'; +import { webflowAdapter } from 'data-liberation/adapters/webflow.js'; +import { weeblyAdapter } from 'data-liberation/adapters/weebly.js'; +import { wixAdapter } from 'data-liberation/adapters/wix.js'; +import type { PlatformAdapter } from 'data-liberation/types.js'; + +const ADAPTERS: PlatformAdapter[] = [ + godaddyWmAdapter, hostingerAdapter, hubspotAdapter, shopifyAdapter, + squarespaceAdapter, webflowAdapter, weeblyAdapter, wixAdapter, +]; + +function findAdapter( platform: string ): PlatformAdapter | null { + return ADAPTERS.find( ( a ) => a.id === platform ) ?? null; +} + +export const liberateInspectTool = defineTool( + 'liberate_inspect', + // Description verbatim from data-liberation src/mcp-server.ts:77 + 'Probe a site to assess extractability: detect platform, check sitemap, probe sample pages', + { + url: Type.String( { description: 'The URL of the website to inspect' } ), + token: Type.Optional( Type.String( { description: 'API token if needed' } ) ), + cdpPort: Type.Optional( Type.Number( { description: 'CDP port for browser-based inspection' } ) ), + }, + async ( { url, token, cdpPort } ) => { + // Mirrored from data-liberation/src/mcp-server.ts:273-311 (liberate_inspect handler). + // Keep this assembly in lock-step with upstream โ€” see CONTRIBUTING.md. + const detection = await detect( url ); + const result: Record< string, unknown > = { + url, + platform: detection.platform, + confidence: detection.confidence, + signals: detection.signals, + sitemapFound: false, + urlCount: 0, + counts: {} as Record< string, number >, + probeResults: [] as unknown[], + authRequired: false, + extractionFeasibility: detection.platform === 'unknown' ? 'limited' : 'ready', + }; + + const urls = await fetchSitemap( url ); + result.sitemapFound = urls.length > 0; + result.urlCount = urls.length; + + const counts: Record< string, number > = {}; + for ( const u of urls ) { + const type = classifyUrl( u ); + counts[ type ] = ( counts[ type ] || 0 ) + 1; + } + result.counts = counts; + + const adapter = findAdapter( detection.platform ); + if ( adapter && typeof adapter.probe === 'function' ) { + result.probeResults = await adapter.probe( url, urls.slice( 0, 3 ), { token, cdpPort } ); + } + + const featureUrls = urls.length > 0 ? urls : [ url ]; + result.platformFeatures = detectFeatures( detection.platform, featureUrls, [] ); + + return textResult( JSON.stringify( result, null, 2 ) ); + } +); +``` + +Both tools register through Studio's existing registry: + +```ts +// apps/cli/ai/tools/liberation/index.ts +import { liberateDetectTool } from './liberate-detect'; +import { liberateInspectTool } from './liberate-inspect'; +// โ€ฆetc. for the other 11 tools + +export const liberationToolDefinitions = [ + liberateDetectTool, + liberateInspectTool, + // โ€ฆ +]; +``` + +```ts +// apps/cli/ai/tools/index.ts (edit existing file) +import { liberationToolDefinitions } from './liberation'; + +export const studioToolDefinitions = [ + // โ€ฆ existing 25 tools โ€ฆ + ...liberationToolDefinitions, +]; +``` + +### Slash-command + skill registration + +```ts +// tools/common/ai/slash-commands.ts (edit existing array) +export const AI_SKILL_COMMANDS: SkillSlashCommand[] = [ + { name: 'annotate', description: __( 'Annotate site elements visually in a browser' ) }, + { name: 'taxonomist', description: __( 'Optimize category taxonomy with AI' ) }, + { name: 'need-for-speed', description: __( 'Run a performance audit on a site' ) }, + { name: 'rank-me-up', description: __( 'Run an on-page SEO audit on a site' ) }, + // New entry + { name: 'migrate', description: __( 'Migrate a site from a closed web platform to WordPress' ) }, +]; +``` + +Then drop a wrapper-skill at `apps/cli/ai/skills/migrate/SKILL.md` (content reused as-is from `prior-art/rsm-3139-spec.md` โ€” runtime-agnostic). + +### Permission gating + +pi has no `canUseTool` hook (per the research-plan context), so per-tool permission buckets land **inside** the wrapper's `handler`. The vendored path makes this slightly cleaner than the bridge path because the policy and the call are in the same file: + +```ts +async ( { url } ) => { + await requirePermission( 'liberate.network.read' ); // throws if unauthorized + const result = await detect( url ); + return textResult( JSON.stringify( result, null, 2 ) ); +} +``` + +Concrete permission-bucket content comes from `prior-art/rsm-3139-spec.md` and is **runtime-agnostic** per the research plan โ€” Studio's `requirePermission` helper lives wherever the existing tools (like `runWpCliTool`) put their gating. Vendoring doesn't make this materially better or worse than the bridge โ€” it just moves the same gating code from a generic MCP-wrapper to a tool-specific wrapper. + +## 9. Verdict + +**Works with caveats. Recommendation strength: medium-high.** + +| Dimension | Assessment | +|---|---| +| Library-as-API self-containment | **Strong.** No Ink/UI leaks, no cwd dependence, MCP `Server` is type-only + optional at runtime, all 13 MCP tools have clean lib entry points. | +| Tool surface coverage | **Complete.** 13/13 tools have lib paths; 7 pass-through, 6 require ~15-50 lines of handler-mirroring code each. | +| Schema / output drift risk | **Medium-low.** Small surface (13 tools, ~30 fields), bounded by integration tests, the schemas have been stable for two weeks. | +| `delegate: true` preservation | **Better than bridge.** Pure literal-object construction in Studio's wrappers; no wire-format hop. | +| Maintenance contract | **Medium.** 17 lib commits in 60 days, but the trend is decelerating (1 commit in the last 14 days). Recommend SHA-pinning + weekly auto-update CI. | +| **Build-time integration** | **The blocker.** DLA ships no `dist/`, has no `prepare` script, no `exports` map, no `main`. Either (a) upstream a `prepare: tsc` + `exports` PR โ€” easiest, requires maintainer cooperation; or (b) vendor DLA's source into `apps/cli/vendor/` and let Vite transpile it โ€” Studio-owned, no upstream dependency, no Playwright postinstall surprise. Recommend (b) as the immediate-path. | +| Recursion in `liberate_preview` | **Caveat.** Studio wrappers must force the Playground branch or call `startStudioPreview` via a Studio-side site-orchestration path that doesn't shell out to a sibling `studio` binary. | +| Playwright postinstall | **Caveat.** ~150 MB Chromium download per Studio CLI install if DLA is a `github:` dep. Vendoring sidesteps this; otherwise mitigate via upstream env-gate or accept the cost. | + +**Versus the MCP-bridge path (Brief 2):** Vendoring trades runtime simplicity (no child-process lifecycle, no `tsx` dep, no stdio parsing) for build-time integration work (the `prepare`-script blocker, the schema/output transcription). Once the build path is sorted, vendoring is the lighter ongoing path โ€” schemas drift once per upstream change, vs. every tool-call paying the bridge tax forever. + +**If DLA's maintainers will not accept a `prepare`-script PR within RSM-3143's timeline:** flip to **vendor-via-submodule** (option 3 in section 7). Studio owns the source. Updates become a manual sync โ€” but that's a once-a-week, 10-minute task at DLA's current churn rate. The wrapper code (the actual surface Studio cares about) stays identical between vendor-via-`github:` and vendor-via-submodule. + +## Sources + +### DLA repo (cloned to `/tmp/dla-rsm-3143`, HEAD `17219c42b0420267302b138bf402930508006e0e`, dated 2026-05-07) + +- `src/mcp-server.ts` (652 LOC) โ€” full read. +- `src/cli.ts` (176 LOC) โ€” full read. +- `src/types.ts` โ€” full read. +- `src/lib/extraction/detect-platform.ts` (193 LOC) โ€” full read. +- `src/lib/extraction/sitemap.ts` (154 LOC) โ€” full read. +- `src/lib/extraction/extraction-log.ts` (164 LOC) โ€” full read. +- `src/lib/extraction/wxr-builder.ts` (791 LOC) โ€” exports surveyed. +- `src/lib/extraction/{adaptive-tuner,content-parser,import-session,media,media-stubs,shopify-graphql,wxr-reader}.ts` โ€” exports surveyed. +- `src/lib/features/detect-features.ts` โ€” exports surveyed. +- `src/lib/preview/{playground-server,studio,blueprint-builder,media-url-map,lockfile,port-picker,types}.ts` โ€” exports + key functions read. +- `src/lib/preview/studio.ts` (377 LOC) โ€” full read for recursion-into-Studio analysis. +- `src/lib/preview/playground-server.ts` (342 LOC) โ€” top + `startPreview` body read. +- `src/lib/qa/{qa-runner,content-differ}.ts` โ€” exports + qa-runner top read. +- `src/lib/setup/wp-setup.ts` (128 LOC) โ€” full read. +- `src/lib/verification/verify.ts` (116 LOC) โ€” full read. +- `src/lib/probe/{browser-probe,map-apis}.ts` โ€” exports + browser-probe top read. +- `src/lib/import/{wp-importer,wp-rest-client,resolve-site-url,http-client,woo-csv-reader,woo-product-csv,woo-rest-client}.ts` โ€” exports surveyed. +- `src/adapters/{shared,wix,squarespace,shopify,hubspot,hostinger,webflow,weebly,godaddy-wm}.ts` โ€” `Server` import + `sendLoggingMessage` usage greps; `shared.ts` top + `sendLog` helper read. +- `package.json`, `tsconfig.json`, `.gitignore`, `AGENTS.md` โ€” full reads. +- `git log --since="60 days ago" -- src/lib/`, `git log --since="30 days ago" --shortstat -- src/lib/`, `git log --since="14 days ago" -- src/lib/` โ€” churn analysis. + +### Studio CLI repo (worktree at `/Users/iamposti/Automattic/repos/studio/.claude/worktrees/rsm-3143-dla-pi-research/`) + +- `apps/cli/package.json` โ€” dep list + scripts. +- `apps/cli/vite.config.base.ts` (118 LOC), `vite.config.npm.ts` โ€” externalization rules. +- `apps/cli/ai/tools/define-tool.ts` (56 LOC) โ€” `defineTool`/`StudioAgentTool` shape. +- `apps/cli/ai/tools/index.ts` (96 LOC) โ€” tool registry. +- `apps/cli/ai/tools/site-info.ts`, `apps/cli/ai/tools/utils.ts` โ€” patterns for a typical AgentTool. +- `apps/cli/ai/mcp-server.ts` (~58 LOC) โ€” Studio's outbound MCP server (for reference; not relevant to vendor path). +- `apps/cli/ai/runtimes/pi/index.ts:260-285` โ€” `createAgentSession` call site showing `customTools` wiring. +- `apps/cli/ai/slash-commands.ts:541` โ€” `...AI_SKILL_COMMANDS` registration. +- `tools/common/ai/slash-commands.ts` (17 LOC) โ€” `AI_SKILL_COMMANDS` registry, the slash-command extension seam. + +### Prior art + +- `issues/rsm-3143-dla-pi-research/tasks/wave-1-vendor-as-agenttools.md` โ€” task brief. +- `issues/rsm-3143-dla-pi-research/research-plan.md` โ€” research framing. +- `issues/rsm-3143-dla-pi-research/prior-art/wave-1-findings/wave-1-dla-inventory.md` โ€” DLA surface inventory. diff --git a/package-lock.json b/package-lock.json index 394f3b70a0..1f3562adc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,6 +75,7 @@ "atomically": "^2.1.1", "chokidar": "^5.0.0", "cli-table3": "^0.6.5", + "data-liberation": "github:Automattic/data-liberation-agent#17219c42b0420267302b138bf402930508006e0e", "fs-extra": "^11.3.4", "http-proxy": "^1.18.1", "ignore": "^7.0.5", @@ -85,6 +86,7 @@ "semver": "^7.7.4", "tar": "^7.5.13", "trash": "^10.0.1", + "tsx": "^4.19.0", "yargs": "^18.0.0", "yargs-parser": "^22.0.0", "yauzl": "^3.3.0", @@ -95,6 +97,7 @@ }, "devDependencies": { "@studio/common": "file:../../tools/common", + "@studio/dla": "file:../../tools/dla", "@types/archiver": "^7.0.0", "@types/http-proxy": "^1.17.17", "@types/node-forge": "^1.3.14", @@ -1406,6 +1409,46 @@ "dev": true, "license": "MIT" }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", + "integrity": "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "dev": true, @@ -4596,7 +4639,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -4712,6 +4755,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4728,6 +4774,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4744,6 +4793,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4760,6 +4812,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4776,6 +4831,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -7524,7 +7582,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7541,7 +7598,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7558,7 +7614,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7575,7 +7630,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7592,7 +7646,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7609,7 +7662,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7626,7 +7678,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7643,7 +7694,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7660,7 +7710,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7677,7 +7726,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7694,7 +7742,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7711,7 +7758,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7728,7 +7774,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7745,7 +7790,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7762,7 +7806,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7779,7 +7822,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7796,7 +7838,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7813,7 +7854,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7830,7 +7870,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7847,7 +7886,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7864,7 +7902,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7881,7 +7918,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7898,7 +7934,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7915,7 +7950,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7932,7 +7966,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7949,7 +7982,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14423,6 +14455,10 @@ "resolved": "tools/common", "link": true }, + "node_modules/@studio/dla": { + "resolved": "tools/dla", + "link": true + }, "node_modules/@studio/ui": { "resolved": "apps/ui", "link": true @@ -15547,6 +15583,15 @@ "@types/node": "*" } }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -18698,6 +18743,18 @@ "node": ">=0.8" } }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/autosize": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/autosize/-/autosize-4.0.4.tgz", @@ -19017,6 +19074,12 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/boolean": { "version": "3.2.0", "dev": true, @@ -19569,6 +19632,70 @@ "dev": true, "license": "MIT" }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/chokidar": { "version": "3.6.0", "dev": true, @@ -19702,9 +19829,20 @@ "node": ">=6" } }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-cursor": { "version": "4.0.0", - "dev": true, "license": "MIT", "dependencies": { "restore-cursor": "^4.0.0" @@ -19718,7 +19856,6 @@ }, "node_modules/cli-spinners": { "version": "2.9.2", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -19885,6 +20022,18 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "license": "MIT", @@ -20248,6 +20397,15 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/cookie": { "version": "0.7.2", "license": "MIT", @@ -20468,6 +20626,22 @@ "node": ">=4" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-to-react-native": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", @@ -20479,6 +20653,18 @@ "postcss-value-parser": "^4.0.2" } }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css.escape": { "version": "1.5.1", "dev": true, @@ -20520,6 +20706,30 @@ "version": "2.1.1", "license": "ISC" }, + "node_modules/data-liberation": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/Automattic/data-liberation-agent.git#17219c42b0420267302b138bf402930508006e0e", + "integrity": "sha512-FCZEig2n/YT2kkjaZOditXgUSocp9RQfqfn1e3MGeecbQnWqG9hivbF9QsFJj4MRHKFUn8yCDD4t0C2H593iiw==", + "hasInstallScript": true, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.0", + "@types/papaparse": "^5.5.2", + "@wp-playground/cli": "^3.1.20", + "cheerio": "^1.2.0", + "fast-xml-parser": "^5.5.10", + "ink": "^6.8.0", + "ink-spinner": "^5.0.0", + "papaparse": "^5.5.3", + "playwright": "^1.44.0", + "react": "^19.2.4" + }, + "bin": { + "data-liberation": "dist/cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-urls": { "version": "5.0.0", "dev": true, @@ -20795,6 +21005,73 @@ "license": "MIT", "peer": true }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dot-case": { "version": "3.0.4", "license": "MIT", @@ -21903,6 +22180,31 @@ "iconv-lite": "^0.6.2" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/encoding/node_modules/iconv-lite": { "version": "0.6.3", "license": "MIT", @@ -21936,7 +22238,6 @@ }, "node_modules/entities": { "version": "6.0.1", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -21953,6 +22254,18 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/equivalent-key-map": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/equivalent-key-map/-/equivalent-key-map-0.2.2.tgz", @@ -22025,6 +22338,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/es6-error": { "version": "4.1.1", "dev": true, @@ -22035,7 +22358,6 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -23517,7 +23839,6 @@ }, "node_modules/fsevents": { "version": "2.3.3", - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -24411,6 +24732,37 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "dev": true, @@ -24657,6 +25009,228 @@ "node": ">=10" } }, + "node_modules/ink": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/ink/-/ink-6.8.0.tgz", + "integrity": "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.2.4", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.6.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^5.1.1", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.39.10", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^8.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.1.1", + "terminal-size": "^4.0.1", + "type-fest": "^5.4.1", + "widest-line": "^6.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": ">=19.0.0", + "react": ">=19.0.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-spinner": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", + "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", + "license": "MIT", + "dependencies": { + "cli-spinners": "^2.7.0" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "ink": ">=4.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/ink/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ink/node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/ink/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/ink/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ink/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -25219,6 +25793,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -29094,6 +29683,18 @@ "node": ">=4" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -29410,6 +30011,12 @@ "version": "1.0.11", "license": "(MIT AND Zlib)" }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, "node_modules/param-case": { "version": "3.0.4", "license": "MIT", @@ -29493,7 +30100,6 @@ }, "node_modules/parse5": { "version": "7.3.0", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -29502,6 +30108,18 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parsel-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/parsel-js/-/parsel-js-1.2.2.tgz", @@ -29539,6 +30157,15 @@ "tslib": "^2.0.3" } }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/patch-package": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", @@ -31180,6 +31807,21 @@ "node": ">=12" } }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", @@ -31768,7 +32410,6 @@ }, "node_modules/restore-cursor": { "version": "4.0.0", - "dev": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -32838,6 +33479,27 @@ "node": ">=12.0.0" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/stackback": { "version": "0.0.2", "dev": true, @@ -33348,6 +34010,18 @@ "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "3.4.1", "dev": true, @@ -33541,6 +34215,18 @@ "rimraf": "bin.js" } }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/terser": { "version": "5.44.0", "license": "BSD-2-Clause", @@ -34079,7 +34765,6 @@ "version": "4.22.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz", "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -35160,7 +35845,6 @@ }, "node_modules/whatwg-encoding": { "version": "3.1.1", - "dev": true, "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" @@ -35171,7 +35855,6 @@ }, "node_modules/whatwg-encoding/node_modules/iconv-lite": { "version": "0.6.3", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -35186,7 +35869,6 @@ }, "node_modules/whatwg-mimetype": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -35263,6 +35945,37 @@ "node": ">=8" } }, + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", + "license": "MIT", + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/winreg": { "version": "1.2.4", "license": "BSD-2-Clause" @@ -35712,6 +36425,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zip-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", @@ -36009,6 +36728,21 @@ "devOptional": true, "license": "MIT" }, + "tools/dla": { + "name": "@studio/dla", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1" + }, + "devDependencies": { + "@earendil-works/pi-agent-core": "0.78.0", + "@earendil-works/pi-ai": "0.78.0", + "@earendil-works/pi-coding-agent": "0.78.0", + "data-liberation": "github:Automattic/data-liberation-agent#17219c42b0420267302b138bf402930508006e0e", + "tsx": "^4.19.0", + "typebox": "^1.1.24" + } + }, "tools/eslint-plugin-studio": { "version": "1.0.0", "dependencies": { diff --git a/tools/common/ai/slash-commands.ts b/tools/common/ai/slash-commands.ts index 9af03e16b8..47c12786c8 100644 --- a/tools/common/ai/slash-commands.ts +++ b/tools/common/ai/slash-commands.ts @@ -10,6 +10,7 @@ export const AI_SKILL_COMMANDS: SkillSlashCommand[] = [ { name: 'taxonomist', description: __( 'Optimize category taxonomy with AI' ) }, { name: 'need-for-speed', description: __( 'Run a performance audit on a site' ) }, { name: 'rank-me-up', description: __( 'Run an on-page SEO audit on a site' ) }, + { name: 'liberate', description: __( 'Liberate a site from a closed platform into Studio' ) }, ]; export function buildSkillInvocationPrompt( name: string ): string { diff --git a/tools/common/ai/tests/slash-commands.test.ts b/tools/common/ai/tests/slash-commands.test.ts new file mode 100644 index 0000000000..7e31327a92 --- /dev/null +++ b/tools/common/ai/tests/slash-commands.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { AI_SKILL_COMMANDS, buildSkillInvocationPrompt } from '../slash-commands'; + +describe( 'AI_SKILL_COMMANDS', () => { + it( 'registers the /liberate skill command', () => { + const liberate = AI_SKILL_COMMANDS.find( ( command ) => command.name === 'liberate' ); + expect( liberate ).toBeDefined(); + expect( liberate?.description ).toBe( 'Liberate a site from a closed platform into Studio' ); + } ); +} ); + +describe( 'buildSkillInvocationPrompt', () => { + it( 'builds the invocation prompt for /liberate', () => { + expect( buildSkillInvocationPrompt( 'liberate' ) ).toBe( + 'Run the /liberate skill using the Skill tool.' + ); + } ); +} ); diff --git a/tools/dla/agent-tool-adapter.ts b/tools/dla/agent-tool-adapter.ts new file mode 100644 index 0000000000..937574f663 --- /dev/null +++ b/tools/dla/agent-tool-adapter.ts @@ -0,0 +1,154 @@ +/** + * Wraps a remote MCP tool descriptor as a pi-coding-agent + * `ToolDefinition` that the Studio CLI runtime can register through its + * `customTools` slot. + * + * The adapter is structurally identical to the inverse direction at + * `apps/cli/ai/mcp-server.ts`: where that file casts pi tools into MCP + * shape, this file casts MCP tools into pi shape. Both rely on the fact + * that pi-ai's `validateToolArguments` accepts plain JSON Schema objects + * โ€” no TypeBox metadata is required at runtime โ€” so the + * `inputSchema as unknown as TSchema` cast is safe. + * + * See `issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-mcp-bridge-feasibility.md` + * ยง2 for the full evidence trail. + */ +import { adaptMcpContent, type McpContentBlock } from './content-adapter'; +import { defaultPolicyBuckets, shouldBlock, type DlaPolicyBuckets } from './policy'; +import type { AgentToolResult } from '@earendil-works/pi-agent-core'; +import type { ToolDefinition } from '@earendil-works/pi-coding-agent'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { TSchema } from 'typebox'; + +/** + * The subset of an MCP `Tool` descriptor returned by `client.listTools()` + * that the adapter consumes. Typed structurally to keep the bridge + * decoupled from the SDK's deeply nested Zod-derived types. + */ +export interface RemoteMcpTool { + /** The MCP tool name (`liberate_detect`, `liberate_inspect`, ...). */ + name: string; + /** Optional human-readable description forwarded to the model. */ + description?: string; + /** JSON Schema for the tool's arguments. Forwarded to pi as-is. */ + inputSchema: { + type: 'object'; + properties?: Record< string, unknown >; + required?: string[]; + [ key: string ]: unknown; + }; +} + +/** + * Options for `adaptMcpToolToPi`. The adapter accepts a policy getter so + * that the runtime can swap buckets at session boundaries without having + * to rebuild every adapted tool definition. + */ +export interface AdaptMcpToolOptions { + /** Resolved buckets for the current session. */ + getBuckets?: () => DlaPolicyBuckets; +} + +/** + * Build a Studio-compatible error class that callers can identify via + * `error instanceof DlaPolicyError` while pi treats it as a regular + * `Error` (caught by `executePreparedToolCall` and surfaced as + * `isError: true` in the model transcript). + */ +export class DlaPolicyError extends Error { + constructor( message: string ) { + super( message ); + this.name = 'DlaPolicyError'; + } +} + +/** + * Wrap a single remote MCP tool as a pi `ToolDefinition`. + * + * On invocation the wrapper: + * + * 1. Consults the policy via `shouldBlock`. A blocking verdict throws a + * `DlaPolicyError`; pi catches it and surfaces the reason as the + * tool-call error. + * 2. Forwards the call to `client.callTool` with the pi-supplied + * `AbortSignal` plumbed through `RequestOptions.signal`. The MCP SDK + * sends `notifications/cancelled` on abort (verified at + * `node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.js` + * lines 677, 709-710). + * 3. Adapts the returned `CallToolResult.content[]` to pi's narrower + * shape via `adaptMcpContent`, and surfaces `structuredContent` as + * `AgentToolResult.details`. + * 4. Detects `result.isError === true` and rethrows with the first text + * block as the error message โ€” matches the contract + * `executePreparedToolCall` expects. + * + * @param remoteTool - The MCP tool descriptor (from `listTools`). + * @param client - The connected MCP client used to forward calls. + * @param options - Optional policy hooks. + * @returns A `ToolDefinition` ready to be passed to pi via `customTools`. + * + * @example + * const tools = listed.tools.map( ( t ) => + * adaptMcpToolToPi( t, client, { getBuckets: () => buckets } ) + * ); + */ +export function adaptMcpToolToPi( + remoteTool: RemoteMcpTool, + client: Pick< Client, 'callTool' >, + options: AdaptMcpToolOptions = {} +): ToolDefinition { + const getBuckets = options.getBuckets ?? ( () => defaultPolicyBuckets ); + + // Schema cast: MCP tools ship plain JSON Schema; pi-ai's + // `validateToolArguments` accepts plain JSON Schema (see + // wave-1-mcp-bridge-feasibility.md ยง2). Same idiom Studio's MCP + // *server* uses in the inverse direction at + // `apps/cli/ai/mcp-server.ts:27`. + + const parameters = remoteTool.inputSchema as unknown as TSchema; + + return { + name: remoteTool.name, + label: remoteTool.name, + description: remoteTool.description ?? '', + parameters, + async execute( + _toolCallId, + params, + signal + ): Promise< AgentToolResult< Record< string, unknown > | undefined > > { + const decision = shouldBlock( remoteTool.name, params, getBuckets() ); + if ( decision.block ) { + throw new DlaPolicyError( + decision.reason ?? `Studio policy blocked tool call "${ remoteTool.name }"` + ); + } + + const result = await client.callTool( + { + name: remoteTool.name, + arguments: ( params ?? {} ) as Record< string, unknown >, + }, + undefined, + { signal } + ); + + const contentBlocks = ( result.content ?? [] ) as McpContentBlock[]; + if ( result.isError ) { + const firstText = contentBlocks.find( + ( block ): block is { type: 'text'; text: string } => + block.type === 'text' && typeof ( block as { text?: unknown } ).text === 'string' + ); + const message = + firstText?.text ?? + `Remote DLA tool "${ remoteTool.name }" reported an error without a text payload.`; + throw new Error( message ); + } + + return { + content: adaptMcpContent( contentBlocks ), + details: result.structuredContent as Record< string, unknown > | undefined, + }; + }, + }; +} diff --git a/tools/dla/bridge.ts b/tools/dla/bridge.ts new file mode 100644 index 0000000000..ec6099f108 --- /dev/null +++ b/tools/dla/bridge.ts @@ -0,0 +1,283 @@ +/** + * MCP-stdio bridge between Studio's pi-agent runtime and the Data + * Liberation Agent (DLA) package's `src/mcp-server.ts` entry point. + * + * `startDlaBridge` spawns DLA's MCP server as a child process driven by + * `tsx`, connects an MCP `Client` over stdio, lists tools, and returns + * a `DlaBridge` handle that exposes: + * + * - `tools`: the adapted pi `ToolDefinition[]` ready to feed into the + * runtime's `customTools` slot. + * - `dispose()`: closes the MCP client (which triggers EOF on the child + * stdin) and, belt-and-braces, force-kills the child after a 2 second + * grace period if it is still alive. + * + * Failures during `listTools` resolve with an empty tool array and a + * warning rather than throwing โ€” a missing or broken DLA install must + * not crash session startup. Callers can fall back to the regular Studio + * toolset when the bridge is degraded. + * + * See `issues/rsm-3143-dla-pi-research/wave-1-findings/wave-1-mcp-bridge-feasibility.md` + * ยง3 and ยง6 for the full design. + */ +import { createRequire } from 'node:module'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { adaptMcpToolToPi, type RemoteMcpTool } from './agent-tool-adapter'; +import { defaultPolicyBuckets, type DlaPolicyBuckets } from './policy'; +import type { ToolDefinition } from '@earendil-works/pi-coding-agent'; + +const require = createRequire( import.meta.url ); + +/** + * The `listTools` timeout in milliseconds. The MCP child needs to boot + * (Node + `tsx` + DLA's module graph) before the first response, but + * anything past ten seconds points at a broken install rather than a + * cold start, so we surrender and continue without DLA. + */ +const LIST_TOOLS_TIMEOUT_MS = 10_000; + +/** + * Grace period in milliseconds between `client.close()` (which sends EOF + * to the child stdin) and a hard SIGKILL on the child's pid. DLA's MCP + * server normally exits on stdin EOF, but `liberate_extract` can hold a + * long-running adapter loop open; the SIGKILL is the safety net. + */ +const KILL_GRACE_MS = 2_000; + +/** + * The set of environment variables forwarded into the DLA child process. + * Studio injects `STUDIO_WPCOM_TOKEN` explicitly via opts so the parent + * never needs to set it on the host environment; the other two are + * end-user-supplied secrets that DLA reads directly. + */ +const PASSTHROUGH_ENV_KEYS = [ + 'LIBERATION_TOKEN', + 'SHOPIFY_ADMIN_TOKEN', + 'NODE_PATH', + 'NODE_OPTIONS', +] as const; + +/** + * Options for `startDlaBridge`. + */ +export interface StartDlaBridgeOptions { + /** Optional WordPress.com bearer forwarded to DLA as `STUDIO_WPCOM_TOKEN`. */ + wpcomToken?: string; + /** Buckets to consult for the policy layer. Defaults to `defaultPolicyBuckets`. */ + policyBuckets?: DlaPolicyBuckets; + /** + * Extra environment variables to merge into the child process. Useful + * for tests that want to pin paths or feature flags. + */ + env?: Record< string, string >; + /** + * Override the spawn/connect plumbing entirely. Tests pass a stub + * here to avoid forking a real Node process; production callers + * should leave this undefined. + */ + transport?: BridgeTransportProvider; + /** Override the `listTools` timeout. Defaults to 10 seconds. */ + listToolsTimeoutMs?: number; +} + +/** + * Internal contract that allows tests to swap out the spawn/connect + * pipeline without forking a real DLA child process. The production + * implementation lives in `defaultTransportProvider`. + */ +export interface BridgeTransportProvider { + /** + * Construct an MCP `Client` and connect it over a transport. The + * returned `pid` is used by `dispose()` to belt-and-braces SIGKILL + * the child after the grace period. + */ + connect( env: Record< string, string > ): Promise< { + client: Pick< Client, 'callTool' | 'listTools' | 'close' >; + pid: number | null; + } >; +} + +/** + * The handle returned by `startDlaBridge`. Consumers consume `tools`, + * then call `dispose()` during session teardown. + */ +export interface DlaBridge { + /** The bridged DLA tools, adapted as pi `ToolDefinition[]`. Empty if `listTools` failed. */ + readonly tools: ToolDefinition[]; + /** Whether the bridge degraded to an empty tool list during startup. */ + readonly degraded: boolean; + /** Optional human-readable reason for degradation. */ + readonly degradationReason?: string; + /** Tear down the MCP client and ensure the child process exits. */ + dispose(): Promise< void >; +} + +/** + * Spawn DLA's MCP server, connect, list tools, and return a bridge handle. + * + * The function never throws on a missing or broken DLA install โ€” failures + * resolve to a bridge whose `tools` is empty and whose `degraded` flag is + * `true`. Callers should log the `degradationReason` and continue. + * + * @param opts - Options for spawn-time customisation (token, env, policy). + * @returns A connected `DlaBridge` handle. + * + * @example + * const bridge = await startDlaBridge( { wpcomToken } ); + * try { + * runtime.use( { customTools: bridge.tools } ); + * // ... + * } finally { + * await bridge.dispose(); + * } + */ +export async function startDlaBridge( opts: StartDlaBridgeOptions = {} ): Promise< DlaBridge > { + const env = buildChildEnv( opts ); + const transportProvider = opts.transport ?? defaultTransportProvider; + + let client: Pick< Client, 'callTool' | 'listTools' | 'close' >; + let pid: number | null = null; + try { + const connected = await transportProvider.connect( env ); + client = connected.client; + pid = connected.pid; + } catch ( error ) { + const reason = error instanceof Error ? error.message : String( error ); + console.warn( + `[@studio/dla] failed to spawn DLA MCP server (${ reason }); continuing without DLA tools.` + ); + return { + tools: [], + degraded: true, + degradationReason: reason, + dispose: async () => {}, + }; + } + + const buckets = opts.policyBuckets ?? defaultPolicyBuckets; + const timeoutMs = opts.listToolsTimeoutMs ?? LIST_TOOLS_TIMEOUT_MS; + + let toolList: RemoteMcpTool[]; + try { + const listed = await client.listTools( undefined, { + signal: AbortSignal.timeout( timeoutMs ), + } ); + toolList = ( listed?.tools ?? [] ) as RemoteMcpTool[]; + } catch ( error ) { + const reason = error instanceof Error ? error.message : String( error ); + console.warn( `[@studio/dla] listTools failed (${ reason }); continuing without DLA tools.` ); + return makeBridge( [], client, pid, true, reason ); + } + + const tools = toolList.map( ( remoteTool ) => + adaptMcpToolToPi( remoteTool, client as Client, { + getBuckets: () => buckets, + } ) + ); + + return makeBridge( tools, client, pid, false ); +} + +/** + * Build a `DlaBridge` handle around a successfully (or partially) + * connected MCP client. Extracted for reuse between the success and + * `listTools`-failure code paths. + */ +function makeBridge( + tools: ToolDefinition[], + client: Pick< Client, 'close' >, + pid: number | null, + degraded: boolean, + degradationReason?: string +): DlaBridge { + let disposed = false; + return { + tools, + degraded, + degradationReason, + async dispose(): Promise< void > { + if ( disposed ) { + return; + } + disposed = true; + try { + await client.close(); + } catch ( error ) { + // Closing a half-broken transport throws; that's expected + // when we are recovering from a startup error. Swallow and + // fall through to the SIGKILL fallback. + const reason = error instanceof Error ? error.message : String( error ); + console.warn( + `[@studio/dla] error closing MCP client (${ reason }); forcing child kill if still alive.` + ); + } + if ( pid !== null && pid > 0 ) { + setTimeout( () => { + try { + process.kill( pid, 'SIGKILL' ); + } catch { + // Already exited โ€” nothing to do. + } + }, KILL_GRACE_MS ).unref(); + } + }, + }; +} + +/** + * Build the environment forwarded to the spawned DLA child. Studio's + * `STUDIO_WPCOM_TOKEN` is supplied via `opts.wpcomToken`; other DLA + * secrets (`LIBERATION_TOKEN`, `SHOPIFY_ADMIN_TOKEN`) are passed through + * from the parent's environment if they are set. + */ +function buildChildEnv( opts: StartDlaBridgeOptions ): Record< string, string > { + const env: Record< string, string > = { + // Always start with a clean PATH so the child can find Node binaries + // without inheriting the entire shell environment. + PATH: process.env.PATH ?? '', + }; + for ( const key of PASSTHROUGH_ENV_KEYS ) { + const value = process.env[ key ]; + if ( typeof value === 'string' && value.length > 0 ) { + env[ key ] = value; + } + } + if ( opts.wpcomToken ) { + env.STUDIO_WPCOM_TOKEN = opts.wpcomToken; + } + if ( opts.env ) { + Object.assign( env, opts.env ); + } + return env; +} + +/** + * The production transport provider โ€” spawns Node + tsx + DLA's + * `src/mcp-server.ts` over stdio. + * + * Path resolution uses `createRequire` against `import.meta.url` so the + * bridge works whether it ships from `tools/dla/` (dev) or from the + * bundled CLI at `apps/cli/dist/cli/`. The `tsx` package is spelled as + * `tsx/cli` (its public `exports` key) rather than `tsx/dist/cli.mjs`, + * because the package's `exports` map does not expose the `dist/` + * subpath directly โ€” the deep path throws `ERR_PACKAGE_PATH_NOT_EXPORTED` + * at runtime. + */ +export const defaultTransportProvider: BridgeTransportProvider = { + async connect( env ) { + const tsxCli = require.resolve( 'tsx/cli' ); + const mcpServerEntry = require.resolve( 'data-liberation/src/mcp-server.ts' ); + + const transport = new StdioClientTransport( { + command: process.execPath, + args: [ tsxCli, mcpServerEntry ], + env, + stderr: 'pipe', + } ); + + const client = new Client( { name: 'studio-cli', version: '1.0.0' }, { capabilities: {} } ); + await client.connect( transport ); + return { client, pid: transport.pid ?? null }; + }, +}; diff --git a/tools/dla/content-adapter.ts b/tools/dla/content-adapter.ts new file mode 100644 index 0000000000..8a8d6b9eb9 --- /dev/null +++ b/tools/dla/content-adapter.ts @@ -0,0 +1,167 @@ +/** + * Adapter that maps MCP `CallToolResult.content[]` blocks down to the + * narrower pi-agent-core `AgentToolResult.content[]` shape. + * + * MCP defines five content variants โ€” `text`, `image`, `audio`, `resource`, + * and `resource_link` โ€” whereas pi only consumes `text` and `image`. The + * adapter: + * + * - passes `text` and `image` through unchanged + * - flattens an inline `resource` (text variant) into a labelled `text` block + * - serialises a `resource_link` to a `text` block describing the link + * - drops `audio` (and any unknown future types) with a `console.warn` + * + * Per wave-1-mcp-bridge-feasibility ยง2, DLA's MCP server emits text-only + * payloads today, so the non-text branches exist purely for forward + * compatibility with other MCP servers that may be wrapped through this + * package in the future. + */ +import type { ImageContent, TextContent } from '@earendil-works/pi-ai'; + +/** + * The subset of the MCP `ContentBlock` discriminated union that the bridge + * needs to inspect. Typed structurally to avoid pulling MCP's deeply nested + * `Zod`-derived runtime types into consumers. + */ +export interface McpTextBlock { + type: 'text'; + text: string; +} + +/** + * MCP image block: base64-encoded payload + mime type. + */ +export interface McpImageBlock { + type: 'image'; + data: string; + mimeType: string; +} + +/** + * MCP audio block. Dropped by the adapter โ€” pi cannot render audio inline + * and the bridged DLA tools never emit this variant. + */ +export interface McpAudioBlock { + type: 'audio'; + data: string; + mimeType: string; +} + +/** + * MCP embedded-resource block. Two variants exist on the wire: a text + * resource (`resource.text`) and a binary resource (`resource.blob`). + * Only the text variant is flattened; the binary variant is dropped. + */ +export interface McpResourceBlock { + type: 'resource'; + resource: + | { uri: string; text: string; mimeType?: string } + | { uri: string; blob: string; mimeType?: string }; +} + +/** + * MCP resource-link block. The adapter serialises it to a `text` block + * so the model receives a human-readable reference to the linked + * resource. + */ +export interface McpResourceLinkBlock { + type: 'resource_link'; + uri: string; + name?: string; + description?: string; + mimeType?: string; +} + +/** + * Union of MCP content blocks the adapter accepts as input. + */ +export type McpContentBlock = + | McpTextBlock + | McpImageBlock + | McpAudioBlock + | McpResourceBlock + | McpResourceLinkBlock + | { type: string; [ key: string ]: unknown }; + +/** + * Maps a single MCP content block to zero or more pi content blocks. + * + * @param block - The MCP content block to adapt. + * @returns An array of pi content blocks. Empty if the block is dropped. + * + * @example + * adaptMcpContentBlock({ type: 'text', text: 'hello' }) + * // -> [{ type: 'text', text: 'hello' }] + */ +export function adaptMcpContentBlock( block: McpContentBlock ): ( TextContent | ImageContent )[] { + switch ( block.type ) { + case 'text': { + const text = ( block as McpTextBlock ).text; + return [ { type: 'text', text } ]; + } + case 'image': { + const { data, mimeType } = block as McpImageBlock; + return [ { type: 'image', data, mimeType } ]; + } + case 'resource': { + const { resource } = block as McpResourceBlock; + if ( 'text' in resource && typeof resource.text === 'string' ) { + return [ + { + type: 'text', + text: `[resource ${ resource.uri }]\n${ resource.text }`, + }, + ]; + } + // Binary resource โ€” surface a stub pointer; the model can ask the + // server to refetch via a more specific tool call if it needs the + // payload. + return [ + { + type: 'text', + text: `[resource ${ resource.uri } (binary, ${ + resource.mimeType ?? 'unknown mime type' + })]`, + }, + ]; + } + case 'resource_link': { + const link = block as McpResourceLinkBlock; + const suffix = link.description ? ` โ€” ${ link.description }` : ''; + return [ + { + type: 'text', + text: `[resource_link ${ link.uri }${ suffix }]`, + }, + ]; + } + case 'audio': { + console.warn( '[@studio/dla] dropping unsupported MCP audio content block' ); + return []; + } + default: { + console.warn( + `[@studio/dla] dropping unknown MCP content block of type "${ String( block.type ) }"` + ); + return []; + } + } +} + +/** + * Maps the full `content[]` array of an MCP `CallToolResult` to pi's + * narrower `(TextContent | ImageContent)[]` shape. + * + * @param blocks - The MCP content blocks emitted by the remote tool. + * @returns The pi-compatible content array, flattened across adaptations. + * + * @example + * adaptMcpContent([ + * { type: 'text', text: 'a' }, + * { type: 'audio', data: '...', mimeType: 'audio/wav' }, + * ]) + * // -> [{ type: 'text', text: 'a' }] + */ +export function adaptMcpContent( blocks: McpContentBlock[] ): ( TextContent | ImageContent )[] { + return blocks.flatMap( adaptMcpContentBlock ); +} diff --git a/tools/dla/index.ts b/tools/dla/index.ts new file mode 100644 index 0000000000..5cde75c972 --- /dev/null +++ b/tools/dla/index.ts @@ -0,0 +1,56 @@ +/** + * Public entry point for the `@studio/dla` workspace package. + * + * The package wraps Data Liberation Agent's stdio MCP server as a set + * of pi-coding-agent `ToolDefinition`s suitable for the Studio CLI's + * `customTools` slot, alongside a policy `ExtensionFactory` that the + * pi runtime can mount via `DefaultResourceLoader.extensionFactories`. + * + * Three public surfaces: + * + * - {@link startDlaBridge}: spawn DLA's MCP server, list tools, return a + * bridge handle whose `tools` are pi-ready and whose `dispose()` tears + * down the child. + * - {@link createDlaPolicyFactory}: build the pi extension factory that + * enforces per-tool permission buckets at the runtime layer. + * - {@link defaultPolicyBuckets}: the canonical bucket assignments for + * DLA's 13 tools, mirroring the RSM-3139 spec. + * + * Implementation details โ€” `bridge.ts`, `agent-tool-adapter.ts`, + * `content-adapter.ts`, `policy.ts` โ€” are private to the package. + */ + +export { + startDlaBridge, + defaultTransportProvider, + type DlaBridge, + type StartDlaBridgeOptions, + type BridgeTransportProvider, +} from './bridge'; + +export { + adaptMcpToolToPi, + DlaPolicyError, + type RemoteMcpTool, + type AdaptMcpToolOptions, +} from './agent-tool-adapter'; + +export { + adaptMcpContent, + adaptMcpContentBlock, + type McpContentBlock, + type McpTextBlock, + type McpImageBlock, + type McpAudioBlock, + type McpResourceBlock, + type McpResourceLinkBlock, +} from './content-adapter'; + +export { + createDlaPolicyFactory, + defaultPolicyBuckets, + shouldBlock, + type DlaPermissionBucket, + type DlaPolicyBuckets, + type DlaPolicyDecision, +} from './policy'; diff --git a/tools/dla/package.json b/tools/dla/package.json new file mode 100644 index 0000000000..d589983724 --- /dev/null +++ b/tools/dla/package.json @@ -0,0 +1,22 @@ +{ + "name": "@studio/dla", + "private": true, + "version": "1.0.0", + "description": "Data Liberation Agent MCP-stdio bridge shared between Studio app and CLI", + "scripts": { + "build": "tsc -p tsconfig.json", + "lint": "eslint .", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1" + }, + "devDependencies": { + "@earendil-works/pi-agent-core": "0.78.0", + "@earendil-works/pi-ai": "0.78.0", + "@earendil-works/pi-coding-agent": "0.78.0", + "data-liberation": "github:Automattic/data-liberation-agent#17219c42b0420267302b138bf402930508006e0e", + "tsx": "^4.19.0", + "typebox": "^1.1.24" + } +} diff --git a/tools/dla/policy.ts b/tools/dla/policy.ts new file mode 100644 index 0000000000..0c251bf273 --- /dev/null +++ b/tools/dla/policy.ts @@ -0,0 +1,193 @@ +/** + * Permission policy for bridged Data Liberation Agent (DLA) tools. + * + * Two layers cooperate to keep destructive DLA actions safe: + * + * 1. An adapter-layer `shouldBlock` check that the per-tool `execute()` + * wrapper consults before forwarding a call to the MCP child. Returning + * `{ block: true }` causes the wrapper to throw, which pi surfaces to + * the model as a tool-call error. + * 2. An optional pi-coding-agent `ExtensionFactory` (created via + * `createDlaPolicyFactory`) that subscribes to the runtime's + * `tool_call` event and applies the same rules at the runtime layer. + * Mounting the factory adds defence in depth: even tools registered + * through paths the adapter does not own (e.g. future direct DLA-MCP + * registrations) still hit the policy. + * + * Bucket assignments mirror the RSM-3139 spec verbatim (see + * `issues/rsm-3143-dla-pi-research/prior-art/rsm-3139-spec.md` step 6 and + * `wave-1-mcp-bridge-feasibility.md` ยง5). The destructive-only escape + * hatch โ€” `liberate_import` with `delegate: true` โ€” returns a manifest + * without writing to the live WordPress site, which is the Studio-driven + * import contract. + */ +import type { + ExtensionAPI, + ExtensionFactory, + ToolCallEventResult, +} from '@earendil-works/pi-coding-agent'; + +/** + * Permission buckets supported by the bridge. The five buckets follow the + * RSM-3139 taxonomy: + * + * - `read-only`: detection/inspection tools that never write outside DLA's + * own state files. Always allowed. + * - `network-read`: tools that hit the network to probe or fingerprint but + * do not write user-visible files. Always allowed. + * - `fs-write`: tools that write files to the extraction output directory + * (WXR, media, fixtures). Allowed by default; can be tightened by + * callers that want explicit confirmation per call. + * - `destructive`: tools that mutate user-visible state outside DLA's + * sandbox (notably `liberate_import` against a live WP site). Blocked + * unless the call carries the `delegate: true` escape hatch. + * - `delegate-only`: never invoke directly; the caller (Studio CLI) is + * expected to honour the returned manifest itself. Reserved for future + * use โ€” no DLA tool sits here today. + */ +export type DlaPermissionBucket = + | 'read-only' + | 'network-read' + | 'fs-write' + | 'destructive' + | 'delegate-only'; + +/** + * Mapping from DLA tool name to permission bucket. Consumers can clone + * this map and override entries when constructing custom buckets. + */ +export type DlaPolicyBuckets = Record< string, DlaPermissionBucket >; + +/** + * Default bucket assignments for the 13 tools exposed by DLA's + * `src/mcp-server.ts` at the pinned SHA. Matches the RSM-3139 spec. + */ +export const defaultPolicyBuckets: DlaPolicyBuckets = { + liberate_detect: 'network-read', + liberate_discover: 'network-read', + liberate_inspect: 'network-read', + liberate_status: 'read-only', + liberate_extract: 'fs-write', + liberate_qa: 'fs-write', + liberate_verify: 'read-only', + liberate_setup: 'read-only', + liberate_import: 'destructive', + liberate_preview: 'fs-write', + liberate_preview_stop: 'read-only', + liberate_map_apis: 'network-read', + liberate_probe: 'network-read', +}; + +/** + * Decision returned by `shouldBlock`. When `block` is true, `reason` is + * the human-readable explanation that surfaces to the model as the + * tool-call error message. + */ +export interface DlaPolicyDecision { + /** Whether to short-circuit the tool call as denied. */ + block: boolean; + /** Reason for the block, surfaced to the model when `block` is true. */ + reason?: string; +} + +/** + * Decide whether a DLA tool invocation should be blocked, based on the + * tool's bucket and any per-tool argument constraints. + * + * Defense-in-depth invariants implemented here: + * + * - Tools in the `destructive` bucket are blocked unless the caller opted + * into DLA's `delegate: true` mode, which guarantees the server + * returns a manifest and performs no writes. + * - Tools absent from `buckets` default to a hard block โ€” if DLA ships a + * new tool we have not reviewed, the bridge refuses to forward calls + * to it until the bucket table is updated. + * + * @param toolName - The bridged tool's MCP name (`liberate_*`). + * @param input - The arguments the model emitted for the call. + * @param buckets - The bucket map to consult. Defaults to + * `defaultPolicyBuckets`. + * @returns A `DlaPolicyDecision` describing whether to block. + * + * @example + * shouldBlock( 'liberate_import', { delegate: false } ) + * // -> { block: true, reason: '...' } + */ +export function shouldBlock( + toolName: string, + input: unknown, + buckets: DlaPolicyBuckets = defaultPolicyBuckets +): DlaPolicyDecision { + const bucket = buckets[ toolName ]; + if ( ! bucket ) { + return { + block: true, + reason: `Studio refused to invoke unknown DLA tool "${ toolName }" โ€” add it to the policy bucket table to allow it.`, + }; + } + + if ( bucket === 'destructive' ) { + const args = ( isRecord( input ) ? input : {} ) as Record< string, unknown >; + if ( args.delegate !== true ) { + return { + block: true, + reason: `Studio enforces "delegate: true" on the destructive DLA tool "${ toolName }". Re-invoke with delegate:true to receive a manifest, then use Studio's own tools to perform the action.`, + }; + } + return { block: false }; + } + + if ( bucket === 'delegate-only' ) { + return { + block: true, + reason: `Studio does not invoke "${ toolName }" directly โ€” handle the result of an upstream delegate call instead.`, + }; + } + + return { block: false }; +} + +/** + * Type guard for plain objects (excludes arrays and null). + */ +function isRecord( value: unknown ): value is Record< string, unknown > { + return typeof value === 'object' && value !== null && ! Array.isArray( value ); +} + +/** + * Build a pi-coding-agent `ExtensionFactory` that hooks the runtime's + * `tool_call` event and enforces `shouldBlock` for any DLA-bridged tool. + * + * This is the policy extension factory used by T4 (the pi-runtime + * wiring): the factory itself only consults the bucket map; the bridge's + * adapter is responsible for wrapping `execute()` and throwing on the + * adapter-layer check. + * + * @param buckets - Bucket map to consult. Defaults to + * `defaultPolicyBuckets`. + * @returns An `ExtensionFactory` suitable for the runtime's + * `DefaultResourceLoader` `extensionFactories` slot. + * + * @example + * const factory = createDlaPolicyFactory( defaultPolicyBuckets ); + * new DefaultResourceLoader( { extensionFactories: [ factory ] } ); + */ +export function createDlaPolicyFactory( + buckets: DlaPolicyBuckets = defaultPolicyBuckets +): ExtensionFactory { + return ( pi: ExtensionAPI ): void => { + pi.on( 'tool_call', ( event ): ToolCallEventResult | undefined => { + // Only enforce policy on DLA-bridged tools; ignore everything + // else so the factory is safe to mount alongside the rest of + // the runtime's customTools. + if ( ! ( event.toolName in buckets ) ) { + return undefined; + } + const decision = shouldBlock( event.toolName, event.input, buckets ); + if ( decision.block ) { + return { block: true, reason: decision.reason }; + } + return undefined; + } ); + }; +} diff --git a/tools/dla/tests/agent-tool-adapter.test.ts b/tools/dla/tests/agent-tool-adapter.test.ts new file mode 100644 index 0000000000..e16b140bb3 --- /dev/null +++ b/tools/dla/tests/agent-tool-adapter.test.ts @@ -0,0 +1,193 @@ +/** + * Tests for `adaptMcpToolToPi` โ€” the MCP tool descriptor โ†’ pi + * `ToolDefinition` shim. + * + * Covers schema-cast shape preservation, abort-signal forwarding, error + * propagation when the remote returns `isError: true`, content adaptation, + * and the policy-block path. + */ +import { describe, expect, it, vi } from 'vitest'; +import { adaptMcpToolToPi, DlaPolicyError, type RemoteMcpTool } from '../agent-tool-adapter'; + +const FAKE_REMOTE_TOOL: RemoteMcpTool = { + name: 'liberate_inspect', + description: 'Inspect a source site', + inputSchema: { + type: 'object', + properties: { url: { type: 'string' } }, + required: [ 'url' ], + }, +}; + +function makeClient( + overrides: { + callTool?: ReturnType< typeof vi.fn >; + } = {} +) { + return { + callTool: + overrides.callTool ?? + vi.fn().mockResolvedValue( { + content: [ { type: 'text', text: 'ok' } ], + } ), + }; +} + +// Minimal ExtensionContext stub โ€” `execute` does not read from ctx in +// the adapter, so an empty object is enough. +const fakeCtx = {} as unknown as Parameters< + ReturnType< typeof adaptMcpToolToPi >[ 'execute' ] +>[ 4 ]; + +describe( 'adaptMcpToolToPi', () => { + it( 'preserves name, label, description, and parameters', () => { + const client = makeClient(); + const tool = adaptMcpToolToPi( FAKE_REMOTE_TOOL, client as never ); + + expect( tool.name ).toBe( 'liberate_inspect' ); + expect( tool.label ).toBe( 'liberate_inspect' ); + expect( tool.description ).toBe( 'Inspect a source site' ); + // Reference equality โ€” we want to know the JSON Schema is passed + // through unchanged so pi-ai can compile it directly. + expect( tool.parameters ).toBe( FAKE_REMOTE_TOOL.inputSchema ); + } ); + + it( 'forwards the abort signal to client.callTool', async () => { + const client = makeClient(); + const tool = adaptMcpToolToPi( FAKE_REMOTE_TOOL, client as never ); + + const controller = new AbortController(); + await tool.execute( + 'tc-1', + { url: 'https://example.com' }, + controller.signal, + undefined, + fakeCtx + ); + + expect( client.callTool ).toHaveBeenCalledWith( + { + name: 'liberate_inspect', + arguments: { url: 'https://example.com' }, + }, + undefined, + { signal: controller.signal } + ); + } ); + + it( 'adapts text content blocks into pi content', async () => { + const client = makeClient( { + callTool: vi.fn().mockResolvedValue( { + content: [ { type: 'text', text: '{"platform":"wix"}' } ], + structuredContent: { platform: 'wix' }, + } ), + } ); + const tool = adaptMcpToolToPi( FAKE_REMOTE_TOOL, client as never ); + + const result = await tool.execute( + 'tc-2', + { url: 'https://example.com' }, + undefined, + undefined, + fakeCtx + ); + + expect( result.content ).toEqual( [ { type: 'text', text: '{"platform":"wix"}' } ] ); + expect( result.details ).toEqual( { platform: 'wix' } ); + } ); + + it( 'throws when the remote returns isError: true (text payload)', async () => { + const client = makeClient( { + callTool: vi.fn().mockResolvedValue( { + isError: true, + content: [ { type: 'text', text: 'remote went boom' } ], + } ), + } ); + const tool = adaptMcpToolToPi( FAKE_REMOTE_TOOL, client as never ); + + await expect( + tool.execute( 'tc-3', { url: 'https://example.com' }, undefined, undefined, fakeCtx ) + ).rejects.toThrow( 'remote went boom' ); + } ); + + it( 'throws a fallback message when isError: true has no text content', async () => { + const client = makeClient( { + callTool: vi.fn().mockResolvedValue( { + isError: true, + content: [], + } ), + } ); + const tool = adaptMcpToolToPi( FAKE_REMOTE_TOOL, client as never ); + + await expect( + tool.execute( 'tc-4', { url: 'https://example.com' }, undefined, undefined, fakeCtx ) + ).rejects.toThrow( /reported an error without a text payload/ ); + } ); + + it( 'throws a DlaPolicyError when policy blocks the call', async () => { + const importTool: RemoteMcpTool = { + name: 'liberate_import', + description: 'Import WXR', + inputSchema: { type: 'object' }, + }; + const client = makeClient(); + const tool = adaptMcpToolToPi( importTool, client as never ); + + await expect( + tool.execute( 'tc-5', { delegate: false }, undefined, undefined, fakeCtx ) + ).rejects.toBeInstanceOf( DlaPolicyError ); + + expect( client.callTool ).not.toHaveBeenCalled(); + } ); + + it( 'allows the call when policy returns block: false', async () => { + const importTool: RemoteMcpTool = { + name: 'liberate_import', + description: 'Import WXR', + inputSchema: { type: 'object' }, + }; + const client = makeClient(); + const tool = adaptMcpToolToPi( importTool, client as never ); + + await tool.execute( 'tc-6', { delegate: true }, undefined, undefined, fakeCtx ); + + expect( client.callTool ).toHaveBeenCalledWith( + { name: 'liberate_import', arguments: { delegate: true } }, + undefined, + { signal: undefined } + ); + } ); + + it( 'consults the buckets getter on every call (supports policy swaps)', async () => { + const client = makeClient(); + const getBuckets = vi + .fn() + .mockReturnValueOnce( { liberate_inspect: 'read-only' } ) + .mockReturnValueOnce( { liberate_inspect: 'destructive' } ); + const tool = adaptMcpToolToPi( FAKE_REMOTE_TOOL, client as never, { + getBuckets, + } ); + + // First call: bucket says read-only โ€” should succeed. + await tool.execute( 'tc-7', { url: 'https://example.com' }, undefined, undefined, fakeCtx ); + expect( client.callTool ).toHaveBeenCalledTimes( 1 ); + + // Second call: bucket says destructive (and no delegate) โ€” should + // be blocked. + await expect( + tool.execute( 'tc-8', { url: 'https://example.com' }, undefined, undefined, fakeCtx ) + ).rejects.toBeInstanceOf( DlaPolicyError ); + expect( client.callTool ).toHaveBeenCalledTimes( 1 ); + + expect( getBuckets ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'defaults a missing description to an empty string', () => { + const noDesc: RemoteMcpTool = { + name: 'tool', + inputSchema: { type: 'object' }, + }; + const tool = adaptMcpToolToPi( noDesc, makeClient() as never ); + expect( tool.description ).toBe( '' ); + } ); +} ); diff --git a/tools/dla/tests/bridge.test.ts b/tools/dla/tests/bridge.test.ts new file mode 100644 index 0000000000..63cf904be0 --- /dev/null +++ b/tools/dla/tests/bridge.test.ts @@ -0,0 +1,355 @@ +/** + * Tests for the bridge lifecycle (`startDlaBridge` / `dispose`). + * + * The bridge spawns a Node + tsx child process in production, which is + * far too heavy for unit tests. We swap in a `BridgeTransportProvider` + * stub that returns a mocked MCP client and a fake pid, then assert on + * the bridge's behaviour around it. + */ +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { defaultTransportProvider, startDlaBridge, type BridgeTransportProvider } from '../bridge'; + +interface MockClient { + callTool: ReturnType< typeof vi.fn >; + listTools: ReturnType< typeof vi.fn >; + close: ReturnType< typeof vi.fn >; +} + +function makeMockClient( overrides: Partial< MockClient > = {} ): MockClient { + return { + callTool: overrides.callTool ?? vi.fn(), + listTools: + overrides.listTools ?? + vi.fn().mockResolvedValue( { + tools: [ + { + name: 'liberate_detect', + description: 'Detect platform', + inputSchema: { type: 'object' }, + }, + { + name: 'liberate_inspect', + description: 'Inspect site', + inputSchema: { type: 'object' }, + }, + { + name: 'liberate_status', + description: 'Read status', + inputSchema: { type: 'object' }, + }, + ], + } ), + close: overrides.close ?? vi.fn().mockResolvedValue( undefined ), + }; +} + +function makeTransport( client: MockClient, pid: number | null = 12345 ): BridgeTransportProvider { + return { + connect: vi.fn().mockResolvedValue( { client, pid } ), + }; +} + +describe( 'startDlaBridge', () => { + let warnSpy: ReturnType< typeof vi.spyOn >; + beforeEach( () => { + warnSpy = vi.spyOn( console, 'warn' ).mockImplementation( () => {} ); + } ); + afterEach( () => { + warnSpy.mockRestore(); + vi.useRealTimers(); + } ); + + it( 'connects, lists tools, and returns adapted tools', async () => { + const client = makeMockClient(); + const transport = makeTransport( client ); + + const bridge = await startDlaBridge( { transport } ); + + expect( transport.connect ).toHaveBeenCalledOnce(); + expect( client.listTools ).toHaveBeenCalledOnce(); + expect( bridge.tools ).toHaveLength( 3 ); + expect( bridge.tools.map( ( t ) => t.name ) ).toEqual( [ + 'liberate_detect', + 'liberate_inspect', + 'liberate_status', + ] ); + expect( bridge.degraded ).toBe( false ); + } ); + + it( 'forwards wpcomToken into the child env as STUDIO_WPCOM_TOKEN', async () => { + const client = makeMockClient(); + const connect = vi.fn().mockResolvedValue( { client, pid: 1 } ); + const transport: BridgeTransportProvider = { connect }; + + await startDlaBridge( { transport, wpcomToken: 'secret-token' } ); + + expect( connect ).toHaveBeenCalledWith( + expect.objectContaining( { STUDIO_WPCOM_TOKEN: 'secret-token' } ) + ); + } ); + + it( 'passes through LIBERATION_TOKEN and SHOPIFY_ADMIN_TOKEN env vars', async () => { + const previousLiberation = process.env.LIBERATION_TOKEN; + const previousShopify = process.env.SHOPIFY_ADMIN_TOKEN; + process.env.LIBERATION_TOKEN = 'lib-token'; + process.env.SHOPIFY_ADMIN_TOKEN = 'shop-token'; + + try { + const client = makeMockClient(); + const connect = vi.fn().mockResolvedValue( { client, pid: 1 } ); + const transport: BridgeTransportProvider = { connect }; + + await startDlaBridge( { transport } ); + + expect( connect ).toHaveBeenCalledWith( + expect.objectContaining( { + LIBERATION_TOKEN: 'lib-token', + SHOPIFY_ADMIN_TOKEN: 'shop-token', + } ) + ); + } finally { + if ( previousLiberation === undefined ) { + delete process.env.LIBERATION_TOKEN; + } else { + process.env.LIBERATION_TOKEN = previousLiberation; + } + if ( previousShopify === undefined ) { + delete process.env.SHOPIFY_ADMIN_TOKEN; + } else { + process.env.SHOPIFY_ADMIN_TOKEN = previousShopify; + } + } + } ); + + it( 'returns empty tools and warns when transport.connect rejects', async () => { + const transport: BridgeTransportProvider = { + connect: vi.fn().mockRejectedValue( new Error( 'spawn failed' ) ), + }; + + const bridge = await startDlaBridge( { transport } ); + + expect( bridge.tools ).toEqual( [] ); + expect( bridge.degraded ).toBe( true ); + expect( bridge.degradationReason ).toMatch( /spawn failed/ ); + expect( warnSpy ).toHaveBeenCalledWith( + expect.stringContaining( 'failed to spawn DLA MCP server' ) + ); + } ); + + it( 'returns empty tools and warns when listTools rejects (timeout path)', async () => { + const client = makeMockClient( { + listTools: vi.fn().mockRejectedValue( new Error( 'aborted' ) ), + } ); + const transport = makeTransport( client ); + + const bridge = await startDlaBridge( { transport } ); + + expect( bridge.tools ).toEqual( [] ); + expect( bridge.degraded ).toBe( true ); + expect( bridge.degradationReason ).toMatch( /aborted/ ); + expect( warnSpy ).toHaveBeenCalledWith( expect.stringContaining( 'listTools failed' ) ); + } ); + + it( 'uses the configured listToolsTimeoutMs', async () => { + const client = makeMockClient( { + listTools: vi.fn().mockImplementation( ( _, opts: { signal?: AbortSignal } ) => { + return new Promise( ( resolve, reject ) => { + if ( opts.signal ) { + opts.signal.addEventListener( 'abort', () => reject( new Error( 'timed out' ) ) ); + } + // Never resolve; rely on signal abort. + } ); + } ), + } ); + const transport = makeTransport( client ); + + const bridge = await startDlaBridge( { + transport, + listToolsTimeoutMs: 5, + } ); + expect( bridge.tools ).toEqual( [] ); + expect( bridge.degraded ).toBe( true ); + } ); + + it( 'dispose() calls client.close exactly once', async () => { + const client = makeMockClient(); + const transport = makeTransport( client ); + + const bridge = await startDlaBridge( { transport } ); + await bridge.dispose(); + await bridge.dispose(); + + expect( client.close ).toHaveBeenCalledOnce(); + } ); + + it( 'dispose() schedules SIGKILL after the grace period', async () => { + vi.useFakeTimers(); + const client = makeMockClient(); + const transport = makeTransport( client, 999_999 ); + const killSpy = vi.spyOn( process, 'kill' ).mockImplementation( () => true ); + + const bridge = await startDlaBridge( { transport } ); + await bridge.dispose(); + + // Grace period not elapsed yet โ€” kill should not have fired. + expect( killSpy ).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync( 2_001 ); + expect( killSpy ).toHaveBeenCalledWith( 999_999, 'SIGKILL' ); + + killSpy.mockRestore(); + } ); + + it( 'dispose() does not schedule SIGKILL when pid is null', async () => { + vi.useFakeTimers(); + const client = makeMockClient(); + const transport = makeTransport( client, null ); + const killSpy = vi.spyOn( process, 'kill' ).mockImplementation( () => true ); + + const bridge = await startDlaBridge( { transport } ); + await bridge.dispose(); + await vi.advanceTimersByTimeAsync( 5_000 ); + + expect( killSpy ).not.toHaveBeenCalled(); + killSpy.mockRestore(); + } ); + + it( 'dispose() tolerates a close() rejection and still schedules SIGKILL', async () => { + vi.useFakeTimers(); + const client = makeMockClient( { + close: vi.fn().mockRejectedValue( new Error( 'already closed' ) ), + } ); + const transport = makeTransport( client, 12 ); + const killSpy = vi.spyOn( process, 'kill' ).mockImplementation( () => true ); + + const bridge = await startDlaBridge( { transport } ); + await bridge.dispose(); + await vi.advanceTimersByTimeAsync( 2_001 ); + + expect( killSpy ).toHaveBeenCalledWith( 12, 'SIGKILL' ); + killSpy.mockRestore(); + } ); +} ); + +describe( 'defaultTransportProvider โ€” real require.resolve paths', () => { + /** + * Regression test for `ERR_PACKAGE_PATH_NOT_EXPORTED`: the bridge previously + * resolved `tsx/dist/cli.mjs`, which is not listed in `tsx`'s package + * `exports` map and throws at runtime. We assert the canonical exports + * key (`tsx/cli`) and DLA's MCP server entry both resolve without + * throwing. Resolution is exercised through the same `createRequire` + * anchor the bridge uses (`tools/dla/bridge.ts`) so test and production + * walk the same `node_modules` chain. + */ + it( 'resolves tsx/cli and data-liberation/src/mcp-server.ts via the bridge module require', () => { + // The vitest environment is jsdom, so `import.meta.url` is a + // `http://localhost:...` URL that `createRequire` rejects. Anchor + // to an absolute filesystem path instead โ€” `process.cwd()` is the + // repo root when vitest runs from the root config. + const bridgePath = path.resolve( process.cwd(), 'tools/dla/bridge.ts' ); + const bridgeRequire = createRequire( bridgePath ); + expect( () => bridgeRequire.resolve( 'tsx/cli' ) ).not.toThrow(); + expect( () => bridgeRequire.resolve( 'data-liberation/src/mcp-server.ts' ) ).not.toThrow(); + } ); + + /** + * The deep `tsx/dist/cli.mjs` subpath must remain unreachable so the + * bridge can never silently regress back to the pre-fix import. If + * upstream `tsx` ever re-exposes the subpath, this assertion will + * fail and prompt us to reconsider which spelling to prefer. + */ + it( 'tsx/dist/cli.mjs subpath is not exported by tsx (regression guard)', () => { + // The vitest environment is jsdom, so `import.meta.url` is a + // `http://localhost:...` URL that `createRequire` rejects. Anchor + // to an absolute filesystem path instead โ€” `process.cwd()` is the + // repo root when vitest runs from the root config. + const bridgePath = path.resolve( process.cwd(), 'tools/dla/bridge.ts' ); + const bridgeRequire = createRequire( bridgePath ); + expect( () => bridgeRequire.resolve( 'tsx/dist/cli.mjs' ) ).toThrow( + /not defined by "exports"/ + ); + } ); + + /** + * End-to-end check that the production transport provider survives + * the real `require.resolve` calls inside its `connect()` and the + * subsequent `StdioClientTransport` construction. We don't drive a + * real DLA child to completion (that needs network/secrets); we + * connect, then immediately tear down. A `Promise.race` against a + * short timer ensures the test fails fast if the bridge enters the + * degraded path because of resolution errors. + */ + it( 'connect() does not throw a path-resolution error before transport handshake', async () => { + // `connect()` will fork a real Node + tsx + DLA process. We don't + // want to leave one running on a green test, so we wrap it in a + // short race: if the connect promise resolves, we close the + // client immediately; if it rejects, we surface the error so a + // resolution regression is loud. + const connectPromise = defaultTransportProvider.connect( { + PATH: process.env.PATH ?? '', + } ); + + let connected: Awaited< ReturnType< typeof defaultTransportProvider.connect > > | null = null; + try { + connected = await connectPromise; + } catch ( error ) { + // The only failure mode we actively guard against is + // `ERR_PACKAGE_PATH_NOT_EXPORTED` from `require.resolve`. + // Any other failure (e.g. DLA's mcp-server failing to boot + // because of a missing secret) is acceptable โ€” the bridge's + // degraded-path tests above cover that. + const code = ( error as NodeJS.ErrnoException ).code ?? ''; + const message = error instanceof Error ? error.message : String( error ); + expect( code ).not.toBe( 'ERR_PACKAGE_PATH_NOT_EXPORTED' ); + expect( message ).not.toMatch( /not defined by "exports"/ ); + return; + } + + try { + expect( connected.pid ).not.toBeNull(); + } finally { + try { + await connected.client.close(); + } catch { + // Closing a half-connected client can throw; we don't care. + } + if ( connected.pid !== null && connected.pid > 0 ) { + try { + process.kill( connected.pid, 'SIGKILL' ); + } catch { + // Already exited. + } + } + } + }, 15_000 ); +} ); + +describe( 'startDlaBridge โ€” integration with adapter', () => { + it( 'adapted tools forward to the same MCP client', async () => { + const client = makeMockClient( { + callTool: vi.fn().mockResolvedValue( { + content: [ { type: 'text', text: 'detected' } ], + } ), + } ); + const transport = makeTransport( client ); + + const bridge = await startDlaBridge( { transport } ); + const detect = bridge.tools.find( ( t ) => t.name === 'liberate_detect' ); + expect( detect ).toBeDefined(); + + const result = await detect!.execute( + 'tc-1', + { url: 'https://example.com' }, + undefined, + undefined, + {} as never + ); + expect( client.callTool ).toHaveBeenCalledWith( + { name: 'liberate_detect', arguments: { url: 'https://example.com' } }, + undefined, + { signal: undefined } + ); + expect( result.content ).toEqual( [ { type: 'text', text: 'detected' } ] ); + } ); +} ); diff --git a/tools/dla/tests/content-adapter.test.ts b/tools/dla/tests/content-adapter.test.ts new file mode 100644 index 0000000000..9ceeba6d4a --- /dev/null +++ b/tools/dla/tests/content-adapter.test.ts @@ -0,0 +1,144 @@ +/** + * Tests for the MCP-to-pi content adapter. + * + * Covers the five MCP content variants โ€” `text`, `image`, `audio`, + * `resource` (text + binary), `resource_link` โ€” and asserts the + * mapping rules described in `content-adapter.ts`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { adaptMcpContent, adaptMcpContentBlock, type McpContentBlock } from '../content-adapter'; + +describe( 'adaptMcpContentBlock', () => { + let warnSpy: ReturnType< typeof vi.spyOn >; + + beforeEach( () => { + warnSpy = vi.spyOn( console, 'warn' ).mockImplementation( () => {} ); + } ); + + afterEach( () => { + warnSpy.mockRestore(); + } ); + + it( 'passes through text blocks unchanged', () => { + expect( adaptMcpContentBlock( { type: 'text', text: 'hello' } ) ).toEqual( [ + { type: 'text', text: 'hello' }, + ] ); + } ); + + it( 'passes through image blocks unchanged', () => { + expect( + adaptMcpContentBlock( { + type: 'image', + data: 'base64==', + mimeType: 'image/png', + } ) + ).toEqual( [ { type: 'image', data: 'base64==', mimeType: 'image/png' } ] ); + } ); + + it( 'flattens inline text resource blocks into a labelled text block', () => { + const result = adaptMcpContentBlock( { + type: 'resource', + resource: { + uri: 'file:///tmp/example.json', + text: '{"key":"value"}', + mimeType: 'application/json', + }, + } ); + expect( result ).toEqual( [ + { + type: 'text', + text: '[resource file:///tmp/example.json]\n{"key":"value"}', + }, + ] ); + } ); + + it( 'stubs out binary resource blocks with a pointer text block', () => { + const result = adaptMcpContentBlock( { + type: 'resource', + resource: { + uri: 'file:///tmp/example.bin', + blob: 'BASE64', + mimeType: 'application/octet-stream', + }, + } ); + expect( result ).toEqual( [ + { + type: 'text', + text: '[resource file:///tmp/example.bin (binary, application/octet-stream)]', + }, + ] ); + } ); + + it( 'serialises resource_link blocks to a text block with description', () => { + const result = adaptMcpContentBlock( { + type: 'resource_link', + uri: 'https://example.com/post/1', + description: 'A linked post', + } ); + expect( result ).toEqual( [ + { + type: 'text', + text: '[resource_link https://example.com/post/1 โ€” A linked post]', + }, + ] ); + } ); + + it( 'omits the description suffix when missing', () => { + const result = adaptMcpContentBlock( { + type: 'resource_link', + uri: 'https://example.com/post/1', + } ); + expect( result ).toEqual( [ + { type: 'text', text: '[resource_link https://example.com/post/1]' }, + ] ); + } ); + + it( 'drops audio blocks with a warning', () => { + const result = adaptMcpContentBlock( { + type: 'audio', + data: 'base64', + mimeType: 'audio/wav', + } ); + expect( result ).toEqual( [] ); + expect( warnSpy ).toHaveBeenCalledWith( + expect.stringContaining( 'unsupported MCP audio content block' ) + ); + } ); + + it( 'drops unknown block types with a warning', () => { + const result = adaptMcpContentBlock( { + type: 'made-up', + payload: 'something', + } as McpContentBlock ); + expect( result ).toEqual( [] ); + expect( warnSpy ).toHaveBeenCalledWith( + expect.stringContaining( 'unknown MCP content block of type "made-up"' ) + ); + } ); +} ); + +describe( 'adaptMcpContent', () => { + let warnSpy: ReturnType< typeof vi.spyOn >; + beforeEach( () => { + warnSpy = vi.spyOn( console, 'warn' ).mockImplementation( () => {} ); + } ); + afterEach( () => { + warnSpy.mockRestore(); + } ); + + it( 'flattens across blocks and drops unsupported types', () => { + const result = adaptMcpContent( [ + { type: 'text', text: 'a' }, + { type: 'audio', data: '', mimeType: 'audio/wav' }, + { + type: 'image', + data: 'b', + mimeType: 'image/jpeg', + }, + ] ); + expect( result ).toEqual( [ + { type: 'text', text: 'a' }, + { type: 'image', data: 'b', mimeType: 'image/jpeg' }, + ] ); + } ); +} ); diff --git a/tools/dla/tests/policy.test.ts b/tools/dla/tests/policy.test.ts new file mode 100644 index 0000000000..d06afa0d7b --- /dev/null +++ b/tools/dla/tests/policy.test.ts @@ -0,0 +1,181 @@ +/** + * Tests for the per-tool permission policy buckets. + * + * Covers each of the 13 DLA tools at the pinned SHA โ€” and the + * `delegate: true` escape hatch on `liberate_import`, plus the + * unknown-tool default-deny rule. + */ +import { describe, expect, it, vi } from 'vitest'; +import { createDlaPolicyFactory, defaultPolicyBuckets, shouldBlock } from '../policy'; + +const DLA_TOOLS = [ + 'liberate_detect', + 'liberate_discover', + 'liberate_inspect', + 'liberate_status', + 'liberate_extract', + 'liberate_qa', + 'liberate_verify', + 'liberate_setup', + 'liberate_import', + 'liberate_preview', + 'liberate_preview_stop', + 'liberate_map_apis', + 'liberate_probe', +]; + +describe( 'shouldBlock โ€” default buckets', () => { + it( 'allows every read-only / network-read / fs-write tool with empty args', () => { + const allowed = [ + 'liberate_detect', + 'liberate_discover', + 'liberate_inspect', + 'liberate_status', + 'liberate_extract', + 'liberate_qa', + 'liberate_verify', + 'liberate_setup', + 'liberate_preview', + 'liberate_preview_stop', + 'liberate_map_apis', + 'liberate_probe', + ]; + for ( const tool of allowed ) { + const decision = shouldBlock( tool, {} ); + expect( decision.block, `should not block ${ tool }` ).toBe( false ); + } + } ); + + it( 'blocks destructive liberate_import without delegate: true', () => { + const decision = shouldBlock( 'liberate_import', { delegate: false } ); + expect( decision.block ).toBe( true ); + expect( decision.reason ).toMatch( /delegate.*true/i ); + } ); + + it( 'blocks destructive liberate_import with missing delegate flag', () => { + const decision = shouldBlock( 'liberate_import', {} ); + expect( decision.block ).toBe( true ); + } ); + + it( 'allows destructive liberate_import with delegate: true', () => { + const decision = shouldBlock( 'liberate_import', { delegate: true } ); + expect( decision.block ).toBe( false ); + } ); + + it( 'tolerates non-record input on destructive tools (defaults to deny)', () => { + expect( shouldBlock( 'liberate_import', null ).block ).toBe( true ); + expect( shouldBlock( 'liberate_import', 'string-input' ).block ).toBe( true ); + expect( shouldBlock( 'liberate_import', undefined ).block ).toBe( true ); + } ); + + it( 'blocks unknown tools defensively', () => { + const decision = shouldBlock( 'liberate_made_up', {} ); + expect( decision.block ).toBe( true ); + expect( decision.reason ).toMatch( /unknown/i ); + } ); +} ); + +describe( 'defaultPolicyBuckets', () => { + it( 'covers all 13 DLA tools at the pinned SHA', () => { + for ( const tool of DLA_TOOLS ) { + expect( defaultPolicyBuckets[ tool ], `bucket should be set for ${ tool }` ).toBeDefined(); + } + } ); + + it( 'classifies the single destructive tool correctly', () => { + expect( defaultPolicyBuckets.liberate_import ).toBe( 'destructive' ); + // no other tool should be destructive + const destructiveTools = Object.entries( defaultPolicyBuckets ) + .filter( ( [ , bucket ] ) => bucket === 'destructive' ) + .map( ( [ name ] ) => name ); + expect( destructiveTools ).toEqual( [ 'liberate_import' ] ); + } ); +} ); + +describe( 'shouldBlock โ€” custom buckets', () => { + it( 'honours an override that elevates a tool to destructive', () => { + const decision = shouldBlock( 'liberate_extract', {}, { liberate_extract: 'destructive' } ); + expect( decision.block ).toBe( true ); + } ); + + it( 'honours an override that downgrades liberate_import to read-only', () => { + const decision = shouldBlock( 'liberate_import', {}, { liberate_import: 'read-only' } ); + expect( decision.block ).toBe( false ); + } ); + + it( 'blocks delegate-only tools regardless of args', () => { + const decision = shouldBlock( + 'something_delegate_only', + { delegate: true }, + { something_delegate_only: 'delegate-only' } + ); + expect( decision.block ).toBe( true ); + expect( decision.reason ).toMatch( /not invoke/i ); + } ); +} ); + +describe( 'createDlaPolicyFactory', () => { + it( 'registers a tool_call handler that defers to shouldBlock', async () => { + const handlers = new Map< string, ( event: unknown ) => unknown | Promise< unknown > >(); + const pi = { + on: vi.fn( + ( + eventName: string, + handler: typeof handlers extends Map< string, infer V > ? V : never + ) => { + handlers.set( eventName, handler ); + } + ), + }; + + const factory = createDlaPolicyFactory(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural mock for the test + await factory( pi as any ); + + expect( pi.on ).toHaveBeenCalledWith( 'tool_call', expect.any( Function ) ); + + const handler = handlers.get( 'tool_call' )!; + const blocked = await handler( { + type: 'tool_call', + toolName: 'liberate_import', + toolCallId: '1', + input: { delegate: false }, + } ); + expect( blocked ).toMatchObject( { block: true } ); + + const allowed = await handler( { + type: 'tool_call', + toolName: 'liberate_inspect', + toolCallId: '2', + input: { url: 'https://example.com' }, + } ); + expect( allowed ).toBeUndefined(); + } ); + + it( 'ignores tool_call events for non-DLA tools', async () => { + const handlers = new Map< string, ( event: unknown ) => unknown | Promise< unknown > >(); + const pi = { + on: vi.fn( + ( + eventName: string, + handler: typeof handlers extends Map< string, infer V > ? V : never + ) => { + handlers.set( eventName, handler ); + } + ), + }; + + const factory = createDlaPolicyFactory(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural mock for the test + await factory( pi as any ); + + const handler = handlers.get( 'tool_call' )!; + const result = await handler( { + type: 'tool_call', + toolName: 'bash', + toolCallId: '3', + input: { command: 'echo hi' }, + } ); + expect( result ).toBeUndefined(); + } ); +} ); diff --git a/tools/dla/tsconfig.json b/tools/dla/tsconfig.json new file mode 100644 index 0000000000..457f7ddbbc --- /dev/null +++ b/tools/dla/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "declaration": true, + "emitDeclarationOnly": true + }, + "include": [ "**/*" ], + "exclude": [ + "**/__mocks__/**/*", + "**/node_modules/**/*", + "**/dist/**/*", + "**/out/**/*", + "vitest.config.ts" + ] +} diff --git a/tools/dla/vitest.config.ts b/tools/dla/vitest.config.ts new file mode 100644 index 0000000000..cea08d3821 --- /dev/null +++ b/tools/dla/vitest.config.ts @@ -0,0 +1,18 @@ +import path from 'path'; +import { defineProject, mergeConfig } from 'vitest/config'; +import sharedConfig from '../../vitest.shared'; + +export default mergeConfig( + sharedConfig, + defineProject( { + test: { + name: 'dla', + include: [ '**/*.{test,spec}.{ts,tsx}' ], + }, + resolve: { + alias: { + '@studio/dla': path.resolve( __dirname, '.' ), + }, + }, + } ) +); diff --git a/vitest.config.ts b/vitest.config.ts index 529fe09adb..ef2c5c424e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig( { './apps/studio/vitest.config.ts', './apps/ui/vitest.config.ts', './tools/common/vitest.config.ts', + './tools/dla/vitest.config.ts', './tools/eslint-plugin-studio/vitest.config.ts', ], },