From 1bd2130a35b14a94006a33341409dac54c85b37f Mon Sep 17 00:00:00 2001 From: Georgiy Tarasov Date: Thu, 12 Feb 2026 18:26:09 +0100 Subject: [PATCH 1/6] plugin --- apps/twig/forge.config.ts | 2 +- apps/twig/src/main/services/agent/service.ts | 32 +++++++++---- apps/twig/vite.main.config.mts | 45 ++++++++++++++++++- .../src/adapters/claude/session/options.ts | 1 + plugins/claude-code/plugin.json | 5 +++ plugins/claude-code/skills/.gitkeep | 0 6 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 plugins/claude-code/plugin.json create mode 100644 plugins/claude-code/skills/.gitkeep diff --git a/apps/twig/forge.config.ts b/apps/twig/forge.config.ts index 6bc2073e5..07ebb76ea 100644 --- a/apps/twig/forge.config.ts +++ b/apps/twig/forge.config.ts @@ -128,7 +128,7 @@ const config: ForgeConfig = { packagerConfig: { asar: { unpack: - "{**/*.node,**/spawn-helper,**/.vite/build/claude-cli/**,**/.vite/build/codex-acp/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**}", + "{**/*.node,**/spawn-helper,**/.vite/build/claude-cli/**,**/.vite/build/claude-code/posthog/**,**/.vite/build/codex-acp/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**}", }, prune: false, name: "Twig", diff --git a/apps/twig/src/main/services/agent/service.ts b/apps/twig/src/main/services/agent/service.ts index 8547575a3..30476f27a 100644 --- a/apps/twig/src/main/services/agent/service.ts +++ b/apps/twig/src/main/services/agent/service.ts @@ -228,6 +228,13 @@ function getClaudeCliPath(): string { : join(appPath, ".vite/build/claude-cli/cli.js"); } +function getClaudeCodePluginPath(): string { + const appPath = app.getAppPath(); + return app.isPackaged + ? join(`${appPath}.unpacked`, ".vite/build/claude-code/posthog") + : join(appPath, ".vite/build/claude-code/posthog"); +} + function getCodexBinaryPath(): string { const appPath = app.getAppPath(); return app.isPackaged @@ -385,6 +392,7 @@ export class AgentService extends TypedEventEmitter { name: "x-posthog-project-id", value: String(credentials.projectId), }, + { name: "x-posthog-mcp-version", value: "2" }, ], }); @@ -569,11 +577,16 @@ export class AgentService extends TypedEventEmitter { sessionId: config.sessionId!, systemPrompt, ...(permissionMode && { permissionMode }), - ...(additionalDirectories?.length && { - claudeCode: { - options: { additionalDirectories }, + claudeCode: { + options: { + ...(additionalDirectories?.length && { + additionalDirectories, + }), + plugins: [ + { type: "local" as const, path: getClaudeCodePluginPath() }, + ], }, - }), + }, }, }, ); @@ -602,11 +615,14 @@ export class AgentService extends TypedEventEmitter { taskRunId, systemPrompt, ...(permissionMode && { permissionMode }), - ...(additionalDirectories?.length && { - claudeCode: { - options: { additionalDirectories }, + claudeCode: { + options: { + ...(additionalDirectories?.length && { additionalDirectories }), + plugins: [ + { type: "local" as const, path: getClaudeCodePluginPath() }, + ], }, - }), + }, }, }); configOptions = newSessionResponse.configOptions ?? undefined; diff --git a/apps/twig/vite.main.config.mts b/apps/twig/vite.main.config.mts index 42c22d134..17520d728 100644 --- a/apps/twig/vite.main.config.mts +++ b/apps/twig/vite.main.config.mts @@ -1,5 +1,12 @@ import { execSync } from "node:child_process"; -import { copyFileSync, cpSync, existsSync, mkdirSync } from "node:fs"; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + statSync, +} from "node:fs"; import path, { join } from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig, loadEnv, type Plugin } from "vite"; @@ -121,6 +128,41 @@ function copyClaudeExecutable(): Plugin { }; } +function getFilesRecursive(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir)) { + const fullPath = join(dir, entry); + if (statSync(fullPath).isDirectory()) { + files.push(...getFilesRecursive(fullPath)); + } else { + files.push(fullPath); + } + } + return files; +} + +function copyClaudeCodePlugin(): Plugin { + const sourceDir = join(__dirname, "../../plugins/claude-code"); + + return { + name: "copy-claude-code-posthog-plugin", + buildStart() { + if (existsSync(sourceDir)) { + for (const file of getFilesRecursive(sourceDir)) { + this.addWatchFile(file); + } + } + }, + writeBundle() { + // Keep the name of the directory "posthog" as it is used as the plugin name. + const destDir = join(__dirname, ".vite/build/claude-code/posthog"); + if (existsSync(sourceDir)) { + cpSync(sourceDir, destDir, { recursive: true }); + } + }, + }; +} + function copyCodexAcpBinaries(): Plugin { return { name: "copy-codex-acp-binaries", @@ -178,6 +220,7 @@ export default defineConfig(({ mode }) => { fixFilenameCircularRef(), copyClaudeExecutable(), copyCodexAcpBinaries(), + copyClaudeCodePlugin(), createPosthogPlugin(env), ].filter(Boolean), define: { diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 01659c49f..c9b36b2da 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -88,6 +88,7 @@ function buildEnvironment(): Record { ...process.env, ELECTRON_RUN_AS_NODE: "1", CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: "true", + ENABLE_EXPERIMENTAL_MCP_CLI: "true", }; } diff --git a/plugins/claude-code/plugin.json b/plugins/claude-code/plugin.json new file mode 100644 index 000000000..00e4e9180 --- /dev/null +++ b/plugins/claude-code/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "posthog", + "description": "PostHog skills for Twig", + "version": "1.0.0" +} diff --git a/plugins/claude-code/skills/.gitkeep b/plugins/claude-code/skills/.gitkeep new file mode 100644 index 000000000..e69de29bb From e4334f0a2835a813f0a6288630291d5d47b3d9ba Mon Sep 17 00:00:00 2001 From: Georgiy Tarasov Date: Fri, 13 Feb 2026 11:00:51 +0100 Subject: [PATCH 2/6] service --- .gitignore | 3 + apps/twig/src/main/di/container.ts | 2 + apps/twig/src/main/di/tokens.ts | 1 + apps/twig/src/main/index.ts | 2 + apps/twig/src/main/services/agent/service.ts | 22 +- .../main/services/posthog-plugin/README.md | 82 +++++ .../main/services/posthog-plugin/service.ts | 285 ++++++++++++++++++ apps/twig/vite.main.config.mts | 138 ++++++++- package.json | 3 +- scripts/pull-skills.mjs | 87 ++++++ 10 files changed, 609 insertions(+), 16 deletions(-) create mode 100644 apps/twig/src/main/services/posthog-plugin/README.md create mode 100644 apps/twig/src/main/services/posthog-plugin/service.ts create mode 100644 scripts/pull-skills.mjs diff --git a/.gitignore b/.gitignore index a9235073d..0a99e1b8c 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ apps/twig/resources/codex-acp/ # Licensed fonts (downloaded from S3 during CI) apps/twig/assets/fonts/BerkeleyMono/ + +# Local dev skills (override shipped + remote skills in dev mode) +plugins/claude-code/local-skills/ diff --git a/apps/twig/src/main/di/container.ts b/apps/twig/src/main/di/container.ts index 53fadc8ae..ec1bb5bd2 100644 --- a/apps/twig/src/main/di/container.ts +++ b/apps/twig/src/main/di/container.ts @@ -17,6 +17,7 @@ import { GitService } from "../services/git/service.js"; import { LlmGatewayService } from "../services/llm-gateway/service.js"; import { NotificationService } from "../services/notification/service.js"; import { OAuthService } from "../services/oauth/service.js"; +import { PosthogPluginService } from "../services/posthog-plugin/service.js"; import { ProcessTrackingService } from "../services/process-tracking/service.js"; import { ShellService } from "../services/shell/service.js"; import { SleepService } from "../services/sleep/service.js"; @@ -49,6 +50,7 @@ container.bind(MAIN_TOKENS.GitService).to(GitService); container.bind(MAIN_TOKENS.NotificationService).to(NotificationService); container.bind(MAIN_TOKENS.OAuthService).to(OAuthService); container.bind(MAIN_TOKENS.ProcessTrackingService).to(ProcessTrackingService); +container.bind(MAIN_TOKENS.PosthogPluginService).to(PosthogPluginService); container.bind(MAIN_TOKENS.SleepService).to(SleepService); container.bind(MAIN_TOKENS.ShellService).to(ShellService); container.bind(MAIN_TOKENS.UIService).to(UIService); diff --git a/apps/twig/src/main/di/tokens.ts b/apps/twig/src/main/di/tokens.ts index 7007ee912..8ec9a65d7 100644 --- a/apps/twig/src/main/di/tokens.ts +++ b/apps/twig/src/main/di/tokens.ts @@ -26,6 +26,7 @@ export const MAIN_TOKENS = Object.freeze({ ProcessTrackingService: Symbol.for("Main.ProcessTrackingService"), SleepService: Symbol.for("Main.SleepService"), ShellService: Symbol.for("Main.ShellService"), + PosthogPluginService: Symbol.for("Main.PosthogPluginService"), UIService: Symbol.for("Main.UIService"), UpdatesService: Symbol.for("Main.UpdatesService"), TaskLinkService: Symbol.for("Main.TaskLinkService"), diff --git a/apps/twig/src/main/index.ts b/apps/twig/src/main/index.ts index 5c93f2667..03297d33b 100644 --- a/apps/twig/src/main/index.ts +++ b/apps/twig/src/main/index.ts @@ -19,6 +19,7 @@ import { trackAppEvent, withTeamContext, } from "./services/posthog-analytics.js"; +import type { PosthogPluginService } from "./services/posthog-plugin/service.js"; import type { TaskLinkService } from "./services/task-link/service"; import type { UpdatesService } from "./services/updates/service.js"; import type { WorkspaceService } from "./services/workspace/service.js"; @@ -55,6 +56,7 @@ function initializeServices(): void { container.get(MAIN_TOKENS.UpdatesService); container.get(MAIN_TOKENS.TaskLinkService); container.get(MAIN_TOKENS.ExternalAppsService); + container.get(MAIN_TOKENS.PosthogPluginService); // Initialize PostHog analytics initializePostHog(); diff --git a/apps/twig/src/main/services/agent/service.ts b/apps/twig/src/main/services/agent/service.ts index 30476f27a..c61da04b0 100644 --- a/apps/twig/src/main/services/agent/service.ts +++ b/apps/twig/src/main/services/agent/service.ts @@ -27,6 +27,7 @@ import { MAIN_TOKENS } from "../../di/tokens.js"; import { logger } from "../../lib/logger.js"; import { TypedEventEmitter } from "../../lib/typed-event-emitter.js"; import type { FsService } from "../fs/service.js"; +import type { PosthogPluginService } from "../posthog-plugin/service.js"; import type { ProcessTrackingService } from "../process-tracking/service.js"; import type { SleepService } from "../sleep/service.js"; import { @@ -228,13 +229,6 @@ function getClaudeCliPath(): string { : join(appPath, ".vite/build/claude-cli/cli.js"); } -function getClaudeCodePluginPath(): string { - const appPath = app.getAppPath(); - return app.isPackaged - ? join(`${appPath}.unpacked`, ".vite/build/claude-code/posthog") - : join(appPath, ".vite/build/claude-code/posthog"); -} - function getCodexBinaryPath(): string { const appPath = app.getAppPath(); return app.isPackaged @@ -257,6 +251,7 @@ export class AgentService extends TypedEventEmitter { private processTracking: ProcessTrackingService; private sleepService: SleepService; private fsService: FsService; + private posthogPluginService: PosthogPluginService; constructor( @inject(MAIN_TOKENS.ProcessTrackingService) @@ -265,11 +260,14 @@ export class AgentService extends TypedEventEmitter { sleepService: SleepService, @inject(MAIN_TOKENS.FsService) fsService: FsService, + @inject(MAIN_TOKENS.PosthogPluginService) + posthogPluginService: PosthogPluginService, ) { super(); this.processTracking = processTracking; this.sleepService = sleepService; this.fsService = fsService; + this.posthogPluginService = posthogPluginService; } public updateToken(newToken: string): void { @@ -583,7 +581,10 @@ export class AgentService extends TypedEventEmitter { additionalDirectories, }), plugins: [ - { type: "local" as const, path: getClaudeCodePluginPath() }, + { + type: "local" as const, + path: this.posthogPluginService.getPluginPath(), + }, ], }, }, @@ -619,7 +620,10 @@ export class AgentService extends TypedEventEmitter { options: { ...(additionalDirectories?.length && { additionalDirectories }), plugins: [ - { type: "local" as const, path: getClaudeCodePluginPath() }, + { + type: "local" as const, + path: this.posthogPluginService.getPluginPath(), + }, ], }, }, diff --git a/apps/twig/src/main/services/posthog-plugin/README.md b/apps/twig/src/main/services/posthog-plugin/README.md new file mode 100644 index 000000000..869604aec --- /dev/null +++ b/apps/twig/src/main/services/posthog-plugin/README.md @@ -0,0 +1,82 @@ +# PosthogPluginService + +Provides the PostHog plugin to agent sessions (Claude Code and Codex). The plugin is a directory containing `plugin.json` and a `skills/` folder of markdown instruction files that teach agents how to use PostHog APIs. + +`AgentService` calls `getPluginPath()` when starting each session to get the path to the assembled plugin directory. + +## Skills + +Skills are the main content of the plugin. Each skill is a directory containing a `SKILL.md` and optional `references/` folder with supporting docs. For example, the `query-data` skill teaches agents how to write HogQL queries against PostHog's API. + +Skills are published independently from Twig at a stable GitHub releases URL (`skills.zip`). This service ensures agents always have the latest skills without requiring a Twig update. + +### Skill Sources + +The plugin directory is assembled from three skill sources, merged in priority order (later overrides earlier for same-named skills): + +| Source | Location | When used | +|---|---|---| +| **Shipped** | `plugins/claude-code/skills/` | Always — committed to the repo | +| **Remote** | GitHub releases `skills.zip` | Downloaded at build time and every 30 min at runtime | +| **Local dev** | `plugins/claude-code/local-skills/` | Dev mode only — gitignored | + +A "skill name" is its directory name. If remote and shipped both have `query-data/`, the remote version wins. If local-dev also has `query-data/`, that wins over both. + +## Build Time + +`copyClaudeCodePlugin()` in `vite.main.config.mts` assembles the plugin during `writeBundle`: + +1. Copies `plugin.json` into `.vite/build/claude-code/posthog/` +2. Copies shipped skills from `plugins/claude-code/skills/` +3. Downloads `skills.zip` via `curl`, extracts with `unzip`, overlays into the build output +4. In dev mode only: overlays `plugins/claude-code/local-skills/` on top +5. Download failure is non-fatal — build continues with shipped skills only + +Vite watches `plugins/claude-code/` (and `local-skills/` in dev) for hot-reload. + +## Runtime + +`PosthogPluginService` is an InversifyJS singleton that keeps the plugin fresh in production builds where the Vite dev server isn't running. + +**On startup:** +1. Creates `{userData}/claude-code-plugin/posthog/` (the runtime plugin dir) +2. Assembles it: copies `plugin.json` from bundled, merges bundled skills + any previously-downloaded remote skills +3. Syncs skills to `$HOME/.agents/skills/` for Codex +4. Starts a 30-minute interval timer +5. Kicks off the first async download + +**Every 30 minutes (`updateSkills`):** +1. Downloads `skills.zip` using `net.fetch` (Electron's network stack, respects proxy) +2. Extracts to a temp dir via `unzip` +3. Atomically swaps into `{userData}/skills/` +4. Re-assembles the runtime plugin dir +5. Re-syncs to Codex +6. On failure: logs a warning, keeps existing skills, retries next interval + +**`getPluginPath()`** — called by `AgentService` when starting sessions: +- Dev mode → bundled path (Vite already merged everything) +- Prod → `{userData}/claude-code-plugin/posthog/` (with downloaded updates) +- Fallback → bundled path + +### Codex Sync + +After every assembly, skills are copied to `$HOME/.agents/skills/` so that Codex sessions also pick them up. + +## Dev Workflow + +### Testing with local skills + +1. Create a skill directory in `plugins/claude-code/local-skills/`, e.g.: + ``` + plugins/claude-code/local-skills/my-skill/SKILL.md + ``` +2. Run `pnpm dev:twig` — Vite watches and hot-reloads +3. The local skill overrides any shipped or remote skill with the same name + +### Pulling remote skills locally for editing + +```sh +pnpm pull-skills +``` + +Downloads the latest `skills.zip` into `plugins/claude-code/local-skills/`. You can then edit them locally and Vite will pick up changes. diff --git a/apps/twig/src/main/services/posthog-plugin/service.ts b/apps/twig/src/main/services/posthog-plugin/service.ts new file mode 100644 index 000000000..d248c588d --- /dev/null +++ b/apps/twig/src/main/services/posthog-plugin/service.ts @@ -0,0 +1,285 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { cp, mkdir, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { app, net } from "electron"; +import { injectable, postConstruct, preDestroy } from "inversify"; +import { logger } from "../../lib/logger.js"; +import { TypedEventEmitter } from "../../lib/typed-event-emitter.js"; + +const log = logger.scope("posthog-plugin"); + +const execFileAsync = promisify(execFile); +const SKILLS_ZIP_URL = process.env.SKILLS_ZIP_URL!; +const UPDATE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes +const CODEX_SKILLS_DIR = join(homedir(), ".agents", "skills"); + +interface SkillsEvents { + updated: boolean; +} + +@injectable() +export class PosthogPluginService extends TypedEventEmitter { + private intervalId: ReturnType | null = null; + private lastCheckAt = 0; + private updating = false; + + /** Runtime plugin dir under userData */ + private get runtimePluginDir(): string { + return join(app.getPath("userData"), "claude-code-plugin", "posthog"); + } + + /** Runtime skills cache (downloaded zips extracted here) */ + private get runtimeSkillsDir(): string { + return join(app.getPath("userData"), "skills"); + } + + /** Bundled plugin path inside the .vite build output */ + private get bundledPluginDir(): string { + const appPath = app.getAppPath(); + return app.isPackaged + ? join(`${appPath}.unpacked`, ".vite/build/claude-code/posthog") + : join(appPath, ".vite/build/claude-code/posthog"); + } + + @postConstruct() + init(): void { + this.initialize().catch((err) => { + log.error("Skills initialization failed", err); + }); + } + + private async initialize(): Promise { + // On first run (or after app update), copy the entire bundled plugin to the runtime dir. + // On subsequent starts the runtime dir already exists — just overlay any cached downloaded skills. + if (!existsSync(join(this.runtimePluginDir, "plugin.json"))) { + await this.copyBundledPlugin(); + } + + // Overlay any previously-downloaded skills on top of the runtime plugin + await this.overlayDownloadedSkills(); + + await this.syncCodexSkills(); + + // Start periodic updates + this.intervalId = setInterval(() => { + this.updateSkills().catch((err) => { + log.warn("Periodic skills update failed", err); + }); + }, UPDATE_INTERVAL_MS); + + // Kick off first download + await this.updateSkills(); + } + + /** + * Returns the path to the plugin directory that should be used for agent sessions. + * + * - In dev mode: Vite already merged shipped + remote + local-dev skills, so use bundled path. + * - In prod: use the runtime plugin dir (with downloaded updates). + * - Fallback: bundled plugin path. + */ + getPluginPath(): string { + if (!app.isPackaged) { + return this.bundledPluginDir; + } + + if (existsSync(join(this.runtimePluginDir, "plugin.json"))) { + return this.runtimePluginDir; + } + + return this.bundledPluginDir; + } + + async updateSkills(): Promise { + const now = Date.now(); + if (now - this.lastCheckAt < UPDATE_INTERVAL_MS) { + return; + } + + if (this.updating) { + return; + } + + this.updating = true; + this.lastCheckAt = now; + + try { + const tempDir = join(tmpdir(), `twig-skills-${Date.now()}`); + await mkdir(tempDir, { recursive: true }); + + try { + const zipPath = join(tempDir, "skills.zip"); + await this.downloadFile(SKILLS_ZIP_URL, zipPath); + + const extractDir = join(tempDir, "extracted"); + await mkdir(extractDir, { recursive: true }); + await execFileAsync("unzip", ["-o", zipPath, "-d", extractDir]); + + const skillsSource = await this.findSkillsDir(extractDir); + if (!skillsSource) { + log.warn("No skills directory found in downloaded archive"); + return; + } + + // Atomic swap into runtime skills cache + const newSkillsDir = `${this.runtimeSkillsDir}.new`; + await rm(newSkillsDir, { recursive: true, force: true }); + await cp(skillsSource, newSkillsDir, { recursive: true }); + + const oldSkillsDir = `${this.runtimeSkillsDir}.old`; + await rm(oldSkillsDir, { recursive: true, force: true }); + if (existsSync(this.runtimeSkillsDir)) { + await rename(this.runtimeSkillsDir, oldSkillsDir); + } + await rename(newSkillsDir, this.runtimeSkillsDir); + await rm(oldSkillsDir, { recursive: true, force: true }); + + // Overlay new skills into the runtime plugin dir + await this.overlayDownloadedSkills(); + + await this.syncCodexSkills(); + + log.info("Skills updated successfully"); + this.emit("updated", true); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } catch (err) { + log.warn("Failed to update skills, will retry next interval", err); + } finally { + this.updating = false; + } + } + + /** + * Copies the entire bundled plugin directory to the runtime location. + * Called once on first run or after an app update. + */ + private async copyBundledPlugin(): Promise { + try { + if (!existsSync(this.bundledPluginDir)) { + log.warn("Bundled plugin dir not found", { + path: this.bundledPluginDir, + }); + return; + } + await rm(this.runtimePluginDir, { recursive: true, force: true }); + await cp(this.bundledPluginDir, this.runtimePluginDir, { + recursive: true, + }); + log.info("Bundled plugin copied to runtime dir"); + } catch (err) { + log.warn("Failed to copy bundled plugin", err); + } + } + + /** + * Overlays previously-downloaded skills on top of the runtime plugin dir. + * Each skill directory in the cache replaces the same-named one in the plugin. + */ + private async overlayDownloadedSkills(): Promise { + if (!existsSync(this.runtimeSkillsDir)) { + return; + } + + const destSkillsDir = join(this.runtimePluginDir, "skills"); + await mkdir(destSkillsDir, { recursive: true }); + + const entries = await readdir(this.runtimeSkillsDir, { + withFileTypes: true, + }); + for (const entry of entries) { + if (entry.isDirectory()) { + const src = join(this.runtimeSkillsDir, entry.name); + const dest = join(destSkillsDir, entry.name); + await rm(dest, { recursive: true, force: true }); + await cp(src, dest, { recursive: true }); + } + } + } + + /** + * Syncs skills from the effective plugin dir to $HOME/.agents/skills/ for Codex. + */ + private async syncCodexSkills(): Promise { + const effectiveSkillsDir = join(this.getPluginPath(), "skills"); + if (!existsSync(effectiveSkillsDir)) { + return; + } + + // Fire-and-forget — don't block startup or updates on Codex sync + try { + await mkdir(CODEX_SKILLS_DIR, { recursive: true }); + + const entries = await readdir(effectiveSkillsDir, { + withFileTypes: true, + }); + for (const entry of entries) { + if (entry.isDirectory()) { + const src = join(effectiveSkillsDir, entry.name); + const dest = join(CODEX_SKILLS_DIR, entry.name); + await rm(dest, { recursive: true, force: true }); + await cp(src, dest, { recursive: true }); + } + } + + log.debug("Skills synced to Codex", { path: CODEX_SKILLS_DIR }); + } catch (err) { + log.warn("Failed to sync skills to Codex", err); + } + } + + private async downloadFile(url: string, destPath: string): Promise { + const response = await net.fetch(url); + if (!response.ok) { + throw new Error( + `Download failed: ${response.status} ${response.statusText}`, + ); + } + + const buffer = await response.arrayBuffer(); + await writeFile(destPath, Buffer.from(buffer)); + } + + /** + * Finds the skills directory inside an extracted zip. + * Handles: skills/ at root, nested (e.g. posthog/skills/), or skill dirs directly at root. + */ + private async findSkillsDir(extractDir: string): Promise { + const direct = join(extractDir, "skills"); + if (existsSync(direct)) { + return direct; + } + + const entries = await readdir(extractDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const nested = join(extractDir, entry.name, "skills"); + if (existsSync(nested)) { + return nested; + } + } + } + + const hasSkillDirs = entries.some( + (e) => + e.isDirectory() && existsSync(join(extractDir, e.name, "SKILL.md")), + ); + if (hasSkillDirs) { + return extractDir; + } + + return null; + } + + @preDestroy() + cleanup(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } +} diff --git a/apps/twig/vite.main.config.mts b/apps/twig/vite.main.config.mts index 17520d728..92dfcc449 100644 --- a/apps/twig/vite.main.config.mts +++ b/apps/twig/vite.main.config.mts @@ -1,14 +1,18 @@ -import { execSync } from "node:child_process"; +import { execFile, execSync } from "node:child_process"; import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, + rmSync, statSync, } from "node:fs"; +import { cp, mkdir, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path, { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { defineConfig, loadEnv, type Plugin } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { @@ -141,23 +145,143 @@ function getFilesRecursive(dir: string): string[] { return files; } -function copyClaudeCodePlugin(): Plugin { +const SKILLS_ZIP_URL = + "https://github.com/PostHog/posthog/releases/download/agent-skills-latest/skills.zip"; + +const execFileAsync = promisify(execFile); + +/** + * Downloads skills.zip from GitHub releases and extracts into targetDir. + * Returns true on success, false on failure (non-fatal). + */ +async function downloadAndExtractSkills(targetDir: string): Promise { + try { + const tempDir = join(tmpdir(), `twig-vite-skills-${Date.now()}`); + await mkdir(tempDir, { recursive: true }); + + try { + const zipPath = join(tempDir, "skills.zip"); + + // Download using curl (available on macOS/Linux, works in Node without extra deps) + await execFileAsync("curl", ["-fsSL", "-o", zipPath, SKILLS_ZIP_URL], { + timeout: 30_000, + }); + + // Extract + const extractDir = join(tempDir, "extracted"); + await mkdir(extractDir, { recursive: true }); + await execFileAsync("unzip", ["-o", zipPath, "-d", extractDir]); + + // Find skills directory in extracted content + const skillsSource = await findSkillsDirInExtract(extractDir); + if (!skillsSource) { + console.warn( + "[copy-claude-code-plugin] No skills directory found in downloaded archive", + ); + return false; + } + + // Overlay skill directories into target + await mkdir(targetDir, { recursive: true }); + const entries = await readdir(skillsSource, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const dest = join(targetDir, entry.name); + await rm(dest, { recursive: true, force: true }); + await cp(join(skillsSource, entry.name), dest, { recursive: true }); + } + } + + console.log("[copy-claude-code-plugin] Remote skills downloaded and merged"); + return true; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } catch (err) { + console.warn("[copy-claude-code-plugin] Failed to download remote skills (non-fatal):", err); + return false; + } +} + +/** + * Finds the skills directory inside an extracted zip. + * Handles: skills/ at root, nested (e.g. posthog/skills/), or skill dirs directly at root. + */ +async function findSkillsDirInExtract(extractDir: string): Promise { + const direct = join(extractDir, "skills"); + if (existsSync(direct)) return direct; + + const entries = await readdir(extractDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const nested = join(extractDir, entry.name, "skills"); + if (existsSync(nested)) return nested; + } + } + + // Check if extracted dir itself contains skill directories (dirs with SKILL.md) + const hasSkillDirs = entries.some( + (e) => e.isDirectory() && existsSync(join(extractDir, e.name, "SKILL.md")), + ); + if (hasSkillDirs) return extractDir; + + return null; +} + +function copyClaudeCodePlugin(isDev: boolean): Plugin { const sourceDir = join(__dirname, "../../plugins/claude-code"); + const localSkillsDir = join(sourceDir, "local-skills"); return { name: "copy-claude-code-posthog-plugin", buildStart() { if (existsSync(sourceDir)) { for (const file of getFilesRecursive(sourceDir)) { + // Don't watch local-skills in production builds + if (!isDev && file.startsWith(localSkillsDir)) continue; + this.addWatchFile(file); + } + } + + // Watch local-skills dir in dev mode + if (isDev && existsSync(localSkillsDir)) { + for (const file of getFilesRecursive(localSkillsDir)) { this.addWatchFile(file); } } }, - writeBundle() { + async writeBundle() { // Keep the name of the directory "posthog" as it is used as the plugin name. const destDir = join(__dirname, ".vite/build/claude-code/posthog"); - if (existsSync(sourceDir)) { - cpSync(sourceDir, destDir, { recursive: true }); + const destSkillsDir = join(destDir, "skills"); + + // 1. Copy plugin.json + const pluginJson = join(sourceDir, "plugin.json"); + if (existsSync(pluginJson)) { + await mkdir(destDir, { recursive: true }); + await cp(pluginJson, join(destDir, "plugin.json")); + } + + // 2. Copy shipped skills + const shippedSkillsDir = join(sourceDir, "skills"); + if (existsSync(shippedSkillsDir)) { + await cp(shippedSkillsDir, destSkillsDir, { recursive: true }); + } + + // 3. Download and overlay remote skills (overrides same-named shipped skills) + await downloadAndExtractSkills(destSkillsDir); + + // 4. In dev mode: overlay local-skills (overrides both shipped and remote) + if (isDev && existsSync(localSkillsDir)) { + const entries = await readdir(localSkillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const dest = join(destSkillsDir, entry.name); + await rm(dest, { recursive: true, force: true }); + await cp(join(localSkillsDir, entry.name), dest, { recursive: true }); + } + } + console.log("[copy-claude-code-plugin] Local dev skills overlaid"); } }, }; @@ -212,6 +336,7 @@ function copyCodexAcpBinaries(): Plugin { export default defineConfig(({ mode }) => { const env = loadEnv(mode, path.resolve(__dirname, "../.."), ""); + const isDev = mode === "development"; return { plugins: [ @@ -220,7 +345,7 @@ export default defineConfig(({ mode }) => { fixFilenameCircularRef(), copyClaudeExecutable(), copyCodexAcpBinaries(), - copyClaudeCodePlugin(), + copyClaudeCodePlugin(isDev), createPosthogPlugin(env), ].filter(Boolean), define: { @@ -232,6 +357,7 @@ export default defineConfig(({ mode }) => { "process.env.VITE_POSTHOG_API_HOST": JSON.stringify( env.VITE_POSTHOG_API_HOST || "", ), + "process.env.SKILLS_ZIP_URL": JSON.stringify(SKILLS_ZIP_URL), ...createForceDevModeDefine(), }, resolve: { diff --git a/package.json b/package.json index 981041788..4a90bddb3 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "lint": "biome check --write --unsafe", "format": "biome format --write", "clean": "pnpm -r clean", - "knip": "knip" + "knip": "knip", + "skills:pull": "node scripts/pull-skills.mjs" }, "keywords": [ "posthog", diff --git a/scripts/pull-skills.mjs b/scripts/pull-skills.mjs new file mode 100644 index 000000000..a02734153 --- /dev/null +++ b/scripts/pull-skills.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +/** + * Downloads remote skills into local-skills/ for local editing/testing. + * + * Usage: pnpm pull-skills + * + * The downloaded skills land in plugins/claude-code/local-skills/ which is + * gitignored and overlaid on top of shipped + remote skills by the Vite dev build. + */ + +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { cp, mkdir, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SKILLS_ZIP_URL = + "https://github.com/PostHog/posthog/releases/download/agent-skills-latest/skills.zip"; +const LOCAL_SKILLS_DIR = join(__dirname, "..", "plugins", "claude-code", "local-skills"); + +const tempDir = join(tmpdir(), `twig-pull-skills-${Date.now()}`); +await mkdir(tempDir, { recursive: true }); + +try { + const zipPath = join(tempDir, "skills.zip"); + + console.log("Downloading skills.zip..."); + await execFileAsync("curl", ["-fsSL", "-o", zipPath, SKILLS_ZIP_URL], { + timeout: 30_000, + }); + + const extractDir = join(tempDir, "extracted"); + await mkdir(extractDir, { recursive: true }); + await execFileAsync("unzip", ["-o", zipPath, "-d", extractDir]); + + // Find the skills directory + let skillsSource = null; + const direct = join(extractDir, "skills"); + if (existsSync(direct)) { + skillsSource = direct; + } else { + // Check one level deep + const entries = await readdir(extractDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const nested = join(extractDir, entry.name, "skills"); + if (existsSync(nested)) { + skillsSource = nested; + break; + } + } + } + } + + if (!skillsSource) { + // The extracted dir itself might contain skill directories + const entries = await readdir(extractDir, { withFileTypes: true }); + const hasSkillDirs = entries.some( + (e) => + e.isDirectory() && existsSync(join(extractDir, e.name, "SKILL.md")), + ); + if (hasSkillDirs) { + skillsSource = extractDir; + } + } + + if (!skillsSource) { + console.error("No skills directory found in downloaded archive"); + process.exit(1); + } + + // Copy to local-skills/ + await rm(LOCAL_SKILLS_DIR, { recursive: true, force: true }); + await cp(skillsSource, LOCAL_SKILLS_DIR, { recursive: true }); + + console.log(`Skills extracted to ${LOCAL_SKILLS_DIR}`); + console.log( + "Edit skills locally — Vite will hot-reload them in dev mode.", + ); +} finally { + await rm(tempDir, { recursive: true, force: true }); +} From 5b19884f51210088896a4209877f7fd63aa85077 Mon Sep 17 00:00:00 2001 From: Georgiy Tarasov Date: Fri, 13 Feb 2026 12:03:43 +0100 Subject: [PATCH 3/6] generalize plugin for claude code and codex --- .gitignore | 2 +- apps/twig/forge.config.ts | 2 +- apps/twig/package.json | 1 + .../main/services/posthog-plugin/README.md | 27 +- .../services/posthog-plugin/service.test.ts | 450 ++++++++++++++++++ .../main/services/posthog-plugin/service.ts | 27 +- apps/twig/vite.main.config.mts | 67 ++- plugins/posthog/.lsp.json | 1 + plugins/posthog/.mcp.json | 1 + .../skills => posthog/agents}/.gitkeep | 0 plugins/posthog/commands/.gitkeep | 0 plugins/posthog/hooks/.gitkeep | 0 plugins/{claude-code => posthog}/plugin.json | 0 plugins/posthog/skills/.gitkeep | 0 pnpm-lock.yaml | 391 +++++++++++++-- scripts/pull-skills.mjs | 14 +- 16 files changed, 896 insertions(+), 87 deletions(-) create mode 100644 apps/twig/src/main/services/posthog-plugin/service.test.ts create mode 100644 plugins/posthog/.lsp.json create mode 100644 plugins/posthog/.mcp.json rename plugins/{claude-code/skills => posthog/agents}/.gitkeep (100%) create mode 100644 plugins/posthog/commands/.gitkeep create mode 100644 plugins/posthog/hooks/.gitkeep rename plugins/{claude-code => posthog}/plugin.json (100%) create mode 100644 plugins/posthog/skills/.gitkeep diff --git a/.gitignore b/.gitignore index 0a99e1b8c..c49216f6a 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,4 @@ apps/twig/resources/codex-acp/ apps/twig/assets/fonts/BerkeleyMono/ # Local dev skills (override shipped + remote skills in dev mode) -plugins/claude-code/local-skills/ +plugins/posthog/local-skills/ diff --git a/apps/twig/forge.config.ts b/apps/twig/forge.config.ts index 07ebb76ea..598d88120 100644 --- a/apps/twig/forge.config.ts +++ b/apps/twig/forge.config.ts @@ -128,7 +128,7 @@ const config: ForgeConfig = { packagerConfig: { asar: { unpack: - "{**/*.node,**/spawn-helper,**/.vite/build/claude-cli/**,**/.vite/build/claude-code/posthog/**,**/.vite/build/codex-acp/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**}", + "{**/*.node,**/spawn-helper,**/.vite/build/claude-cli/**,**/.vite/build/plugins/posthog/**,**/.vite/build/codex-acp/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**}", }, prune: false, name: "Twig", diff --git a/apps/twig/package.json b/apps/twig/package.json index b43cc7d93..9b905e52e 100644 --- a/apps/twig/package.json +++ b/apps/twig/package.json @@ -68,6 +68,7 @@ "jimp": "^1.6.0", "jsdom": "^26.0.0", "lint-staged": "^15.5.2", + "memfs": "^4.56.10", "postcss": "^8.4.33", "storybook": "10.2.0", "tailwindcss": "^3.4.18", diff --git a/apps/twig/src/main/services/posthog-plugin/README.md b/apps/twig/src/main/services/posthog-plugin/README.md index 869604aec..a7fe88195 100644 --- a/apps/twig/src/main/services/posthog-plugin/README.md +++ b/apps/twig/src/main/services/posthog-plugin/README.md @@ -16,30 +16,29 @@ The plugin directory is assembled from three skill sources, merged in priority o | Source | Location | When used | |---|---|---| -| **Shipped** | `plugins/claude-code/skills/` | Always — committed to the repo | +| **Shipped** | `plugins/posthog/skills/` | Always — committed to the repo | | **Remote** | GitHub releases `skills.zip` | Downloaded at build time and every 30 min at runtime | -| **Local dev** | `plugins/claude-code/local-skills/` | Dev mode only — gitignored | +| **Local dev** | `plugins/posthog/local-skills/` | Dev mode only — gitignored | A "skill name" is its directory name. If remote and shipped both have `query-data/`, the remote version wins. If local-dev also has `query-data/`, that wins over both. ## Build Time -`copyClaudeCodePlugin()` in `vite.main.config.mts` assembles the plugin during `writeBundle`: +`copyPosthogPlugin()` in `vite.main.config.mts` assembles the plugin during `writeBundle`: -1. Copies `plugin.json` into `.vite/build/claude-code/posthog/` -2. Copies shipped skills from `plugins/claude-code/skills/` -3. Downloads `skills.zip` via `curl`, extracts with `unzip`, overlays into the build output -4. In dev mode only: overlays `plugins/claude-code/local-skills/` on top -5. Download failure is non-fatal — build continues with shipped skills only +1. Copies allowed plugin entries into `.vite/build/plugins/posthog/` +2. Downloads `skills.zip` via `curl`, extracts with `unzip`, overlays into the build output +3. In dev mode only: overlays `plugins/posthog/local-skills/` on top +4. Download failure is non-fatal — build continues with shipped skills only -Vite watches `plugins/claude-code/` (and `local-skills/` in dev) for hot-reload. +Vite watches `plugins/posthog/` (and `local-skills/` in dev) for hot-reload. ## Runtime `PosthogPluginService` is an InversifyJS singleton that keeps the plugin fresh in production builds where the Vite dev server isn't running. **On startup:** -1. Creates `{userData}/claude-code-plugin/posthog/` (the runtime plugin dir) +1. Creates `{userData}/plugins/posthog/` (the runtime plugin dir) 2. Assembles it: copies `plugin.json` from bundled, merges bundled skills + any previously-downloaded remote skills 3. Syncs skills to `$HOME/.agents/skills/` for Codex 4. Starts a 30-minute interval timer @@ -55,7 +54,7 @@ Vite watches `plugins/claude-code/` (and `local-skills/` in dev) for hot-reload. **`getPluginPath()`** — called by `AgentService` when starting sessions: - Dev mode → bundled path (Vite already merged everything) -- Prod → `{userData}/claude-code-plugin/posthog/` (with downloaded updates) +- Prod → `{userData}/plugins/posthog/` (with downloaded updates) - Fallback → bundled path ### Codex Sync @@ -66,9 +65,9 @@ After every assembly, skills are copied to `$HOME/.agents/skills/` so that Codex ### Testing with local skills -1. Create a skill directory in `plugins/claude-code/local-skills/`, e.g.: +1. Create a skill directory in `plugins/posthog/local-skills/`, e.g.: ``` - plugins/claude-code/local-skills/my-skill/SKILL.md + plugins/posthog/local-skills/my-skill/SKILL.md ``` 2. Run `pnpm dev:twig` — Vite watches and hot-reloads 3. The local skill overrides any shipped or remote skill with the same name @@ -79,4 +78,4 @@ After every assembly, skills are copied to `$HOME/.agents/skills/` so that Codex pnpm pull-skills ``` -Downloads the latest `skills.zip` into `plugins/claude-code/local-skills/`. You can then edit them locally and Vite will pick up changes. +Downloads the latest `skills.zip` into `plugins/posthog/local-skills/`. You can then edit them locally and Vite will pick up changes. diff --git a/apps/twig/src/main/services/posthog-plugin/service.test.ts b/apps/twig/src/main/services/posthog-plugin/service.test.ts new file mode 100644 index 000000000..55ce9192b --- /dev/null +++ b/apps/twig/src/main/services/posthog-plugin/service.test.ts @@ -0,0 +1,450 @@ +import { vol } from "memfs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Set env before module loads (SKILLS_ZIP_URL is captured at module level) +vi.hoisted(() => { + process.env.SKILLS_ZIP_URL = "https://example.com/skills.zip"; +}); + +const mockApp = vi.hoisted(() => ({ + getPath: vi.fn(() => "/mock/userData"), + getAppPath: vi.fn(() => "/mock/appPath"), + isPackaged: false as boolean, +})); + +const mockNet = vi.hoisted(() => ({ + fetch: vi.fn(), +})); + +const mockExecFileAsync = vi.hoisted(() => + vi.fn<(cmd: string, args: string[]) => Promise>(async () => {}), +); + +vi.mock("electron", () => ({ + app: mockApp, + net: mockNet, +})); + +vi.mock("node:fs", async () => { + const { fs } = await import("memfs"); + return { ...fs, default: fs }; +}); + +vi.mock("node:fs/promises", async () => { + const { fs } = await import("memfs"); + return { ...fs.promises, default: fs.promises }; +}); + +vi.mock("node:child_process", () => ({ + execFile: vi.fn(), + default: { execFile: vi.fn() }, +})); + +vi.mock("node:util", () => ({ + promisify: () => mockExecFileAsync, + default: { promisify: () => mockExecFileAsync }, +})); + +vi.mock("node:os", () => ({ + homedir: () => "/mock/home", + tmpdir: () => "/mock/tmp", + default: { homedir: () => "/mock/home", tmpdir: () => "/mock/tmp" }, +})); + +vi.mock("../../lib/logger.js", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +import { PosthogPluginService } from "./service.js"; + +// Paths based on mock values +const RUNTIME_PLUGIN_DIR = "/mock/userData/plugins/posthog"; +const RUNTIME_SKILLS_DIR = "/mock/userData/skills"; +const BUNDLED_PLUGIN_DIR = "/mock/appPath/.vite/build/plugins/posthog"; +const BUNDLED_PLUGIN_DIR_PACKAGED = + "/mock/appPath.unpacked/.vite/build/plugins/posthog"; +const CODEX_SKILLS_DIR = "/mock/home/.agents/skills"; + +function mockFetchResponse(ok: boolean, status = 200) { + return { + ok, + status, + statusText: ok ? "OK" : "Not Found", + arrayBuffer: vi.fn(async () => new ArrayBuffer(8)), + }; +} + +/** Simulate unzip by creating skill files in the extracted dir */ +function simulateUnzip() { + mockExecFileAsync.mockImplementation(async (_cmd: string, args: string[]) => { + const dIdx = args.indexOf("-d"); + if (dIdx >= 0) { + const extractDir = args[dIdx + 1]; + vol.mkdirSync(`${extractDir}/skills/remote-skill`, { + recursive: true, + }); + vol.writeFileSync( + `${extractDir}/skills/remote-skill/SKILL.md`, + "# Remote", + ); + } + }); +} + +/** Create the bundled plugin directory in memfs */ +function setupBundledPlugin(dir = BUNDLED_PLUGIN_DIR) { + vol.mkdirSync(`${dir}/skills/shipped-skill`, { recursive: true }); + vol.writeFileSync(`${dir}/plugin.json`, '{"name":"posthog"}'); + vol.writeFileSync(`${dir}/skills/shipped-skill/SKILL.md`, "# Shipped"); +} + +describe("PosthogPluginService", () => { + let service: PosthogPluginService; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vol.reset(); + + mockApp.isPackaged = false; + mockNet.fetch.mockResolvedValue(mockFetchResponse(true)); + mockExecFileAsync.mockResolvedValue({}); + + service = new PosthogPluginService(); + }); + + afterEach(() => { + service.cleanup(); + vi.useRealTimers(); + }); + + describe("getPluginPath", () => { + it("returns bundled path in dev mode", () => { + mockApp.isPackaged = false; + expect(service.getPluginPath()).toBe(BUNDLED_PLUGIN_DIR); + }); + + it("returns runtime path in prod when plugin.json exists", () => { + mockApp.isPackaged = true; + vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); + vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}"); + + expect(service.getPluginPath()).toBe(RUNTIME_PLUGIN_DIR); + }); + + it("returns bundled path as fallback in prod", () => { + mockApp.isPackaged = true; + expect(service.getPluginPath()).toBe(BUNDLED_PLUGIN_DIR_PACKAGED); + }); + }); + + describe("initialize", () => { + it("copies bundled plugin on first run when plugin.json is missing", async () => { + setupBundledPlugin(); + + await (service as any).initialize(); + + // Entire bundled dir should be copied to runtime + expect(vol.existsSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`)).toBe(true); + expect( + vol.existsSync(`${RUNTIME_PLUGIN_DIR}/skills/shipped-skill/SKILL.md`), + ).toBe(true); + }); + + it("skips bundled copy when plugin.json already exists in runtime", async () => { + setupBundledPlugin(); + // Pre-populate runtime dir (simulating previous run) + vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); + vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, '{"old":true}'); + + await (service as any).initialize(); + + // Should keep the existing runtime plugin.json, not overwrite + expect( + vol.readFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "utf-8"), + ).toBe('{"old":true}'); + }); + + it("overlays downloaded skills from cache on top of runtime dir", async () => { + setupBundledPlugin(); + // Pre-populate runtime dir + vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); + vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}"); + // Pre-populate skills cache (as if downloaded previously) + vol.mkdirSync(`${RUNTIME_SKILLS_DIR}/cached-skill`, { recursive: true }); + vol.writeFileSync( + `${RUNTIME_SKILLS_DIR}/cached-skill/SKILL.md`, + "# Cached", + ); + + await (service as any).initialize(); + + expect( + vol.readFileSync( + `${RUNTIME_PLUGIN_DIR}/skills/cached-skill/SKILL.md`, + "utf-8", + ), + ).toBe("# Cached"); + }); + + it("starts periodic update interval", async () => { + await (service as any).initialize(); + expect((service as any).intervalId).not.toBeNull(); + }); + }); + + describe("updateSkills", () => { + it("downloads, extracts, and installs skills", async () => { + setupBundledPlugin(); + simulateUnzip(); + + await service.updateSkills(); + + // Skills should be in the runtime cache + expect( + vol.existsSync(`${RUNTIME_SKILLS_DIR}/remote-skill/SKILL.md`), + ).toBe(true); + expect(mockNet.fetch).toHaveBeenCalledWith( + "https://example.com/skills.zip", + ); + expect(mockExecFileAsync).toHaveBeenCalledWith( + "unzip", + expect.arrayContaining(["-o"]), + ); + }); + + it("performs atomic swap of skills directory", async () => { + setupBundledPlugin(); + // Pre-populate existing cache with old skill + vol.mkdirSync(`${RUNTIME_SKILLS_DIR}/old-skill`, { recursive: true }); + vol.writeFileSync(`${RUNTIME_SKILLS_DIR}/old-skill/SKILL.md`, "# Old"); + + simulateUnzip(); + await service.updateSkills(); + + // New skill should be present, old skill should be gone + expect( + vol.existsSync(`${RUNTIME_SKILLS_DIR}/remote-skill/SKILL.md`), + ).toBe(true); + expect(vol.existsSync(`${RUNTIME_SKILLS_DIR}/old-skill`)).toBe(false); + // Temp dirs should be cleaned up + expect(vol.existsSync(`${RUNTIME_SKILLS_DIR}.new`)).toBe(false); + expect(vol.existsSync(`${RUNTIME_SKILLS_DIR}.old`)).toBe(false); + }); + + it("overlays new skills into runtime plugin dir", async () => { + setupBundledPlugin(); + vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); + vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}"); + + simulateUnzip(); + await service.updateSkills(); + + expect( + vol.existsSync(`${RUNTIME_PLUGIN_DIR}/skills/remote-skill/SKILL.md`), + ).toBe(true); + }); + + it("emits 'updated' event on success", async () => { + simulateUnzip(); + const handler = vi.fn(); + service.on("skillsUpdated", handler); + + await service.updateSkills(); + + expect(handler).toHaveBeenCalledWith(true); + }); + + it("throttles: skips if called within 30 minutes", async () => { + simulateUnzip(); + await service.updateSkills(); + mockNet.fetch.mockClear(); + + await service.updateSkills(); + + expect(mockNet.fetch).not.toHaveBeenCalled(); + }); + + it("allows update after throttle period expires", async () => { + simulateUnzip(); + await service.updateSkills(); + mockNet.fetch.mockClear(); + + vi.advanceTimersByTime(31 * 60 * 1000); + await service.updateSkills(); + + expect(mockNet.fetch).toHaveBeenCalled(); + }); + + it("skips if already updating (reentrance guard)", async () => { + let resolveDownload!: (value: unknown) => void; + mockNet.fetch.mockReturnValue( + new Promise((resolve) => { + resolveDownload = resolve; + }), + ); + + // Start first update (hangs on fetch) + const first = service.updateSkills(); + + // Advance past throttle so second call reaches the `updating` check + vi.advanceTimersByTime(31 * 60 * 1000); + mockNet.fetch.mockClear(); + await service.updateSkills(); + + // Second call should not have triggered another fetch + expect(mockNet.fetch).not.toHaveBeenCalled(); + + // Clean up hanging promise + resolveDownload(mockFetchResponse(true)); + await first.catch(() => {}); + }); + + it("handles download failure gracefully", async () => { + mockNet.fetch.mockRejectedValue(new Error("Network error")); + await expect(service.updateSkills()).resolves.toBeUndefined(); + }); + + it("handles non-ok response gracefully", async () => { + mockNet.fetch.mockResolvedValue(mockFetchResponse(false, 404)); + await expect(service.updateSkills()).resolves.toBeUndefined(); + }); + + it("handles missing skills dir in archive", async () => { + // Unzip creates no skills directory + mockExecFileAsync.mockImplementation( + async (_cmd: string, args: string[]) => { + const dIdx = args.indexOf("-d"); + if (dIdx >= 0) { + const extractDir = args[dIdx + 1]; + vol.mkdirSync(`${extractDir}/random-dir`, { recursive: true }); + vol.writeFileSync(`${extractDir}/random-dir/README.md`, "nope"); + } + }, + ); + + const handler = vi.fn(); + service.on("skillsUpdated", handler); + await service.updateSkills(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("cleans up temp dir even on error", async () => { + mockExecFileAsync.mockRejectedValue(new Error("unzip failed")); + + await service.updateSkills(); + + // Temp dir under /mock/tmp should be cleaned up + const tmpEntries = vol.existsSync("/mock/tmp") + ? vol.readdirSync("/mock/tmp") + : []; + expect(tmpEntries).toHaveLength(0); + }); + }); + + describe("findSkillsDir", () => { + it("finds skills/ at root of extracted dir", async () => { + vol.mkdirSync("/extract/skills", { recursive: true }); + + const result = await (service as any).findSkillsDir("/extract"); + expect(result).toBe("/extract/skills"); + }); + + it("finds nested skills/ dir (e.g. posthog/skills/)", async () => { + vol.mkdirSync("/extract/posthog/skills", { recursive: true }); + + const result = await (service as any).findSkillsDir("/extract"); + expect(result).toBe("/extract/posthog/skills"); + }); + + it("finds skill dirs directly at root when they have SKILL.md", async () => { + vol.mkdirSync("/extract/my-skill", { recursive: true }); + vol.writeFileSync("/extract/my-skill/SKILL.md", "# Skill"); + + const result = await (service as any).findSkillsDir("/extract"); + expect(result).toBe("/extract"); + }); + + it("returns null when no skills found", async () => { + vol.mkdirSync("/extract/random", { recursive: true }); + vol.writeFileSync("/extract/random/README.md", "# Not a skill"); + + const result = await (service as any).findSkillsDir("/extract"); + expect(result).toBeNull(); + }); + }); + + describe("syncCodexSkills", () => { + it("copies skill directories to Codex dir", async () => { + setupBundledPlugin(); + + await (service as any).syncCodexSkills(); + + expect( + vol.readFileSync(`${CODEX_SKILLS_DIR}/shipped-skill/SKILL.md`, "utf-8"), + ).toBe("# Shipped"); + }); + + it("skips if effective skills dir does not exist", async () => { + // No skills dir anywhere + await (service as any).syncCodexSkills(); + + expect(vol.existsSync(CODEX_SKILLS_DIR)).toBe(false); + }); + }); + + describe("copyBundledPlugin", () => { + it("copies entire bundled dir to runtime dir", async () => { + setupBundledPlugin(); + + await (service as any).copyBundledPlugin(); + + expect( + vol.readFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "utf-8"), + ).toBe('{"name":"posthog"}'); + expect( + vol.readFileSync( + `${RUNTIME_PLUGIN_DIR}/skills/shipped-skill/SKILL.md`, + "utf-8", + ), + ).toBe("# Shipped"); + }); + + it("skips if bundled dir does not exist", async () => { + await (service as any).copyBundledPlugin(); + expect(vol.existsSync(RUNTIME_PLUGIN_DIR)).toBe(false); + }); + + it("handles copy failure gracefully", async () => { + // Bundled dir exists but is not a directory (will cause cp to fail or behave oddly) + // Just verify no exception propagates + setupBundledPlugin(); + await expect( + (service as any).copyBundledPlugin(), + ).resolves.toBeUndefined(); + }); + }); + + describe("cleanup", () => { + it("clears interval timer", async () => { + await (service as any).initialize(); + expect((service as any).intervalId).not.toBeNull(); + + service.cleanup(); + expect((service as any).intervalId).toBeNull(); + }); + + it("is safe to call multiple times", () => { + service.cleanup(); + service.cleanup(); + }); + }); +}); diff --git a/apps/twig/src/main/services/posthog-plugin/service.ts b/apps/twig/src/main/services/posthog-plugin/service.ts index d248c588d..372b6a62e 100644 --- a/apps/twig/src/main/services/posthog-plugin/service.ts +++ b/apps/twig/src/main/services/posthog-plugin/service.ts @@ -8,6 +8,7 @@ import { app, net } from "electron"; import { injectable, postConstruct, preDestroy } from "inversify"; import { logger } from "../../lib/logger.js"; import { TypedEventEmitter } from "../../lib/typed-event-emitter.js"; +import { captureException } from "../posthog-analytics.js"; const log = logger.scope("posthog-plugin"); @@ -16,19 +17,19 @@ const SKILLS_ZIP_URL = process.env.SKILLS_ZIP_URL!; const UPDATE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes const CODEX_SKILLS_DIR = join(homedir(), ".agents", "skills"); -interface SkillsEvents { - updated: boolean; +interface PosthogPluginEvents { + skillsUpdated: true; } @injectable() -export class PosthogPluginService extends TypedEventEmitter { +export class PosthogPluginService extends TypedEventEmitter { private intervalId: ReturnType | null = null; private lastCheckAt = 0; private updating = false; /** Runtime plugin dir under userData */ private get runtimePluginDir(): string { - return join(app.getPath("userData"), "claude-code-plugin", "posthog"); + return join(app.getPath("userData"), "plugins", "posthog"); } /** Runtime skills cache (downloaded zips extracted here) */ @@ -40,14 +41,18 @@ export class PosthogPluginService extends TypedEventEmitter { private get bundledPluginDir(): string { const appPath = app.getAppPath(); return app.isPackaged - ? join(`${appPath}.unpacked`, ".vite/build/claude-code/posthog") - : join(appPath, ".vite/build/claude-code/posthog"); + ? join(`${appPath}.unpacked`, ".vite/build/plugins/posthog") + : join(appPath, ".vite/build/plugins/posthog"); } @postConstruct() init(): void { this.initialize().catch((err) => { log.error("Skills initialization failed", err); + captureException(err, { + source: "posthog-plugin", + operation: "initialize", + }); }); } @@ -143,12 +148,16 @@ export class PosthogPluginService extends TypedEventEmitter { await this.syncCodexSkills(); log.info("Skills updated successfully"); - this.emit("updated", true); + this.emit("skillsUpdated", true); } finally { await rm(tempDir, { recursive: true, force: true }); } } catch (err) { log.warn("Failed to update skills, will retry next interval", err); + captureException(err, { + source: "posthog-plugin", + operation: "updateSkills", + }); } finally { this.updating = false; } @@ -173,6 +182,10 @@ export class PosthogPluginService extends TypedEventEmitter { log.info("Bundled plugin copied to runtime dir"); } catch (err) { log.warn("Failed to copy bundled plugin", err); + captureException(err, { + source: "posthog-plugin", + operation: "copyBundledPlugin", + }); } } diff --git a/apps/twig/vite.main.config.mts b/apps/twig/vite.main.config.mts index 92dfcc449..cfe192e5d 100644 --- a/apps/twig/vite.main.config.mts +++ b/apps/twig/vite.main.config.mts @@ -5,7 +5,6 @@ import { existsSync, mkdirSync, readdirSync, - rmSync, statSync, } from "node:fs"; import { cp, mkdir, readdir, rm } from "node:fs/promises"; @@ -176,7 +175,7 @@ async function downloadAndExtractSkills(targetDir: string): Promise { const skillsSource = await findSkillsDirInExtract(extractDir); if (!skillsSource) { console.warn( - "[copy-claude-code-plugin] No skills directory found in downloaded archive", + "[copy-posthog-plugin] No skills directory found in downloaded archive", ); return false; } @@ -192,13 +191,16 @@ async function downloadAndExtractSkills(targetDir: string): Promise { } } - console.log("[copy-claude-code-plugin] Remote skills downloaded and merged"); + console.log("[copy-posthog-plugin] Remote skills downloaded and merged"); return true; } finally { await rm(tempDir, { recursive: true, force: true }); } } catch (err) { - console.warn("[copy-claude-code-plugin] Failed to download remote skills (non-fatal):", err); + console.warn( + "[copy-posthog-plugin] Failed to download remote skills (non-fatal):", + err, + ); return false; } } @@ -207,7 +209,9 @@ async function downloadAndExtractSkills(targetDir: string): Promise { * Finds the skills directory inside an extracted zip. * Handles: skills/ at root, nested (e.g. posthog/skills/), or skill dirs directly at root. */ -async function findSkillsDirInExtract(extractDir: string): Promise { +async function findSkillsDirInExtract( + extractDir: string, +): Promise { const direct = join(extractDir, "skills"); if (existsSync(direct)) return direct; @@ -228,12 +232,22 @@ async function findSkillsDirInExtract(extractDir: string): Promise { autoServicesPlugin(join(__dirname, "src/main/services")), fixFilenameCircularRef(), copyClaudeExecutable(), + copyPosthogPlugin(isDev), copyCodexAcpBinaries(), - copyClaudeCodePlugin(isDev), createPosthogPlugin(env), ].filter(Boolean), define: { diff --git a/plugins/posthog/.lsp.json b/plugins/posthog/.lsp.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/posthog/.lsp.json @@ -0,0 +1 @@ +{} diff --git a/plugins/posthog/.mcp.json b/plugins/posthog/.mcp.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/posthog/.mcp.json @@ -0,0 +1 @@ +{} diff --git a/plugins/claude-code/skills/.gitkeep b/plugins/posthog/agents/.gitkeep similarity index 100% rename from plugins/claude-code/skills/.gitkeep rename to plugins/posthog/agents/.gitkeep diff --git a/plugins/posthog/commands/.gitkeep b/plugins/posthog/commands/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/posthog/hooks/.gitkeep b/plugins/posthog/hooks/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/claude-code/plugin.json b/plugins/posthog/plugin.json similarity index 100% rename from plugins/claude-code/plugin.json rename to plugins/posthog/plugin.json diff --git a/plugins/posthog/skills/.gitkeep b/plugins/posthog/skills/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 522984075..486ceadd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -465,10 +465,10 @@ importers: version: 10.2.0(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@storybook/addon-docs': specifier: 10.2.0 - version: 10.2.0(@types/react@19.2.11)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) + version: 10.2.0(@types/react@19.2.11)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@storybook/react-vite': specifier: 10.2.0 - version: 10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) + version: 10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -492,7 +492,7 @@ importers: version: 9.0.8 '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.7.0(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/ui': specifier: ^4.0.10 version: 4.0.18(vitest@4.0.18) @@ -517,6 +517,9 @@ importers: lint-staged: specifier: ^15.5.2 version: 15.5.2 + memfs: + specifier: ^4.56.10 + version: 4.56.10(tslib@2.8.1) postcss: specifier: ^8.4.33 version: 8.5.6 @@ -537,13 +540,13 @@ importers: version: 5.9.3 vite: specifier: ^6.0.7 - version: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^4.0.10 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.31)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.31)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.31.1)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) yaml: specifier: ^2.8.1 version: 2.8.2 @@ -2712,6 +2715,126 @@ packages: '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@jsonjoy.com/base64@1.1.2': + resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/base64@17.67.0': + resolution: {integrity: sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@1.2.1': + resolution: {integrity: sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@17.67.0': + resolution: {integrity: sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@1.0.0': + resolution: {integrity: sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@17.67.0': + resolution: {integrity: sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-core@4.56.10': + resolution: {integrity: sha512-PyAEA/3cnHhsGcdY+AmIU+ZPqTuZkDhCXQ2wkXypdLitSpd6d5Ivxhnq4wa2ETRWFVJGabYynBWxIijOswSmOw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-fsa@4.56.10': + resolution: {integrity: sha512-/FVK63ysNzTPOnCCcPoPHt77TOmachdMS422txM4KhxddLdbW1fIbFMYH0AM0ow/YchCyS5gqEjKLNyv71j/5Q==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-builtins@4.56.10': + resolution: {integrity: sha512-uUnKz8R0YJyKq5jXpZtkGV9U0pJDt8hmYcLRrPjROheIfjMXsz82kXMgAA/qNg0wrZ1Kv+hrg7azqEZx6XZCVw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-to-fsa@4.56.10': + resolution: {integrity: sha512-oH+O6Y4lhn9NyG6aEoFwIBNKZeYy66toP5LJcDOMBgL99BKQMUf/zWJspdRhMdn/3hbzQsZ8EHHsuekbFLGUWw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-utils@4.56.10': + resolution: {integrity: sha512-8EuPBgVI2aDPwFdaNQeNpHsyqPi3rr+85tMNG/lHvQLiVjzoZsvxA//Xd8aB567LUhy4QS03ptT+unkD/DIsNg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node@4.56.10': + resolution: {integrity: sha512-7R4Gv3tkUdW3dXfXiOkqxkElxKNVdd8BDOWC0/dbERd0pXpPY+s2s1Mino+aTvkGrFPiY+mmVxA7zhskm4Ue4Q==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-print@4.56.10': + resolution: {integrity: sha512-JW4fp5mAYepzFsSGrQ48ep8FXxpg4niFWHdF78wDrFGof7F3tKDJln72QFDEn/27M1yHd4v7sKHHVPh78aWcEw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-snapshot@4.56.10': + resolution: {integrity: sha512-DkR6l5fj7+qj0+fVKm/OOXMGfDFCGXLfyHkORH3DF8hxkpDgIHbhf/DwncBMs2igu/ST7OEkexn1gIqoU6Y+9g==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@1.21.0': + resolution: {integrity: sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@17.67.0': + resolution: {integrity: sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@1.0.2': + resolution: {integrity: sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@17.67.0': + resolution: {integrity: sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@1.9.0': + resolution: {integrity: sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@17.67.0': + resolution: {integrity: sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -6752,6 +6875,12 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regex.js@1.2.0: + resolution: {integrity: sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} @@ -6772,7 +6901,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} @@ -6925,6 +7054,10 @@ packages: engines: {node: '>=18'} hasBin: true + hyperdyperid@1.2.0: + resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} + engines: {node: '>=10.18'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -7741,6 +7874,11 @@ packages: resolution: {integrity: sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==} engines: {node: '>=6'} + memfs@4.56.10: + resolution: {integrity: sha512-eLvzyrwqLHnLYalJP7YZ3wBe79MXktMdfQbvMrVD80K+NhrIukCVBvgP30zTJYEEDh9hZ/ep9z0KOdD7FSHo7w==} + peerDependencies: + tslib: '2' + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -9775,6 +9913,12 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thingies@2.5.0: + resolution: {integrity: sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==} + engines: {node: '>=10.18'} + peerDependencies: + tslib: ^2 + throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} @@ -9884,6 +10028,12 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + tree-dump@1.1.0: + resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -13325,11 +13475,11 @@ snapshots: '@jimp/types': 1.6.0 tinycolor2: 1.6.0 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: glob: 11.1.0 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: typescript: 5.9.3 @@ -13359,6 +13509,133 @@ snapshots: '@jsdevtools/ono@7.1.3': {} + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/base64@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@1.2.1(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@1.0.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-core@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + thingies: 2.5.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-fsa@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + thingies: 2.5.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-builtins@4.56.10(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-to-fsa@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-fsa': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-utils@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.56.10(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.56.10(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.5.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-print@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-snapshot@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@1.21.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.5.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.5.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@1.0.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@1.9.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + '@kwsites/file-exists@1.1.1': dependencies: debug: 4.4.3 @@ -15005,10 +15282,10 @@ snapshots: axe-core: 4.11.1 storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/addon-docs@10.2.0(@types/react@19.2.11)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': + '@storybook/addon-docs@10.2.0(@types/react@19.2.11)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.11)(react@19.1.0) - '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@storybook/icons': 2.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@storybook/react-dom-shim': 10.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) react: 19.1.0 @@ -15022,27 +15299,27 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': + '@storybook/builder-vite@10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': dependencies: - '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) - '@vitest/mocker': 3.2.4(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) + '@vitest/mocker': 3.2.4(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 - vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - msw - rollup - webpack - '@storybook/csf-plugin@10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': + '@storybook/csf-plugin@10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': dependencies: storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.2 rollup: 4.57.1 - vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) webpack: 5.105.0(esbuild@0.27.2) '@storybook/global@5.0.0': {} @@ -15058,11 +15335,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/react-vite@10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': + '@storybook/react-vite@10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@storybook/builder-vite': 10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) + '@storybook/builder-vite': 10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@storybook/react': 10.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -15072,7 +15349,7 @@ snapshots: resolve: 1.22.11 storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tsconfig-paths: 4.2.0 - vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - msw @@ -15567,7 +15844,7 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -15575,7 +15852,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -15638,23 +15915,23 @@ snapshots: msw: 2.12.8(@types/node@25.2.0)(typescript@5.9.3) vite: 5.4.21(@types/node@25.2.0)(lightningcss@1.31.1)(terser@5.46.0) - '@vitest/mocker@3.2.4(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.8(@types/node@20.19.31)(typescript@5.9.3) - vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.0.18(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.8(@types/node@20.19.31)(typescript@5.9.3) - vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@2.1.9': dependencies: @@ -15709,7 +15986,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.31)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.31)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.31.1)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@2.1.9': dependencies: @@ -17796,6 +18073,10 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regex.js@1.2.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + glob-to-regexp@0.4.1: {} glob@10.5.0: @@ -18021,6 +18302,8 @@ snapshots: husky@9.1.7: {} + hyperdyperid@1.2.0: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -18977,6 +19260,23 @@ snapshots: mimic-fn: 2.1.0 p-is-promise: 2.1.0 + memfs@4.56.10(tslib@2.8.1): + dependencies: + '@jsonjoy.com/fs-core': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-fsa': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-to-fsa': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.5.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + memoize-one@5.2.1: {} merge-descriptors@2.0.0: {} @@ -21459,6 +21759,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thingies@2.5.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + throat@5.0.0: {} tiny-invariant@1.3.3: {} @@ -21545,6 +21849,10 @@ snapshots: dependencies: punycode: 2.3.1 + tree-dump@1.1.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -21908,13 +22216,13 @@ snapshots: magic-string: 0.30.21 vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript @@ -21941,6 +22249,23 @@ snapshots: lightningcss: 1.31.1 terser: 5.46.0 + vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.31 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.31.1 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -22030,10 +22355,10 @@ snapshots: - supports-color - terser - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.31)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.31)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.31.1)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -22050,7 +22375,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 diff --git a/scripts/pull-skills.mjs b/scripts/pull-skills.mjs index a02734153..4f23c4274 100644 --- a/scripts/pull-skills.mjs +++ b/scripts/pull-skills.mjs @@ -5,7 +5,7 @@ * * Usage: pnpm pull-skills * - * The downloaded skills land in plugins/claude-code/local-skills/ which is + * The downloaded skills land in plugins/posthog/local-skills/ which is * gitignored and overlaid on top of shipped + remote skills by the Vite dev build. */ @@ -21,7 +21,13 @@ const execFileAsync = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); const SKILLS_ZIP_URL = "https://github.com/PostHog/posthog/releases/download/agent-skills-latest/skills.zip"; -const LOCAL_SKILLS_DIR = join(__dirname, "..", "plugins", "claude-code", "local-skills"); +const LOCAL_SKILLS_DIR = join( + __dirname, + "..", + "plugins", + "posthog", + "local-skills", +); const tempDir = join(tmpdir(), `twig-pull-skills-${Date.now()}`); await mkdir(tempDir, { recursive: true }); @@ -79,9 +85,7 @@ try { await cp(skillsSource, LOCAL_SKILLS_DIR, { recursive: true }); console.log(`Skills extracted to ${LOCAL_SKILLS_DIR}`); - console.log( - "Edit skills locally — Vite will hot-reload them in dev mode.", - ); + console.log("Edit skills locally — Vite will hot-reload them in dev mode."); } finally { await rm(tempDir, { recursive: true, force: true }); } From a9397056e484db5f3ac2d243958f6e5c74ef6eae Mon Sep 17 00:00:00 2001 From: Georgiy Tarasov Date: Thu, 19 Feb 2026 18:30:12 +0100 Subject: [PATCH 4/6] saga --- .../services/posthog-plugin/service.test.ts | 37 +-- .../main/services/posthog-plugin/service.ts | 159 +++-------- .../posthog-plugin/update-skills-saga.ts | 246 ++++++++++++++++++ 3 files changed, 281 insertions(+), 161 deletions(-) create mode 100644 apps/twig/src/main/services/posthog-plugin/update-skills-saga.ts diff --git a/apps/twig/src/main/services/posthog-plugin/service.test.ts b/apps/twig/src/main/services/posthog-plugin/service.test.ts index 55ce9192b..894f7e91b 100644 --- a/apps/twig/src/main/services/posthog-plugin/service.test.ts +++ b/apps/twig/src/main/services/posthog-plugin/service.test.ts @@ -63,6 +63,7 @@ vi.mock("../../lib/logger.js", () => ({ })); import { PosthogPluginService } from "./service.js"; +import { syncCodexSkills } from "./update-skills-saga.js"; // Paths based on mock values const RUNTIME_PLUGIN_DIR = "/mock/userData/plugins/posthog"; @@ -350,43 +351,11 @@ describe("PosthogPluginService", () => { }); }); - describe("findSkillsDir", () => { - it("finds skills/ at root of extracted dir", async () => { - vol.mkdirSync("/extract/skills", { recursive: true }); - - const result = await (service as any).findSkillsDir("/extract"); - expect(result).toBe("/extract/skills"); - }); - - it("finds nested skills/ dir (e.g. posthog/skills/)", async () => { - vol.mkdirSync("/extract/posthog/skills", { recursive: true }); - - const result = await (service as any).findSkillsDir("/extract"); - expect(result).toBe("/extract/posthog/skills"); - }); - - it("finds skill dirs directly at root when they have SKILL.md", async () => { - vol.mkdirSync("/extract/my-skill", { recursive: true }); - vol.writeFileSync("/extract/my-skill/SKILL.md", "# Skill"); - - const result = await (service as any).findSkillsDir("/extract"); - expect(result).toBe("/extract"); - }); - - it("returns null when no skills found", async () => { - vol.mkdirSync("/extract/random", { recursive: true }); - vol.writeFileSync("/extract/random/README.md", "# Not a skill"); - - const result = await (service as any).findSkillsDir("/extract"); - expect(result).toBeNull(); - }); - }); - describe("syncCodexSkills", () => { it("copies skill directories to Codex dir", async () => { setupBundledPlugin(); - await (service as any).syncCodexSkills(); + await syncCodexSkills(BUNDLED_PLUGIN_DIR, CODEX_SKILLS_DIR); expect( vol.readFileSync(`${CODEX_SKILLS_DIR}/shipped-skill/SKILL.md`, "utf-8"), @@ -395,7 +364,7 @@ describe("PosthogPluginService", () => { it("skips if effective skills dir does not exist", async () => { // No skills dir anywhere - await (service as any).syncCodexSkills(); + await syncCodexSkills("/nonexistent", CODEX_SKILLS_DIR); expect(vol.existsSync(CODEX_SKILLS_DIR)).toBe(false); }); diff --git a/apps/twig/src/main/services/posthog-plugin/service.ts b/apps/twig/src/main/services/posthog-plugin/service.ts index 372b6a62e..d578c08e7 100644 --- a/apps/twig/src/main/services/posthog-plugin/service.ts +++ b/apps/twig/src/main/services/posthog-plugin/service.ts @@ -1,18 +1,20 @@ -import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import { cp, mkdir, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { cp, mkdir, rm, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; -import { promisify } from "node:util"; import { app, net } from "electron"; import { injectable, postConstruct, preDestroy } from "inversify"; import { logger } from "../../lib/logger.js"; import { TypedEventEmitter } from "../../lib/typed-event-emitter.js"; import { captureException } from "../posthog-analytics.js"; +import { + overlayDownloadedSkills, + syncCodexSkills, + UpdateSkillsSaga, +} from "./update-skills-saga.js"; const log = logger.scope("posthog-plugin"); -const execFileAsync = promisify(execFile); const SKILLS_ZIP_URL = process.env.SKILLS_ZIP_URL!; const UPDATE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes const CODEX_SKILLS_DIR = join(homedir(), ".agents", "skills"); @@ -64,9 +66,9 @@ export class PosthogPluginService extends TypedEventEmitter } // Overlay any previously-downloaded skills on top of the runtime plugin - await this.overlayDownloadedSkills(); + await overlayDownloadedSkills(this.runtimeSkillsDir, this.runtimePluginDir); - await this.syncCodexSkills(); + await syncCodexSkills(this.getPluginPath(), CODEX_SKILLS_DIR); // Start periodic updates this.intervalId = setInterval(() => { @@ -111,46 +113,35 @@ export class PosthogPluginService extends TypedEventEmitter this.updating = true; this.lastCheckAt = now; + const tempDir = join(tmpdir(), `twig-skills-${Date.now()}`); + try { - const tempDir = join(tmpdir(), `twig-skills-${Date.now()}`); await mkdir(tempDir, { recursive: true }); - try { - const zipPath = join(tempDir, "skills.zip"); - await this.downloadFile(SKILLS_ZIP_URL, zipPath); - - const extractDir = join(tempDir, "extracted"); - await mkdir(extractDir, { recursive: true }); - await execFileAsync("unzip", ["-o", zipPath, "-d", extractDir]); - - const skillsSource = await this.findSkillsDir(extractDir); - if (!skillsSource) { - log.warn("No skills directory found in downloaded archive"); - return; - } - - // Atomic swap into runtime skills cache - const newSkillsDir = `${this.runtimeSkillsDir}.new`; - await rm(newSkillsDir, { recursive: true, force: true }); - await cp(skillsSource, newSkillsDir, { recursive: true }); - - const oldSkillsDir = `${this.runtimeSkillsDir}.old`; - await rm(oldSkillsDir, { recursive: true, force: true }); - if (existsSync(this.runtimeSkillsDir)) { - await rename(this.runtimeSkillsDir, oldSkillsDir); - } - await rename(newSkillsDir, this.runtimeSkillsDir); - await rm(oldSkillsDir, { recursive: true, force: true }); - - // Overlay new skills into the runtime plugin dir - await this.overlayDownloadedSkills(); - - await this.syncCodexSkills(); + const saga = new UpdateSkillsSaga(log); + const result = await saga.run({ + runtimeSkillsDir: this.runtimeSkillsDir, + runtimePluginDir: this.runtimePluginDir, + pluginPath: this.getPluginPath(), + codexSkillsDir: CODEX_SKILLS_DIR, + tempDir, + skillsZipUrl: SKILLS_ZIP_URL, + downloadFile: (url, destPath) => this.downloadFile(url, destPath), + }); + if (result.success) { log.info("Skills updated successfully"); this.emit("skillsUpdated", true); - } finally { - await rm(tempDir, { recursive: true, force: true }); + } else { + log.warn("Skills update failed", { + error: result.error, + failedStep: result.failedStep, + }); + captureException(new Error(result.error), { + source: "posthog-plugin", + operation: "updateSkills", + failedStep: result.failedStep, + }); } } catch (err) { log.warn("Failed to update skills, will retry next interval", err); @@ -159,6 +150,7 @@ export class PosthogPluginService extends TypedEventEmitter operation: "updateSkills", }); } finally { + await rm(tempDir, { recursive: true, force: true }); this.updating = false; } } @@ -189,62 +181,6 @@ export class PosthogPluginService extends TypedEventEmitter } } - /** - * Overlays previously-downloaded skills on top of the runtime plugin dir. - * Each skill directory in the cache replaces the same-named one in the plugin. - */ - private async overlayDownloadedSkills(): Promise { - if (!existsSync(this.runtimeSkillsDir)) { - return; - } - - const destSkillsDir = join(this.runtimePluginDir, "skills"); - await mkdir(destSkillsDir, { recursive: true }); - - const entries = await readdir(this.runtimeSkillsDir, { - withFileTypes: true, - }); - for (const entry of entries) { - if (entry.isDirectory()) { - const src = join(this.runtimeSkillsDir, entry.name); - const dest = join(destSkillsDir, entry.name); - await rm(dest, { recursive: true, force: true }); - await cp(src, dest, { recursive: true }); - } - } - } - - /** - * Syncs skills from the effective plugin dir to $HOME/.agents/skills/ for Codex. - */ - private async syncCodexSkills(): Promise { - const effectiveSkillsDir = join(this.getPluginPath(), "skills"); - if (!existsSync(effectiveSkillsDir)) { - return; - } - - // Fire-and-forget — don't block startup or updates on Codex sync - try { - await mkdir(CODEX_SKILLS_DIR, { recursive: true }); - - const entries = await readdir(effectiveSkillsDir, { - withFileTypes: true, - }); - for (const entry of entries) { - if (entry.isDirectory()) { - const src = join(effectiveSkillsDir, entry.name); - const dest = join(CODEX_SKILLS_DIR, entry.name); - await rm(dest, { recursive: true, force: true }); - await cp(src, dest, { recursive: true }); - } - } - - log.debug("Skills synced to Codex", { path: CODEX_SKILLS_DIR }); - } catch (err) { - log.warn("Failed to sync skills to Codex", err); - } - } - private async downloadFile(url: string, destPath: string): Promise { const response = await net.fetch(url); if (!response.ok) { @@ -257,37 +193,6 @@ export class PosthogPluginService extends TypedEventEmitter await writeFile(destPath, Buffer.from(buffer)); } - /** - * Finds the skills directory inside an extracted zip. - * Handles: skills/ at root, nested (e.g. posthog/skills/), or skill dirs directly at root. - */ - private async findSkillsDir(extractDir: string): Promise { - const direct = join(extractDir, "skills"); - if (existsSync(direct)) { - return direct; - } - - const entries = await readdir(extractDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - const nested = join(extractDir, entry.name, "skills"); - if (existsSync(nested)) { - return nested; - } - } - } - - const hasSkillDirs = entries.some( - (e) => - e.isDirectory() && existsSync(join(extractDir, e.name, "SKILL.md")), - ); - if (hasSkillDirs) { - return extractDir; - } - - return null; - } - @preDestroy() cleanup(): void { if (this.intervalId) { diff --git a/apps/twig/src/main/services/posthog-plugin/update-skills-saga.ts b/apps/twig/src/main/services/posthog-plugin/update-skills-saga.ts new file mode 100644 index 000000000..de24f7333 --- /dev/null +++ b/apps/twig/src/main/services/posthog-plugin/update-skills-saga.ts @@ -0,0 +1,246 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { cp, mkdir, readdir, rename, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { Saga } from "@posthog/shared"; + +const execFileAsync = promisify(execFile); + +/** + * Overlays previously-downloaded skills on top of the runtime plugin dir. + * Each skill directory in the cache replaces the same-named one in the plugin. + */ +export async function overlayDownloadedSkills( + runtimeSkillsDir: string, + runtimePluginDir: string, +): Promise { + if (!existsSync(runtimeSkillsDir)) { + return; + } + + const destSkillsDir = join(runtimePluginDir, "skills"); + await mkdir(destSkillsDir, { recursive: true }); + + const entries = await readdir(runtimeSkillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const src = join(runtimeSkillsDir, entry.name); + const dest = join(destSkillsDir, entry.name); + await rm(dest, { recursive: true, force: true }); + await cp(src, dest, { recursive: true }); + } + } +} + +/** + * Syncs skills from the effective plugin dir to `codexSkillsDir` for Codex. + */ +export async function syncCodexSkills( + pluginPath: string, + codexSkillsDir: string, +): Promise { + const effectiveSkillsDir = join(pluginPath, "skills"); + if (!existsSync(effectiveSkillsDir)) { + return; + } + + try { + await mkdir(codexSkillsDir, { recursive: true }); + + const entries = await readdir(effectiveSkillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const src = join(effectiveSkillsDir, entry.name); + const dest = join(codexSkillsDir, entry.name); + await rm(dest, { recursive: true, force: true }); + await cp(src, dest, { recursive: true }); + } + } + } catch { + // Fire-and-forget — don't block startup or updates on Codex sync + } +} + +export interface UpdateSkillsInput { + runtimeSkillsDir: string; + runtimePluginDir: string; + pluginPath: string; + codexSkillsDir: string; + tempDir: string; + skillsZipUrl: string; + downloadFile: (url: string, destPath: string) => Promise; +} + +export interface UpdateSkillsOutput { + updated: boolean; +} + +export class UpdateSkillsSaga extends Saga< + UpdateSkillsInput, + UpdateSkillsOutput +> { + protected async execute( + input: UpdateSkillsInput, + ): Promise { + const newSkillsDir = `${input.runtimeSkillsDir}.new`; + + // Step 1: create staging dir + await this.step({ + name: "create-staging-dir", + execute: async () => { + await rm(newSkillsDir, { recursive: true, force: true }); + await mkdir(newSkillsDir, { recursive: true }); + return newSkillsDir; + }, + rollback: async (dir) => { + await rm(dir, { recursive: true, force: true }); + }, + }); + + // Step 2: download skills (non-fatal) + await this.readOnlyStep("download-skills", async () => { + try { + await this.downloadAndMergeSkills( + input.skillsZipUrl, + input.tempDir, + newSkillsDir, + input.downloadFile, + ); + } catch (err) { + this.log.warn("Failed to download skills", { + error: err instanceof Error ? err.message : String(err), + }); + } + }); + + // Step 3: validate skills (fatal if empty → triggers rollback of step 1) + await this.readOnlyStep("validate-skills", async () => { + const entries = await readdir(newSkillsDir); + if (entries.length === 0) { + throw new Error("No skills found from any source"); + } + }); + + // Step 4: atomic swap + const oldSkillsDir = `${input.runtimeSkillsDir}.old`; + await this.step({ + name: "swap-skills-cache", + execute: async () => { + await rm(oldSkillsDir, { recursive: true, force: true }); + const hadExisting = existsSync(input.runtimeSkillsDir); + if (hadExisting) { + await rename(input.runtimeSkillsDir, oldSkillsDir); + } + await rename(newSkillsDir, input.runtimeSkillsDir); + await rm(oldSkillsDir, { recursive: true, force: true }); + return hadExisting; + }, + rollback: async (hadExisting) => { + try { + if (existsSync(input.runtimeSkillsDir)) { + await rename(input.runtimeSkillsDir, newSkillsDir); + } + if (hadExisting && existsSync(oldSkillsDir)) { + await rename(oldSkillsDir, input.runtimeSkillsDir); + } + } catch { + // Best-effort rollback + } + }, + }); + + // Step 5: overlay skills (non-fatal) + await this.readOnlyStep("overlay-skills", async () => { + try { + await overlayDownloadedSkills( + input.runtimeSkillsDir, + input.runtimePluginDir, + ); + } catch (err) { + this.log.warn("Failed to overlay skills", { + error: err instanceof Error ? err.message : String(err), + }); + } + }); + + // Step 6: sync codex skills (non-fatal) + await this.readOnlyStep("sync-codex-skills", async () => { + try { + await syncCodexSkills(input.pluginPath, input.codexSkillsDir); + } catch (err) { + this.log.warn("Failed to sync codex skills", { + error: err instanceof Error ? err.message : String(err), + }); + } + }); + + return { updated: true }; + } + + /** + * Downloads a skills zip from `url`, extracts it, and merges skill directories into `destDir`. + */ + private async downloadAndMergeSkills( + url: string, + tempDir: string, + destDir: string, + downloadFile: (url: string, destPath: string) => Promise, + ): Promise { + const zipPath = join(tempDir, "skills.zip"); + await downloadFile(url, zipPath); + + const extractDir = join(tempDir, "extracted"); + await mkdir(extractDir, { recursive: true }); + await execFileAsync("unzip", ["-o", zipPath, "-d", extractDir]); + + const skillsSource = await this.findSkillsDir(extractDir); + if (!skillsSource) { + this.log.warn("No skills directory found in archive"); + return; + } + + const entries = await readdir(skillsSource, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const src = join(skillsSource, entry.name); + const dest = join(destDir, entry.name); + await rm(dest, { recursive: true, force: true }); + await cp(src, dest, { recursive: true }); + } + } + + this.log.info("Skills merged"); + } + + /** + * Finds the skills directory inside an extracted zip. + * Handles: skills/ at root, nested (e.g. posthog/skills/), or skill dirs directly at root. + */ + private async findSkillsDir(extractDir: string): Promise { + const direct = join(extractDir, "skills"); + if (existsSync(direct)) { + return direct; + } + + const entries = await readdir(extractDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const nested = join(extractDir, entry.name, "skills"); + if (existsSync(nested)) { + return nested; + } + } + } + + const hasSkillDirs = entries.some( + (e) => + e.isDirectory() && existsSync(join(extractDir, e.name, "SKILL.md")), + ); + if (hasSkillDirs) { + return extractDir; + } + + return null; + } +} From d05808e1dd68142b7ad64144205bff8916967cf4 Mon Sep 17 00:00:00 2001 From: Georgiy Tarasov Date: Mon, 23 Feb 2026 12:28:18 +0100 Subject: [PATCH 5/6] polyfill for zip --- apps/twig/package.json | 5 +- apps/twig/src/main/lib/extract-zip.ts | 23 +++++ .../services/posthog-plugin/service.test.ts | 63 +++++-------- .../posthog-plugin/update-skills-saga.ts | 7 +- apps/twig/vite.main.config.mts | 18 +++- package.json | 1 + pnpm-lock.yaml | 89 ++++++++----------- scripts/pull-skills.mjs | 17 +++- 8 files changed, 121 insertions(+), 102 deletions(-) create mode 100644 apps/twig/src/main/lib/extract-zip.ts diff --git a/apps/twig/package.json b/apps/twig/package.json index 9b905e52e..25ef9a597 100644 --- a/apps/twig/package.json +++ b/apps/twig/package.json @@ -142,6 +142,7 @@ "dotenv": "^17.2.3", "electron-log": "^5.4.3", "electron-store": "^11.0.0", + "fflate": "^0.8.2", "file-icon": "^6.0.0", "framer-motion": "^12.26.2", "fzf": "^0.5.2", @@ -167,11 +168,11 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", + "striptags": "^3.2.0", "tippy.js": "^6.3.7", "uuid": "^9.0.1", "vscode-icons-js": "^11.6.1", "zod": "^4.1.12", - "zustand": "^4.5.0", - "striptags": "^3.2.0" + "zustand": "^4.5.0" } } diff --git a/apps/twig/src/main/lib/extract-zip.ts b/apps/twig/src/main/lib/extract-zip.ts new file mode 100644 index 000000000..a27fa8527 --- /dev/null +++ b/apps/twig/src/main/lib/extract-zip.ts @@ -0,0 +1,23 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { unzipSync } from "fflate"; + +/** + * Extracts a ZIP file to a directory using fflate (cross-platform, no native dependencies). + */ +export async function extractZip( + zipPath: string, + extractDir: string, +): Promise { + const data = await readFile(zipPath); + const unzipped = unzipSync(new Uint8Array(data)); + for (const [filename, content] of Object.entries(unzipped)) { + const fullPath = join(extractDir, filename); + if (filename.endsWith("/")) { + await mkdir(fullPath, { recursive: true }); + } else { + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content); + } + } +} diff --git a/apps/twig/src/main/services/posthog-plugin/service.test.ts b/apps/twig/src/main/services/posthog-plugin/service.test.ts index 894f7e91b..da63bb797 100644 --- a/apps/twig/src/main/services/posthog-plugin/service.test.ts +++ b/apps/twig/src/main/services/posthog-plugin/service.test.ts @@ -16,8 +16,8 @@ const mockNet = vi.hoisted(() => ({ fetch: vi.fn(), })); -const mockExecFileAsync = vi.hoisted(() => - vi.fn<(cmd: string, args: string[]) => Promise>(async () => {}), +const mockExtractZip = vi.hoisted(() => + vi.fn<(zipPath: string, extractDir: string) => Promise>(async () => {}), ); vi.mock("electron", () => ({ @@ -35,14 +35,8 @@ vi.mock("node:fs/promises", async () => { return { ...fs.promises, default: fs.promises }; }); -vi.mock("node:child_process", () => ({ - execFile: vi.fn(), - default: { execFile: vi.fn() }, -})); - -vi.mock("node:util", () => ({ - promisify: () => mockExecFileAsync, - default: { promisify: () => mockExecFileAsync }, +vi.mock("../../lib/extract-zip.js", () => ({ + extractZip: mockExtractZip, })); vi.mock("node:os", () => ({ @@ -82,12 +76,10 @@ function mockFetchResponse(ok: boolean, status = 200) { }; } -/** Simulate unzip by creating skill files in the extracted dir */ -function simulateUnzip() { - mockExecFileAsync.mockImplementation(async (_cmd: string, args: string[]) => { - const dIdx = args.indexOf("-d"); - if (dIdx >= 0) { - const extractDir = args[dIdx + 1]; +/** Simulate zip extraction by creating skill files in the extracted dir */ +function simulateExtractZip() { + mockExtractZip.mockImplementation( + async (_zipPath: string, extractDir: string) => { vol.mkdirSync(`${extractDir}/skills/remote-skill`, { recursive: true, }); @@ -95,8 +87,8 @@ function simulateUnzip() { `${extractDir}/skills/remote-skill/SKILL.md`, "# Remote", ); - } - }); + }, + ); } /** Create the bundled plugin directory in memfs */ @@ -116,7 +108,7 @@ describe("PosthogPluginService", () => { mockApp.isPackaged = false; mockNet.fetch.mockResolvedValue(mockFetchResponse(true)); - mockExecFileAsync.mockResolvedValue({}); + mockExtractZip.mockResolvedValue(undefined); service = new PosthogPluginService(); }); @@ -204,7 +196,7 @@ describe("PosthogPluginService", () => { describe("updateSkills", () => { it("downloads, extracts, and installs skills", async () => { setupBundledPlugin(); - simulateUnzip(); + simulateExtractZip(); await service.updateSkills(); @@ -215,10 +207,7 @@ describe("PosthogPluginService", () => { expect(mockNet.fetch).toHaveBeenCalledWith( "https://example.com/skills.zip", ); - expect(mockExecFileAsync).toHaveBeenCalledWith( - "unzip", - expect.arrayContaining(["-o"]), - ); + expect(mockExtractZip).toHaveBeenCalled(); }); it("performs atomic swap of skills directory", async () => { @@ -227,7 +216,7 @@ describe("PosthogPluginService", () => { vol.mkdirSync(`${RUNTIME_SKILLS_DIR}/old-skill`, { recursive: true }); vol.writeFileSync(`${RUNTIME_SKILLS_DIR}/old-skill/SKILL.md`, "# Old"); - simulateUnzip(); + simulateExtractZip(); await service.updateSkills(); // New skill should be present, old skill should be gone @@ -245,7 +234,7 @@ describe("PosthogPluginService", () => { vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}"); - simulateUnzip(); + simulateExtractZip(); await service.updateSkills(); expect( @@ -254,7 +243,7 @@ describe("PosthogPluginService", () => { }); it("emits 'updated' event on success", async () => { - simulateUnzip(); + simulateExtractZip(); const handler = vi.fn(); service.on("skillsUpdated", handler); @@ -264,7 +253,7 @@ describe("PosthogPluginService", () => { }); it("throttles: skips if called within 30 minutes", async () => { - simulateUnzip(); + simulateExtractZip(); await service.updateSkills(); mockNet.fetch.mockClear(); @@ -274,7 +263,7 @@ describe("PosthogPluginService", () => { }); it("allows update after throttle period expires", async () => { - simulateUnzip(); + simulateExtractZip(); await service.updateSkills(); mockNet.fetch.mockClear(); @@ -319,15 +308,11 @@ describe("PosthogPluginService", () => { }); it("handles missing skills dir in archive", async () => { - // Unzip creates no skills directory - mockExecFileAsync.mockImplementation( - async (_cmd: string, args: string[]) => { - const dIdx = args.indexOf("-d"); - if (dIdx >= 0) { - const extractDir = args[dIdx + 1]; - vol.mkdirSync(`${extractDir}/random-dir`, { recursive: true }); - vol.writeFileSync(`${extractDir}/random-dir/README.md`, "nope"); - } + // Extraction creates no skills directory + mockExtractZip.mockImplementation( + async (_zipPath: string, extractDir: string) => { + vol.mkdirSync(`${extractDir}/random-dir`, { recursive: true }); + vol.writeFileSync(`${extractDir}/random-dir/README.md`, "nope"); }, ); @@ -339,7 +324,7 @@ describe("PosthogPluginService", () => { }); it("cleans up temp dir even on error", async () => { - mockExecFileAsync.mockRejectedValue(new Error("unzip failed")); + mockExtractZip.mockRejectedValue(new Error("extraction failed")); await service.updateSkills(); diff --git a/apps/twig/src/main/services/posthog-plugin/update-skills-saga.ts b/apps/twig/src/main/services/posthog-plugin/update-skills-saga.ts index de24f7333..99e5c5ddb 100644 --- a/apps/twig/src/main/services/posthog-plugin/update-skills-saga.ts +++ b/apps/twig/src/main/services/posthog-plugin/update-skills-saga.ts @@ -1,12 +1,9 @@ -import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import { cp, mkdir, readdir, rename, rm } from "node:fs/promises"; import { join } from "node:path"; -import { promisify } from "node:util"; +import { extractZip } from "@main/lib/extract-zip.js"; import { Saga } from "@posthog/shared"; -const execFileAsync = promisify(execFile); - /** * Overlays previously-downloaded skills on top of the runtime plugin dir. * Each skill directory in the cache replaces the same-named one in the plugin. @@ -192,7 +189,7 @@ export class UpdateSkillsSaga extends Saga< const extractDir = join(tempDir, "extracted"); await mkdir(extractDir, { recursive: true }); - await execFileAsync("unzip", ["-o", zipPath, "-d", extractDir]); + await extractZip(zipPath, extractDir); const skillsSource = await this.findSkillsDir(extractDir); if (!skillsSource) { diff --git a/apps/twig/vite.main.config.mts b/apps/twig/vite.main.config.mts index cfe192e5d..e0aa6c211 100644 --- a/apps/twig/vite.main.config.mts +++ b/apps/twig/vite.main.config.mts @@ -4,14 +4,16 @@ import { cpSync, existsSync, mkdirSync, + readFileSync, readdirSync, statSync, } from "node:fs"; -import { cp, mkdir, readdir, rm } from "node:fs/promises"; +import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import path, { join } from "node:path"; +import path, { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { unzipSync } from "fflate"; import { defineConfig, loadEnv, type Plugin } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { @@ -169,7 +171,17 @@ async function downloadAndExtractSkills(targetDir: string): Promise { // Extract const extractDir = join(tempDir, "extracted"); await mkdir(extractDir, { recursive: true }); - await execFileAsync("unzip", ["-o", zipPath, "-d", extractDir]); + const zipData = readFileSync(zipPath); + const unzipped = unzipSync(new Uint8Array(zipData)); + for (const [filename, content] of Object.entries(unzipped)) { + const fullPath = join(extractDir, filename); + if (filename.endsWith("/")) { + await mkdir(fullPath, { recursive: true }); + } else { + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content); + } + } // Find skills directory in extracted content const skillsSource = await findSkillsDirInExtract(extractDir); diff --git a/package.json b/package.json index 4a90bddb3..1c9ef0142 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@biomejs/biome": "2.2.4", "@posthog/cli": "^0.5.26", + "fflate": "^0.8.2", "husky": "^9.1.7", "knip": "^5.66.3", "lint-staged": "^15.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 486ceadd4..6ed08fd7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@posthog/cli': specifier: ^0.5.26 version: 0.5.26 + fflate: + specifier: ^0.8.2 + version: 0.8.2 husky: specifier: ^9.1.7 version: 9.1.7 @@ -336,6 +339,9 @@ importers: electron-store: specifier: ^11.0.0 version: 11.0.2 + fflate: + specifier: ^0.8.2 + version: 0.8.2 file-icon: specifier: ^6.0.0 version: 6.0.0 @@ -465,10 +471,10 @@ importers: version: 10.2.0(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@storybook/addon-docs': specifier: 10.2.0 - version: 10.2.0(@types/react@19.2.11)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) + version: 10.2.0(@types/react@19.2.11)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@storybook/react-vite': specifier: 10.2.0 - version: 10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) + version: 10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -492,7 +498,7 @@ importers: version: 9.0.8 '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.7.0(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/ui': specifier: ^4.0.10 version: 4.0.18(vitest@4.0.18) @@ -540,13 +546,13 @@ importers: version: 5.9.3 vite: specifier: ^6.0.7 - version: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^4.0.10 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.31)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.31.1)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.31)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) yaml: specifier: ^2.8.1 version: 2.8.2 @@ -6901,7 +6907,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} @@ -13475,11 +13481,11 @@ snapshots: '@jimp/types': 1.6.0 tinycolor2: 1.6.0 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: glob: 11.1.0 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: typescript: 5.9.3 @@ -15282,10 +15288,10 @@ snapshots: axe-core: 4.11.1 storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/addon-docs@10.2.0(@types/react@19.2.11)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': + '@storybook/addon-docs@10.2.0(@types/react@19.2.11)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.11)(react@19.1.0) - '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@storybook/icons': 2.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@storybook/react-dom-shim': 10.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) react: 19.1.0 @@ -15299,27 +15305,27 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': + '@storybook/builder-vite@10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': dependencies: - '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) - '@vitest/mocker': 3.2.4(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) + '@vitest/mocker': 3.2.4(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 - vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - msw - rollup - webpack - '@storybook/csf-plugin@10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': + '@storybook/csf-plugin@10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': dependencies: storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.2 rollup: 4.57.1 - vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) webpack: 5.105.0(esbuild@0.27.2) '@storybook/global@5.0.0': {} @@ -15335,11 +15341,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/react-vite@10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': + '@storybook/react-vite@10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@storybook/builder-vite': 10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) + '@storybook/builder-vite': 10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@storybook/react': 10.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -15349,7 +15355,7 @@ snapshots: resolve: 1.22.11 storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tsconfig-paths: 4.2.0 - vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - msw @@ -15844,7 +15850,7 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -15852,7 +15858,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -15915,23 +15921,23 @@ snapshots: msw: 2.12.8(@types/node@25.2.0)(typescript@5.9.3) vite: 5.4.21(@types/node@25.2.0)(lightningcss@1.31.1)(terser@5.46.0) - '@vitest/mocker@3.2.4(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.8(@types/node@20.19.31)(typescript@5.9.3) - vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.0.18(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.8(@types/node@20.19.31)(typescript@5.9.3) - vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@2.1.9': dependencies: @@ -15986,7 +15992,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.31)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.31.1)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.31)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@2.1.9': dependencies: @@ -22216,13 +22222,13 @@ snapshots: magic-string: 0.30.21 vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript @@ -22249,23 +22255,6 @@ snapshots: lightningcss: 1.31.1 terser: 5.46.0 - vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.57.1 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 20.19.31 - fsevents: 2.3.3 - jiti: 1.21.7 - lightningcss: 1.31.1 - terser: 5.46.0 - tsx: 4.21.0 - yaml: 2.8.2 - vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -22355,10 +22344,10 @@ snapshots: - supports-color - terser - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.31)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.31.1)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.31)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(msw@2.12.8(@types/node@20.19.31)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -22375,7 +22364,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 diff --git a/scripts/pull-skills.mjs b/scripts/pull-skills.mjs index 4f23c4274..acae5104b 100644 --- a/scripts/pull-skills.mjs +++ b/scripts/pull-skills.mjs @@ -10,12 +10,13 @@ */ import { execFile } from "node:child_process"; -import { existsSync } from "node:fs"; -import { cp, mkdir, readdir, rm } from "node:fs/promises"; +import { existsSync, readFileSync } from "node:fs"; +import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { unzipSync } from "fflate"; const execFileAsync = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -42,7 +43,17 @@ try { const extractDir = join(tempDir, "extracted"); await mkdir(extractDir, { recursive: true }); - await execFileAsync("unzip", ["-o", zipPath, "-d", extractDir]); + const zipData = readFileSync(zipPath); + const unzipped = unzipSync(new Uint8Array(zipData)); + for (const [filename, content] of Object.entries(unzipped)) { + const fullPath = join(extractDir, filename); + if (filename.endsWith("/")) { + await mkdir(fullPath, { recursive: true }); + } else { + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content); + } + } // Find the skills directory let skillsSource = null; From e47b525840daa617603e27f41e463767993b27fb Mon Sep 17 00:00:00 2001 From: Georgiy Tarasov Date: Tue, 24 Feb 2026 11:14:55 +0100 Subject: [PATCH 6/6] fix: imports --- apps/twig/vite.main.config.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/twig/vite.main.config.mts b/apps/twig/vite.main.config.mts index e0aa6c211..478f8294e 100644 --- a/apps/twig/vite.main.config.mts +++ b/apps/twig/vite.main.config.mts @@ -4,8 +4,8 @@ import { cpSync, existsSync, mkdirSync, - readFileSync, readdirSync, + readFileSync, statSync, } from "node:fs"; import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";