diff --git a/apps/backend/src/config/installed-apps.ts b/apps/backend/src/config/installed-apps.ts index c52ae78b8..62273b964 100644 --- a/apps/backend/src/config/installed-apps.ts +++ b/apps/backend/src/config/installed-apps.ts @@ -25,22 +25,24 @@ function uniqueExisting(paths: Array): string[] { return existing; } -function resolveDevManifest(): string | null { +function resolveDevManifest(packagePath: string): string | null { try { const root = process.env.DEUS_REPO_ROOT ?? resolveRepoRoot(process.cwd()); - return resolve(root, "packages/device-use/agentic-app.json"); + return resolve(root, packagePath); } catch { return null; } } -function resolvePackagedManifest(): string | null { +function resolvePackagedManifest(relPath: string): string | null { const resourcesPath = (process as { resourcesPath?: string }).resourcesPath; if (!resourcesPath) return null; - return resolve(resourcesPath, "agentic-apps/device-use/agentic-app.json"); + return resolve(resourcesPath, relPath); } export const INSTALLED_APP_MANIFESTS: readonly string[] = uniqueExisting([ - resolvePackagedManifest(), - resolveDevManifest(), + resolvePackagedManifest("agentic-apps/device-use/agentic-app.json"), + resolveDevManifest("packages/device-use/agentic-app.json"), + resolvePackagedManifest("agentic-apps/pencil/agentic-app.json"), + resolveDevManifest("packages/pencil/agentic-app.json"), ]); diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index f3d4ef01b..056405358 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -12,7 +12,7 @@ import { reconcile as reconcileSimulators, destroyAll as destroyAllSimulators, } from "./services/simulator-context"; -import { stopAllApps, sweepOrphanApps } from "./services/aap"; +import { prefetchInstalledAppAssets, stopAllApps, sweepOrphanApps } from "./services/aap"; import { setApp } from "./services/route-delegate"; import { invalidate } from "./services/query-engine"; import { @@ -46,6 +46,7 @@ const db = initDatabase(); // remembers their pids across restarts; we kill any still alive before // accepting new commands so a re-launch allocates a fresh instance cleanly. sweepOrphanApps(); +prefetchInstalledAppAssets(); // Backfill git_origin_url for repos added before we tracked origin URLs. // Runs once at startup (fire-and-forget) so WS subscribers get the data diff --git a/apps/backend/src/services/aap/apps.service.ts b/apps/backend/src/services/aap/apps.service.ts index a0fd32df5..e652985a9 100644 --- a/apps/backend/src/services/aap/apps.service.ts +++ b/apps/backend/src/services/aap/apps.service.ts @@ -19,10 +19,16 @@ // 2. invalidate(["apps","running_apps"]) for WS subscribers // 3. apps:launched q:event on successful ready (Phase 4 consumer) -import { spawnSync, type ChildProcess } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { isAbsolute, resolve as resolvePath } from "node:path"; import type { Manifest } from "@shared/aap/manifest"; -import { substituteTemplate, type TemplateVars } from "@shared/aap/template"; +import { + substituteArgs, + substituteEnv, + substituteTemplate, + type TemplateVars, +} from "@shared/aap/template"; import type { InstalledApp, LaunchAppArgs, @@ -37,7 +43,14 @@ import { invalidate } from "../query-engine"; import { broadcast } from "../ws.service"; import { allocateFreePort } from "./port-allocator"; -import { isProcessAlive, killByPid, spawnApp, stopChild, waitForReady } from "./lifecycle"; +import { + isProcessAlive, + killByPid, + resolveCommand, + spawnApp, + stopChild, + waitForReady, +} from "./lifecycle"; import { registerMcpForRunningApp, unregisterMcpForRunningApp } from "./mcp-bridge"; import { clearPids, readPids, recordPid } from "./pid-journal"; import { @@ -92,6 +105,10 @@ const runningApps = new Map(); * in-flight promise — same result, one spawn. Cleared in a `finally`. */ const launching = new Map>(); +/** App ids whose optional boot prefetch has already been started this backend + * process. Prefetch is best-effort and idempotent at the app layer. */ +const prefetchStarted = new Set(); + // ---------------------------------------------------------------------------- // public read API // ---------------------------------------------------------------------------- @@ -124,6 +141,90 @@ export function getRunningApps(workspaceId?: string | null): RunningApp[] { return matches.map(toView); } +// ---------------------------------------------------------------------------- +// background prefetch +// ---------------------------------------------------------------------------- + +/** Start optional app-defined warmup commands without blocking backend boot. + * Used by heavy embedded apps (Pencil) to populate caches before the user opens + * the panel. Failures are logged and never affect app availability. */ +export function prefetchInstalledAppAssets(): void { + for (const installed of loadInstalledApps()) { + const { manifest } = installed; + if (!manifest.prefetch || prefetchStarted.has(manifest.id)) continue; + if (!isPlatformCompatible(manifest)) continue; + + prefetchStarted.add(manifest.id); + void runPrefetch(installed); + } +} + +async function runPrefetch(installed: InstalledAppEntry): Promise { + const { manifest, packageRoot } = installed; + const prefetch = manifest.prefetch; + if (!prefetch) return; + + const vars: TemplateVars = {}; + const rawCwd = prefetch.cwd ? substituteTemplate(prefetch.cwd, vars) : packageRoot; + const cwd = isAbsolute(rawCwd) ? rawCwd : resolvePath(packageRoot, rawCwd); + const command = resolveCommand(prefetch.command, packageRoot); + const args = substituteArgs(prefetch.args, vars); + const env: NodeJS.ProcessEnv = { + ...process.env, + ...substituteEnv(prefetch.env, vars), + DEUS_APP_ID: manifest.id, + DEUS_PREFETCH: "1", + }; + + await new Promise((resolve) => { + let child: ChildProcess; + try { + child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); + } catch (err) { + console.warn(`[AAP] Prefetch failed to spawn: ${manifest.id}`, { + error: getErrorMessage(err), + }); + resolve(); + return; + } + const stdout: string[] = []; + const stderr: string[] = []; + let finished = false; + const finish = (): void => { + if (finished) return; + finished = true; + resolve(); + }; + const push = (ring: string[], chunk: Buffer): void => { + ring.push(chunk.toString("utf8")); + while (ring.join("").length > 2_048) ring.shift(); + }; + + child.stdout?.on("data", (chunk: Buffer) => push(stdout, chunk)); + child.stderr?.on("data", (chunk: Buffer) => push(stderr, chunk)); + child.once("error", (err) => { + console.warn(`[AAP] Prefetch failed to spawn: ${manifest.id}`, { + error: getErrorMessage(err), + }); + finish(); + }); + child.once("exit", (code, signal) => { + if (finished) return; + if (code === 0) { + console.log(`[AAP] Prefetch complete: ${manifest.id}`); + } else { + console.warn(`[AAP] Prefetch failed: ${manifest.id}`, { + exitCode: code, + signal, + stderrTail: stderr.join("").slice(-1_024).trim() || undefined, + stdoutTail: stdout.join("").slice(-1_024).trim() || undefined, + }); + } + finish(); + }); + }); +} + // ---------------------------------------------------------------------------- // launch // ---------------------------------------------------------------------------- @@ -460,6 +561,15 @@ function validateRequires(manifest: Manifest): void { } } +function isPlatformCompatible(manifest: Manifest): boolean { + for (const req of manifest.requires) { + if (req.type !== "platform") continue; + if (req.os && req.os !== process.platform) return false; + if (req.arch && req.arch !== process.arch) return false; + } + return true; +} + function isCliOnPath(name: string): boolean { // `command -v` is POSIX and honours shell builtins + PATH lookup. On macOS // it covers xcrun, git, node, bun, etc. without needing `which`. diff --git a/apps/backend/src/services/aap/index.ts b/apps/backend/src/services/aap/index.ts index a2970cf2d..e5ab91e35 100644 --- a/apps/backend/src/services/aap/index.ts +++ b/apps/backend/src/services/aap/index.ts @@ -10,6 +10,7 @@ export { stopAllApps, sweepOrphanApps, readAppSkill, + prefetchInstalledAppAssets, } from "./apps.service"; // Re-export the public view + contract types from shared so backend-internal diff --git a/apps/backend/src/services/aap/lifecycle.ts b/apps/backend/src/services/aap/lifecycle.ts index 0c8818286..e18dbcda4 100644 --- a/apps/backend/src/services/aap/lifecycle.ts +++ b/apps/backend/src/services/aap/lifecycle.ts @@ -274,7 +274,7 @@ export async function stopChild( * * Returning absolute paths means Electron / Finder-launched backends don't * depend on the inherited PATH to find workspace binaries. */ -function resolveCommand(command: string, packageRoot: string): string { +export function resolveCommand(command: string, packageRoot: string): string { if (isAbsolute(command)) return command; // (1.5) Path-form command (`./dist/cli.js`, `bin/foo`, etc.) — Node's spawn diff --git a/apps/backend/test/integration/aap.test.ts b/apps/backend/test/integration/aap.test.ts index b4f910a9f..b4ab4acc0 100644 --- a/apps/backend/test/integration/aap.test.ts +++ b/apps/backend/test/integration/aap.test.ts @@ -10,7 +10,7 @@ // it, then asserts on the Map's observable state via getRunningApps. import { spawn } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -43,6 +43,8 @@ vi.mock("../../src/services/agent", () => ({ // Register it as the only installed app by replacing the manifest list. const fakeAppDir = mkdtempSync(join(tmpdir(), "aap-integration-")); const fakeAppServer = join(fakeAppDir, "server.js"); +const fakePrefetchScript = join(fakeAppDir, "prefetch.js"); +const fakePrefetchMarker = join(fakeAppDir, "prefetched.txt"); writeFileSync( fakeAppServer, ` @@ -60,6 +62,14 @@ console.log("fake app on " + port); `, "utf8" ); +writeFileSync( + fakePrefetchScript, + ` +const fs = require("node:fs"); +fs.writeFileSync(process.argv[2], process.env.DEUS_APP_ID + ":" + process.env.DEUS_PREFETCH, "utf8"); +`, + "utf8" +); const fakeManifest = { $schema: "https://agenticapps.dev/schema/v1.json", @@ -74,6 +84,10 @@ const fakeManifest = { env: {}, ready: { type: "http", path: "/health", timeoutMs: 10_000 }, }, + prefetch: { + command: "bun", + args: ["run", fakePrefetchScript, fakePrefetchMarker], + }, ui: { url: "http://127.0.0.1:{port}/" }, agent: { tools: { type: "mcp-http", url: "http://127.0.0.1:{port}/mcp" } }, storage: {}, @@ -83,9 +97,12 @@ const fakeManifest = { const fakeManifestPath = join(fakeAppDir, "agentic-app.json"); writeFileSync(fakeManifestPath, JSON.stringify(fakeManifest, null, 2), "utf8"); +const fakeManifestWithoutPrefetch = { ...fakeManifest }; +delete (fakeManifestWithoutPrefetch as { prefetch?: unknown }).prefetch; + // Second manifest for the ENOENT test — a command that doesn't exist on PATH. const bogusManifest = { - ...fakeManifest, + ...fakeManifestWithoutPrefetch, id: "test.bogus-command", launch: { ...fakeManifest.launch, command: "this-binary-does-not-exist-xyz123" }, }; @@ -95,7 +112,7 @@ writeFileSync(bogusManifestPath, JSON.stringify(bogusManifest, null, 2), "utf8") // Third manifest for the cli-requires test — declares a missing CLI with an // install hint. Validated BEFORE spawn so the hint reaches the user. const needsCliManifest = { - ...fakeManifest, + ...fakeManifestWithoutPrefetch, id: "test.needs-missing-cli", requires: [ { @@ -125,6 +142,7 @@ const { stopApp, stopAppsForWorkspace, stopAllApps, + prefetchInstalledAppAssets, sweepOrphanApps, } = await import("../../src/services/aap"); const { __clearRegistryCacheForTests } = await import("../../src/services/aap/registry"); @@ -173,6 +191,16 @@ describe("aap/apps.service (integration, in-memory)", () => { ]); }); + it("runs app prefetch commands in the background", async () => { + rmSync(fakePrefetchMarker, { force: true }); + prefetchInstalledAppAssets(); + await waitForCondition( + () => existsSync(fakePrefetchMarker), + (exists) => exists + ); + expect(readFileSync(fakePrefetchMarker, "utf8")).toBe("test.fake-app:1"); + }); + it("launches, becomes ready, and is reachable on /health", async () => { const result = await launchApp({ appId: "test.fake-app", diff --git a/apps/backend/test/unit/shared/aap/manifest.test.ts b/apps/backend/test/unit/shared/aap/manifest.test.ts index 17c107bb5..2cabf87ba 100644 --- a/apps/backend/test/unit/shared/aap/manifest.test.ts +++ b/apps/backend/test/unit/shared/aap/manifest.test.ts @@ -107,6 +107,22 @@ describe("shared/aap/manifest", () => { expect(tcp.launch.ready.type).toBe("tcp"); }); + it("parses optional prefetch commands", () => { + const manifest = parseManifest({ + ...VALID_MANIFEST, + prefetch: { + command: "node", + args: ["./dist/serve.js", "--prefetch"], + env: { WARM_CACHE: "1" }, + }, + }); + expect(manifest.prefetch).toEqual({ + command: "node", + args: ["./dist/serve.js", "--prefetch"], + env: { WARM_CACHE: "1" }, + }); + }); + it("rejects unsupported ready probe types", () => { const stdout = safeParseManifest({ ...VALID_MANIFEST, diff --git a/bun.lock b/bun.lock index 774d412b2..ca56b60b1 100644 --- a/bun.lock +++ b/bun.lock @@ -152,6 +152,18 @@ "vite": "^7.0.0", }, }, + "packages/pencil": { + "name": "@deus/pencil-app", + "version": "0.2.0", + "dependencies": { + "@pencil.dev/cli": "^0.2.5", + "unzipper": "^0.12.3", + "ws": "^8.18.3", + }, + "devDependencies": { + "@types/ws": "^8.18.1", + }, + }, "packages/screen-studio": { "name": "@deus/screen-studio", "version": "0.1.0", @@ -258,6 +270,8 @@ "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@deus/pencil-app": ["@deus/pencil-app@workspace:packages/pencil"], + "@deus/screen-studio": ["@deus/screen-studio@workspace:packages/screen-studio"], "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], @@ -442,6 +456,38 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.5", "", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="], + + "@inquirer/checkbox": ["@inquirer/checkbox@5.1.4", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-w6KF8ZYRvqHhROkOTHXYC3qIV/KYEu5o12oLqQySvch61vrYtRxNSHTONSdJqWiFJPlCUQAHT5OgOIyuTr+MHQ=="], + + "@inquirer/confirm": ["@inquirer/confirm@6.0.12", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og=="], + + "@inquirer/core": ["@inquirer/core@11.1.9", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg=="], + + "@inquirer/editor": ["@inquirer/editor@5.1.1", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/external-editor": "^3.0.0", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6y11LgmNpmn5D2aB5FgnCfBUBK8ZstwLCalyJmORcJZ/WrhOjm16mu6eSqIx8DnErxDqSLr+Jkp+GP8/Nwd5tA=="], + + "@inquirer/expand": ["@inquirer/expand@5.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-dF2zvrFo9LshkcB23/O1il13kBkBltWIXzut1evfbuBLXMiGIuC45c+ZQ0uukjCDsvI8OWqun4FRYMnzFCQa3g=="], + + "@inquirer/external-editor": ["@inquirer/external-editor@3.0.0", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg=="], + + "@inquirer/figures": ["@inquirer/figures@2.0.5", "", {}, "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ=="], + + "@inquirer/input": ["@inquirer/input@5.0.12", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-uiMFBl4LqFzJClh80Q3f9hbOFJ6kgkDWI4LjAeBuyO6EanVVMF69AgOvpi1qdqjDSjDN6578B6nky9ceEpI+1Q=="], + + "@inquirer/number": ["@inquirer/number@4.0.12", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/vrwhEf7Xsuh+YlHF4IjSy3g1cyrQuPaSiHIxCEbLu8qnfvrcvJyCkoktOOF+xV9gSb77/G0n3h04RbMDW2sIg=="], + + "@inquirer/password": ["@inquirer/password@5.0.12", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-CBh7YHju623lxJRcAOo498ZUwIuMy63bqW/vVq0tQAZVv+lkWlHkP9ealYE1utWSisEShY5VMdzIXRmyEODzcQ=="], + + "@inquirer/prompts": ["@inquirer/prompts@8.4.2", "", { "dependencies": { "@inquirer/checkbox": "^5.1.4", "@inquirer/confirm": "^6.0.12", "@inquirer/editor": "^5.1.1", "@inquirer/expand": "^5.0.13", "@inquirer/input": "^5.0.12", "@inquirer/number": "^4.0.12", "@inquirer/password": "^5.0.12", "@inquirer/rawlist": "^5.2.8", "@inquirer/search": "^4.1.8", "@inquirer/select": "^5.1.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@5.2.8", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Su7FQvp5buZmCymN3PPoYv31ZQQX4ve2j02k7piGgKAWgE+AQRB5YoYVveGXcl3TZ9ldgRMSxj56YfDFmmaqLg=="], + + "@inquirer/search": ["@inquirer/search@4.1.8", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-fGiHKGD6DyPIYUWxoXnQTeXeyYqSOUrasDMABBmMHUalH/LxkuzY0xVRtimXAt1sUeeyYkVuKQx1bebMuN11Kw=="], + + "@inquirer/select": ["@inquirer/select@5.1.4", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-2kWcGKPMLAXAWRp1AH1SLsQmX+j0QjeljyXMUji9WMZC8nRDO0b7qquIGr6143E7KMLt3VAIGNXzwa/6PXQs4Q=="], + + "@inquirer/type": ["@inquirer/type@4.0.5", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -732,6 +778,8 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="], + "@pencil.dev/cli": ["@pencil.dev/cli@0.2.5", "", { "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.87", "@inquirer/prompts": "^8.3.0", "eventemitter3": "^5.0.1", "semver": "^7.7.4", "svgo": "^4.0.0", "ws": "^8.18.3" }, "bin": { "pencil": "dist/index.cjs" } }, "sha512-3qgm/NYfj9Nqz4adO1/N8KIsKd30/MtNw9qxOqMXQGgcCJxWoNI/0j9Gixo7ng/Ux8IDyqjIRuZFvftfogAf0g=="], + "@pierre/diffs": ["@pierre/diffs@1.1.7", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-FWs2hHrjZPXmJl6ewnfFzOjNEM3aeSH1CB8ynZg4SOg95Wc5AxomeyJJhXf44PK9Cc+PNm1CgsJ1IvOdfgHyHA=="], "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], @@ -1404,10 +1452,14 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -1466,6 +1518,8 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], "chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="], @@ -1492,6 +1546,8 @@ "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], @@ -1552,8 +1608,16 @@ "css-loader": ["css-loader@5.2.7", "", { "dependencies": { "icss-utils": "^5.1.0", "loader-utils": "^2.0.0", "postcss": "^8.2.15", "postcss-modules-extract-imports": "^3.0.0", "postcss-modules-local-by-default": "^4.0.0", "postcss-modules-scope": "^3.0.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.1.0", "schema-utils": "^3.0.0", "semver": "^7.3.5" }, "peerDependencies": { "webpack": "^4.27.0 || ^5.0.0" } }, "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "cytoscape": ["cytoscape@3.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="], @@ -1686,8 +1750,16 @@ "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="], "dotenv": ["dotenv@9.0.2", "", {}, "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg=="], @@ -1696,6 +1768,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -1836,8 +1910,14 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], @@ -2304,6 +2384,8 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "memfs": ["memfs@3.4.3", "", { "dependencies": { "fs-monkey": "1.0.3" } }, "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg=="], @@ -2420,6 +2502,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], @@ -2448,6 +2532,8 @@ "node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -2458,6 +2544,8 @@ "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "number-flow": ["number-flow@0.5.10", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-Ss6fU7zZgfAlhT4KFcUVOafStQVHs22xTx7/Fm6nj9j9WUREYlQpBgFX+vzdnB2PpGPTrtdGZPJou6Yyu/uDeA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -2630,6 +2718,8 @@ "proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], @@ -2690,7 +2780,7 @@ "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], @@ -2886,7 +2976,7 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], @@ -2920,6 +3010,8 @@ "svg-parser": ["svg-parser@2.0.4", "", {}, "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ=="], + "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], + "tailwind-merge": ["tailwind-merge@3.4.1", "", {}, "sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], @@ -3054,6 +3146,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unzipper": ["unzipper@0.12.3", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "^11.2.0", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -3216,6 +3310,10 @@ "@fastify/otel/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -3424,6 +3522,8 @@ "app-builder-lib/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -3440,6 +3540,8 @@ "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], @@ -3530,6 +3632,10 @@ "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -3542,14 +3648,20 @@ "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], @@ -3566,6 +3678,8 @@ "tsx/get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "unzipper/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], + "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -3720,6 +3834,8 @@ "app-builder-lib/minimatch/brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], + "bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "cacache/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "cacache/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], @@ -3728,6 +3844,8 @@ "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], @@ -3758,6 +3876,8 @@ "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "tar-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "terser-webpack-plugin/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "terser-webpack-plugin/schema-utils/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], diff --git a/electron-builder.yml b/electron-builder.yml index adfe07145..fb873f687 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -5,6 +5,7 @@ directories: output: dist-electron electronLanguages: [en] beforePack: "scripts/runtime/electron-builder-before-pack.cjs" +afterPack: "scripts/prune-pencil-cli-binaries.cjs" files: - "out/**/*" - "resources/**/*" @@ -15,6 +16,7 @@ asarUnpack: - "resources/**" - "node_modules/better-sqlite3/**" - "node_modules/node-pty/**" + - "node_modules/@pencil.dev/cli/dist/out/**" extraResources: - from: "dist/runtime/electron/backend" to: "backend" @@ -22,6 +24,19 @@ extraResources: - "**/*" - from: "dist/runtime/electron/bin/index.bundled.cjs" to: "bin/index.bundled.cjs" + - from: "packages/pencil" + to: "agentic-apps/pencil" + filter: + - "agentic-app.json" + - "package.json" + - "dist/**/*" + - "skills/**/*" + - from: "node_modules/@pencil.dev/cli" + to: "agentic-apps/pencil/node_modules/@pencil.dev/cli" + filter: + - "package.json" + - "dist/**/*" + - "SKILL.md" mac: category: public.app-category.developer-tools extraResources: diff --git a/package.json b/package.json index 35baf9af1..33918c462 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,15 @@ "build": "electron-vite build", "build:agent-server": "bunx tsx apps/agent-server/build.ts", "build:backend": "bunx tsx apps/backend/build.ts", + "build:pencil": "cd packages/pencil && bun run build", "dev:relay": "cd apps/cloud-relay && wrangler dev", "build:relay": "cd apps/cloud-relay && wrangler deploy --dry-run --outdir dist", "deploy:relay": "cd apps/cloud-relay && wrangler deploy", "build:web": "vite build --config apps/web/vite.config.ts --outDir dist/web", "build:cli": "bun run build:runtime && bunx tsx apps/cli/build.ts", - "build:all": "bun run build:runtime && bun run build", + "build:all": "bun run build:runtime && bun run build:pencil && bun run build", "package:mac": "bun run build:all && bun run prepare:gh-cli && electron-builder --mac", + "package:win": "bun run build:all && electron-builder --win", "package:linux": "bun run build:all && electron-builder --linux", "postinstall": "bun run prepare:device-use", "native:electron": "electron-builder install-app-deps", diff --git a/packages/pencil/agentic-app.json b/packages/pencil/agentic-app.json new file mode 100644 index 000000000..50103aeec --- /dev/null +++ b/packages/pencil/agentic-app.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://agenticapps.dev/schema/v1.json", + "protocolVersion": "1", + + "id": "pencil.app", + "name": "Pencil", + "description": "AI-powered design tool for web and mobile apps. Agents can generate, modify, and export .pen design files. Backed by the Pencil CLI; no Pencil Desktop required.", + "version": "0.2.0", + + "launch": { + "command": "node", + "args": [ + "./dist/serve.js", + "--port", + "{port}", + "--workspace", + "{workspace}", + "--storage", + "{storage.workspace}" + ], + "ready": { + "type": "http", + "path": "/health", + "timeoutMs": 30000 + } + }, + + "prefetch": { + "command": "node", + "args": ["./dist/serve.js", "--prefetch-editor"] + }, + + "ui": { + "url": "http://127.0.0.1:{port}/" + }, + + "agent": { + "tools": { + "type": "mcp-http", + "url": "http://127.0.0.1:{port}/mcp" + }, + "bootstrap": "ALL design work happens on the live iframe canvas — every op the agent makes renders in real time and the user watches it build. Workflow: (1) `pencil_get_active` or `pencil_list_designs` to know what's open / what exists; (2) `pencil_new` to start a blank canvas, OR `pencil_open` to switch to an existing .pen; (3) `get_guidelines` once at the start (provides .pen op syntax + style); (4) `get_editor_state({ include_schema: true })` to read the document; (5) drive the design with `batch_design` ops — small batches (≤25 ops) so progress is visible. Other live tools as needed: `batch_get`, `snapshot_layout`, `get_screenshot`, `find_empty_space_on_canvas`, `replace_all_matching_properties`, `search_all_unique_properties`, `set_variables`, `get_variables`, `export_nodes`, `open_document`. Call `mcp__deus__read_app_skill({ appId: 'pencil.app' })` for full guidance." + }, + + "storage": { + "workspace": "{workspace}/.pencil" + }, + + "lifecycle": { + "scope": "workspace", + "stopTimeoutMs": 5000 + }, + + "requires": [ + { + "type": "platform", + "os": "darwin" + } + ], + + "skills": ["skills/pencil/SKILL.md"] +} diff --git a/packages/pencil/build.ts b/packages/pencil/build.ts new file mode 100644 index 000000000..2147b2976 --- /dev/null +++ b/packages/pencil/build.ts @@ -0,0 +1,58 @@ +// packages/pencil/build.ts +// +// Two esbuild bundles: the Node launcher (src/serve.ts → dist/serve.js) +// and the browser-side iframe controller (src/ui/app.ts → dist/ui/app.js). +// Static assets (parent.html, styles.css) are copied as-is. +// +// Run with: `bun run build` from the package root, or `bunx tsx build.ts`. + +import esbuild from "esbuild"; +import { copyFileSync, mkdirSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(fileURLToPath(import.meta.url)); +const distDir = join(root, "dist"); + +rmSync(distDir, { recursive: true, force: true }); +mkdirSync(join(distDir, "ui"), { recursive: true }); + +// ---- Node launcher -------------------------------------------------------- +// +// Bundle into a single ESM file. Mark @pencil.dev/cli as external so we +// dynamically resolve its package.json at runtime via require.resolve() +// rather than baking a path into the bundle. The banner injects a CommonJS +// `require` so we can keep using `require.resolve()` from ESM. +await esbuild.build({ + entryPoints: [join(root, "src/serve.ts")], + bundle: true, + platform: "node", + target: "node18", + format: "esm", + outfile: join(distDir, "serve.js"), + external: ["@pencil.dev/cli", "@aws-sdk/client-s3", "bufferutil", "utf-8-validate"], + banner: { + js: "import { createRequire } from 'node:module';\nconst require = createRequire(import.meta.url);\n", + }, + logLevel: "info", +}); + +// ---- Browser iframe controller ------------------------------------------- +// +// Browser target with DOM types. Single-file bundle so the iframe loads +// one script tag. +await esbuild.build({ + entryPoints: [join(root, "src/ui/app.ts")], + bundle: true, + platform: "browser", + target: "es2022", + format: "esm", + outfile: join(distDir, "ui/app.js"), + logLevel: "info", +}); + +// ---- Static assets -------------------------------------------------------- +copyFileSync(join(root, "src/ui/parent.html"), join(distDir, "ui/parent.html")); +copyFileSync(join(root, "src/ui/styles.css"), join(distDir, "ui/styles.css")); + +console.log("[pencil] build complete"); diff --git a/packages/pencil/package.json b/packages/pencil/package.json new file mode 100644 index 000000000..57008413b --- /dev/null +++ b/packages/pencil/package.json @@ -0,0 +1,26 @@ +{ + "name": "@deus/pencil-app", + "version": "0.2.0", + "private": true, + "description": "Pencil design AAP — wraps the Pencil CLI as MCP-callable design tools.", + "type": "module", + "main": "dist/serve.js", + "files": [ + "dist", + "skills", + "agentic-app.json" + ], + "dependencies": { + "@pencil.dev/cli": "^0.2.5", + "unzipper": "^0.12.3", + "ws": "^8.18.3" + }, + "devDependencies": { + "@types/ws": "^8.18.1" + }, + "scripts": { + "build": "bunx tsx build.ts", + "test": "vitest run test", + "typecheck": "tsc --noEmit" + } +} diff --git a/packages/pencil/skills/pencil/SKILL.md b/packages/pencil/skills/pencil/SKILL.md new file mode 100644 index 000000000..24ed8c66f --- /dev/null +++ b/packages/pencil/skills/pencil/SKILL.md @@ -0,0 +1,81 @@ +--- +name: pencil +description: "Generate, modify, and export visual designs (web, mobile, marketing) by describing them and operating on a live canvas. Use whenever the user asks for a design, mockup, landing page, screen, layout, or any visual asset." +--- + +# Pencil + +Pencil renders a real interactive canvas in the user's panel. **Every op the agent makes shows up on the canvas immediately** — there's no batch mode, no spinner, no 30‑second waits. The user watches the design build. + +## Tool surface + +All design work goes through the **live editor** tools (provided by the bundled MCP binary, bridged to the iframe). The agent never spawns a CLI subprocess. + +### Workspace navigation (4 — ours) + +- `pencil_list_designs()` — every `.pen` in the workspace (workspace files + agent‑generated). Filesystem only. +- `pencil_get_active()` — which `.pen` is currently displayed in the panel. Use when the user says "this design" / "the open one". +- `pencil_open({ file? | name? })` — switch the editor panel to a different `.pen`. Workspace‑aware path resolution. +- `pencil_new({ name })` — create a brand‑new blank canvas. Sets the save target to `/.pencil/designs/.pen` and tells the editor to open a fresh empty document. After this, drive the design with `batch_design`. + +### Live editor (13 — Pencil's native tools, bridged) + +- **`batch_design({ operations })`** — the workhorse. Run a script of insert/copy/update/replace/move/delete/image ops in one call. ≤25 ops per batch so the user sees progress. +- **`batch_get({ patterns?, nodeIds? })`** — read nodes by ID or pattern. Use to discover structure before editing. +- **`get_editor_state({ include_schema })`** — current document, selection, and (if requested) the `.pen` schema. Call once with `include_schema: true` at the start of a task; `false` for follow‑ups. +- `get_screenshot({ nodeId })` — PNG of any node for visual verification. +- `snapshot_layout({ parentId, maxDepth?, problemsOnly? })` — compact node tree for layout reasoning. +- `find_empty_space_on_canvas({ width, height, direction, padding })` — pick a non‑overlapping region for new content. +- `open_document(filePath | "new")` — switch documents at the editor level (lower‑level than `pencil_open`; prefer `pencil_open` for existing files because it also updates the panel switcher). +- `replace_all_matching_properties` / `search_all_unique_properties` — mass property edits / discovery. +- `set_variables` / `get_variables` — design tokens. +- `export_nodes({ nodeIds, format, scale, quality? })` — render specific nodes to image files. +- **`get_guidelines(category?, name?)`** — Pencil's own `.pen` syntax + style guides. **Always call `get_guidelines("general")` once at the start of any `batch_design` work** — the op syntax is non‑obvious and these guides are how you learn it. + +## Standard workflow + +```text +1. get_guidelines("general") // load .pen op syntax +2. pencil_get_active // know what's open + (or: pencil_list_designs → pencil_open / pencil_new) +3. get_editor_state({ include_schema: true }) // load document + schema +4. batch_design({ operations: [ ... ] }) // build, ≤25 ops/batch +5. get_screenshot({ nodeId }) // verify visually +6. iterate: batch_get → batch_design → screenshot +``` + +## Patterns + +**New design from scratch:** + +```text +user: "design me an agent control center" +→ pencil_new({ name: "agent-control-center" }) // blank canvas, visible +→ get_guidelines("general") // .pen syntax +→ batch_design({ operations: [...frame, sidebar, header...] }) // user watches it appear +→ batch_design({ operations: [...activity feed cards...] }) +→ ... continue in small batches +``` + +**Edit an existing design:** + +```text +user: "make the title bigger" +→ get_editor_state({ include_schema: true }) // find the title node +→ batch_design({ operations: [ "U('title-id', { fontSize: 48 })" ] }) // immediate +``` + +**Switch context:** + +```text +user: "show me the dashboard instead" +→ pencil_list_designs // find it +→ pencil_open({ file: "design/dashboard.pen" }) // switch panel +``` + +## Behavior notes + +- **Small batches.** Even though `batch_design` accepts up to 25 ops per call, prefer 5–15 — the user perceives smoother progress with more frequent updates. +- **Read before write.** Always `get_editor_state` (or `batch_get` for targeted reads) before a `batch_design` that references existing nodes. Node IDs aren't guessable. +- **Guidelines are mandatory.** `get_guidelines("general")` returns the canonical `.pen` op syntax. Skipping this leads to invalid ops and wasted batches. +- **Auth.** The live editor tools work as long as the iframe is connected — no Pencil CLI key needed. (The CLI key in the sign‑in card is only used for the editor's own cloud features like AI image gen.) diff --git a/packages/pencil/src/lib/auth.ts b/packages/pencil/src/lib/auth.ts new file mode 100644 index 000000000..85342ef0e --- /dev/null +++ b/packages/pencil/src/lib/auth.ts @@ -0,0 +1,138 @@ +// packages/pencil/src/lib/auth.ts +// +// Auth state surface. Three sources, in priority order: +// 1. PENCIL_CLI_KEY env var (lets CI / shell setups override anything) +// 2. ~/.deus/pencil/cli-key (Deus-managed, set via the iframe paste form) +// 3. ~/.pencil/session-cli.json (the CLI's own `pencil login` output) + +import * as fs from "node:fs"; +import { dirname } from "node:path"; +import { DEUS_CLI_KEY_FILE, DEUS_EDITOR_SESSION_FILE, PENCIL_SESSION_FILE } from "./config.ts"; +import type { AuthState, ResolvedKey } from "./types.ts"; + +// ---- Editor web-session --------------------------------------------------- +// +// The Pencil editor (the iframe) maintains its own web session for cloud +// features (AI image gen, library browsing, design-kit fetch). When the +// user signs in via Pencil's email-OTP card, the editor pushes +// `notify("set-session", {email, token})` to us. We persist that here so +// the next iframe launch can return it from get-session and skip the +// sign-in card entirely. + +export interface EditorSession { + email: string; + token: string; + /** When we received it. Useful for invalidation; the cloud may also revoke. */ + savedAt: string; +} + +export function readEditorSession(): EditorSession | null { + try { + const raw = fs.readFileSync(DEUS_EDITOR_SESSION_FILE, "utf8"); + const data = JSON.parse(raw) as Partial; + if (typeof data.email === "string" && typeof data.token === "string") { + return { + email: data.email, + token: data.token, + savedAt: typeof data.savedAt === "string" ? data.savedAt : new Date(0).toISOString(), + }; + } + } catch { + /* missing or malformed */ + } + return null; +} + +export function writeEditorSession(session: { email: string; token: string }): void { + fs.mkdirSync(dirname(DEUS_EDITOR_SESSION_FILE), { recursive: true }); + fs.writeFileSync( + DEUS_EDITOR_SESSION_FILE, + JSON.stringify({ ...session, savedAt: new Date().toISOString() }, null, 2), + { mode: 0o600 } + ); + fs.chmodSync(DEUS_EDITOR_SESSION_FILE, 0o600); +} + +export function clearEditorSession(): void { + try { + if (fs.existsSync(DEUS_EDITOR_SESSION_FILE)) fs.unlinkSync(DEUS_EDITOR_SESSION_FILE); + } catch { + /* best effort */ + } +} + +/** Read the Deus-managed CLI key, trim trailing whitespace (paste hygiene). */ +export function readDeusCliKeyFile(): string | null { + try { + const contents = fs.readFileSync(DEUS_CLI_KEY_FILE, "utf8").trim(); + return contents.length > 0 ? contents : null; + } catch { + return null; + } +} + +/** Pick whichever key source has a value, env taking precedence. */ +export function resolveCliKey(): ResolvedKey | null { + const fromEnv = process.env.PENCIL_CLI_KEY; + if (fromEnv && fromEnv.length > 0) return { key: fromEnv, source: "env" }; + const fromFile = readDeusCliKeyFile(); + if (fromFile) return { key: fromFile, source: "file" }; + return null; +} + +/** Full snapshot for `/auth-status` and the iframe sign-in panel. Never + * echoes the key value. */ +export function authState(): AuthState { + const resolved = resolveCliKey(); + const sessionExists = fs.existsSync(PENCIL_SESSION_FILE); + let sessionValid = false; + let sessionEmail: string | null = null; + if (sessionExists) { + try { + const raw = fs.readFileSync(PENCIL_SESSION_FILE, "utf8"); + const data = JSON.parse(raw) as { token?: string; email?: string }; + sessionValid = Boolean(data.token); + sessionEmail = typeof data.email === "string" ? data.email : null; + } catch { + /* malformed → not valid */ + } + } + return { + authed: Boolean(resolved) || sessionValid, + cliKeySet: Boolean(resolved), + cliKeySource: resolved?.source ?? null, + sessionFile: PENCIL_SESSION_FILE, + sessionExists, + sessionValid, + sessionEmail, + deusCliKeyFile: DEUS_CLI_KEY_FILE, + }; +} + +export function isAuthenticated(): boolean { + return authState().authed; +} + +/** Format check only — doesn't talk to the API. Use cli.verifyCliKey() to + * actually round-trip the key against api.pencil.dev. */ +export function validateCliKey(key: unknown): key is string { + if (typeof key !== "string") return false; + const trimmed = key.trim(); + return trimmed.startsWith("pencil_cli_") && trimmed.length > "pencil_cli_".length; +} + +/** Persist a user-supplied key with strict perms. Throws on FS error so + * the route handler can surface the message. */ +export function persistKey(key: string): void { + fs.mkdirSync(dirname(DEUS_CLI_KEY_FILE), { recursive: true }); + fs.writeFileSync(DEUS_CLI_KEY_FILE, key.trim(), { mode: 0o600 }); + fs.chmodSync(DEUS_CLI_KEY_FILE, 0o600); +} + +export function clearKey(): void { + try { + if (fs.existsSync(DEUS_CLI_KEY_FILE)) fs.unlinkSync(DEUS_CLI_KEY_FILE); + } catch { + /* best effort */ + } +} diff --git a/packages/pencil/src/lib/cli.ts b/packages/pencil/src/lib/cli.ts new file mode 100644 index 000000000..53de2ab24 --- /dev/null +++ b/packages/pencil/src/lib/cli.ts @@ -0,0 +1,225 @@ +// packages/pencil/src/lib/cli.ts +// +// Pencil CLI surface — discovery, env hardening, runner, and the small +// commands we shell out to (status, version, --list-models). The ops +// module wraps `spawnCli` with phase tracking; everything else is direct. + +import * as fs from "node:fs"; +import { dirname, join } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import { PENCIL_PROD_API_BASE, STDERR_TAIL_BYTES } from "./config.ts"; +import { resolveCliKey } from "./auth.ts"; +import type { CliErrorParse, CliResult, CliVerifyResult, Context, Op } from "./types.ts"; + +// ---- discovery ------------------------------------------------------------ + +/** The bundled CLI is a workspace dep; bun hoists it to repo root. We + * resolve via require so the path is correct regardless of where this + * bundle is loaded from. */ +export function findPencilCli(): { command: string; args: string[] } { + try { + const pkgJson = require.resolve("@pencil.dev/cli/package.json"); + const entry = join(dirname(pkgJson), "dist", "index.cjs"); + if (fs.existsSync(entry)) return { command: "node", args: [entry] }; + } catch { + /* fall through */ + } + return { command: "pencil", args: [] }; +} + +// ---- env hardening -------------------------------------------------------- + +/** Compose the env we hand to every CLI subprocess. Pinning two values + * matters: NODE_ENV=development from a dev shell silently routes the + * CLI to http://localhost:3001, and a stale PENCIL_API_BASE pointing at + * localhost has the same effect. */ +export function buildCliEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, ...overrides }; + + if (!env.PENCIL_API_BASE || /localhost|127\.0\.0\.1/.test(env.PENCIL_API_BASE)) { + env.PENCIL_API_BASE = PENCIL_PROD_API_BASE; + } + if (env.NODE_ENV === "development" || !env.NODE_ENV) { + env.NODE_ENV = "production"; + } + if (!env.PENCIL_CLI_KEY || env.PENCIL_CLI_KEY.length === 0) { + const resolved = resolveCliKey(); + if (resolved) env.PENCIL_CLI_KEY = resolved.key; + } + + return env; +} + +// ---- runner --------------------------------------------------------------- + +function appendCappedTail(existing: string, chunk: string, max = STDERR_TAIL_BYTES): string { + const next = existing + chunk; + return next.length <= max ? next : next.slice(next.length - max); +} + +export interface SpawnOpts { + op?: Op; + onChunk?: (stream: "stdout" | "stderr", chunk: string) => void; + env?: NodeJS.ProcessEnv; +} + +/** Run the CLI to completion. Resolves with stdout/stderr captured. */ +export function spawnCli( + extraArgs: string[], + ctx: Context, + { op, onChunk, env }: SpawnOpts = {} +): Promise { + const cli = findPencilCli(); + const { workspace, storage } = ctx; + return new Promise((resolve) => { + fs.mkdirSync(join(storage, "designs"), { recursive: true }); + + const child: ChildProcess = spawn(cli.command, [...cli.args, ...extraArgs], { + cwd: workspace, + env: buildCliEnv(env), + stdio: ["ignore", "pipe", "pipe"], + }); + if (op) { + op.child = child; + op.pid = child.pid ?? null; + } + + let stdout = ""; + let stderr = ""; + + const pump = (stream: NodeJS.ReadableStream | null, kind: "stdout" | "stderr"): void => { + if (!stream) return; + stream.on("data", (c: Buffer | string) => { + const chunk = c.toString(); + if (kind === "stdout") stdout = appendCappedTail(stdout, chunk); + else stderr = appendCappedTail(stderr, chunk); + // Mirror to the launcher's stdio so `bun run dev` sees CLI logs. + process[kind === "stdout" ? "stdout" : "stderr"].write(`[pencil-cli] ${chunk}`); + if (op) op.stderrTail = stderr; + onChunk?.(kind, chunk); + }); + }; + pump(child.stdout, "stdout"); + pump(child.stderr, "stderr"); + + child.on("error", (err) => { + resolve({ + ok: false, + code: -1, + signal: null, + stdout, + stderr: stderr + `\n${err.message}`, + }); + }); + child.on("exit", (code, signal) => { + resolve({ + ok: code === 0, + code: code ?? -1, + signal, + stdout, + stderr, + }); + }); + }); +} + +// ---- status / version / models ------------------------------------------- + +/** Round-trip a key against the Pencil API by running `pencil status`. + * Used to verify a freshly-pasted key before persisting. */ +export async function verifyCliKey(key: string, ctx: Context): Promise { + const result = await spawnCli(["status"], ctx, { env: { PENCIL_CLI_KEY: key } }); + if (!result.ok) { + return { + ok: false, + error: parseStatusError(result.stdout + "\n" + result.stderr), + raw: result.stdout + result.stderr, + }; + } + const clean = stripAnsi(result.stdout); + const emailMatch = clean.match(/Email\s+([^\s]+@[^\s]+)/); + return { + ok: true, + email: emailMatch?.[1] ?? null, + raw: clean, + }; +} + +let cachedVersion: string | null = null; +export async function getCliVersion(ctx: Context): Promise { + if (cachedVersion !== null) return cachedVersion; + const result = await spawnCli(["version"], ctx, {}); + if (!result.ok) { + cachedVersion = ""; + return cachedVersion; + } + const clean = stripAnsi(result.stdout); + const match = clean.match(/v?(\d+\.\d+\.\d+(?:[a-z0-9.-]*)?)/i); + cachedVersion = match?.[1] ?? clean.trim(); + return cachedVersion; +} + +// ---- error parsing -------------------------------------------------------- + +export function parseCliError(text: string): CliErrorParse { + const clean = stripAnsi(text || ""); + if (/Authentication required/i.test(clean)) { + return { code: "auth_missing", message: "Pencil CLI is not authenticated. Set a CLI key." }; + } + if (/invalid|unauthorized|expired/i.test(clean) && /key|token|session/i.test(clean)) { + return { + code: "auth_invalid", + message: "Pencil CLI key was rejected by the API. Check that it's not revoked.", + }; + } + if (/Failed to connect|ECONNREFUSED|ENOTFOUND|fetch failed|network/i.test(clean)) { + return { + code: "network", + message: "Couldn't reach the Pencil API. Check your network connection.", + }; + } + if (/ANTHROPIC_API_KEY/i.test(clean)) { + return { + code: "anthropic_key_missing", + message: + "The Pencil CLI needs an Anthropic API key (set ANTHROPIC_API_KEY) or a Claude Code subscription.", + }; + } + if (/rate.?limit|429|quota/i.test(clean)) { + return { + code: "rate_limit", + message: "Anthropic rate-limited the request. Wait a moment and try again.", + }; + } + if (/Unknown model|model.*not found/i.test(clean)) { + return { + code: "model_not_found", + message: "The requested model isn't available on this account.", + }; + } + const last = clean + .trim() + .split(/\r?\n/) + .reverse() + .find((l) => l.trim().length > 0) + ?.replace(/^\[(INFO|WARN|ERROR|DEBUG)\]\s*/, "") + .trim(); + return { code: "unknown", message: last || "Pencil CLI failed." }; +} + +function parseStatusError(text: string): string { + const parsed = parseCliError(text); + if (parsed.code === "auth_missing") { + return "The CLI key is missing or empty. Paste a valid key."; + } + return parsed.message; +} + +/** Strip ANSI color/style escape sequences from CLI output. */ +export function stripAnsi(s: string): string { + return String(s).replace( + // eslint-disable-next-line no-control-regex + /[›][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-ntqry=><]/g, + "" + ); +} diff --git a/packages/pencil/src/lib/config.ts b/packages/pencil/src/lib/config.ts new file mode 100644 index 000000000..f00e74893 --- /dev/null +++ b/packages/pencil/src/lib/config.ts @@ -0,0 +1,46 @@ +// packages/pencil/src/lib/config.ts + +import { homedir } from "node:os"; +import { join } from "node:path"; + +const HOME = homedir(); + +// ---- App identity --------------------------------------------------------- +export const APP_NAME = "pencil-app"; +export const APP_VERSION = "0.2.0"; + +// ---- Pencil cloud --------------------------------------------------------- +/** Default production API base. We pin this when spawning the CLI so a + * dev shell with NODE_ENV=development can't redirect to localhost. */ +export const PENCIL_PROD_API_BASE = "https://api.pencil.dev"; + +// ---- Auth files ----------------------------------------------------------- +/** Where Deus persists a user-pasted CLI key. mode 0600 enforced on write. */ +export const DEUS_CLI_KEY_FILE = join(HOME, ".deus", "pencil", "cli-key"); +/** Pencil CLI's own session file from `pencil login`. We read but never write. */ +export const PENCIL_SESSION_FILE = join(HOME, ".pencil", "session-cli.json"); +/** Where the embedded editor's web session lives. The editor sends + * `notify("set-session", {email, token})` after the user signs in via + * the cloud sign-in card. We persist that {email, token} here so the + * next launch's `get-session` returns it instead of forcing a re-login. */ +export const DEUS_EDITOR_SESSION_FILE = join(HOME, ".deus", "pencil", "editor-session.json"); + +// ---- Pencil host (TransportServer) --------------------------------------- +/** App registry directory — the bundled mcp-server binary reads + * `~/.pencil/apps/` to find the WebSocket port of the host + * it's been told to connect to via `-app `. */ +export const PENCIL_APPS_DIR = join(HOME, ".pencil", "apps"); +/** Our app name in the registry. Anything unique works; "deus" is the + * obvious choice. */ +export const PENCIL_HOST_APP_NAME = "deus"; + +// ---- Tools / format whitelist -------------------------------------------- +export const ALLOWED_EXPORT_FORMATS = ["png", "jpeg", "webp", "pdf"] as const; +export type ExportFormat = (typeof ALLOWED_EXPORT_FORMATS)[number]; + +// ---- Limits --------------------------------------------------------------- +/** Tail size we keep around for op stderr — surfaced to the agent on + * failure. 32 KB is enough for stack traces; bigger wastes JSON bytes. */ +export const STDERR_TAIL_BYTES = 32 * 1024; +/** Max log buffer the iframe shows in its tail strip. */ +export const IFRAME_LOG_TAIL_BYTES = 3 * 1024; diff --git a/packages/pencil/src/lib/designs.ts b/packages/pencil/src/lib/designs.ts new file mode 100644 index 000000000..6ae9e6f6d --- /dev/null +++ b/packages/pencil/src/lib/designs.ts @@ -0,0 +1,264 @@ +// packages/pencil/src/lib/designs.ts +// +// File-system view of designs. Two scopes: +// 1. Workspace files (/**/*.pen) — anywhere in the user's +// project. Discovered via a recursive scan that respects standard +// ignore patterns (node_modules, .git, dist, etc.). +// 2. Storage files (/designs/.pen) — agent-generated +// designs default here so we don't pollute the user's repo. +// +// State files under : +// active-pen.txt — absolute path to the active .pen +// cache/.preview.png — per-design live preview cache +// designs/.pen — agent-generated designs (legacy default) + +import * as fs from "node:fs"; +import { createHash } from "node:crypto"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { Design } from "./types.ts"; + +const IGNORED_DIRS = new Set([ + "node_modules", + ".git", + "dist", + "build", + "out", + ".next", + ".nuxt", + ".cache", + ".turbo", + "coverage", + ".svelte-kit", + "__pycache__", + ".venv", + "venv", +]); +const MAX_SCAN_DEPTH = 8; +const MAX_SCAN_FILES = 500; + +/** Restrict to filesystem-safe filenames. */ +export function safePenName(name: unknown): string { + const cleaned = String(name ?? "") + .trim() + .replace(/[^A-Za-z0-9_-]/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); + return cleaned.length > 0 ? cleaned : "design"; +} + +/** Default save location for agent‑generated designs. We put them under + * `/designs/` so they're committable to git as part of the + * user's repo (instead of buried in the AAP's hidden `.pencil/` storage). + * The `storage` arg is kept for back‑compat with old call sites that may + * still pass it; if the workspace is unknown we fall back to storage. */ +export function penPathFor(name: string, storage: string, workspace?: string): string { + const root = workspace ?? storage; + return join(root, "designs", `${safePenName(name)}.pen`); +} + +// ---------- active-design pointer ---------------------------------------- +// +// We store the absolute path of the active .pen file. Older builds wrote +// `active-preview.txt` pointing at a .preview.png — kept readable for +// backwards compat (we strip the `.preview.png` to derive the .pen). + +function activePenPointer(storage: string): string { + return join(storage, "active-pen.txt"); +} +function legacyPreviewPointer(storage: string): string { + return join(storage, "active-preview.txt"); +} + +/** Set the currently-active design by absolute .pen path. */ +export function setActivePen(storage: string, penPath: string): void { + fs.mkdirSync(dirname(activePenPointer(storage)), { recursive: true }); + fs.writeFileSync(activePenPointer(storage), penPath, "utf8"); +} + +/** Read the active .pen path. Returns null if nothing's been opened. */ +export function getActivePen(storage: string): string | null { + try { + const v = fs.readFileSync(activePenPointer(storage), "utf8").trim(); + if (v.length > 0) return v; + } catch { + /* fall through to legacy */ + } + // Legacy: /active-preview.txt held a .preview.png path; the + // sibling .pen is what we actually need. + try { + const legacy = fs.readFileSync(legacyPreviewPointer(storage), "utf8").trim(); + if (legacy.endsWith(".preview.png")) return legacy.replace(/\.preview\.png$/, ".pen"); + } catch { + /* nothing */ + } + return null; +} + +// ---------- preview path management -------------------------------------- +// +// Previews live in a content-addressed cache so .pen files anywhere in +// the workspace get a stable preview location without polluting the +// user's repo. + +export function previewPathForPen(penPath: string, storage: string): string { + // Sibling location for files inside /designs/ (back-compat with + // the agent-generated flow); cache directory for everything else. + if (penPath.startsWith(join(storage, "designs") + sep)) { + return penPath.replace(/\.pen$/, ".preview.png"); + } + const hash = createHash("sha1").update(penPath).digest("hex").slice(0, 16); + const baseName = + penPath + .split("/") + .pop() + ?.replace(/\.pen$/, "") ?? "design"; + return join(storage, "cache", `${baseName}-${hash}.preview.png`); +} + +// Legacy helpers — keep so existing code paths compile during the transition. +export function setActivePreview(storage: string, previewPath: string): void { + setActivePen(storage, previewPath.replace(/\.preview\.png$/, ".pen")); +} +export function getActivePreview(storage: string): string | null { + const penPath = getActivePen(storage); + return penPath ? previewPathForPen(penPath, storage) : null; +} + +// ---------- design discovery --------------------------------------------- + +/** Resolve a user-supplied identifier (name OR path) to an absolute .pen + * path. Validates the result is somewhere safe (workspace or storage). */ +export function resolvePenPath(input: string, ctx: { workspace: string; storage: string }): string { + if (!input || typeof input !== "string") { + throw new Error("missing path/name"); + } + const trimmed = input.trim(); + // Absolute path: keep, but validate it's inside workspace or storage. + if (isAbsolute(trimmed)) { + const abs = resolve(trimmed); + const inWorkspace = !relative(ctx.workspace, abs).startsWith(".."); + const inStorage = !relative(ctx.storage, abs).startsWith(".."); + if (!inWorkspace && !inStorage) { + throw new Error(`path is outside the workspace: ${abs}`); + } + return abs; + } + // Relative path with separators or .pen extension: anchor at workspace. + if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.endsWith(".pen")) { + const abs = resolve(ctx.workspace, trimmed); + if (relative(ctx.workspace, abs).startsWith("..")) { + throw new Error(`path escapes the workspace: ${abs}`); + } + return abs.endsWith(".pen") ? abs : abs + ".pen"; + } + // Bare name: agent default location (under /designs/). + return penPathFor(trimmed, ctx.storage, ctx.workspace); +} + +/** Recursive scan of the workspace for .pen files. Respects ignore dirs, + * caps depth and total file count to keep large monorepos snappy. */ +export function findWorkspaceDesigns(workspace: string, storage: string): Design[] { + const found: Design[] = []; + const stack: { dir: string; depth: number }[] = [{ dir: workspace, depth: 0 }]; + while (stack.length && found.length < MAX_SCAN_FILES) { + const { dir, depth } = stack.pop()!; + if (depth > MAX_SCAN_DEPTH) continue; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + const full = join(dir, e.name); + if (e.isDirectory()) { + if (IGNORED_DIRS.has(e.name) || (e.name.startsWith(".") && e.name !== ".pencil")) continue; + stack.push({ dir: full, depth: depth + 1 }); + continue; + } + if (!e.isFile() || !e.name.endsWith(".pen")) continue; + let stat: fs.Stats; + try { + stat = fs.statSync(full); + } catch { + continue; + } + // Use the same content-addressed cache rule as previewPathForPen + // so the `preview` field always points at the actual cache entry + // (sibling `.preview.png` only exists for files inside /designs/). + const preview = previewPathForPen(full, storage); + found.push({ + name: e.name.slice(0, -".pen".length), + file: full, + preview, + sizeBytes: stat.size, + modifiedAt: stat.mtime.toISOString(), + previewExists: fs.existsSync(preview), + }); + } + } + found.sort((a, b) => (a.modifiedAt < b.modifiedAt ? 1 : -1)); + return found; +} + +/** All designs visible to the editor: agent-generated (in storage) + any + * .pen files the user already has in the workspace. Workspace files are + * tagged as `inWorkspace: true` so the UI can group / mark them. */ +export function listAllDesigns(ctx: { + workspace: string; + storage: string; +}): Array { + const storage = listStorageDesigns(ctx.storage); + const workspace = findWorkspaceDesigns(ctx.workspace, ctx.storage); + // Avoid double-listing: if a workspace file is INSIDE storage (e.g. + // workspace points at a parent containing .pencil/designs/), de-dup by + // absolute path. + const seen = new Set(storage.map((d) => d.file)); + const merged: Array = []; + for (const d of storage) merged.push({ ...d, inWorkspace: false }); + for (const d of workspace) { + if (seen.has(d.file)) continue; + merged.push({ ...d, inWorkspace: true }); + } + merged.sort((a, b) => (a.modifiedAt < b.modifiedAt ? 1 : -1)); + return merged; +} + +/** Just the storage scope. Kept for the legacy `pencil_list_designs` tool + * which the agent uses to find designs IT created. */ +export function listStorageDesigns(storage: string): Design[] { + const dir = join(storage, "designs"); + let entries: string[]; + try { + entries = fs.readdirSync(dir); + } catch { + return []; + } + const out: Design[] = []; + for (const name of entries) { + if (!name.endsWith(".pen")) continue; + const p = join(dir, name); + let stat: fs.Stats; + try { + stat = fs.statSync(p); + } catch { + continue; + } + const base = name.slice(0, -".pen".length); + const previewSibling = join(dir, `${base}.preview.png`); + out.push({ + name: base, + file: p, + preview: previewSibling, + sizeBytes: stat.size, + modifiedAt: stat.mtime.toISOString(), + previewExists: fs.existsSync(previewSibling), + }); + } + out.sort((a, b) => (a.modifiedAt < b.modifiedAt ? 1 : -1)); + return out; +} + +/** Legacy: alias that points at storage-only listing. Old callers (router, + * mcp) still import this name. */ +export const listDesigns = listStorageDesigns; diff --git a/packages/pencil/src/lib/editor-bundle.ts b/packages/pencil/src/lib/editor-bundle.ts new file mode 100644 index 000000000..8fd6c29fe --- /dev/null +++ b/packages/pencil/src/lib/editor-bundle.ts @@ -0,0 +1,234 @@ +// packages/pencil/src/lib/editor-bundle.ts +// +// Resolves the Pencil editor bundle (HTML + JS + WASM) we serve at /editor. +// Source priority: +// 1. Cloud (canonical): hit api.pencil.dev/public/versions, download the +// ZIP from Vercel Blob Storage on first run, cache by version. +// 2. Cursor / VS Code globalStorage (the Pencil extension already +// caches a copy on the same machine). +// 3. Stale local cache as a last resort. +// +// Cloud is preferred: any user with Deus + a CLI key can use the AAP, no +// other Pencil software required. + +import * as fs from "node:fs"; +import * as https from "node:https"; +import * as os from "node:os"; +import { join } from "node:path"; +import * as unzipper from "unzipper"; + +const PENCIL_VERSION_MANIFEST_URL = "https://api.pencil.dev/public/versions"; +const EDITOR_CACHE_ROOT = join(os.homedir(), ".deus", "pencil-editor"); + +interface VersionManifest { + version: string; + minimumExtensionVersion: string; + downloadUrl: string; + cliVersion: string; +} + +/** Resolve a usable editor-bundle directory (containing index.html + assets/). */ +export async function ensureEditorBundle(): Promise { + try { + return await ensureLatestVersionCached(); + } catch (err) { + console.warn(`[pencil-aap] cloud bundle unavailable: ${(err as Error).message}`); + } + const local = findLocalEditorBundle(); + if (local) { + console.log(`[pencil-aap] using local editor bundle: ${local}`); + return local; + } + const cached = newestCachedVersion(); + if (cached) { + const dir = join(EDITOR_CACHE_ROOT, cached); + console.log(`[pencil-aap] using stale cached editor bundle: ${dir}`); + return dir; + } + throw new Error("could not locate Pencil editor bundle (offline + no local install)"); +} + +async function ensureLatestVersionCached(): Promise { + fs.mkdirSync(EDITOR_CACHE_ROOT, { recursive: true }); + const manifest = await fetchJson(PENCIL_VERSION_MANIFEST_URL); + if (!manifest.version || !manifest.downloadUrl) { + throw new Error("pencil version manifest missing version or downloadUrl"); + } + const target = join(EDITOR_CACHE_ROOT, manifest.version); + if (fs.existsSync(join(target, "index.html"))) { + console.log(`[pencil-aap] cached editor bundle: ${target} (v${manifest.version})`); + return target; + } + console.log( + `[pencil-aap] downloading editor bundle v${manifest.version} (this only happens once per version)…` + ); + await downloadAndUnzip(manifest.downloadUrl, EDITOR_CACHE_ROOT, manifest.version); + console.log(`[pencil-aap] cached editor bundle: ${target}`); + return target; +} + +function newestCachedVersion(): string | null { + let entries: string[]; + try { + entries = fs.readdirSync(EDITOR_CACHE_ROOT); + } catch { + return null; + } + const versions = entries + .filter((d) => /^\d+\.\d+\.\d+/.test(d)) + .filter((d) => fs.existsSync(join(EDITOR_CACHE_ROOT, d, "index.html"))) + .sort((a, b) => semverCompare(b, a)); + return versions[0] ?? null; +} + +function semverCompare(a: string, b: string): number { + const pa = a.split(".").map((n) => parseInt(n, 10)); + const pb = b.split(".").map((n) => parseInt(n, 10)); + for (let i = 0; i < 3; i++) { + if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0); + } + return 0; +} + +function findLocalEditorBundle(): string | null { + const HOME = os.homedir(); + const candidates = [ + join(HOME, "Library/Application Support/Cursor/User/globalStorage/highagency.pencildev/editor"), + join(HOME, "Library/Application Support/Code/User/globalStorage/highagency.pencildev/editor"), + join(HOME, ".config/Code/User/globalStorage/highagency.pencildev/editor"), + join(HOME, ".config/Cursor/User/globalStorage/highagency.pencildev/editor"), + ]; + return candidates.find((p) => fs.existsSync(join(p, "index.html"))) ?? null; +} + +// ---- HTTP helpers --------------------------------------------------------- + +function fetchJson(url: string): Promise { + return new Promise((resolve, reject) => { + const req = https.get(url, { timeout: 10_000 }, (res) => { + if (res.statusCode !== 200) { + res.resume(); + return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`)); + } + let body = ""; + res.setEncoding("utf8"); + res.on("data", (c: string) => (body += c)); + res.on("end", () => { + try { + resolve(JSON.parse(body) as T); + } catch (err) { + reject(new Error(`invalid JSON from ${url}: ${(err as Error).message}`)); + } + }); + }); + req.on("timeout", () => req.destroy(new Error(`timeout fetching ${url}`))); + req.on("error", reject); + }); +} + +function downloadStream(url: string, destPath: string, redirectsLeft = 3): Promise { + return new Promise((resolve, reject) => { + const attempt = (currentUrl: string): void => { + const req = https.get(currentUrl, { timeout: 60_000 }, (res) => { + if ([301, 302, 303, 307, 308].includes(res.statusCode ?? 0)) { + res.resume(); + if (redirectsLeft <= 0) return reject(new Error(`too many redirects fetching ${url}`)); + redirectsLeft--; + return attempt(res.headers.location ?? ""); + } + if (res.statusCode !== 200) { + res.resume(); + return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`)); + } + const file = fs.createWriteStream(destPath); + res.pipe(file); + file.on("finish", () => file.close(() => resolve())); + file.on("error", (err) => fs.unlink(destPath, () => reject(err))); + }); + req.on("timeout", () => req.destroy(new Error(`timeout fetching ${url}`))); + req.on("error", reject); + }; + attempt(url); + }); +} + +async function downloadAndUnzip(url: string, cacheRoot: string, version: string): Promise { + const tmpZip = join(cacheRoot, `${version}.tmp.zip`); + const tmpDir = join(cacheRoot, `${version}.tmp`); + const finalDir = join(cacheRoot, version); + try { + await downloadStream(url, tmpZip); + fs.mkdirSync(tmpDir, { recursive: true }); + await new Promise((resolve, reject) => { + fs.createReadStream(tmpZip) + .pipe(unzipper.Extract({ path: tmpDir })) + .on("close", resolve) + .on("error", reject); + }); + const extracted = join(tmpDir, "out"); + if (!fs.existsSync(join(extracted, "index.html"))) { + throw new Error("editor bundle ZIP did not contain out/index.html"); + } + if (fs.existsSync(finalDir)) fs.rmSync(finalDir, { recursive: true, force: true }); + fs.renameSync(extracted, finalDir); + } finally { + if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); + if (fs.existsSync(tmpZip)) fs.unlinkSync(tmpZip); + } +} + +// ---- HTML rewriter -------------------------------------------------------- + +/** Serve the editor's index.html with a ` + `; + // Fail closed if the upstream bundle ever changes the entry-script + // shape — `replace` would silently return the original HTML and the + // IPC bridge would break at runtime with no early signal. + const pattern = /]*type="module"[^>]*>/; + if (!pattern.test(html)) { + throw new Error( + "rewriteEditorIndex: editor index.html no longer contains the expected " + + ' + + diff --git a/packages/pencil/src/ui/styles.css b/packages/pencil/src/ui/styles.css new file mode 100644 index 000000000..3badfaa1d --- /dev/null +++ b/packages/pencil/src/ui/styles.css @@ -0,0 +1,592 @@ +/* packages/pencil/src/ui/styles.css + * + * Thin chrome around the embedded Pencil editor iframe. The editor itself + * brings its own design language; we just need a topbar and the sign-in + * overlay. Restraint over decoration. + */ + +/* ---------- tokens ------------------------------------------------------- */ + +/* Aligned with Deus's dark theme tokens (apps/web/src/global.css `.dark`). + * Pure neutral grays, OKLCH text scale, rose accent. We hard-code values + * here (instead of importing) because the AAP renders inside its own + * iframe with no shared stylesheet. Keep these in sync if Deus's palette + * shifts. */ +:root { + --bg: #0b0b0b; /* bg-base */ + --bg-surface: #141414; + --bg-elev: #1a1a1a; /* bg-elevated */ + --bg-raised: #212121; + --bg-overlay-solid: #242424; + --bg-muted: #2a2a2a; + --bg-sunken: #0b0b0b; + --bg-overlay: rgba(0, 0, 0, 0.55); + + --border: color-mix(in oklch, oklch(0.86 0 0) 5%, transparent); + --border-strong: color-mix(in oklch, oklch(0.86 0 0) 8%, transparent); + + --fg: oklch(0.86 0 0); /* text-primary */ + --fg-secondary: oklch(0.75 0 0); + --fg-muted: oklch(0.58 0 0); /* text-tertiary */ + --fg-subtle: oklch(0.5 0 0); /* text-muted */ + --fg-disabled: oklch(0.32 0 0); + + --accent: oklch(0.78 0.09 345); /* primary — cool rose */ + --accent-fg: #0b0b0b; + --accent-soft: color-mix(in oklch, oklch(0.78 0.09 345) 14%, transparent); + --ring: color-mix(in oklch, oklch(0.78 0.09 345) 40%, transparent); + + --success: oklch(0.68 0.12 150); + --success-soft: color-mix(in oklch, oklch(0.68 0.12 150) 18%, transparent); + --warn: oklch(0.68 0.13 75); + --err: oklch(0.65 0.13 20); + --err-soft: color-mix(in oklch, oklch(0.65 0.13 20) 35%, transparent); + --fg-strong: oklch(0.95 0 0); /* hover state for primary buttons */ + + --shadow-card: 0 24px 80px rgba(0, 0, 0, 0.6); + --shadow-pop: 0 12px 36px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--border-strong); + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 10px; + + --font-sans: -apple-system, "BlinkMacSystemFont", "Inter", "SF Pro Text", system-ui, sans-serif; + --font-mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", "Menlo", monospace; +} + +/* ---------- reset -------------------------------------------------------- */ + +*, +*::before, +*::after { + box-sizing: border-box; +} +/* `display: ...` rules below would otherwise win against the user-agent's + * implicit `[hidden] { display: none }`. Force the attribute to mean what + * it says regardless of any element's own display rule. */ +[hidden] { + display: none !important; +} +html, +body { + height: 100%; + margin: 0; + background: var(--bg); + color: var(--fg); + font: 13px/1.5 var(--font-sans); + -webkit-font-smoothing: antialiased; +} +body { + display: flex; + flex-direction: column; + overflow: hidden; +} +button { + font: inherit; + color: inherit; +} + +/* ---------- topbar ------------------------------------------------------- */ + +.topbar { + display: flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 12px; + border-bottom: 1px solid var(--border); + font-size: 11px; + color: var(--fg-muted); + user-select: none; + background: var(--bg-surface); + flex: 0 0 auto; +} +.brand { + display: flex; + align-items: center; + gap: 7px; +} +.brand-name { + color: var(--fg); + font-weight: 600; + font-size: 12px; + letter-spacing: -0.01em; +} +.brand-version { + font-family: var(--font-mono); + font-size: 10px; + color: var(--fg-subtle); + padding: 1px 5px; + border: 1px solid var(--border); + border-radius: 999px; +} +.brand-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--fg-subtle); + transition: + background 0.2s, + box-shadow 0.2s; +} +.brand-dot[data-state="live"] { + background: var(--success); + box-shadow: 0 0 0 4px var(--success-soft); +} +.brand-dot[data-state="run"] { + background: var(--accent); + box-shadow: 0 0 0 4px var(--accent-soft); + animation: pulse 1.4s ease-in-out infinite; +} +.brand-dot[data-state="warn"] { + background: var(--warn); +} +.topbar-status { + font-size: 11px; +} +.topbar-spacer { + flex: 1; +} + +/* ---------- design switcher (Linear-style file dropdown) -------------- */ + +.switcher { + position: relative; +} +.switcher-trigger { + display: inline-flex; + align-items: center; + gap: 7px; + height: 22px; + padding: 0 9px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--fg); + font: 11px var(--font-sans); + font-feature-settings: "tnum"; + cursor: pointer; + transition: + background 0.12s, + border-color 0.12s; + max-width: 320px; +} +.switcher-trigger:hover { + background: var(--bg-raised); + border-color: var(--border-strong); +} +.switcher-trigger[aria-expanded="true"] { + background: var(--bg-raised); + border-color: var(--border-strong); +} +.switcher-trigger:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--ring); +} +.switcher-trigger.is-empty { + color: var(--fg-muted); +} +.switcher-name { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: -0.01em; +} +.switcher-name-empty { + font-family: var(--font-sans); + color: var(--fg-muted); + font-size: 11px; +} +.switcher-chevron { + width: 10px; + height: 10px; + color: var(--fg-muted); + flex-shrink: 0; + transition: transform 0.15s; +} +.switcher-trigger[aria-expanded="true"] .switcher-chevron { + transform: rotate(180deg); + color: var(--fg); +} +.switcher-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--fg-subtle); + flex-shrink: 0; +} +.switcher-dot.is-workspace { + background: var(--accent); +} + +.switcher-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 280px; + max-width: 460px; + max-height: 360px; + overflow-y: auto; + background: var(--bg-overlay-solid); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + padding: 4px; + box-shadow: var(--shadow-pop); + z-index: 60; + animation: menu-in 0.12s ease-out; +} +@keyframes menu-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.switcher-section { + padding: 6px 8px 3px; + font-size: 9.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-subtle); +} +.switcher-section + .switcher-item { + margin-top: 2px; +} +.switcher-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: 0; + background: transparent; + color: var(--fg-secondary); + font: 11.5px var(--font-sans); + text-align: left; + cursor: pointer; + border-radius: var(--radius-sm); + transition: + background 0.1s, + color 0.1s; +} +.switcher-item:hover, +.switcher-item:focus-visible { + background: var(--bg-raised); + color: var(--fg); + outline: none; +} +.switcher-item.is-active { + background: var(--bg-raised); + color: var(--fg); +} +.switcher-item-name { + font-family: var(--font-mono); + font-size: 10.5px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.switcher-item-meta { + font-size: 10px; + color: var(--fg-subtle); + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} +.switcher-empty { + padding: 10px 8px; + color: var(--fg-muted); + font-size: 11px; + text-align: center; +} +.switcher-divider { + height: 1px; + margin: 4px 0; + background: var(--border); +} +.switcher-new { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 8px; + border: 0; + background: transparent; + color: var(--fg); + font: 11.5px var(--font-sans); + text-align: left; + cursor: pointer; + border-radius: var(--radius-sm); + transition: background 0.1s; +} +.switcher-new:hover, +.switcher-new:focus-visible { + background: var(--accent-soft); + color: var(--fg); + outline: none; +} +.switcher-new svg { + width: 11px; + height: 11px; + color: var(--accent); +} +.switcher-form { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; +} +.switcher-form input, +.switcher-form textarea { + background: var(--bg-elev); + color: var(--fg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 6px 8px; + font: 11.5px var(--font-sans); + outline: none; + transition: border-color 0.12s; + resize: none; +} +.switcher-form input:focus, +.switcher-form textarea:focus { + border-color: var(--accent); +} +.switcher-form textarea { + min-height: 64px; + font-family: inherit; +} +.switcher-form-row { + display: flex; + gap: 6px; + justify-content: flex-end; +} +.switcher-form-row .btn--primary, +.switcher-form-row .btn--ghost { + height: 26px; + padding: 0 12px; + font-size: 11px; + border-radius: var(--radius-sm); +} +.btn--ghost { + background: transparent; + color: var(--fg-muted); + border: 1px solid var(--border-strong); +} +.btn--ghost:hover { + color: var(--fg); + background: var(--bg-raised); +} +.topbar-running { + display: inline-flex; + align-items: center; + gap: 8px; + height: 22px; + padding: 0 8px 0 10px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--accent-soft); + color: var(--fg); + font-size: 11px; +} +.topbar-auth { + font-size: 11px; +} +.dot { + width: 6px; + height: 6px; + border-radius: 50%; +} +.dot--run { + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + animation: pulse 1.4s ease-in-out infinite; +} +.elapsed { + font-family: var(--font-mono); + font-size: 10px; + color: var(--fg-muted); +} +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.55; + } +} + +/* ---------- editor iframe ----------------------------------------------- */ + +.editor-frame { + flex: 1; + width: 100%; + height: 100%; + border: 0; + background: var(--bg); + display: block; +} + +/* ---------- buttons ------------------------------------------------------ */ + +.btn { + border: 0; + cursor: pointer; + font: 600 12px var(--font-sans); + padding: 6px 12px; + border-radius: 6px; + transition: + background 0.12s, + border-color 0.12s, + opacity 0.12s; +} +.btn--primary { + background: var(--fg); + color: var(--bg); +} +.btn--primary:hover { + background: var(--fg-strong); +} +.btn--primary:disabled { + opacity: 0.5; + cursor: default; +} +.btn--danger { + background: transparent; + color: var(--err); + border: 1px solid color-mix(in oklch, var(--err) 35%, transparent); + font-weight: 500; + padding: 0 9px; + height: 18px; + font-size: 10.5px; + border-radius: var(--radius-sm); +} +.btn--danger:hover { + border-color: var(--err); + background: color-mix(in oklch, var(--err) 10%, transparent); +} + +/* ---------- sign-in overlay ---------------------------------------------- */ + +.signin-overlay { + position: fixed; + inset: 32px 0 0 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-overlay); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + z-index: 100; +} +.signin-card { + background: var(--bg-elev); + border: 1px solid var(--border-strong); + border-radius: 10px; + padding: 28px; + max-width: 460px; + width: calc(100% - 48px); + box-shadow: var(--shadow-card); +} +.signin-card h1 { + font-size: 17px; + font-weight: 600; + letter-spacing: -0.01em; + margin: 0 0 8px; +} +.signin-card p { + color: var(--fg-muted); + font-size: 13px; + margin: 0 0 16px; + line-height: 1.55; +} +.signin-card code { + font-family: var(--font-mono); + font-size: 12px; + background: var(--bg-sunken); + padding: 1px 5px; + border-radius: 3px; + color: var(--fg); +} +.signin-card a { + color: var(--fg); + text-decoration: underline; + text-decoration-color: var(--fg-subtle); + text-underline-offset: 2px; +} +.signin-steps { + margin: 0 0 16px; + padding-left: 20px; + color: var(--fg-secondary); + font-size: 12px; + line-height: 1.55; +} +.signin-steps li + li { + margin-top: 5px; +} +.signin-steps code { + font-size: 11px; +} +.signin-form { + display: flex; + gap: 8px; + margin-bottom: 8px; +} +.signin-form input { + flex: 1; + padding: 9px 11px; + background: var(--bg-sunken); + color: var(--fg); + border: 1px solid var(--border-strong); + border-radius: 6px; + font: 12px var(--font-mono); + outline: none; + -webkit-text-security: disc; + transition: border-color 0.12s; +} +.signin-form input:focus { + border-color: var(--accent); +} +.signin-error { + color: var(--err); + font-size: 12px; + min-height: 18px; +} + +/* ---------- toast -------------------------------------------------------- */ + +.toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translate(-50%, 8px); + background: var(--bg-elev); + color: var(--fg); + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 8px 14px; + font-size: 12px; + opacity: 0; + pointer-events: none; + transition: + opacity 0.18s, + transform 0.18s; + z-index: 1000; + box-shadow: var(--shadow-card); +} +.toast--show { + opacity: 1; + transform: translate(-50%, 0); +} +.toast--error { + border-color: var(--err-soft); + color: var(--err); +} diff --git a/packages/pencil/test/cli.test.ts b/packages/pencil/test/cli.test.ts new file mode 100644 index 000000000..829177e8b --- /dev/null +++ b/packages/pencil/test/cli.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { buildCliEnv } from "../src/lib/cli.ts"; + +describe("buildCliEnv", () => { + it("uses per-call CLI key overrides without mutating process.env", () => { + const previous = process.env.PENCIL_CLI_KEY; + delete process.env.PENCIL_CLI_KEY; + + try { + const env = buildCliEnv({ PENCIL_CLI_KEY: "pencil_cli_test" }); + expect(env.PENCIL_CLI_KEY).toBe("pencil_cli_test"); + expect(process.env.PENCIL_CLI_KEY).toBeUndefined(); + } finally { + if (previous === undefined) delete process.env.PENCIL_CLI_KEY; + else process.env.PENCIL_CLI_KEY = previous; + } + }); + + it("pins local API and development NODE_ENV to production defaults", () => { + const env = buildCliEnv({ + PENCIL_API_BASE: "http://localhost:3001", + NODE_ENV: "development", + }); + + expect(env.PENCIL_API_BASE).toBe("https://api.pencil.dev"); + expect(env.NODE_ENV).toBe("production"); + }); +}); diff --git a/packages/pencil/test/iframe-rpc.test.ts b/packages/pencil/test/iframe-rpc.test.ts new file mode 100644 index 000000000..3adcf9811 --- /dev/null +++ b/packages/pencil/test/iframe-rpc.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeIframePayload } from "../src/lib/iframe-rpc.ts"; + +describe("normalizeIframePayload", () => { + it("maps batch_design operations string to the editor's input field", () => { + expect( + normalizeIframePayload("batch-design", { + filePath: "/tmp/demo.pen", + operations: 'screen=I(document,{type:"frame"})', + }) + ).toEqual({ + filePath: "/tmp/demo.pen", + operations: 'screen=I(document,{type:"frame"})', + input: 'screen=I(document,{type:"frame"})', + }); + }); + + it("joins operation arrays for callers that follow the skill examples", () => { + expect( + normalizeIframePayload("batch-design", { + operations: ['screen=I(document,{type:"frame"})', 'title=I(screen,{type:"text"})'], + }) + ).toMatchObject({ + input: 'screen=I(document,{type:"frame"})\ntitle=I(screen,{type:"text"})', + }); + }); + + it("preserves explicit input and unrelated payloads", () => { + const payload = { input: "already-normalized", operations: ["ignored"] }; + expect(normalizeIframePayload("batch-design", payload)).toBe(payload); + expect(normalizeIframePayload("get-editor-state", { operations: ["ignored"] })).toEqual({ + operations: ["ignored"], + }); + }); +}); diff --git a/packages/pencil/tsconfig.json b/packages/pencil/tsconfig.json new file mode 100644 index 000000000..6a8495c7c --- /dev/null +++ b/packages/pencil/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"] + }, + "include": ["src/**/*", "build.ts"] +} diff --git a/scripts/prune-pencil-cli-binaries.cjs b/scripts/prune-pencil-cli-binaries.cjs new file mode 100644 index 000000000..267616227 --- /dev/null +++ b/scripts/prune-pencil-cli-binaries.cjs @@ -0,0 +1,105 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const ARCH_BY_BUILDER_VALUE = new Map([ + [1, "x64"], + [3, "arm64"], + ["x64", "x64"], + ["arm64", "arm64"], +]); + +function platformSegment(electronPlatformName) { + if (electronPlatformName === "darwin") return "darwin"; + if (electronPlatformName === "linux") return "linux"; + if (electronPlatformName === "win32") return "windows"; + return null; +} + +function binaryNamesForTarget(electronPlatformName, archValue) { + const platform = platformSegment(electronPlatformName); + if (!platform) return new Set(); + + const arch = ARCH_BY_BUILDER_VALUE.get(archValue); + const arches = arch ? [arch] : ["arm64", "x64"]; + const ext = platform === "windows" ? ".exe" : ""; + return new Set(arches.map((item) => `mcp-server-${platform}-${item}${ext}`)); +} + +function resourcesDirForContext(context) { + const productName = context.packager?.appInfo?.productFilename ?? "Deus"; + if (context.electronPlatformName === "darwin") { + return path.join(context.appOutDir, `${productName}.app`, "Contents", "Resources"); + } + return path.join(context.appOutDir, "resources"); +} + +function candidateOutDirs(resourcesDir) { + return [ + path.join( + resourcesDir, + "app.asar.unpacked", + "node_modules", + "@pencil.dev", + "cli", + "dist", + "out" + ), + path.join(resourcesDir, "node_modules", "@pencil.dev", "cli", "dist", "out"), + path.join(resourcesDir, "app", "node_modules", "@pencil.dev", "cli", "dist", "out"), + path.join( + resourcesDir, + "agentic-apps", + "pencil", + "node_modules", + "@pencil.dev", + "cli", + "dist", + "out" + ), + ]; +} + +function pruneOutDir(outDir, keepNames) { + if (!fs.existsSync(outDir)) return { removed: 0, kept: 0 }; + + let removed = 0; + let kept = 0; + for (const entry of fs.readdirSync(outDir, { withFileTypes: true })) { + if (!entry.name.startsWith("mcp-server-")) continue; + const entryPath = path.join(outDir, entry.name); + if (keepNames.has(entry.name)) { + kept++; + continue; + } + fs.rmSync(entryPath, { recursive: true, force: true }); + removed++; + } + return { removed, kept }; +} + +function prunePencilCliBinaries(context) { + const keepNames = binaryNamesForTarget(context.electronPlatformName, context.arch); + if (keepNames.size === 0) return { removed: 0, kept: 0 }; + + const resourcesDir = context.resourcesDir ?? resourcesDirForContext(context); + const totals = { removed: 0, kept: 0 }; + for (const outDir of candidateOutDirs(resourcesDir)) { + const result = pruneOutDir(outDir, keepNames); + totals.removed += result.removed; + totals.kept += result.kept; + } + + if (totals.removed > 0 || totals.kept > 0) { + console.log( + `[prune-pencil-cli] kept ${[...keepNames].join(", ")}; removed ${totals.removed} unused MCP binaries` + ); + } + return totals; +} + +module.exports = async function afterPack(context) { + prunePencilCliBinaries(context); +}; + +module.exports.prunePencilCliBinaries = prunePencilCliBinaries; +module.exports.binaryNamesForTarget = binaryNamesForTarget; diff --git a/shared/aap/manifest.ts b/shared/aap/manifest.ts index bb206bee2..22685d647 100644 --- a/shared/aap/manifest.ts +++ b/shared/aap/manifest.ts @@ -57,6 +57,18 @@ export const LaunchSchema = z.object({ }); export type Launch = z.infer; +// ---------------------------------------------------------------------------- +// prefetch — optional one-shot warmup command run at host boot +// ---------------------------------------------------------------------------- + +export const PrefetchSchema = z.object({ + command: z.string().min(1), + args: z.array(z.string()).default([]), + cwd: z.string().optional(), + env: z.record(z.string(), z.string()).default({}), +}); +export type Prefetch = z.infer; + // ---------------------------------------------------------------------------- // ui — just a URL in v1 // ---------------------------------------------------------------------------- @@ -148,6 +160,7 @@ export const ManifestSchema = z.object({ icon: z.string().optional(), launch: LaunchSchema, + prefetch: PrefetchSchema.optional(), ui: UiSchema, agent: AgentSchema, storage: StorageSchema, diff --git a/test/unit/runtime/prune-pencil-cli-binaries.test.ts b/test/unit/runtime/prune-pencil-cli-binaries.test.ts new file mode 100644 index 000000000..12b469c08 --- /dev/null +++ b/test/unit/runtime/prune-pencil-cli-binaries.test.ts @@ -0,0 +1,99 @@ +import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { binaryNamesForTarget, prunePencilCliBinaries } = + require("../../../scripts/prune-pencil-cli-binaries.cjs") as { + binaryNamesForTarget: (platform: string, arch: string | number) => Set; + prunePencilCliBinaries: (context: { + electronPlatformName: string; + arch: string | number; + resourcesDir: string; + }) => { removed: number; kept: number }; + }; + +const tempRoots: string[] = []; + +function createOutDir( + candidatePath = ["app.asar.unpacked", "node_modules", "@pencil.dev", "cli", "dist", "out"] +): string { + const root = path.join(os.tmpdir(), `deus-pencil-prune-${Date.now()}-${Math.random()}`); + tempRoots.push(root); + const outDir = path.join(root, ...candidatePath); + mkdirSync(path.join(outDir, "data"), { recursive: true }); + for (const name of [ + "mcp-server-darwin-arm64", + "mcp-server-darwin-x64", + "mcp-server-linux-x64", + "mcp-server-windows-x64.exe", + ]) { + writeFileSync(path.join(outDir, name), name); + } + writeFileSync(path.join(outDir, "data", "shadcn.lib.pen"), "library"); + return root; +} + +function outDirFor(root: string, candidatePath: string[]): string { + return path.join(root, ...candidatePath); +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("prune-pencil-cli-binaries", () => { + it("keeps only the target MCP binary and shared data files", () => { + const resourcesDir = createOutDir(); + const result = prunePencilCliBinaries({ + electronPlatformName: "darwin", + arch: "arm64", + resourcesDir, + }); + + const outDir = outDirFor(resourcesDir, [ + "app.asar.unpacked", + "node_modules", + "@pencil.dev", + "cli", + "dist", + "out", + ]); + expect(result).toEqual({ removed: 3, kept: 1 }); + expect(readdirSync(outDir).sort()).toEqual(["data", "mcp-server-darwin-arm64"]); + expect(readdirSync(path.join(outDir, "data"))).toEqual(["shadcn.lib.pen"]); + }); + + it("also prunes the packaged Pencil app dependency copy", () => { + const candidatePath = [ + "agentic-apps", + "pencil", + "node_modules", + "@pencil.dev", + "cli", + "dist", + "out", + ]; + const resourcesDir = createOutDir(candidatePath); + const result = prunePencilCliBinaries({ + electronPlatformName: "linux", + arch: "x64", + resourcesDir, + }); + + expect(result).toEqual({ removed: 3, kept: 1 }); + expect(readdirSync(outDirFor(resourcesDir, candidatePath)).sort()).toEqual([ + "data", + "mcp-server-linux-x64", + ]); + }); + + it("maps electron-builder arch numbers to Pencil binary names", () => { + expect(binaryNamesForTarget("win32", 1)).toEqual(new Set(["mcp-server-windows-x64.exe"])); + expect(binaryNamesForTarget("linux", 3)).toEqual(new Set(["mcp-server-linux-arm64"])); + }); +});