diff --git a/.cta.json b/.cta.json index c2b42e04..1355dd5b 100644 --- a/.cta.json +++ b/.cta.json @@ -9,7 +9,5 @@ "addOnOptions": {}, "version": 1, "framework": "react-cra", - "chosenAddOns": [ - "nitro" - ] -} \ No newline at end of file + "chosenAddOns": ["nitro"] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..c6c8b362 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 2c146989..28e59e3a 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -28,3 +28,35 @@ done if [ "$CHANGED" = "1" ]; then echo "[version-sync] branch $BRANCH → $VERSION" fi + +# --- Auto-format via the same script CI uses, then re-stage staged files --- +# +# Run `bun run format:check` first; if it fails, run `bun run format` to +# auto-fix and re-stage only the files that were already staged. New +# files added without going through the formatter (the most common cause +# of CI format-check failures) get fixed silently here. The format +# scripts already respect .prettierignore so the agent-native submodule +# and generated files are untouched. +# +# NOTE: re-adding files pulls in any unstaged hunks from a partially +# staged file. Stage cleanly if that matters to you. +if ! bun run format:check > /dev/null 2>&1; then + echo "[pre-commit] format drift detected — running \`bun run format\`" + if ! bun run format > /dev/null 2>&1; then + echo "[pre-commit] \`bun run format\` failed"; exit 1; + fi + STAGED=$(git diff --cached --name-only --diff-filter=d \ + | grep -E '\.(ts|tsx|js|jsx|cjs|mjs|json|jsonc|md|yml|yaml)$' || true) + if [ -n "$STAGED" ]; then + echo "$STAGED" | xargs git add + fi +fi + +# --- Lint staged TS/JS files (no auto-fix, fail loudly on errors) --- +LINT_STAGED=$(git diff --cached --name-only --diff-filter=d \ + | grep -E '\.(ts|tsx|js|jsx|cjs|mjs)$' || true) +if [ -n "$LINT_STAGED" ]; then + echo "$LINT_STAGED" | xargs npx oxlint || { + echo "[pre-commit] lint failed. Run: bun run lint:fix"; exit 1; + } +fi diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..03ca2d1b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [ZSeven-W] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5b0b66f1..a42cb702 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: Bug Report description: Report a bug or unexpected behavior -labels: ["bug"] +labels: ['bug'] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 8fdc66bb..e32ed587 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,6 @@ name: Feature Request description: Suggest a new feature or improvement -labels: ["enhancement"] +labels: ['enhancement'] body: - type: markdown attributes: diff --git a/.github/workflows/build-electron.yml b/.github/workflows/build-electron.yml index 62f31d45..f7269336 100644 --- a/.github/workflows/build-electron.yml +++ b/.github/workflows/build-electron.yml @@ -34,6 +34,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: oven-sh/setup-bun@v2 with: @@ -43,6 +45,10 @@ jobs: with: node-version: 20 + - uses: mlugg/setup-zig@v2 + with: + version: 0.15.2 + - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b0913d2..6acb4124 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,17 +14,35 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: oven-sh/setup-bun@v2 with: bun-version: latest + - uses: mlugg/setup-zig@v2 + with: + version: 0.15.2 + + - name: Configure git for tests + run: | + git config --global user.name "CI" + git config --global user.email "ci@example.com" + git config --global init.defaultBranch main + - name: Install dependencies run: bun install --frozen-lockfile - name: Generate skill registry run: bun run generate + - name: Lint + run: bun run lint + + - name: Format check + run: bun run format:check + - name: Type check run: npx tsc --noEmit @@ -39,11 +57,17 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: oven-sh/setup-bun@v2 with: bun-version: latest + - uses: mlugg/setup-zig@v2 + with: + version: 0.15.2 + - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4eb1ac9c..4e7e8a10 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -52,6 +52,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Log in to GitHub Container Registry uses: docker/login-action@v3 diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 8974b992..5cf0e86e 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -17,6 +17,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: oven-sh/setup-bun@v2 with: @@ -27,6 +29,10 @@ jobs: node-version: 20 registry-url: https://registry.npmjs.org + - uses: mlugg/setup-zig@v2 + with: + version: 0.15.2 + - name: Install dependencies run: bun install --frozen-lockfile @@ -74,12 +80,6 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Publish pen-codegen - run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-codegen@${{ steps.version.outputs.version }}" version - working-directory: packages/pen-codegen - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Publish pen-figma run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-figma@${{ steps.version.outputs.version }}" version working-directory: packages/pen-figma @@ -92,6 +92,12 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish pen-mcp + run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-mcp@${{ steps.version.outputs.version }}" version + working-directory: packages/pen-mcp + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish pen-sdk run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-sdk@${{ steps.version.outputs.version }}" version working-directory: packages/pen-sdk diff --git a/.gitignore b/.gitignore index 5231f631..15a4a7a8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ count.txt __unconfig* todos.json .openpencil-tmp/ +.superpowers/ docs/ @@ -20,3 +21,5 @@ dist-ssr/ electron-dist/ dist-electron/ .claude +.omx +.worktrees/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..f4c48de4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "packages/agent-native"] + path = packages/agent-native + url = https://github.com/ZSeven-W/agent.git diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..a4b1a13f --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,7 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..d351490b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +# oxfmt picks this file up automatically (alongside .gitignore). + +# Git submodule — owned by a separate repo, not formatted from here. +packages/agent-native/ + +# Auto-generated by @tanstack/router-plugin. +apps/web/src/routeTree.gen.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 30510812..d5238791 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,8 +8,5 @@ "files.readonlyInclude": { "**/routeTree.gen.ts": true }, - "i18n-ally.localesPaths": [ - "src/i18n", - "src/i18n/locales" - ] + "i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"] } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e422e495 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,147 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. +Detailed module docs are in `packages/AGENTS.md`, `apps/web/AGENTS.md`, `apps/desktop/AGENTS.md`, and `apps/cli/AGENTS.md` — loaded automatically when working in those directories. + +## Commands + +- **Dev server:** `bun --bun run dev` (runs on port 3000) +- **Build:** `bun --bun run build` +- **Preview production build:** `bun --bun run preview` +- **Run all tests:** `bun --bun run test` (Vitest) +- **Run a single test:** `bun --bun vitest run path/to/test.ts` +- **Type check:** `npx tsc --noEmit` +- **Install dependencies:** `bun install` +- **Bump version:** `bun run bump ` (syncs all package.json files) +- **Electron dev:** `bun run electron:dev` (starts Vite + Electron together) +- **Electron compile:** `bun run electron:compile` (esbuild electron/ to out/desktop/) +- **Electron build:** `bun run electron:build` (full web build + compile + electron-builder package) +- **CLI compile:** `bun run cli:compile` (esbuild CLI to apps/cli/dist/) +- **CLI dev:** `bun run cli:dev` (run CLI from source via Bun) +- **Publish beta:** `bun run publish:beta [N]` (publish all npm packages with beta tag) + +## Architecture + +OpenPencil is an open-source vector design tool (alternative to Pencil.dev) with a Design-as-Code philosophy. Organized as a **Bun monorepo** with workspaces: + +```text +openpencil/ +├── apps/ +│ ├── web/ TanStack Start full-stack React app (Vite + Nitro) +│ ├── desktop/ Electron desktop app (macOS, Windows, Linux) +│ └── cli/ CLI tool — control the design tool from the terminal +├── packages/ +│ ├── pen-types/ Type definitions for PenDocument model +│ ├── pen-core/ Document tree ops, layout engine, variables, boolean ops, clone utilities +│ ├── pen-codegen/ Multi-platform code generators +│ ├── pen-figma/ Figma .fig file parser and converter +│ ├── pen-renderer/ Standalone CanvasKit/Skia renderer +│ ├── pen-sdk/ Umbrella SDK (re-exports all packages) +│ ├── pen-ai-skills/ AI prompt skill engine (phase-driven prompt loading + design memory) +│ └── agent/ Domain-agnostic AI agent SDK (Vercel AI SDK, multi-provider, agent teams) +├── scripts/ Build and publish scripts +└── .githooks/ Pre-commit version sync from branch name +``` + +**Key technologies:** React 19, CanvasKit/Skia WASM (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), Vercel AI SDK v6 (agent framework), i18next (15 locales), TypeScript (strict mode). + +### Data Flow + +```text +React Components (Toolbar, LayerPanel, PropertyPanel) + │ Zustand hooks + ▼ +┌─────────────────┐ ┌───────────────────┐ +│ canvas-store │ │ document-store │ ← single source of truth +│ (UI state: │ │ (PenDocument) │ +│ tool/selection │ │ CRUD / tree ops │ +│ /viewport) │ │ │ +└────────┬────────┘ └────────┬──────────┘ + │ │ + ▼ ▼ + CanvasKit/Skia canvas-sync-lock + (GPU-accelerated (prevents circular sync) + WASM renderer) +``` + +- **document-store** is the single source of truth. CanvasKit only renders. +- User edits on canvas → SkiaEngine events → update document-store +- User edits in panels → update document-store → SkiaEngine `syncFromDocument()` re-renders +- `canvas-sync-lock.ts` prevents circular updates when canvas events write to the store + +### Multi-Page Architecture + +```text +PenDocument + ├── pages?: PenPage[] (id, name, children) + └── children: PenNode[] (default/single-page fallback) +``` + +### Design Variables Architecture + +- **`$variable` references are preserved** in the document store (e.g. `$color-1` in fill color) +- `resolveNodeForCanvas()` resolves `$refs` on-the-fly before CanvasKit rendering +- Code generators output `var(--name)` for `$ref` values +- Multiple theme axes supported (e.g. Theme-1 with Light/Dark, Theme-2 with Compact/Comfortable) + +### MCP Layered Design Workflow + +External LLMs (Codex, Codex, Gemini CLI, etc.) can generate designs via MCP: + +- **Single-shot**: `batch_design` or `insert_node` — one call +- **Layered**: `design_skeleton` → `design_content` × N → `design_refine` — phased generation with focused context +- **Segmented prompts**: `get_design_prompt(section=...)` loads focused subsets (schema, layout, roles, icons, etc.) + +### Path Aliases + +`@/*` maps to `./src/*` (configured in `apps/web/tsconfig.json` and `apps/web/vite.config.ts`). + +### Styling + +Tailwind CSS v4 imported via `apps/web/src/styles.css`. UI primitives from shadcn/ui. Icons from `lucide-react`. + +### CLI (`apps/cli/`) + +The `op` command-line tool controls the desktop app or web server from the terminal. Arguments that accept JSON or DSL support three input methods: inline string, `@filepath` (read from file), or `-` (read from stdin). + +- **App control:** `op start [--desktop|--web]`, `op stop`, `op status` +- **Design:** `op design ` — batch design DSL operations +- **Document:** `op open`, `op save`, `op get`, `op selection` +- **Nodes:** `op insert`, `op update`, `op delete`, `op move`, `op copy`, `op replace` +- **Export:** `op export ` +- **Cross-platform:** macOS, Windows (NSIS/portable), Linux (AppImage/deb/snap/flatpak) + +### CI / CD + +- **`.github/workflows/ci.yml`** — Push/PR on `main` and `v*` branches: type check, tests, web build +- **`.github/workflows/build-electron.yml`** — Tag push (`v*`) or manual: builds Electron for all platforms, creates draft GitHub Release +- **`.github/workflows/publish-cli.yml`** — Tag push (`v*`) or manual: publishes all `@zseven-w/*` npm packages in topological order +- **`.github/workflows/docker.yml`** — Docker image build and push + +### Version Sync + +- **Pre-commit hook** (`.githooks/pre-commit`): extracts version from branch name (e.g. `v0.5.0` → `0.5.0`) and syncs to all `package.json` files +- **Manual bump:** `bun run bump ` to set a specific version across all workspaces +- Requires `git config core.hooksPath .githooks` (one-time setup per clone) + +## Code Style + +- Single files must not exceed 800 lines. Split into smaller modules when they grow beyond this limit. +- One component per file, each with a single responsibility. +- `.ts` and `.tsx` files use kebab-case naming, e.g. `canvas-store.ts`, `use-keyboard-shortcuts.ts`. +- UI components must use shadcn/ui design tokens (`bg-card`, `text-foreground`, `border-border`, etc.). No hardcoded Tailwind colors like `gray-*`, `blue-*`. +- Toolbar button active state uses `isActive` conditional className (`bg-primary text-primary-foreground`), not Radix Toggle's `data-[state=on]:` selector (has twMerge conflicts). + +## Git Commit Convention + +Use [Conventional Commits](https://www.conventionalcommits.org/) format: `(): ` + +**Types:** `feat`, `fix`, `refactor`, `perf`, `style`, `docs`, `test`, `chore` + +**Scopes:** `editor`, `canvas`, `panels`, `history`, `ai`, `codegen`, `store`, `types`, `variables`, `figma`, `mcp`, `electron`, `renderer`, `sdk`, `cli`, `agent`, `i18n` + +**Rules:** Subject in English, lowercase start, no period, imperative mood. Body is optional; explain **why** not what. One commit per change. + +## License + +MIT License. See [LICENSE](./LICENSE) for details. diff --git a/CLAUDE.md b/CLAUDE.md index 6dce0c45..f5829c57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,8 @@ Detailed module docs are in `packages/CLAUDE.md`, `apps/web/CLAUDE.md`, `apps/de - **Run all tests:** `bun --bun run test` (Vitest) - **Run a single test:** `bun --bun vitest run path/to/test.ts` - **Type check:** `npx tsc --noEmit` +- **Lint:** `bun run lint` (oxlint) +- **Format:** `bun run format` (oxfmt) - **Install dependencies:** `bun install` - **Bump version:** `bun run bump ` (syncs all package.json files) - **Electron dev:** `bun run electron:dev` (starts Vite + Electron together) @@ -18,6 +20,7 @@ Detailed module docs are in `packages/CLAUDE.md`, `apps/web/CLAUDE.md`, `apps/de - **Electron build:** `bun run electron:build` (full web build + compile + electron-builder package) - **CLI compile:** `bun run cli:compile` (esbuild CLI to apps/cli/dist/) - **CLI dev:** `bun run cli:dev` (run CLI from source via Bun) +- **MCP dev:** `bun run mcp:dev` (run MCP server from source) - **Publish beta:** `bun run publish:beta [N]` (publish all npm packages with beta tag) ## Architecture @@ -33,17 +36,20 @@ openpencil/ ├── packages/ │ ├── pen-types/ Type definitions for PenDocument model │ ├── pen-core/ Document tree ops, layout engine, variables, boolean ops, clone utilities +│ ├── pen-engine/ Headless design engine — framework-free document, selection, history, viewport +│ ├── pen-react/ React UI SDK — DesignProvider, DesignCanvas, hooks, panels, toolbar │ ├── pen-codegen/ Multi-platform code generators │ ├── pen-figma/ Figma .fig file parser and converter │ ├── pen-renderer/ Standalone CanvasKit/Skia renderer +│ ├── pen-mcp/ MCP server — tools, routes, document manager for external CLI integration │ ├── pen-sdk/ Umbrella SDK (re-exports all packages) │ ├── pen-ai-skills/ AI prompt skill engine (phase-driven prompt loading + design memory) -│ └── agent/ Domain-agnostic AI agent SDK (Vercel AI SDK, multi-provider, agent teams) +│ └── agent-native/ Native AI agent runtime (Zig NAPI, multi-provider, agent teams) ├── scripts/ Build and publish scripts └── .githooks/ Pre-commit version sync from branch name ``` -**Key technologies:** React 19, CanvasKit/Skia WASM (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), Vercel AI SDK v6 (agent framework), i18next (15 locales), TypeScript (strict mode). +**Key technologies:** React 19, CanvasKit/Skia WASM (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), Vercel AI SDK v6 (agent framework), i18next (15 locales), TypeScript (strict mode), oxlint/oxfmt (linting & formatting). ### Data Flow diff --git a/Dockerfile b/Dockerfile index 1297e8ed..33f01352 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,30 @@ +# syntax=docker/dockerfile:1 # ── Stage 1: Build web app ── FROM oven/bun:1 AS builder +# Install Zig. agent-native postinstall prefers downloading a prebuilt .node +# matching the submodule commit from the ZSeven-W/agent release, but falls +# back to `zig build napi` when no matching asset exists (e.g. building for +# an arch we don't publish yet). Pin 0.15.2 because the Zig source uses the +# unmanaged ArrayList / std.process.getEnvVarOwned shape introduced in 0.15. +RUN apt-get update && apt-get install -y --no-install-recommends curl xz-utils ca-certificates \ + && ARCH="$(uname -m)" \ + && case "$ARCH" in \ + x86_64) ZIG_ARCH=x86_64 ;; \ + aarch64) ZIG_ARCH=aarch64 ;; \ + *) echo "Unsupported arch: $ARCH" && exit 1 ;; \ + esac \ + && curl -fsSL "https://ziglang.org/download/0.15.2/zig-${ZIG_ARCH}-linux-0.15.2.tar.xz" \ + | tar -xJ -C /usr/local \ + && ln -sf "/usr/local/zig-${ZIG_ARCH}-linux-0.15.2/zig" /usr/local/bin/zig \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY package.json bun.lock ./ -COPY packages/pen-types/package.json packages/pen-types/ -COPY packages/pen-core/package.json packages/pen-core/ -COPY packages/pen-codegen/package.json packages/pen-codegen/ -COPY packages/pen-figma/package.json packages/pen-figma/ -COPY packages/pen-renderer/package.json packages/pen-renderer/ -COPY packages/pen-sdk/package.json packages/pen-sdk/ -COPY packages/pen-ai-skills/package.json packages/pen-ai-skills/ -COPY packages/agent/package.json packages/agent/ -COPY apps/web/package.json apps/web/ -COPY apps/desktop/package.json apps/desktop/ -COPY apps/cli/package.json apps/cli/ +COPY --parents packages/*/package.json apps/*/package.json ./ +# agent-native is a git submodule with a nested workspace package (napi/) +# and Zig sources needed by the postinstall hook — copy it whole. +COPY packages/agent-native ./packages/agent-native RUN bun install --frozen-lockfile COPY . . ENV NODE_OPTIONS="--max-old-space-size=4096" diff --git a/README.de.md b/README.de.md index 9aae2e80..4796ad37 100644 --- a/README.de.md +++ b/README.de.md @@ -124,15 +124,15 @@ bun run electron:dev Mehrere Image-Varianten sind verfügbar — wählen Sie die passende für Ihre Anforderungen: -| Image | Größe | Enthält | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | Nur Web-App | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | Alle CLI-Tools | +| Image | Größe | Enthält | +| ---------------------------- | ------- | -------------------- | +| `openpencil:latest` | ~226 MB | Nur Web-App | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | Alle CLI-Tools | **Ausführen (nur Web):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## KI-natives Design **Vom Prompt zur UI** + - **Text-zu-Design** — eine Seite beschreiben und sie wird in Echtzeit mit Streaming-Animation auf der Canvas generiert - **Orchestrierer** — zerlegt komplexe Seiten in räumliche Teilaufgaben zur parallelen Generierung - **Design-Modifikation** — Elemente auswählen und Änderungen in natürlicher Sprache beschreiben @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **Multi-Agenten-Unterstützung** -| Agent | Einrichtung | -| --- | --- | +| Agent | Einrichtung | +| ---------------------------- | ----------------------------------------------------------------------------------------------- | | **Integriert (9+ Anbieter)** | Auswahl aus Anbieter-Presets mit Region-Switcher — Anthropic, OpenAI, Google, DeepSeek und mehr | -| **Claude Code** | Keine Konfiguration — verwendet Claude Agent SDK mit lokalem OAuth | -| **Codex CLI** | In den Agenteneinstellungen verbinden (`Cmd+,`) | -| **OpenCode** | In den Agenteneinstellungen verbinden (`Cmd+,`) | -| **GitHub Copilot** | `copilot login` dann in den Agenteneinstellungen verbinden (`Cmd+,`) | -| **Gemini CLI** | In den Agenteneinstellungen verbinden (`Cmd+,`) | +| **Claude Code** | Keine Konfiguration — verwendet Claude Agent SDK mit lokalem OAuth | +| **Codex CLI** | In den Agenteneinstellungen verbinden (`Cmd+,`) | +| **OpenCode** | In den Agenteneinstellungen verbinden (`Cmd+,`) | +| **GitHub Copilot** | `copilot login` dann in den Agenteneinstellungen verbinden (`Cmd+,`) | +| **Gemini CLI** | In den Agenteneinstellungen verbinden (`Cmd+,`) | **Modell-Fähigkeitsprofile** — passt Prompts, Thinking-Modus und Timeouts automatisch pro Modellstufe an. Modelle der Vollstufe (Claude) erhalten vollständige Prompts; Standardstufe (GPT-4o, Gemini, DeepSeek) deaktiviert Thinking; Basisstufe (MiniMax, Qwen, Llama, Mistral) erhält vereinfachte verschachtelte JSON-Prompts für maximale Zuverlässigkeit. **i18n** — Vollständige Interface-Lokalisierung in 15 Sprachen: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia. **MCP-Server** + - Eingebauter MCP-Server — Ein-Klick-Installation in Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs - Automatische Node.js-Erkennung — falls nicht installiert, automatischer Fallback auf HTTP-Transport und automatischer Start des MCP-HTTP-Servers - Design-Automatisierung vom Terminal aus: `.op`-Dateien über jeden MCP-kompatiblen Agenten lesen, erstellen und bearbeiten @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - Mehrseitige Unterstützung — Seiten erstellen, umbenennen, neu ordnen und duplizieren über MCP-Tools **Codegenerierung** + - React + Tailwind CSS, HTML + CSS, CSS Variables - Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native @@ -229,6 +232,7 @@ Unterstützt drei Eingabemethoden: Inline-String, `@filepath` (aus Datei lesen) ## Funktionen **Canvas und Zeichnen** + - Unendliche Canvas mit Pan, Zoom, intelligenten Ausrichtungshilfslinien und Einrasten - Rechteck, Ellipse, Linie, Polygon, Stift (Bezier), Frame, Text - Boolesche Operationen — Vereinigung, Subtraktion, Schnittmenge mit kontextbezogener Werkzeugleiste @@ -237,15 +241,18 @@ Unterstützt drei Eingabemethoden: Inline-String, `@filepath` (aus Datei lesen) - Mehrseitige Dokumente mit Tab-Navigation **Designsystem** + - Designvariablen — Farb-, Zahl- und Text-Tokens mit `$variable`-Referenzen - Multi-Theme-Unterstützung — mehrere Achsen, jeweils mit Varianten (Hell/Dunkel, Kompakt/Komfortabel) - Komponentensystem — wiederverwendbare Komponenten mit Instanzen und Überschreibungen - CSS-Synchronisierung — automatisch generierte benutzerdefinierte Eigenschaften, `var(--name)` in der Code-Ausgabe **Figma-Import** + - `.fig`-Dateien importieren mit erhaltenem Layout, Füllungen, Konturen, Effekten, Text, Bildern und Vektoren **Desktop-App** + - Natives macOS, Windows und Linux über Electron - `.op`-Dateizuordnung — Doppelklick zum Öffnen, Einzelinstanzsperre - Automatische Aktualisierung über GitHub Releases @@ -253,17 +260,17 @@ Unterstützt drei Eingabemethoden: Inline-String, `@filepath` (aus Datei lesen) ## Technologie-Stack -| | | -| --- | --- | -| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **Canvas** | CanvasKit/Skia (WASM, GPU-beschleunigt) | -| **State** | Zustand v5 | -| **Server** | Nitro | -| **Desktop** | Electron 35 | -| **CLI** | `op` — Terminal-Steuerung, Batch-Design-DSL, Code-Export | -| **KI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **Laufzeit** | Bun · Vite 7 | -| **Dateiformat** | `.op` — JSON-basiert, menschenlesbar, Git-freundlich | +| | | +| --------------- | -------------------------------------------------------------------------------- | +| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **Canvas** | CanvasKit/Skia (WASM, GPU-beschleunigt) | +| **State** | Zustand v5 | +| **Server** | Nitro | +| **Desktop** | Electron 35 | +| **CLI** | `op` — Terminal-Steuerung, Batch-Design-DSL, Code-Export | +| **KI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **Laufzeit** | Bun · Vite 7 | +| **Dateiformat** | `.op` — JSON-basiert, menschenlesbar, Git-freundlich | ## Projektstruktur @@ -304,21 +311,21 @@ openpencil/ ## Tastaturkürzel -| Taste | Aktion | | Taste | Aktion | -| --- | --- | --- | --- | --- | -| `V` | Auswählen | | `Cmd+S` | Speichern | -| `R` | Rechteck | | `Cmd+Z` | Rückgängig | -| `O` | Ellipse | | `Cmd+Shift+Z` | Wiederholen | -| `L` | Linie | | `Cmd+C/X/V/D` | Kopieren/Ausschneiden/Einfügen/Duplizieren | -| `T` | Text | | `Cmd+G` | Gruppieren | -| `F` | Frame | | `Cmd+Shift+G` | Gruppierung aufheben | -| `P` | Stiftwerkzeug | | `Cmd+Shift+E` | Exportieren | -| `H` | Hand (Pan) | | `Cmd+Shift+C` | Code-Panel | -| `Del` | Löschen | | `Cmd+Shift+V` | Variablen-Panel | -| `[ / ]` | Reihenfolge ändern | | `Cmd+J` | KI-Chat | -| Pfeiltasten | 1px verschieben | | `Cmd+,` | Agenteneinstellungen | -| `Cmd+Alt+U` | Boolesche Vereinigung | | `Cmd+Alt+S` | Boolesche Subtraktion | -| `Cmd+Alt+I` | Boolesche Schnittmenge | | | | +| Taste | Aktion | | Taste | Aktion | +| ----------- | ---------------------- | --- | ------------- | ------------------------------------------ | +| `V` | Auswählen | | `Cmd+S` | Speichern | +| `R` | Rechteck | | `Cmd+Z` | Rückgängig | +| `O` | Ellipse | | `Cmd+Shift+Z` | Wiederholen | +| `L` | Linie | | `Cmd+C/X/V/D` | Kopieren/Ausschneiden/Einfügen/Duplizieren | +| `T` | Text | | `Cmd+G` | Gruppieren | +| `F` | Frame | | `Cmd+Shift+G` | Gruppierung aufheben | +| `P` | Stiftwerkzeug | | `Cmd+Shift+E` | Exportieren | +| `H` | Hand (Pan) | | `Cmd+Shift+C` | Code-Panel | +| `Del` | Löschen | | `Cmd+Shift+V` | Variablen-Panel | +| `[ / ]` | Reihenfolge ändern | | `Cmd+J` | KI-Chat | +| Pfeiltasten | 1px verschieben | | `Cmd+,` | Agenteneinstellungen | +| `Cmd+Alt+U` | Boolesche Vereinigung | | `Cmd+Alt+S` | Boolesche Subtraktion | +| `Cmd+Alt+I` | Boolesche Schnittmenge | | | | ## Skripte diff --git a/README.es.md b/README.es.md index b5eb9f6b..ed90fc72 100644 --- a/README.es.md +++ b/README.es.md @@ -124,15 +124,15 @@ bun run electron:dev Hay varias variantes de imagen disponibles — elige la que se ajuste a tus necesidades: -| Imagen | Tamaño | Incluye | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | Solo aplicación web | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | Todas las herramientas CLI | +| Imagen | Tamaño | Incluye | +| ---------------------------- | ------- | -------------------------- | +| `openpencil:latest` | ~226 MB | Solo aplicación web | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | Todas las herramientas CLI | **Ejecutar (solo web):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## Diseño Nativo de IA **De Prompt a Interfaz** + - **Texto a diseño** — describe una página y se genera en el lienzo en tiempo real con animación de transmisión - **Orquestador** — descompone páginas complejas en subtareas espaciales para generación en paralelo - **Modificación de diseño** — selecciona elementos y describe los cambios en lenguaje natural @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **Soporte Multiagente** -| Agente | Configuración | -| --- | --- | +| Agente | Configuración | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------- | | **Integrado (9+ proveedores)** | Selecciona de preajustes de proveedores con selector de región — Anthropic, OpenAI, Google, DeepSeek y más | -| **Claude Code** | Sin configuración — usa Claude Agent SDK con OAuth local | -| **Codex CLI** | Conectar en Configuración de Agente (`Cmd+,`) | -| **OpenCode** | Conectar en Configuración de Agente (`Cmd+,`) | -| **GitHub Copilot** | `copilot login` y luego conectar en Configuración de Agente (`Cmd+,`) | -| **Gemini CLI** | Conectar en Configuración de Agente (`Cmd+,`) | +| **Claude Code** | Sin configuración — usa Claude Agent SDK con OAuth local | +| **Codex CLI** | Conectar en Configuración de Agente (`Cmd+,`) | +| **OpenCode** | Conectar en Configuración de Agente (`Cmd+,`) | +| **GitHub Copilot** | `copilot login` y luego conectar en Configuración de Agente (`Cmd+,`) | +| **Gemini CLI** | Conectar en Configuración de Agente (`Cmd+,`) | **Perfiles de Capacidad de Modelos** — adapta automáticamente los prompts, el modo de pensamiento y los tiempos de espera según el nivel del modelo. Los modelos de nivel completo (Claude) reciben prompts completos; los de nivel estándar (GPT-4o, Gemini, DeepSeek) desactivan el pensamiento; los de nivel básico (MiniMax, Qwen, Llama, Mistral) reciben prompts simplificados de JSON anidado para máxima fiabilidad. **i18n** — Localización completa de la interfaz en 15 idiomas: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia. **Servidor MCP** + - Servidor MCP integrado — instalación con un clic en Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs - Detección automática de Node.js — si no está instalado, recurre automáticamente al transporte HTTP e inicia el servidor MCP HTTP - Automatización de diseño desde la terminal: leer, crear y modificar archivos `.op` a través de cualquier agente compatible con MCP @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - Soporte multipágina — crear, renombrar, reordenar y duplicar páginas mediante herramientas MCP **Generación de Código** + - React + Tailwind CSS, HTML + CSS, CSS Variables - Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native @@ -229,6 +232,7 @@ Soporta tres métodos de entrada: cadena inline, `@filepath` (leer desde archivo ## Características **Lienzo y Dibujo** + - Lienzo infinito con panorámica, zoom, guías de alineación inteligentes y ajuste - Rectángulo, Elipse, Línea, Polígono, Pluma (Bezier), Frame, Texto - Operaciones booleanas — unión, resta, intersección con barra de herramientas contextual @@ -237,15 +241,18 @@ Soporta tres métodos de entrada: cadena inline, `@filepath` (leer desde archivo - Documentos multipágina con navegación por pestañas **Sistema de Diseño** + - Variables de diseño — tokens de color, número y texto con referencias `$variable` - Soporte multitema — múltiples ejes, cada uno con variantes (Claro/Oscuro, Compacto/Cómodo) - Sistema de componentes — componentes reutilizables con instancias y sobreescrituras - Sincronización CSS — propiedades personalizadas autogeneradas, `var(--name)` en la salida de código **Importación de Figma** + - Importa archivos `.fig` conservando diseño, rellenos, trazos, efectos, texto, imágenes y vectores **Aplicación de Escritorio** + - Compatible de forma nativa con macOS, Windows y Linux mediante Electron - Asociación de archivos `.op` — doble clic para abrir, bloqueo de instancia única - Actualización automática desde GitHub Releases @@ -253,17 +260,17 @@ Soporta tres métodos de entrada: cadena inline, `@filepath` (leer desde archivo ## Stack Tecnológico -| | | -| --- | --- | -| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **Lienzo** | CanvasKit/Skia (WASM, acelerado por GPU) | -| **Estado** | Zustand v5 | -| **Servidor** | Nitro | -| **Escritorio** | Electron 35 | -| **CLI** | `op` — control desde terminal, DSL de diseño por lotes, exportación de código | -| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **Runtime** | Bun · Vite 7 | -| **Formato de archivo** | `.op` — basado en JSON, legible por humanos, compatible con Git | +| | | +| ---------------------- | -------------------------------------------------------------------------------- | +| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **Lienzo** | CanvasKit/Skia (WASM, acelerado por GPU) | +| **Estado** | Zustand v5 | +| **Servidor** | Nitro | +| **Escritorio** | Electron 35 | +| **CLI** | `op` — control desde terminal, DSL de diseño por lotes, exportación de código | +| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **Runtime** | Bun · Vite 7 | +| **Formato de archivo** | `.op` — basado en JSON, legible por humanos, compatible con Git | ## Estructura del Proyecto @@ -304,21 +311,21 @@ openpencil/ ## Atajos de Teclado -| Tecla | Acción | | Tecla | Acción | -| --- | --- | --- | --- | --- | -| `V` | Seleccionar | | `Cmd+S` | Guardar | -| `R` | Rectángulo | | `Cmd+Z` | Deshacer | -| `O` | Elipse | | `Cmd+Shift+Z` | Rehacer | -| `L` | Línea | | `Cmd+C/X/V/D` | Copiar/Cortar/Pegar/Duplicar | -| `T` | Texto | | `Cmd+G` | Agrupar | -| `F` | Frame | | `Cmd+Shift+G` | Desagrupar | -| `P` | Herramienta pluma | | `Cmd+Shift+E` | Exportar | -| `H` | Mano (panorámica) | | `Cmd+Shift+C` | Panel de código | -| `Del` | Eliminar | | `Cmd+Shift+V` | Panel de variables | -| `[ / ]` | Reordenar | | `Cmd+J` | Chat de IA | -| Flechas | Mover 1px | | `Cmd+,` | Configuración de agente | -| `Cmd+Alt+U` | Unión booleana | | `Cmd+Alt+S` | Resta booleana | -| `Cmd+Alt+I` | Intersección booleana | | | | +| Tecla | Acción | | Tecla | Acción | +| ----------- | --------------------- | --- | ------------- | ---------------------------- | +| `V` | Seleccionar | | `Cmd+S` | Guardar | +| `R` | Rectángulo | | `Cmd+Z` | Deshacer | +| `O` | Elipse | | `Cmd+Shift+Z` | Rehacer | +| `L` | Línea | | `Cmd+C/X/V/D` | Copiar/Cortar/Pegar/Duplicar | +| `T` | Texto | | `Cmd+G` | Agrupar | +| `F` | Frame | | `Cmd+Shift+G` | Desagrupar | +| `P` | Herramienta pluma | | `Cmd+Shift+E` | Exportar | +| `H` | Mano (panorámica) | | `Cmd+Shift+C` | Panel de código | +| `Del` | Eliminar | | `Cmd+Shift+V` | Panel de variables | +| `[ / ]` | Reordenar | | `Cmd+J` | Chat de IA | +| Flechas | Mover 1px | | `Cmd+,` | Configuración de agente | +| `Cmd+Alt+U` | Unión booleana | | `Cmd+Alt+S` | Resta booleana | +| `Cmd+Alt+I` | Intersección booleana | | | | ## Scripts diff --git a/README.fr.md b/README.fr.md index 581310a4..42b23ada 100644 --- a/README.fr.md +++ b/README.fr.md @@ -124,15 +124,15 @@ bun run electron:dev Plusieurs variantes d'images sont disponibles — choisissez celle qui correspond à vos besoins : -| Image | Taille | Contenu | -| --- | --- | --- | -| `openpencil:latest` | ~226 Mo | Application web uniquement | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 Go | Tous les outils CLI | +| Image | Taille | Contenu | +| ---------------------------- | ------- | -------------------------- | +| `openpencil:latest` | ~226 Mo | Application web uniquement | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 Go | Tous les outils CLI | **Exécuter (web uniquement) :** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## Design natif IA **Du prompt à l'interface** + - **Texte vers design** — décrivez une page, elle est générée en temps réel sur le canevas avec une animation en streaming - **Orchestrateur** — décompose les pages complexes en sous-tâches spatiales pour une génération parallèle - **Modification de design** — sélectionnez des éléments, puis décrivez les modifications en langage naturel @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **Support multi-agents** -| Agent | Configuration | -| --- | --- | +| Agent | Configuration | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | **Intégré (9+ fournisseurs)** | Choisissez parmi les préréglages de fournisseurs avec sélecteur de région — Anthropic, OpenAI, Google, DeepSeek et plus | -| **Claude Code** | Aucune configuration — utilise le Claude Agent SDK avec OAuth local | -| **Codex CLI** | Connecter dans les Paramètres de l'agent (`Cmd+,`) | -| **OpenCode** | Connecter dans les Paramètres de l'agent (`Cmd+,`) | -| **GitHub Copilot** | `copilot login` puis connecter dans les Paramètres de l'agent (`Cmd+,`) | -| **Gemini CLI** | Connecter dans les Paramètres de l'agent (`Cmd+,`) | +| **Claude Code** | Aucune configuration — utilise le Claude Agent SDK avec OAuth local | +| **Codex CLI** | Connecter dans les Paramètres de l'agent (`Cmd+,`) | +| **OpenCode** | Connecter dans les Paramètres de l'agent (`Cmd+,`) | +| **GitHub Copilot** | `copilot login` puis connecter dans les Paramètres de l'agent (`Cmd+,`) | +| **Gemini CLI** | Connecter dans les Paramètres de l'agent (`Cmd+,`) | **Profils de capacités des modèles** — adapte automatiquement les prompts, le mode de réflexion et les délais d'attente par niveau de modèle. Les modèles de niveau complet (Claude) reçoivent des prompts complets ; le niveau standard (GPT-4o, Gemini, DeepSeek) désactive la réflexion ; le niveau basique (MiniMax, Qwen, Llama, Mistral) reçoit des prompts JSON imbriqués simplifiés pour une fiabilité maximale. **i18n** — Localisation complète de l'interface en 15 langues : English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia. **Serveur MCP** + - Serveur MCP intégré — installation en un clic dans les CLI Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot - Détection automatique de Node.js — si non installé, bascule vers le transport HTTP et démarre automatiquement le serveur MCP HTTP - Automatisation du design depuis le terminal : lire, créer et modifier des fichiers `.op` via tout agent compatible MCP @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - Support multi-pages — créer, renommer, réordonner et dupliquer des pages via les outils MCP **Génération de code** + - React + Tailwind CSS, HTML + CSS, CSS Variables - Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native @@ -229,6 +232,7 @@ Supporte trois méthodes d'entrée : chaîne en ligne, `@filepath` (lecture depu ## Fonctionnalités **Canevas et dessin** + - Canevas infini avec panoramique, zoom, guides d'alignement intelligents et magnétisme - Rectangle, Ellipse, Ligne, Polygone, Plume (Bézier), Frame, Texte - Opérations booléennes — union, soustraction, intersection avec barre d'outils contextuelle @@ -237,15 +241,18 @@ Supporte trois méthodes d'entrée : chaîne en ligne, `@filepath` (lecture depu - Documents multi-pages avec navigation par onglets **Système de design** + - Variables de design — tokens de couleur, nombre et chaîne avec références `$variable` - Support multi-thèmes — plusieurs axes, chacun avec des variantes (Clair/Sombre, Compact/Confortable) - Système de composants — composants réutilisables avec instances et substitutions - Synchronisation CSS — propriétés personnalisées auto-générées, `var(--name)` dans la sortie de code **Import Figma** + - Importer des fichiers `.fig` en préservant la mise en page, les remplissages, les contours, les effets, le texte, les images et les vecteurs **Application de bureau** + - macOS, Windows et Linux natifs via Electron - Association de fichiers `.op` — double-cliquez pour ouvrir, verrouillage d'instance unique - Mise à jour automatique depuis GitHub Releases @@ -253,17 +260,17 @@ Supporte trois méthodes d'entrée : chaîne en ligne, `@filepath` (lecture depu ## Stack technique -| | | -| --- | --- | -| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **Canevas** | CanvasKit/Skia (WASM, accélération GPU) | -| **État** | Zustand v5 | -| **Serveur** | Nitro | -| **Bureau** | Electron 35 | -| **CLI** | `op` — contrôle depuis le terminal, DSL de design par lots, export de code | -| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **Runtime** | Bun · Vite 7 | -| **Format de fichier** | `.op` — basé sur JSON, lisible par l'humain, compatible Git | +| | | +| --------------------- | -------------------------------------------------------------------------------- | +| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **Canevas** | CanvasKit/Skia (WASM, accélération GPU) | +| **État** | Zustand v5 | +| **Serveur** | Nitro | +| **Bureau** | Electron 35 | +| **CLI** | `op` — contrôle depuis le terminal, DSL de design par lots, export de code | +| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **Runtime** | Bun · Vite 7 | +| **Format de fichier** | `.op` — basé sur JSON, lisible par l'humain, compatible Git | ## Structure du projet @@ -304,21 +311,21 @@ openpencil/ ## Raccourcis clavier -| Touche | Action | | Touche | Action | -| --- | --- | --- | --- | --- | -| `V` | Sélectionner | | `Cmd+S` | Enregistrer | -| `R` | Rectangle | | `Cmd+Z` | Annuler | -| `O` | Ellipse | | `Cmd+Shift+Z` | Rétablir | -| `L` | Ligne | | `Cmd+C/X/V/D` | Copier/Couper/Coller/Dupliquer | -| `T` | Texte | | `Cmd+G` | Grouper | -| `F` | Frame | | `Cmd+Shift+G` | Dégrouper | -| `P` | Outil plume | | `Cmd+Shift+E` | Exporter | -| `H` | Main (panoramique) | | `Cmd+Shift+C` | Panneau de code | -| `Del` | Supprimer | | `Cmd+Shift+V` | Panneau des variables | -| `[ / ]` | Réordonner | | `Cmd+J` | Chat IA | -| Flèches | Déplacer de 1px | | `Cmd+,` | Paramètres de l'agent | -| `Cmd+Alt+U` | Union booléenne | | `Cmd+Alt+S` | Soustraction booléenne | -| `Cmd+Alt+I` | Intersection booléenne | | | | +| Touche | Action | | Touche | Action | +| ----------- | ---------------------- | --- | ------------- | ------------------------------ | +| `V` | Sélectionner | | `Cmd+S` | Enregistrer | +| `R` | Rectangle | | `Cmd+Z` | Annuler | +| `O` | Ellipse | | `Cmd+Shift+Z` | Rétablir | +| `L` | Ligne | | `Cmd+C/X/V/D` | Copier/Couper/Coller/Dupliquer | +| `T` | Texte | | `Cmd+G` | Grouper | +| `F` | Frame | | `Cmd+Shift+G` | Dégrouper | +| `P` | Outil plume | | `Cmd+Shift+E` | Exporter | +| `H` | Main (panoramique) | | `Cmd+Shift+C` | Panneau de code | +| `Del` | Supprimer | | `Cmd+Shift+V` | Panneau des variables | +| `[ / ]` | Réordonner | | `Cmd+J` | Chat IA | +| Flèches | Déplacer de 1px | | `Cmd+,` | Paramètres de l'agent | +| `Cmd+Alt+U` | Union booléenne | | `Cmd+Alt+S` | Soustraction booléenne | +| `Cmd+Alt+I` | Intersection booléenne | | | | ## Scripts diff --git a/README.hi.md b/README.hi.md index d3dcc6a8..db8dbefa 100644 --- a/README.hi.md +++ b/README.hi.md @@ -124,15 +124,15 @@ bun run electron:dev कई इमेज वेरिएंट उपलब्ध हैं — अपनी ज़रूरत के अनुसार चुनें: -| इमेज | आकार | शामिल | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | केवल वेब ऐप | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | सभी CLI टूल | +| इमेज | आकार | शामिल | +| ---------------------------- | ------- | -------------------- | +| `openpencil:latest` | ~226 MB | केवल वेब ऐप | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | सभी CLI टूल | **चलाएँ (केवल वेब):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## AI-नेटिव डिज़ाइन **प्रॉम्प्ट से UI तक** + - **टेक्स्ट-टू-डिज़ाइन** — एक पेज का विवरण दें, और स्ट्रीमिंग एनिमेशन के साथ रियल-टाइम में कैनवास पर जनरेट करें - **ऑर्केस्ट्रेटर** — जटिल पेजों को समानांतर जनरेशन के लिए स्थानिक सब-टास्क में विभाजित करता है - **डिज़ाइन संशोधन** — एलिमेंट चुनें, फिर प्राकृतिक भाषा में बदलाव का विवरण दें @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **मल्टी-एजेंट सपोर्ट** -| एजेंट | सेटअप | -| --- | --- | +| एजेंट | सेटअप | +| ------------------------- | -------------------------------------------------------------------------------------------- | | **बिल्ट-इन (9+ प्रदाता)** | प्रदाता प्रीसेट से चुनें और क्षेत्र स्विच करें — Anthropic, OpenAI, Google, DeepSeek और अन्य | -| **Claude Code** | कोई कॉन्फ़िग नहीं — लोकल OAuth के साथ Claude Agent SDK का उपयोग करता है | -| **Codex CLI** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) | -| **OpenCode** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) | -| **GitHub Copilot** | `copilot login` फिर एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) | -| **Gemini CLI** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) | +| **Claude Code** | कोई कॉन्फ़िग नहीं — लोकल OAuth के साथ Claude Agent SDK का उपयोग करता है | +| **Codex CLI** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) | +| **OpenCode** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) | +| **GitHub Copilot** | `copilot login` फिर एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) | +| **Gemini CLI** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) | **मॉडल क्षमता प्रोफ़ाइल** — प्रत्येक मॉडल टियर के अनुसार प्रॉम्प्ट, थिंकिंग मोड और टाइमआउट को स्वचालित रूप से अनुकूलित करता है। फुल-टियर मॉडल (Claude) को पूर्ण प्रॉम्प्ट मिलते हैं; स्टैंडर्ड-टियर (GPT-4o, Gemini, DeepSeek) में थिंकिंग अक्षम होती है; बेसिक-टियर (MiniMax, Qwen, Llama, Mistral) को अधिकतम विश्वसनीयता के लिए सरलीकृत नेस्टेड-JSON प्रॉम्प्ट मिलते हैं। **i18n** — 15 भाषाओं में पूर्ण इंटरफ़ेस स्थानीयकरण: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia। **MCP सर्वर** + - बिल्ट-इन MCP सर्वर — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs में वन-क्लिक इंस्टॉल - Node.js स्वचालित पहचान — यदि इंस्टॉल नहीं है तो HTTP ट्रांसपोर्ट पर स्वचालित फ़ॉलबैक और MCP HTTP सर्वर ऑटो-स्टार्ट - टर्मिनल से डिज़ाइन ऑटोमेशन: किसी भी MCP-संगत एजेंट के ज़रिए `.op` फ़ाइलें पढ़ें, बनाएँ और संपादित करें @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - मल्टी-पेज सपोर्ट — MCP टूल के ज़रिए पेज बनाएँ, नाम बदलें, क्रम बदलें और डुप्लिकेट करें **कोड जनरेशन** + - React + Tailwind CSS, HTML + CSS, CSS Variables - Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native @@ -229,6 +232,7 @@ cat design.dsl | op design - # stdin से पाइप करें ## विशेषताएँ **कैनवास और ड्रॉइंग** + - पैन, ज़ूम, स्मार्ट अलाइनमेंट गाइड और स्नैपिंग के साथ अनंत कैनवास - Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text - बूलियन ऑपरेशन — संयोजन, घटाना, प्रतिच्छेदन संदर्भ टूलबार के साथ @@ -237,15 +241,18 @@ cat design.dsl | op design - # stdin से पाइप करें - टैब नेवीगेशन के साथ मल्टी-पेज दस्तावेज़ **डिज़ाइन सिस्टम** + - डिज़ाइन वेरिएबल — `$variable` रेफ़रेंस के साथ कलर, नंबर, स्ट्रिंग टोकन - मल्टी-थीम सपोर्ट — कई अक्ष, प्रत्येक में वेरिएंट (Light/Dark, Compact/Comfortable) - कम्पोनेंट सिस्टम — इंस्टेंस और ओवरराइड के साथ पुन: उपयोगी कम्पोनेंट - CSS सिंक — स्वतः-जनरेटेड कस्टम प्रॉपर्टीज़, कोड आउटपुट में `var(--name)` **Figma इम्पोर्ट** + - लेआउट, फ़िल, स्ट्रोक, इफ़ेक्ट, टेक्स्ट, इमेज और वेक्टर को सुरक्षित रखते हुए `.fig` फ़ाइलें इम्पोर्ट करें **डेस्कटॉप ऐप** + - Electron के ज़रिए नेटिव macOS, Windows और Linux सपोर्ट - `.op` फ़ाइल एसोसिएशन — डबल-क्लिक से खोलें, सिंगल-इंस्टेंस लॉक - GitHub Releases से ऑटो-अपडेट @@ -253,17 +260,17 @@ cat design.dsl | op design - # stdin से पाइप करें ## टेक स्टैक -| | | -| --- | --- | -| **फ्रंटएंड** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **कैनवास** | CanvasKit/Skia (WASM, GPU-एक्सेलेरेटेड) | -| **स्टेट** | Zustand v5 | -| **सर्वर** | Nitro | -| **डेस्कटॉप** | Electron 35 | -| **CLI** | `op` — टर्मिनल नियंत्रण, बैच डिज़ाइन DSL, कोड एक्सपोर्ट | -| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **रनटाइम** | Bun · Vite 7 | -| **फ़ाइल फ़ॉर्मेट** | `.op` — JSON-आधारित, मानव-पठनीय, Git-फ्रेंडली | +| | | +| ------------------ | -------------------------------------------------------------------------------- | +| **फ्रंटएंड** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **कैनवास** | CanvasKit/Skia (WASM, GPU-एक्सेलेरेटेड) | +| **स्टेट** | Zustand v5 | +| **सर्वर** | Nitro | +| **डेस्कटॉप** | Electron 35 | +| **CLI** | `op` — टर्मिनल नियंत्रण, बैच डिज़ाइन DSL, कोड एक्सपोर्ट | +| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **रनटाइम** | Bun · Vite 7 | +| **फ़ाइल फ़ॉर्मेट** | `.op` — JSON-आधारित, मानव-पठनीय, Git-फ्रेंडली | ## प्रोजेक्ट संरचना @@ -304,21 +311,21 @@ openpencil/ ## कीबोर्ड शॉर्टकट -| कुंजी | क्रिया | | कुंजी | क्रिया | -| --- | --- | --- | --- | --- | -| `V` | चुनें | | `Cmd+S` | सहेजें | -| `R` | Rectangle | | `Cmd+Z` | पूर्ववत करें | -| `O` | Ellipse | | `Cmd+Shift+Z` | फिर से करें | -| `L` | Line | | `Cmd+C/X/V/D` | कॉपी/कट/पेस्ट/डुप्लिकेट | -| `T` | Text | | `Cmd+G` | ग्रुप करें | -| `F` | Frame | | `Cmd+Shift+G` | अनग्रुप करें | -| `P` | Pen tool | | `Cmd+Shift+E` | एक्सपोर्ट | -| `H` | Hand (pan) | | `Cmd+Shift+C` | कोड पैनल | -| `Del` | हटाएँ | | `Cmd+Shift+V` | वेरिएबल पैनल | -| `[ / ]` | क्रम बदलें | | `Cmd+J` | AI चैट | -| Arrows | 1px नज | | `Cmd+,` | एजेंट सेटिंग्स | -| `Cmd+Alt+U` | बूलियन संयोजन | | `Cmd+Alt+S` | बूलियन घटाना | -| `Cmd+Alt+I` | बूलियन प्रतिच्छेदन | | | | +| कुंजी | क्रिया | | कुंजी | क्रिया | +| ----------- | ------------------ | --- | ------------- | ----------------------- | +| `V` | चुनें | | `Cmd+S` | सहेजें | +| `R` | Rectangle | | `Cmd+Z` | पूर्ववत करें | +| `O` | Ellipse | | `Cmd+Shift+Z` | फिर से करें | +| `L` | Line | | `Cmd+C/X/V/D` | कॉपी/कट/पेस्ट/डुप्लिकेट | +| `T` | Text | | `Cmd+G` | ग्रुप करें | +| `F` | Frame | | `Cmd+Shift+G` | अनग्रुप करें | +| `P` | Pen tool | | `Cmd+Shift+E` | एक्सपोर्ट | +| `H` | Hand (pan) | | `Cmd+Shift+C` | कोड पैनल | +| `Del` | हटाएँ | | `Cmd+Shift+V` | वेरिएबल पैनल | +| `[ / ]` | क्रम बदलें | | `Cmd+J` | AI चैट | +| Arrows | 1px नज | | `Cmd+,` | एजेंट सेटिंग्स | +| `Cmd+Alt+U` | बूलियन संयोजन | | `Cmd+Alt+S` | बूलियन घटाना | +| `Cmd+Alt+I` | बूलियन प्रतिच्छेदन | | | | ## स्क्रिप्ट diff --git a/README.id.md b/README.id.md index a164f42f..d8dacaae 100644 --- a/README.id.md +++ b/README.id.md @@ -124,15 +124,15 @@ bun run electron:dev Tersedia beberapa varian image — pilih yang sesuai kebutuhan Anda: -| Image | Ukuran | Termasuk | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | Hanya aplikasi web | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | Semua alat CLI | +| Image | Ukuran | Termasuk | +| ---------------------------- | ------- | -------------------- | +| `openpencil:latest` | ~226 MB | Hanya aplikasi web | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | Semua alat CLI | **Jalankan (hanya web):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## Desain Berbasis AI **Dari Prompt ke UI** + - **Teks ke desain** — deskripsikan halaman, dan hasilkan di kanvas secara real-time dengan animasi streaming - **Orkestrator** — menguraikan halaman kompleks menjadi sub-tugas spasial untuk pembuatan secara paralel - **Modifikasi desain** — pilih elemen, lalu deskripsikan perubahan dalam bahasa alami @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **Dukungan Multi-Agen** -| Agen | Pengaturan | -| --- | --- | +| Agen | Pengaturan | +| ------------------------ | --------------------------------------------------------------------------------------------------- | | **Bawaan (9+ penyedia)** | Pilih dari preset penyedia dengan pemilih wilayah — Anthropic, OpenAI, Google, DeepSeek dan lainnya | -| **Claude Code** | Tanpa konfigurasi — menggunakan Claude Agent SDK dengan OAuth lokal | -| **Codex CLI** | Hubungkan di Pengaturan Agen (`Cmd+,`) | -| **OpenCode** | Hubungkan di Pengaturan Agen (`Cmd+,`) | -| **GitHub Copilot** | `copilot login` lalu hubungkan di Pengaturan Agen (`Cmd+,`) | -| **Gemini CLI** | Hubungkan di Pengaturan Agen (`Cmd+,`) | +| **Claude Code** | Tanpa konfigurasi — menggunakan Claude Agent SDK dengan OAuth lokal | +| **Codex CLI** | Hubungkan di Pengaturan Agen (`Cmd+,`) | +| **OpenCode** | Hubungkan di Pengaturan Agen (`Cmd+,`) | +| **GitHub Copilot** | `copilot login` lalu hubungkan di Pengaturan Agen (`Cmd+,`) | +| **Gemini CLI** | Hubungkan di Pengaturan Agen (`Cmd+,`) | **Profil Kemampuan Model** — secara otomatis menyesuaikan prompt, mode thinking, dan timeout per tingkatan model. Model tingkat penuh (Claude) mendapat prompt lengkap; tingkat standar (GPT-4o, Gemini, DeepSeek) menonaktifkan thinking; tingkat dasar (MiniMax, Qwen, Llama, Mistral) mendapat prompt JSON bertingkat yang disederhanakan untuk keandalan maksimum. **i18n** — Lokalisasi antarmuka lengkap dalam 15 bahasa: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia. **Server MCP** + - Server MCP bawaan — instal satu klik ke Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI - Deteksi otomatis Node.js — jika tidak terinstal, otomatis beralih ke transport HTTP dan memulai server MCP HTTP - Otomasi desain dari terminal: baca, buat, dan modifikasi file `.op` melalui agen yang kompatibel dengan MCP @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - Dukungan multi-halaman — buat, ganti nama, urutkan ulang, dan duplikasi halaman melalui alat MCP **Pembuatan Kode** + - React + Tailwind CSS, HTML + CSS, CSS Variables - Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native @@ -229,6 +232,7 @@ Mendukung tiga metode input: string inline, `@filepath` (baca dari file), atau ` ## Fitur **Kanvas & Menggambar** + - Kanvas tak terbatas dengan pan, zoom, panduan perataan cerdas, dan snapping - Persegi panjang, Elips, Garis, Poligon, Pen (Bezier), Frame, Teks - Operasi Boolean — gabungan, kurangi, irisan dengan toolbar kontekstual @@ -237,15 +241,18 @@ Mendukung tiga metode input: string inline, `@filepath` (baca dari file), atau ` - Dokumen multi-halaman dengan navigasi tab **Sistem Desain** + - Variabel desain — token warna, angka, string dengan referensi `$variable` - Dukungan multi-tema — beberapa sumbu, masing-masing dengan varian (Terang/Gelap, Ringkas/Nyaman) - Sistem komponen — komponen yang dapat digunakan ulang dengan instans dan penggantian - Sinkronisasi CSS — properti kustom yang dibuat otomatis, `var(--name)` dalam keluaran kode **Impor Figma** + - Impor file `.fig` dengan tata letak, fill, stroke, efek, teks, gambar, dan vektor tetap terjaga **Aplikasi Desktop** + - macOS, Windows, dan Linux native melalui Electron - Asosiasi file `.op` — klik dua kali untuk membuka, kunci instans tunggal - Pembaruan otomatis dari GitHub Releases @@ -253,17 +260,17 @@ Mendukung tiga metode input: string inline, `@filepath` (baca dari file), atau ` ## Tumpukan Teknologi -| | | -| --- | --- | -| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **Kanvas** | CanvasKit/Skia (WASM, akselerasi GPU) | -| **State** | Zustand v5 | -| **Server** | Nitro | -| **Desktop** | Electron 35 | -| **CLI** | `op` — kontrol terminal, batch design DSL, ekspor kode | -| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **Runtime** | Bun · Vite 7 | -| **Format file** | `.op` — berbasis JSON, mudah dibaca manusia, ramah Git | +| | | +| --------------- | -------------------------------------------------------------------------------- | +| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **Kanvas** | CanvasKit/Skia (WASM, akselerasi GPU) | +| **State** | Zustand v5 | +| **Server** | Nitro | +| **Desktop** | Electron 35 | +| **CLI** | `op` — kontrol terminal, batch design DSL, ekspor kode | +| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **Runtime** | Bun · Vite 7 | +| **Format file** | `.op` — berbasis JSON, mudah dibaca manusia, ramah Git | ## Struktur Proyek @@ -304,21 +311,21 @@ openpencil/ ## Pintasan Keyboard -| Tombol | Aksi | | Tombol | Aksi | -| --- | --- | --- | --- | --- | -| `V` | Pilih | | `Cmd+S` | Simpan | -| `R` | Persegi panjang | | `Cmd+Z` | Batalkan | -| `O` | Elips | | `Cmd+Shift+Z` | Ulangi | -| `L` | Garis | | `Cmd+C/X/V/D` | Salin/Potong/Tempel/Duplikat | -| `T` | Teks | | `Cmd+G` | Grup | -| `F` | Frame | | `Cmd+Shift+G` | Pisahkan grup | -| `P` | Alat pen | | `Cmd+Shift+E` | Ekspor | -| `H` | Hand (pan) | | `Cmd+Shift+C` | Panel kode | -| `Del` | Hapus | | `Cmd+Shift+V` | Panel variabel | -| `[ / ]` | Ubah urutan | | `Cmd+J` | Chat AI | -| Panah | Geser 1px | | `Cmd+,` | Pengaturan agen | -| `Cmd+Alt+U` | Union Boolean | | `Cmd+Alt+S` | Subtract Boolean | -| `Cmd+Alt+I` | Intersect Boolean | | | | +| Tombol | Aksi | | Tombol | Aksi | +| ----------- | ----------------- | --- | ------------- | ---------------------------- | +| `V` | Pilih | | `Cmd+S` | Simpan | +| `R` | Persegi panjang | | `Cmd+Z` | Batalkan | +| `O` | Elips | | `Cmd+Shift+Z` | Ulangi | +| `L` | Garis | | `Cmd+C/X/V/D` | Salin/Potong/Tempel/Duplikat | +| `T` | Teks | | `Cmd+G` | Grup | +| `F` | Frame | | `Cmd+Shift+G` | Pisahkan grup | +| `P` | Alat pen | | `Cmd+Shift+E` | Ekspor | +| `H` | Hand (pan) | | `Cmd+Shift+C` | Panel kode | +| `Del` | Hapus | | `Cmd+Shift+V` | Panel variabel | +| `[ / ]` | Ubah urutan | | `Cmd+J` | Chat AI | +| Panah | Geser 1px | | `Cmd+,` | Pengaturan agen | +| `Cmd+Alt+U` | Union Boolean | | `Cmd+Alt+S` | Subtract Boolean | +| `Cmd+Alt+I` | Intersect Boolean | | | | ## Skrip diff --git a/README.ja.md b/README.ja.md index f5925fcd..0b1f36c2 100644 --- a/README.ja.md +++ b/README.ja.md @@ -124,15 +124,15 @@ bun run electron:dev 複数のイメージバリアントが利用可能です — ニーズに合ったものを選択してください: -| イメージ | サイズ | 含まれるもの | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | Web アプリのみ | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | すべての CLI ツール | +| イメージ | サイズ | 含まれるもの | +| ---------------------------- | ------- | -------------------- | +| `openpencil:latest` | ~226 MB | Web アプリのみ | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | すべての CLI ツール | **実行(Web のみ):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## AI ネイティブデザイン **プロンプトから UI へ** + - **テキストからデザインへ** — ページを説明すると、ストリーミングアニメーションでリアルタイムにキャンバス上に生成 - **オーケストレーター** — 複雑なページを空間サブタスクに分解し、並列生成をサポート - **デザイン修正** — 要素を選択し、自然言語で変更内容を記述 @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **マルチエージェントサポート** -| エージェント | 設定方法 | -| --- | --- | +| エージェント | 設定方法 | +| --------------------------------- | ------------------------------------------------------------------------------------------------- | | **ビルトイン(9+ プロバイダー)** | プロバイダープリセットから選択し、リージョンを切り替え — Anthropic、OpenAI、Google、DeepSeek など | -| **Claude Code** | 設定不要 — ローカル OAuth で Claude Agent SDK を使用 | -| **Codex CLI** | エージェント設定で接続(`Cmd+,`) | -| **OpenCode** | エージェント設定で接続(`Cmd+,`) | -| **GitHub Copilot** | `copilot login` 後、エージェント設定で接続(`Cmd+,`) | -| **Gemini CLI** | エージェント設定で接続(`Cmd+,`) | +| **Claude Code** | 設定不要 — ローカル OAuth で Claude Agent SDK を使用 | +| **Codex CLI** | エージェント設定で接続(`Cmd+,`) | +| **OpenCode** | エージェント設定で接続(`Cmd+,`) | +| **GitHub Copilot** | `copilot login` 後、エージェント設定で接続(`Cmd+,`) | +| **Gemini CLI** | エージェント設定で接続(`Cmd+,`) | **モデル能力プロファイル** — モデルの階層に応じてプロンプト、シンキングモード、タイムアウトを自動適応。フル階層モデル(Claude)には完全なプロンプト、標準階層(GPT-4o、Gemini、DeepSeek)ではシンキングを無効化、ベーシック階層(MiniMax、Qwen、Llama、Mistral)には最大限の信頼性のために簡略化されたネスト JSON プロンプトを使用。 **i18n** — 15言語での完全なインターフェースローカライゼーション:English、简体中文、繁體中文、日本語、한국어、Français、Español、Deutsch、Português、Русский、हिन्दी、Türkçe、ไทย、Tiếng Việt、Bahasa Indonesia。 **MCP サーバー** + - 内蔵 MCP サーバー — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI にワンクリックでインストール - Node.js を自動検出 — 未インストールの場合は HTTP トランスポートに自動フォールバックし、MCP HTTP サーバーを自動起動 - ターミナルからのデザイン自動化:MCP 対応エージェントを通じて `.op` ファイルの読み取り、作成、編集が可能 @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - マルチページサポート — MCP ツールを通じてページの作成、名前変更、並べ替え、複製が可能 **コード生成** + - React + Tailwind CSS、HTML + CSS、CSS Variables - Vue、Svelte、Flutter、SwiftUI、Jetpack Compose、React Native @@ -229,6 +232,7 @@ cat design.dsl | op design - # stdin からパイプ入力 ## 機能 **キャンバスと描画** + - パン、ズーム、スマートアライメントガイド、スナッピング対応の無限キャンバス - 矩形、楕円、直線、多角形、ペン(ベジェ)、Frame、テキスト - ブーリアン演算 — 合体、型抜き、交差(コンテキストツールバー付き) @@ -237,15 +241,18 @@ cat design.dsl | op design - # stdin からパイプ入力 - タブナビゲーション付きマルチページドキュメント **デザインシステム** + - デザイン変数 — カラー・数値・文字列トークン、`$variable` 参照付き - マルチテーマサポート — 複数のテーマ軸、各軸に複数バリアント(Light/Dark、Compact/Comfortable) - コンポーネントシステム — インスタンスとオーバーライドを持つ再利用可能なコンポーネント - CSS 同期 — カスタムプロパティの自動生成、コード出力に `var(--name)` を使用 **Figma インポート** + - レイアウト、フィル、ストローク、エフェクト、テキスト、画像、ベクターを保持して `.fig` ファイルをインポート **デスクトップアプリ** + - Electron によるネイティブ macOS・Windows・Linux 対応 - `.op` ファイル関連付け — ダブルクリックで開く、シングルインスタンスロック - GitHub Releases からの自動アップデート @@ -253,17 +260,17 @@ cat design.dsl | op design - # stdin からパイプ入力 ## 技術スタック -| | | -| --- | --- | -| **フロントエンド** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **キャンバス** | CanvasKit/Skia(WASM、GPU アクセラレーション) | -| **状態管理** | Zustand v5 | -| **サーバー** | Nitro | -| **デスクトップ** | Electron 35 | -| **CLI** | `op` — ターミナル制御、バッチデザインDSL、コードエクスポート | -| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **ランタイム** | Bun · Vite 7 | -| **ファイル形式** | `.op` — JSON ベース、人間が読みやすく、Git フレンドリー | +| | | +| ------------------ | -------------------------------------------------------------------------------- | +| **フロントエンド** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **キャンバス** | CanvasKit/Skia(WASM、GPU アクセラレーション) | +| **状態管理** | Zustand v5 | +| **サーバー** | Nitro | +| **デスクトップ** | Electron 35 | +| **CLI** | `op` — ターミナル制御、バッチデザインDSL、コードエクスポート | +| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **ランタイム** | Bun · Vite 7 | +| **ファイル形式** | `.op` — JSON ベース、人間が読みやすく、Git フレンドリー | ## プロジェクト構成 @@ -304,21 +311,21 @@ openpencil/ ## キーボードショートカット -| キー | 操作 | | キー | 操作 | -| --- | --- | --- | --- | --- | -| `V` | 選択 | | `Cmd+S` | 保存 | -| `R` | 矩形 | | `Cmd+Z` | 元に戻す | -| `O` | 楕円 | | `Cmd+Shift+Z` | やり直す | -| `L` | 直線 | | `Cmd+C/X/V/D` | コピー/カット/ペースト/複製 | -| `T` | テキスト | | `Cmd+G` | グループ化 | -| `F` | Frame | | `Cmd+Shift+G` | グループ解除 | -| `P` | ペンツール | | `Cmd+Shift+E` | エクスポート | -| `H` | ハンド(パン) | | `Cmd+Shift+C` | コードパネル | -| `Del` | 削除 | | `Cmd+Shift+V` | 変数パネル | -| `[ / ]` | 重ね順の変更 | | `Cmd+J` | AI チャット | -| 矢印キー | 1px 微調整 | | `Cmd+,` | エージェント設定 | -| `Cmd+Alt+U` | ブーリアン合体 | | `Cmd+Alt+S` | ブーリアン型抜き | -| `Cmd+Alt+I` | ブーリアン交差 | | | | +| キー | 操作 | | キー | 操作 | +| ----------- | -------------- | --- | ------------- | --------------------------- | +| `V` | 選択 | | `Cmd+S` | 保存 | +| `R` | 矩形 | | `Cmd+Z` | 元に戻す | +| `O` | 楕円 | | `Cmd+Shift+Z` | やり直す | +| `L` | 直線 | | `Cmd+C/X/V/D` | コピー/カット/ペースト/複製 | +| `T` | テキスト | | `Cmd+G` | グループ化 | +| `F` | Frame | | `Cmd+Shift+G` | グループ解除 | +| `P` | ペンツール | | `Cmd+Shift+E` | エクスポート | +| `H` | ハンド(パン) | | `Cmd+Shift+C` | コードパネル | +| `Del` | 削除 | | `Cmd+Shift+V` | 変数パネル | +| `[ / ]` | 重ね順の変更 | | `Cmd+J` | AI チャット | +| 矢印キー | 1px 微調整 | | `Cmd+,` | エージェント設定 | +| `Cmd+Alt+U` | ブーリアン合体 | | `Cmd+Alt+S` | ブーリアン型抜き | +| `Cmd+Alt+I` | ブーリアン交差 | | | | ## スクリプト diff --git a/README.ko.md b/README.ko.md index 34b0406d..28f0b0f7 100644 --- a/README.ko.md +++ b/README.ko.md @@ -124,15 +124,15 @@ bun run electron:dev 여러 이미지 변형을 사용할 수 있습니다 — 필요에 맞는 것을 선택하세요: -| 이미지 | 크기 | 포함 내용 | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | 웹 앱만 | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | 모든 CLI 도구 | +| 이미지 | 크기 | 포함 내용 | +| ---------------------------- | ------- | -------------------- | +| `openpencil:latest` | ~226 MB | 웹 앱만 | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | 모든 CLI 도구 | **실행 (웹만):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## AI 네이티브 디자인 **프롬프트에서 UI로** + - **텍스트-투-디자인** — 페이지를 설명하면 스트리밍 애니메이션으로 실시간으로 캔버스에 생성 - **오케스트레이터** — 복잡한 페이지를 공간적 서브태스크로 분해하여 병렬 생성 - **디자인 수정** — 요소를 선택하고 자연어로 변경 사항을 설명 @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **멀티 에이전트 지원** -| 에이전트 | 설정 방법 | -| --- | --- | +| 에이전트 | 설정 방법 | +| -------------------- | ------------------------------------------------------------------------------- | | **내장 (9+ 제공자)** | 제공자 프리셋에서 선택하고 지역을 전환 — Anthropic, OpenAI, Google, DeepSeek 등 | -| **Claude Code** | 설정 불필요 — 로컬 OAuth로 Claude Agent SDK 사용 | -| **Codex CLI** | 에이전트 설정에서 연결 (`Cmd+,`) | -| **OpenCode** | 에이전트 설정에서 연결 (`Cmd+,`) | -| **GitHub Copilot** | `copilot login` 후 에이전트 설정에서 연결 (`Cmd+,`) | -| **Gemini CLI** | 에이전트 설정에서 연결 (`Cmd+,`) | +| **Claude Code** | 설정 불필요 — 로컬 OAuth로 Claude Agent SDK 사용 | +| **Codex CLI** | 에이전트 설정에서 연결 (`Cmd+,`) | +| **OpenCode** | 에이전트 설정에서 연결 (`Cmd+,`) | +| **GitHub Copilot** | `copilot login` 후 에이전트 설정에서 연결 (`Cmd+,`) | +| **Gemini CLI** | 에이전트 설정에서 연결 (`Cmd+,`) | **모델 역량 프로파일** — 모델 티어에 따라 프롬프트, 사고 모드, 타임아웃을 자동 조정합니다. 풀 티어 모델(Claude)은 완전한 프롬프트를 받고, 스탠다드 티어(GPT-4o, Gemini, DeepSeek)는 사고 모드를 비활성화하며, 베이직 티어(MiniMax, Qwen, Llama, Mistral)는 최대 안정성을 위해 단순화된 중첩 JSON 프롬프트를 받습니다. **i18n** — 15개 언어로 완전한 인터페이스 지역화: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia. **MCP 서버** + - 내장 MCP 서버 — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI에 원클릭 설치 - Node.js 자동 감지 — 설치되지 않은 경우 HTTP 전송 모드로 자동 대체하고 MCP HTTP 서버를 자동 시작 - 터미널에서 디자인 자동화: MCP 호환 에이전트를 통해 `.op` 파일 읽기, 생성, 편집 @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - 멀티 페이지 지원 — MCP 도구를 통해 페이지 생성, 이름 변경, 순서 변경, 복제 **코드 생성** + - React + Tailwind CSS, HTML + CSS, CSS Variables - Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native @@ -229,6 +232,7 @@ cat design.dsl | op design - # stdin에서 파이프 입력 ## 기능 **캔버스 & 드로잉** + - 팬, 줌, 스마트 정렬 가이드, 스냅 지원의 무한 캔버스 - Rectangle, Ellipse, Line, Polygon, Pen(Bezier), Frame, Text - 불리언 연산 — 합치기, 빼기, 교차 (컨텍스트 툴바) @@ -237,15 +241,18 @@ cat design.dsl | op design - # stdin에서 파이프 입력 - 탭 내비게이션이 있는 멀티 페이지 문서 **디자인 시스템** + - 디자인 변수 — 컬러, 숫자, 문자열 토큰, `$variable` 참조 지원 - 멀티 테마 지원 — 여러 테마 축, 각 축에 변형(Light/Dark, Compact/Comfortable) - 컴포넌트 시스템 — 인스턴스와 오버라이드를 가진 재사용 가능한 컴포넌트 - CSS 동기화 — 커스텀 프로퍼티 자동 생성, 코드 출력에 `var(--name)` 사용 **Figma 가져오기** + - 레이아웃, 채우기, 선, 효과, 텍스트, 이미지, 벡터를 유지하며 `.fig` 파일 가져오기 **데스크톱 앱** + - Electron을 통한 네이티브 macOS, Windows, Linux 지원 - `.op` 파일 연결 — 더블 클릭으로 열기, 단일 인스턴스 잠금 - GitHub Releases에서 자동 업데이트 @@ -253,17 +260,17 @@ cat design.dsl | op design - # stdin에서 파이프 입력 ## 기술 스택 -| | | -| --- | --- | -| **프론트엔드** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **캔버스** | CanvasKit/Skia (WASM, GPU 가속) | -| **상태 관리** | Zustand v5 | -| **서버** | Nitro | -| **데스크톱** | Electron 35 | -| **CLI** | `op` — 터미널 제어, 배치 디자인 DSL, 코드 내보내기 | -| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **런타임** | Bun · Vite 7 | -| **파일 형식** | `.op` — JSON 기반, 사람이 읽을 수 있는, Git 친화적 | +| | | +| -------------- | -------------------------------------------------------------------------------- | +| **프론트엔드** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **캔버스** | CanvasKit/Skia (WASM, GPU 가속) | +| **상태 관리** | Zustand v5 | +| **서버** | Nitro | +| **데스크톱** | Electron 35 | +| **CLI** | `op` — 터미널 제어, 배치 디자인 DSL, 코드 내보내기 | +| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **런타임** | Bun · Vite 7 | +| **파일 형식** | `.op` — JSON 기반, 사람이 읽을 수 있는, Git 친화적 | ## 프로젝트 구조 @@ -304,21 +311,21 @@ openpencil/ ## 키보드 단축키 -| 키 | 동작 | | 키 | 동작 | -| --- | --- | --- | --- | --- | -| `V` | 선택 | | `Cmd+S` | 저장 | -| `R` | 사각형 | | `Cmd+Z` | 실행 취소 | -| `O` | 타원 | | `Cmd+Shift+Z` | 다시 실행 | -| `L` | 직선 | | `Cmd+C/X/V/D` | 복사/잘라내기/붙여넣기/복제 | -| `T` | 텍스트 | | `Cmd+G` | 그룹화 | -| `F` | Frame | | `Cmd+Shift+G` | 그룹 해제 | -| `P` | 펜 툴 | | `Cmd+Shift+E` | 내보내기 | -| `H` | 핸드(팬) | | `Cmd+Shift+C` | 코드 패널 | -| `Del` | 삭제 | | `Cmd+Shift+V` | 변수 패널 | -| `[ / ]` | 순서 변경 | | `Cmd+J` | AI 채팅 | -| 화살표 키 | 1px 이동 | | `Cmd+,` | 에이전트 설정 | -| `Cmd+Alt+U` | 불리언 합치기 | | `Cmd+Alt+S` | 불리언 빼기 | -| `Cmd+Alt+I` | 불리언 교차 | | | | +| 키 | 동작 | | 키 | 동작 | +| ----------- | ------------- | --- | ------------- | --------------------------- | +| `V` | 선택 | | `Cmd+S` | 저장 | +| `R` | 사각형 | | `Cmd+Z` | 실행 취소 | +| `O` | 타원 | | `Cmd+Shift+Z` | 다시 실행 | +| `L` | 직선 | | `Cmd+C/X/V/D` | 복사/잘라내기/붙여넣기/복제 | +| `T` | 텍스트 | | `Cmd+G` | 그룹화 | +| `F` | Frame | | `Cmd+Shift+G` | 그룹 해제 | +| `P` | 펜 툴 | | `Cmd+Shift+E` | 내보내기 | +| `H` | 핸드(팬) | | `Cmd+Shift+C` | 코드 패널 | +| `Del` | 삭제 | | `Cmd+Shift+V` | 변수 패널 | +| `[ / ]` | 순서 변경 | | `Cmd+J` | AI 채팅 | +| 화살표 키 | 1px 이동 | | `Cmd+,` | 에이전트 설정 | +| `Cmd+Alt+U` | 불리언 합치기 | | `Cmd+Alt+S` | 불리언 빼기 | +| `Cmd+Alt+I` | 불리언 교차 | | | | ## 스크립트 diff --git a/README.md b/README.md index d7126cf8..5c983116 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Describe any UI in natural language. Watch it appear on the infinite canvas in r ### 🤖 Concurrent Agent Teams -The orchestrator decomposes complex pages into spatial sub-tasks. Multiple AI agents work on different sections simultaneously — hero, features, footer — all streaming in parallel. +The orchestrator decomposes complex pages into spatial sub-tasks. Multiple AI agents work on different sections simultaneously — hero, features, footer — all streaming in parallel with per-member canvas indicators. @@ -71,11 +71,20 @@ One-click install into Claude Code, Codex, Gemini, OpenCode, Kiro, or Copilot CL +### 🎨 Style Guides + +Built-in style guide library with tag-based fuzzy matching. Apply visual styles (glassmorphism, brutalist, retro, etc.) to AI-generated designs. MCP tools for external agent access. + + + + ### 📦 Design-as-Code `.op` files are JSON — human-readable, Git-friendly, diffable. Design variables generate CSS custom properties. Code export to React + Tailwind or HTML + CSS. + + ### 🖥️ Runs Everywhere @@ -83,8 +92,6 @@ One-click install into Claude Code, Codex, Gemini, OpenCode, Kiro, or Copilot CL Web app + native desktop on macOS, Windows, and Linux via Electron. Auto-updates from GitHub Releases. `.op` file association — double-click to open. - - ### ⌨️ CLI — `op` @@ -92,12 +99,32 @@ Web app + native desktop on macOS, Windows, and Linux via Electron. Auto-updates Control the design tool from your terminal. `op design`, `op insert`, `op export` — batch design DSL, node manipulation, code export. Pipe in from files or stdin. Works with desktop app or web server. + + ### 🎯 Multi-Platform Code Export Export to React + Tailwind, HTML + CSS, Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native — all from one `.op` file. Design variables become CSS custom properties. + + + +### 🧩 Embeddable SDK + +`pen-engine` (headless) + `pen-react` (React UI SDK) — embed the design engine in your own app. DesignProvider, DesignCanvas, hooks, panels, and toolbar components out of the box. + + + + + + +### 🛡️ Design System Kit + +Manage reusable UIKits with style switching and component composition. Import/export kits from `.pen` files. Built-in registry with MCP tools for external access. + + + @@ -142,21 +169,21 @@ Or run as a desktop app: bun run electron:dev ``` -> **Prerequisites:** [Bun](https://bun.sh/) >= 1.0 and [Node.js](https://nodejs.org/) >= 18 +> **Prerequisites:** [Bun](https://bun.sh/) >= 1.0 and [Node.js](https://nodejs.org/) >= 18. Optional: [Zig](https://ziglang.org/) >= 0.14 for building `agent-native` from source (a prebuilt binary will be downloaded automatically if Zig is not installed). ### Docker Multiple image variants are available — pick the one that fits your needs: -| Image | Size | Includes | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | Web app only | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | All CLI tools | +| Image | Size | Includes | +| ---------------------------- | ------- | -------------------- | +| `openpencil:latest` | ~226 MB | Web app only | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | All CLI tools | **Run (web only):** @@ -197,35 +224,42 @@ docker build --target full -t openpencil-full . ## AI-Native Design **Prompt to UI** -- **Text-to-design** — describe a page, get it generated on canvas in real-time with streaming animation + +- **Text-to-design** — describe a page, get it generated on canvas in real-time with SSE streaming animation - **Orchestrator** — decomposes complex pages into spatial sub-tasks for parallel generation +- **Agent Teams** — concurrent team members with delegate tool, per-member canvas indicators, and fallback strategies - **Design modification** — select elements, then describe changes in natural language - **Vision input** — attach screenshots or mockups for reference-based design +- **Style Guides** — apply visual styles (glassmorphism, brutalist, retro, etc.) via tag-based fuzzy matching +- **Anti-slop** — cross-generation diversity tracking to avoid repetitive AI output **Multi-Agent Support** -| Agent | Setup | -| --- | --- | +| Agent | Setup | +| --------------------------- | ------------------------------------------------------------------------------------------------- | | **Built-in (9+ providers)** | Select from provider presets with region switcher — Anthropic, OpenAI, Google, DeepSeek, and more | -| **Claude Code** | No config — uses Claude Agent SDK with local OAuth | -| **Codex CLI** | Connect in Agent Settings (`Cmd+,`) | -| **OpenCode** | Connect in Agent Settings (`Cmd+,`) | -| **GitHub Copilot** | `copilot login` then connect in Agent Settings (`Cmd+,`) | -| **Gemini CLI** | Connect in Agent Settings (`Cmd+,`) | +| **Claude Code** | No config — uses Claude Agent SDK with local OAuth | +| **Codex CLI** | Connect in Agent Settings (`Cmd+,`) | +| **OpenCode** | Connect in Agent Settings (`Cmd+,`) | +| **GitHub Copilot** | `copilot login` then connect in Agent Settings (`Cmd+,`) | +| **Gemini CLI** | Connect in Agent Settings (`Cmd+,`) | **Model Capability Profiles** — automatically adapts prompts, thinking mode, and timeouts per model tier. Full-tier models (Claude) get complete prompts; standard-tier (GPT-4o, Gemini, DeepSeek) disable thinking; basic-tier (MiniMax, Qwen, Llama, Mistral) get simplified nested-JSON prompts for maximum reliability. **i18n** — Full interface localization in 15 languages: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia. **MCP Server** -- Built-in MCP server — one-click install into Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs + +- Built-in MCP server (`pen-mcp` package) — one-click install into Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs - Auto-detects Node.js — if not installed, falls back to HTTP transport and auto-starts the MCP HTTP server - Design automation from terminal: read, create, and modify `.op` files via any MCP-compatible agent - **Layered design workflow** — `design_skeleton` → `design_content` → `design_refine` for higher-fidelity multi-section designs - **Segmented prompt retrieval** — load only the design knowledge you need (schema, layout, roles, icons, planning, etc.) +- **Style guide tools** — `get_style_guide_tags` and `get_style_guide` for applying visual styles via MCP - Multi-page support — create, rename, reorder, and duplicate pages via MCP tools **Code Generation** + - React + Tailwind CSS, HTML + CSS, CSS Variables - Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native @@ -253,6 +287,7 @@ Supports three input methods: inline string, `@filepath` (read from file), or `- ## Features **Canvas & Drawing** + - Infinite canvas with pan, zoom, smart alignment guides, and snapping - Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text - Boolean operations — union, subtract, intersect with contextual toolbar @@ -261,15 +296,18 @@ Supports three input methods: inline string, `@filepath` (read from file), or `- - Multi-page documents with tab navigation **Design System** + - Design variables — color, number, string tokens with `$variable` references - Multi-theme support — multiple axes, each with variants (Light/Dark, Compact/Comfortable) - Component system — reusable components with instances and overrides - CSS sync — auto-generated custom properties, `var(--name)` in code output **Figma Import** + - Import `.fig` files with layout, fills, strokes, effects, text, images, and vectors preserved **Desktop App** + - Native macOS, Windows, and Linux via Electron - `.op` file association — double-click to open, single-instance lock - Auto-update from GitHub Releases @@ -277,17 +315,19 @@ Supports three input methods: inline string, `@filepath` (read from file), or `- ## Tech Stack -| | | -| --- | --- | -| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **Canvas** | CanvasKit/Skia (WASM, GPU-accelerated) | -| **State** | Zustand v5 | -| **Server** | Nitro | -| **Desktop** | Electron 35 | -| **CLI** | `op` — terminal control, batch design DSL, code export | -| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **Runtime** | Bun · Vite 7 | -| **File format** | `.op` — JSON-based, human-readable, Git-friendly | +| | | +| --------------- | --------------------------------------------------------------------------------------- | +| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **Canvas** | CanvasKit/Skia (WASM, GPU-accelerated) | +| **Engine** | pen-engine (headless) · pen-react (React UI SDK) | +| **State** | Zustand v5 | +| **Server** | Nitro | +| **Desktop** | Electron 35 | +| **CLI** | `op` — terminal control, batch design DSL, code export | +| **AI** | agent-native (Zig NAPI) · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **Runtime** | Bun · Vite 7 | +| **Lint** | oxlint · oxfmt | +| **File format** | `.op` — JSON-based, human-readable, Git-friendly | ## Project Structure @@ -301,7 +341,6 @@ openpencil/ │ │ │ ├── services/ai/ AI chat, orchestrator, design generation, streaming │ │ │ ├── services/codegen/ Code generation service wrappers │ │ │ ├── stores/ Zustand — canvas, document, pages, history, AI -│ │ │ ├── mcp/ MCP server tools for external CLI integration │ │ │ ├── hooks/ Keyboard shortcuts, file drop, Figma paste, MCP sync │ │ │ ├── i18n/ Internationalization — 15 locales │ │ │ └── uikit/ Reusable component kit system @@ -320,32 +359,35 @@ openpencil/ ├── packages/ │ ├── pen-types/ Type definitions for PenDocument model │ ├── pen-core/ Document tree ops, layout engine, variables +│ ├── pen-engine/ Headless design engine — document, selection, history, viewport +│ ├── pen-react/ React UI SDK — provider, canvas, hooks, panels, toolbar │ ├── pen-codegen/ Code generators (React, HTML, Vue, Flutter, ...) │ ├── pen-figma/ Figma .fig file parser and converter │ ├── pen-renderer/ Standalone CanvasKit/Skia renderer +│ ├── pen-mcp/ MCP server — tools, routes, document manager │ ├── pen-sdk/ Umbrella SDK (re-exports all packages) │ ├── pen-ai-skills/ AI prompt skill engine (phase-driven prompt loading) -│ └── agent/ AI agent SDK (Vercel AI SDK, multi-provider, agent teams) +│ └── agent-native/ Native AI agent runtime (Zig NAPI, multi-provider, teams) └── .githooks/ Pre-commit version sync from branch name ``` ## Keyboard Shortcuts -| Key | Action | | Key | Action | -| --- | --- | --- | --- | --- | -| `V` | Select | | `Cmd+S` | Save | -| `R` | Rectangle | | `Cmd+Z` | Undo | -| `O` | Ellipse | | `Cmd+Shift+Z` | Redo | -| `L` | Line | | `Cmd+C/X/V/D` | Copy/Cut/Paste/Duplicate | -| `T` | Text | | `Cmd+G` | Group | -| `F` | Frame | | `Cmd+Shift+G` | Ungroup | -| `P` | Pen tool | | `Cmd+Shift+E` | Export | -| `H` | Hand (pan) | | `Cmd+Shift+C` | Code panel | -| `Del` | Delete | | `Cmd+Shift+V` | Variables panel | -| `[ / ]` | Reorder | | `Cmd+J` | AI chat | -| Arrows | Nudge 1px | | `Cmd+,` | Agent settings | -| `Cmd+Alt+U` | Boolean union | | `Cmd+Alt+S` | Boolean subtract | -| `Cmd+Alt+I` | Boolean intersect | | | | +| Key | Action | | Key | Action | +| ----------- | ----------------- | --- | ------------- | ------------------------ | +| `V` | Select | | `Cmd+S` | Save | +| `R` | Rectangle | | `Cmd+Z` | Undo | +| `O` | Ellipse | | `Cmd+Shift+Z` | Redo | +| `L` | Line | | `Cmd+C/X/V/D` | Copy/Cut/Paste/Duplicate | +| `T` | Text | | `Cmd+G` | Group | +| `F` | Frame | | `Cmd+Shift+G` | Ungroup | +| `P` | Pen tool | | `Cmd+Shift+E` | Export | +| `H` | Hand (pan) | | `Cmd+Shift+C` | Code panel | +| `Del` | Delete | | `Cmd+Shift+V` | Variables panel | +| `[ / ]` | Reorder | | `Cmd+J` | AI chat | +| Arrows | Nudge 1px | | `Cmd+,` | Agent settings | +| `Cmd+Alt+U` | Boolean union | | `Cmd+Alt+S` | Boolean subtract | +| `Cmd+Alt+I` | Boolean intersect | | | | ## Scripts @@ -354,11 +396,14 @@ bun --bun run dev # Dev server (port 3000) bun --bun run build # Production build bun --bun run test # Run tests (Vitest) npx tsc --noEmit # Type check +bun run lint # Lint (oxlint) +bun run format # Format (oxfmt) bun run bump # Sync version across all package.json bun run electron:dev # Electron dev bun run electron:build # Electron package bun run cli:dev # Run CLI from source bun run cli:compile # Compile CLI to dist +bun run mcp:dev # Run MCP server from source ``` ## Contributing @@ -386,6 +431,10 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details - [x] CLI tool (`op`) for terminal control - [x] Built-in AI agent SDK with multi-provider support - [x] i18n — 15 languages +- [x] Headless design engine (`pen-engine`) + React UI SDK (`pen-react`) +- [x] Style Guides with tag-based matching and MCP tools +- [x] Concurrent Agent Teams with delegate tool and canvas indicators +- [x] Native agent runtime (`agent-native` — Zig NAPI) - [ ] Collaborative editing - [ ] Plugin system diff --git a/README.pt.md b/README.pt.md index 287b184f..7f553421 100644 --- a/README.pt.md +++ b/README.pt.md @@ -124,15 +124,15 @@ bun run electron:dev Várias variantes de imagem estão disponíveis — escolha a que se adequa às suas necessidades: -| Imagem | Tamanho | Inclui | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | Apenas aplicação web | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | Todas as ferramentas CLI | +| Imagem | Tamanho | Inclui | +| ---------------------------- | ------- | ------------------------ | +| `openpencil:latest` | ~226 MB | Apenas aplicação web | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | Todas as ferramentas CLI | **Executar (apenas web):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## Design Nativo com IA **Do Prompt à UI** + - **Texto para design** — descreva uma página e ela será gerada no canvas em tempo real com animação de streaming - **Orquestrador** — decompõe páginas complexas em sub-tarefas espaciais para geração paralela - **Modificação de design** — selecione elementos e descreva as alterações em linguagem natural @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **Suporte Multi-Agente** -| Agente | Configuração | -| --- | --- | +| Agente | Configuração | +| ----------------------------- | -------------------------------------------------------------------------------------------------------- | | **Integrado (9+ provedores)** | Selecione entre presets de provedores com seletor de região — Anthropic, OpenAI, Google, DeepSeek e mais | -| **Claude Code** | Sem configuração — usa o Claude Agent SDK com OAuth local | -| **Codex CLI** | Conectar nas Configurações do Agente (`Cmd+,`) | -| **OpenCode** | Conectar nas Configurações do Agente (`Cmd+,`) | -| **GitHub Copilot** | `copilot login` e depois conectar nas Configurações do Agente (`Cmd+,`) | -| **Gemini CLI** | Conectar nas Configurações do Agente (`Cmd+,`) | +| **Claude Code** | Sem configuração — usa o Claude Agent SDK com OAuth local | +| **Codex CLI** | Conectar nas Configurações do Agente (`Cmd+,`) | +| **OpenCode** | Conectar nas Configurações do Agente (`Cmd+,`) | +| **GitHub Copilot** | `copilot login` e depois conectar nas Configurações do Agente (`Cmd+,`) | +| **Gemini CLI** | Conectar nas Configurações do Agente (`Cmd+,`) | **Perfis de Capacidade de Modelo** — adapta automaticamente prompts, modo de thinking e timeouts por nível de modelo. Modelos de nível completo (Claude) recebem prompts completos; nível padrão (GPT-4o, Gemini, DeepSeek) desativam thinking; nível básico (MiniMax, Qwen, Llama, Mistral) recebem prompts simplificados de JSON aninhado para máxima confiabilidade. **i18n** — Localização completa da interface em 15 idiomas: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia. **Servidor MCP** + - Servidor MCP integrado — instalação com um clique no Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs - Detecção automática de Node.js — se não instalado, recurso automático para transporte HTTP e início automático do servidor MCP HTTP - Automação de design pelo terminal: leia, crie e modifique arquivos `.op` via qualquer agente compatível com MCP @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - Suporte a múltiplas páginas — crie, renomeie, reordene e duplique páginas via ferramentas MCP **Geração de Código** + - React + Tailwind CSS, HTML + CSS, CSS Variables - Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native @@ -229,6 +232,7 @@ Suporta três métodos de entrada: string inline, `@filepath` (ler de arquivo) o ## Funcionalidades **Canvas e Desenho** + - Canvas infinito com pan, zoom, guias de alinhamento inteligentes e snapping - Retângulo, Elipse, Linha, Polígono, Caneta (Bezier), Frame, Texto - Operações booleanas — união, subtração, interseção com barra de ferramentas contextual @@ -237,15 +241,18 @@ Suporta três métodos de entrada: string inline, `@filepath` (ler de arquivo) o - Documentos com múltiplas páginas e navegação por abas **Sistema de Design** + - Variáveis de design — tokens de cor, número e string com referências `$variable` - Suporte a múltiplos temas — vários eixos, cada um com variantes (Claro/Escuro, Compacto/Confortável) - Sistema de componentes — componentes reutilizáveis com instâncias e substituições - Sincronização CSS — propriedades personalizadas geradas automaticamente, `var(--name)` na saída de código **Importação do Figma** + - Importe arquivos `.fig` preservando layout, preenchimentos, traços, efeitos, texto, imagens e vetores **Aplicativo Desktop** + - macOS, Windows e Linux nativos via Electron - Associação de arquivos `.op` — clique duplo para abrir, bloqueio de instância única - Atualização automática a partir do GitHub Releases @@ -253,17 +260,17 @@ Suporta três métodos de entrada: string inline, `@filepath` (ler de arquivo) o ## Stack Tecnológica -| | | -| --- | --- | -| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **Canvas** | CanvasKit/Skia (WASM, acelerado por GPU) | -| **Estado** | Zustand v5 | -| **Servidor** | Nitro | -| **Desktop** | Electron 35 | -| **CLI** | `op` — controle pelo terminal, DSL de design em lote, exportação de código | -| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **Runtime** | Bun · Vite 7 | -| **Formato de arquivo** | `.op` — baseado em JSON, legível por humanos, compatível com Git | +| | | +| ---------------------- | -------------------------------------------------------------------------------- | +| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **Canvas** | CanvasKit/Skia (WASM, acelerado por GPU) | +| **Estado** | Zustand v5 | +| **Servidor** | Nitro | +| **Desktop** | Electron 35 | +| **CLI** | `op` — controle pelo terminal, DSL de design em lote, exportação de código | +| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **Runtime** | Bun · Vite 7 | +| **Formato de arquivo** | `.op` — baseado em JSON, legível por humanos, compatível com Git | ## Estrutura do Projeto @@ -304,21 +311,21 @@ openpencil/ ## Atalhos de Teclado -| Tecla | Ação | | Tecla | Ação | -| --- | --- | --- | --- | --- | -| `V` | Selecionar | | `Cmd+S` | Salvar | -| `R` | Retângulo | | `Cmd+Z` | Desfazer | -| `O` | Elipse | | `Cmd+Shift+Z` | Refazer | -| `L` | Linha | | `Cmd+C/X/V/D` | Copiar/Recortar/Colar/Duplicar | -| `T` | Texto | | `Cmd+G` | Agrupar | -| `F` | Frame | | `Cmd+Shift+G` | Desagrupar | -| `P` | Ferramenta caneta | | `Cmd+Shift+E` | Exportar | -| `H` | Mão (pan) | | `Cmd+Shift+C` | Painel de código | -| `Del` | Excluir | | `Cmd+Shift+V` | Painel de variáveis | -| `[ / ]` | Reordenar | | `Cmd+J` | Chat IA | -| Setas | Mover 1px | | `Cmd+,` | Configurações do agente | -| `Cmd+Alt+U` | União booleana | | `Cmd+Alt+S` | Subtração booleana | -| `Cmd+Alt+I` | Interseção booleana | | | | +| Tecla | Ação | | Tecla | Ação | +| ----------- | ------------------- | --- | ------------- | ------------------------------ | +| `V` | Selecionar | | `Cmd+S` | Salvar | +| `R` | Retângulo | | `Cmd+Z` | Desfazer | +| `O` | Elipse | | `Cmd+Shift+Z` | Refazer | +| `L` | Linha | | `Cmd+C/X/V/D` | Copiar/Recortar/Colar/Duplicar | +| `T` | Texto | | `Cmd+G` | Agrupar | +| `F` | Frame | | `Cmd+Shift+G` | Desagrupar | +| `P` | Ferramenta caneta | | `Cmd+Shift+E` | Exportar | +| `H` | Mão (pan) | | `Cmd+Shift+C` | Painel de código | +| `Del` | Excluir | | `Cmd+Shift+V` | Painel de variáveis | +| `[ / ]` | Reordenar | | `Cmd+J` | Chat IA | +| Setas | Mover 1px | | `Cmd+,` | Configurações do agente | +| `Cmd+Alt+U` | União booleana | | `Cmd+Alt+S` | Subtração booleana | +| `Cmd+Alt+I` | Interseção booleana | | | | ## Scripts diff --git a/README.ru.md b/README.ru.md index 82e440a4..af4137b4 100644 --- a/README.ru.md +++ b/README.ru.md @@ -124,15 +124,15 @@ bun run electron:dev Доступно несколько вариантов образов — выберите подходящий для ваших нужд: -| Образ | Размер | Содержит | -| --- | --- | --- | -| `openpencil:latest` | ~226 МБ | Только веб-приложение | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 ГБ | Все CLI-инструменты | +| Образ | Размер | Содержит | +| ---------------------------- | ------- | --------------------- | +| `openpencil:latest` | ~226 МБ | Только веб-приложение | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 ГБ | Все CLI-инструменты | **Запуск (только веб):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## AI-нативный дизайн **От запроса к UI** + - **Текст в дизайн** — опишите страницу и получите её на холсте в реальном времени со стриминговой анимацией - **Оркестратор** — разбивает сложные страницы на пространственные подзадачи для параллельной генерации - **Изменение дизайна** — выберите элементы и опишите изменения на естественном языке @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **Поддержка нескольких агентов** -| Агент | Настройка | -| --- | --- | +| Агент | Настройка | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------- | | **Встроенный (9+ провайдеров)** | Выбор из предустановленных провайдеров с переключателем региона — Anthropic, OpenAI, Google, DeepSeek и другие | -| **Claude Code** | Без настройки — использует Claude Agent SDK с локальным OAuth | -| **Codex CLI** | Подключить в настройках агента (`Cmd+,`) | -| **OpenCode** | Подключить в настройках агента (`Cmd+,`) | -| **GitHub Copilot** | `copilot login`, затем подключить в настройках агента (`Cmd+,`) | -| **Gemini CLI** | Подключить в настройках агента (`Cmd+,`) | +| **Claude Code** | Без настройки — использует Claude Agent SDK с локальным OAuth | +| **Codex CLI** | Подключить в настройках агента (`Cmd+,`) | +| **OpenCode** | Подключить в настройках агента (`Cmd+,`) | +| **GitHub Copilot** | `copilot login`, затем подключить в настройках агента (`Cmd+,`) | +| **Gemini CLI** | Подключить в настройках агента (`Cmd+,`) | **Профили возможностей моделей** — автоматически адаптирует промпты, режим thinking и таймауты для каждого уровня моделей. Модели полного уровня (Claude) получают полные промпты; стандартного уровня (GPT-4o, Gemini, DeepSeek) — с отключённым thinking; базового уровня (MiniMax, Qwen, Llama, Mistral) — упрощённые промпты с вложенным JSON для максимальной надёжности. **i18n** — Полная локализация интерфейса на 15 языках: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia. **MCP-сервер** + - Встроенный MCP-сервер — установка в один клик в Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI - Автоопределение Node.js — если не установлен, автоматический переход на HTTP-транспорт и автозапуск MCP HTTP-сервера - Автоматизация дизайна из терминала: чтение, создание и изменение файлов `.op` через любой MCP-совместимый агент @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - Поддержка нескольких страниц — создание, переименование, переупорядочивание и дублирование страниц через инструменты MCP **Генерация кода** + - React + Tailwind CSS, HTML + CSS, CSS Variables - Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native @@ -229,6 +232,7 @@ cat design.dsl | op design - # Передача через stdin ## Возможности **Холст и рисование** + - Бесконечный холст с панорамированием, масштабированием, умными направляющими и привязкой - Прямоугольник, Эллипс, Линия, Многоугольник, Перо (Безье), Frame, Текст - Булевы операции — объединение, вычитание, пересечение с контекстной панелью инструментов @@ -237,15 +241,18 @@ cat design.dsl | op design - # Передача через stdin - Многостраничные документы с навигацией по вкладкам **Система дизайна** + - Переменные дизайна — цветовые, числовые и строковые токены со ссылками `$variable` - Поддержка нескольких тем — несколько осей, каждая с вариантами (Светлая/Тёмная, Компактная/Комфортная) - Система компонентов — переиспользуемые компоненты с экземплярами и переопределениями - CSS-синхронизация — автоматически генерируемые пользовательские свойства, `var(--name)` в выводе кода **Импорт из Figma** + - Импорт файлов `.fig` с сохранением раскладки, заливок, обводок, эффектов, текста, изображений и векторов **Десктопное приложение** + - Нативная поддержка macOS, Windows и Linux через Electron - Ассоциация файлов `.op` — двойной клик для открытия, блокировка единственного экземпляра - Автообновление из GitHub Releases @@ -253,17 +260,17 @@ cat design.dsl | op design - # Передача через stdin ## Технологический стек -| | | -| --- | --- | -| **Фронтенд** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **Холст** | CanvasKit/Skia (WASM, GPU-ускорение) | -| **Состояние** | Zustand v5 | -| **Сервер** | Nitro | -| **Десктоп** | Electron 35 | -| **CLI** | `op` — управление из терминала, пакетный DSL дизайна, экспорт кода | -| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **Среда выполнения** | Bun · Vite 7 | -| **Формат файла** | `.op` — на основе JSON, удобочитаемый, дружественный к Git | +| | | +| -------------------- | -------------------------------------------------------------------------------- | +| **Фронтенд** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **Холст** | CanvasKit/Skia (WASM, GPU-ускорение) | +| **Состояние** | Zustand v5 | +| **Сервер** | Nitro | +| **Десктоп** | Electron 35 | +| **CLI** | `op` — управление из терминала, пакетный DSL дизайна, экспорт кода | +| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **Среда выполнения** | Bun · Vite 7 | +| **Формат файла** | `.op` — на основе JSON, удобочитаемый, дружественный к Git | ## Структура проекта @@ -304,21 +311,21 @@ openpencil/ ## Горячие клавиши -| Клавиша | Действие | | Клавиша | Действие | -| --- | --- | --- | --- | --- | -| `V` | Выбор | | `Cmd+S` | Сохранить | -| `R` | Прямоугольник | | `Cmd+Z` | Отменить | -| `O` | Эллипс | | `Cmd+Shift+Z` | Повторить | -| `L` | Линия | | `Cmd+C/X/V/D` | Копировать/Вырезать/Вставить/Дублировать | -| `T` | Текст | | `Cmd+G` | Сгруппировать | -| `F` | Frame | | `Cmd+Shift+G` | Разгруппировать | -| `P` | Инструмент пера | | `Cmd+Shift+E` | Экспорт | -| `H` | Рука (панорама) | | `Cmd+Shift+C` | Панель кода | -| `Del` | Удалить | | `Cmd+Shift+V` | Панель переменных | -| `[ / ]` | Изменить порядок | | `Cmd+J` | AI-чат | -| Стрелки | Сдвиг на 1px | | `Cmd+,` | Настройки агента | -| `Cmd+Alt+U` | Булево объединение | | `Cmd+Alt+S` | Булево вычитание | -| `Cmd+Alt+I` | Булево пересечение | | | | +| Клавиша | Действие | | Клавиша | Действие | +| ----------- | ------------------ | --- | ------------- | ---------------------------------------- | +| `V` | Выбор | | `Cmd+S` | Сохранить | +| `R` | Прямоугольник | | `Cmd+Z` | Отменить | +| `O` | Эллипс | | `Cmd+Shift+Z` | Повторить | +| `L` | Линия | | `Cmd+C/X/V/D` | Копировать/Вырезать/Вставить/Дублировать | +| `T` | Текст | | `Cmd+G` | Сгруппировать | +| `F` | Frame | | `Cmd+Shift+G` | Разгруппировать | +| `P` | Инструмент пера | | `Cmd+Shift+E` | Экспорт | +| `H` | Рука (панорама) | | `Cmd+Shift+C` | Панель кода | +| `Del` | Удалить | | `Cmd+Shift+V` | Панель переменных | +| `[ / ]` | Изменить порядок | | `Cmd+J` | AI-чат | +| Стрелки | Сдвиг на 1px | | `Cmd+,` | Настройки агента | +| `Cmd+Alt+U` | Булево объединение | | `Cmd+Alt+S` | Булево вычитание | +| `Cmd+Alt+I` | Булево пересечение | | | | ## Скрипты diff --git a/README.th.md b/README.th.md index b0ce8cc9..a0f04039 100644 --- a/README.th.md +++ b/README.th.md @@ -124,15 +124,15 @@ bun run electron:dev มี image หลายรูปแบบให้เลือก — เลือกแบบที่เหมาะกับความต้องการของคุณ: -| Image | ขนาด | รวม | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | เว็บแอปเท่านั้น | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | เครื่องมือ CLI ทั้งหมด | +| Image | ขนาด | รวม | +| ---------------------------- | ------- | ---------------------- | +| `openpencil:latest` | ~226 MB | เว็บแอปเท่านั้น | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | เครื่องมือ CLI ทั้งหมด | **รัน (เว็บเท่านั้น):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## การออกแบบที่ขับเคลื่อนด้วย AI **จาก Prompt สู่ UI** + - **ข้อความเป็นดีไซน์** — อธิบายหน้า แล้วสร้างขึ้นบน Canvas แบบเรียลไทม์พร้อม animation แบบ streaming - **Orchestrator** — แบ่งหน้าที่ซับซ้อนออกเป็น sub-task เชิงพื้นที่เพื่อการสร้างแบบขนาน - **การแก้ไขดีไซน์** — เลือกองค์ประกอบ แล้วอธิบายการเปลี่ยนแปลงด้วยภาษาธรรมชาติ @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **รองรับหลาย Agent** -| Agent | วิธีตั้งค่า | -| --- | --- | +| Agent | วิธีตั้งค่า | +| --------------------------- | ---------------------------------------------------------------------------------------------- | | **ในตัว (9+ ผู้ให้บริการ)** | เลือกจากพรีเซ็ตผู้ให้บริการพร้อมตัวสลับภูมิภาค — Anthropic, OpenAI, Google, DeepSeek และอื่น ๆ | -| **Claude Code** | ไม่ต้องตั้งค่า — ใช้ Claude Agent SDK พร้อม local OAuth | -| **Codex CLI** | เชื่อมต่อใน Agent Settings (`Cmd+,`) | -| **OpenCode** | เชื่อมต่อใน Agent Settings (`Cmd+,`) | -| **GitHub Copilot** | `copilot login` จากนั้นเชื่อมต่อใน Agent Settings (`Cmd+,`) | -| **Gemini CLI** | เชื่อมต่อใน Agent Settings (`Cmd+,`) | +| **Claude Code** | ไม่ต้องตั้งค่า — ใช้ Claude Agent SDK พร้อม local OAuth | +| **Codex CLI** | เชื่อมต่อใน Agent Settings (`Cmd+,`) | +| **OpenCode** | เชื่อมต่อใน Agent Settings (`Cmd+,`) | +| **GitHub Copilot** | `copilot login` จากนั้นเชื่อมต่อใน Agent Settings (`Cmd+,`) | +| **Gemini CLI** | เชื่อมต่อใน Agent Settings (`Cmd+,`) | **โปรไฟล์ความสามารถของโมเดล** — ปรับ prompt, โหมด thinking และ timeout ตามระดับโมเดลโดยอัตโนมัติ โมเดลระดับเต็ม (Claude) ได้ prompt ครบถ้วน; โมเดลระดับมาตรฐาน (GPT-4o, Gemini, DeepSeek) ปิด thinking; โมเดลระดับพื้นฐาน (MiniMax, Qwen, Llama, Mistral) ได้ prompt แบบ nested-JSON ที่ย่อลงเพื่อความเสถียรสูงสุด **i18n** — การแปลภาษาเต็มรูปแบบใน 15 ภาษา: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia **MCP Server** + - MCP Server ในตัว — ติดตั้งได้ด้วยคลิกเดียวใน Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs - ตรวจจับ Node.js อัตโนมัติ — หากไม่ได้ติดตั้ง จะสำรองไปใช้ HTTP transport และเริ่ม MCP HTTP server โดยอัตโนมัติ - การทำ Design automation จาก terminal: อ่าน สร้าง และแก้ไขไฟล์ `.op` ผ่าน agent ที่รองรับ MCP @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - รองรับหลายหน้า — สร้าง เปลี่ยนชื่อ เรียงลำดับ และทำซ้ำหน้าผ่าน MCP tools **การสร้างโค้ด** + - React + Tailwind CSS, HTML + CSS, CSS Variables - Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native @@ -229,6 +232,7 @@ cat design.dsl | op design - # Pipe จาก stdin ## ฟีเจอร์ **Canvas และการวาด** + - Canvas ไม่จำกัดขนาดพร้อม pan, zoom, smart alignment guides และ snapping - Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text - การดำเนินการบูลีน — รวม ลบ ตัดกัน พร้อมแถบเครื่องมือตามบริบท @@ -237,15 +241,18 @@ cat design.dsl | op design - # Pipe จาก stdin - เอกสารหลายหน้าพร้อมการนำทางด้วย tab **Design System** + - Design variables — color, number, string tokens พร้อมการอ้างอิง `$variable` - รองรับหลาย theme — หลาย axis แต่ละ axis มี variants (Light/Dark, Compact/Comfortable) - ระบบ Component — component ที่นำกลับมาใช้ใหม่ได้พร้อม instance และ override - CSS sync — สร้าง custom properties อัตโนมัติ, `var(--name)` ในผลลัพธ์โค้ด **นำเข้าจาก Figma** + - นำเข้าไฟล์ `.fig` โดยคงไว้ซึ่ง layout, fills, strokes, effects, text, images และ vectors **Desktop App** + - รองรับ macOS, Windows และ Linux แบบ native ผ่าน Electron - เชื่อมโยงไฟล์ `.op` — ดับเบิลคลิกเพื่อเปิด, single-instance lock - อัปเดตอัตโนมัติจาก GitHub Releases @@ -253,17 +260,17 @@ cat design.dsl | op design - # Pipe จาก stdin ## Tech Stack -| | | -| --- | --- | -| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **Canvas** | CanvasKit/Skia (WASM, GPU-accelerated) | -| **State** | Zustand v5 | -| **Server** | Nitro | -| **Desktop** | Electron 35 | -| **CLI** | `op` — ควบคุมจาก terminal, batch design DSL, ส่งออกโค้ด | -| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **Runtime** | Bun · Vite 7 | -| **รูปแบบไฟล์** | `.op` — ใช้ JSON, อ่านได้โดยมนุษย์, Git-friendly | +| | | +| -------------- | -------------------------------------------------------------------------------- | +| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **Canvas** | CanvasKit/Skia (WASM, GPU-accelerated) | +| **State** | Zustand v5 | +| **Server** | Nitro | +| **Desktop** | Electron 35 | +| **CLI** | `op` — ควบคุมจาก terminal, batch design DSL, ส่งออกโค้ด | +| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **Runtime** | Bun · Vite 7 | +| **รูปแบบไฟล์** | `.op` — ใช้ JSON, อ่านได้โดยมนุษย์, Git-friendly | ## โครงสร้างโปรเจกต์ @@ -304,21 +311,21 @@ openpencil/ ## คีย์ลัด -| คีย์ | การทำงาน | | คีย์ | การทำงาน | -| --- | --- | --- | --- | --- | -| `V` | เลือก | | `Cmd+S` | บันทึก | -| `R` | Rectangle | | `Cmd+Z` | เลิกทำ | -| `O` | Ellipse | | `Cmd+Shift+Z` | ทำซ้ำ | -| `L` | Line | | `Cmd+C/X/V/D` | คัดลอก/ตัด/วาง/ทำซ้ำ | -| `T` | Text | | `Cmd+G` | จัดกลุ่ม | -| `F` | Frame | | `Cmd+Shift+G` | ยกเลิกการจัดกลุ่ม | -| `P` | Pen tool | | `Cmd+Shift+E` | ส่งออก | -| `H` | Hand (pan) | | `Cmd+Shift+C` | Code panel | -| `Del` | ลบ | | `Cmd+Shift+V` | Variables panel | -| `[ / ]` | เรียงลำดับ | | `Cmd+J` | AI chat | -| ลูกศร | เลื่อน 1px | | `Cmd+,` | Agent settings | -| `Cmd+Alt+U` | รวมบูลีน | | `Cmd+Alt+S` | ลบบูลีน | -| `Cmd+Alt+I` | ตัดกันบูลีน | | | | +| คีย์ | การทำงาน | | คีย์ | การทำงาน | +| ----------- | ----------- | --- | ------------- | -------------------- | +| `V` | เลือก | | `Cmd+S` | บันทึก | +| `R` | Rectangle | | `Cmd+Z` | เลิกทำ | +| `O` | Ellipse | | `Cmd+Shift+Z` | ทำซ้ำ | +| `L` | Line | | `Cmd+C/X/V/D` | คัดลอก/ตัด/วาง/ทำซ้ำ | +| `T` | Text | | `Cmd+G` | จัดกลุ่ม | +| `F` | Frame | | `Cmd+Shift+G` | ยกเลิกการจัดกลุ่ม | +| `P` | Pen tool | | `Cmd+Shift+E` | ส่งออก | +| `H` | Hand (pan) | | `Cmd+Shift+C` | Code panel | +| `Del` | ลบ | | `Cmd+Shift+V` | Variables panel | +| `[ / ]` | เรียงลำดับ | | `Cmd+J` | AI chat | +| ลูกศร | เลื่อน 1px | | `Cmd+,` | Agent settings | +| `Cmd+Alt+U` | รวมบูลีน | | `Cmd+Alt+S` | ลบบูลีน | +| `Cmd+Alt+I` | ตัดกันบูลีน | | | | ## Scripts diff --git a/README.tr.md b/README.tr.md index e030fbcd..11fc2741 100644 --- a/README.tr.md +++ b/README.tr.md @@ -124,15 +124,15 @@ bun run electron:dev Birden fazla görüntü varyantı mevcuttur — ihtiyaçlarınıza uygun olanı seçin: -| Görüntü | Boyut | İçerik | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | Yalnızca web uygulaması | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | Tüm CLI araçları | +| Görüntü | Boyut | İçerik | +| ---------------------------- | ------- | ----------------------- | +| `openpencil:latest` | ~226 MB | Yalnızca web uygulaması | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | Tüm CLI araçları | **Çalıştır (yalnızca web):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## AI Destekli Tasarım **Prompttan UI'ye** + - **Metinden tasarıma** — bir sayfayı tanımlayın, gerçek zamanlı akış animasyonuyla kanvasta oluşturulsun - **Orkestratör** — karmaşık sayfaları paralel üretim için uzamsal alt görevlere ayırır - **Tasarım değişikliği** — öğeleri seçin, ardından değişiklikleri doğal dille tanımlayın @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **Çok Ajanlı Destek** -| Ajan | Kurulum | -| --- | --- | +| Ajan | Kurulum | +| --------------------------- | --------------------------------------------------------------------------------------------------------- | | **Yerleşik (9+ sağlayıcı)** | Sağlayıcı ön ayarlarından seçin ve bölge değiştirin — Anthropic, OpenAI, Google, DeepSeek ve daha fazlası | -| **Claude Code** | Yapılandırma gerekmez — yerel OAuth ile Claude Agent SDK kullanır | -| **Codex CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) | -| **OpenCode** | Ajan Ayarlarından bağlanın (`Cmd+,`) | -| **GitHub Copilot** | `copilot login` ardından Ajan Ayarlarından bağlanın (`Cmd+,`) | -| **Gemini CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) | +| **Claude Code** | Yapılandırma gerekmez — yerel OAuth ile Claude Agent SDK kullanır | +| **Codex CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) | +| **OpenCode** | Ajan Ayarlarından bağlanın (`Cmd+,`) | +| **GitHub Copilot** | `copilot login` ardından Ajan Ayarlarından bağlanın (`Cmd+,`) | +| **Gemini CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) | **Model Yetenek Profilleri** — promptları, düşünme modunu ve zaman aşımlarını model katmanına göre otomatik olarak uyarlar. Tam katman modeller (Claude) eksiksiz promptlar alır; standart katman (GPT-4o, Gemini, DeepSeek) düşünme modunu devre dışı bırakır; temel katman (MiniMax, Qwen, Llama, Mistral) maksimum güvenilirlik için basitleştirilmiş iç içe JSON promptları alır. **i18n** — 15 dilde tam arayüz yerelleştirmesi: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia. **MCP Sunucusu** + - Yerleşik MCP sunucusu — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI'larına tek tıkla kurulum - Otomatik Node.js algılama — kurulu değilse otomatik olarak HTTP aktarımına geçer ve MCP HTTP sunucusunu otomatik başlatır - Terminalden tasarım otomasyonu: herhangi bir MCP uyumlu ajan aracılığıyla `.op` dosyalarını okuyun, oluşturun ve düzenleyin @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - Çok sayfa desteği — MCP araçları ile sayfaları oluşturun, yeniden adlandırın, sıralayın ve çoğaltın **Kod Üretimi** + - React + Tailwind CSS, HTML + CSS, CSS Variables - Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native @@ -229,6 +232,7 @@ cat design.dsl | op design - # stdin'den pipe ile besle ## Özellikler **Kanvas ve Çizim** + - Kaydırma, yakınlaştırma, akıllı hizalama kılavuzları ve yakalamayı destekleyen sonsuz kanvas - Dikdörtgen, Elips, Çizgi, Çokgen, Kalem (Bezier), Frame, Metin - Boolean işlemler — bağlamsal araç çubuğuyla birleştir, çıkar, kesiştir @@ -237,15 +241,18 @@ cat design.dsl | op design - # stdin'den pipe ile besle - Sekme navigasyonlu çok sayfalı belgeler **Tasarım Sistemi** + - Tasarım değişkenleri — `$variable` referanslı renk, sayı, metin tokenları - Çok tema desteği — birden fazla tema ekseni, her biri varyantlarıyla (Açık/Koyu, Kompakt/Rahat) - Bileşen sistemi — örnekler ve geçersiz kılmalarla yeniden kullanılabilir bileşenler - CSS senkronizasyonu — otomatik oluşturulan özel özellikler, kod çıktısında `var(--name)` **Figma İçe Aktarma** + - Düzen, dolgu, kontur, efektler, metin, görseller ve vektörler korunarak `.fig` dosyalarını içe aktarın **Masaüstü Uygulaması** + - Electron aracılığıyla yerel macOS, Windows ve Linux desteği - `.op` dosya ilişkilendirmesi — açmak için çift tıklayın, tekli örnek kilidi - GitHub Releases'ten otomatik güncelleme @@ -253,17 +260,17 @@ cat design.dsl | op design - # stdin'den pipe ile besle ## Teknoloji Yığını -| | | -| --- | --- | -| **Ön Uç** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **Kanvas** | CanvasKit/Skia (WASM, GPU hızlandırmalı) | -| **Durum Yönetimi** | Zustand v5 | -| **Sunucu** | Nitro | -| **Masaüstü** | Electron 35 | -| **CLI** | `op` — terminal kontrolü, toplu tasarım DSL, kod dışa aktarımı | -| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **Çalışma Ortamı** | Bun · Vite 7 | -| **Dosya Formatı** | `.op` — JSON tabanlı, insan tarafından okunabilir, Git dostu | +| | | +| ------------------ | -------------------------------------------------------------------------------- | +| **Ön Uç** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **Kanvas** | CanvasKit/Skia (WASM, GPU hızlandırmalı) | +| **Durum Yönetimi** | Zustand v5 | +| **Sunucu** | Nitro | +| **Masaüstü** | Electron 35 | +| **CLI** | `op` — terminal kontrolü, toplu tasarım DSL, kod dışa aktarımı | +| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **Çalışma Ortamı** | Bun · Vite 7 | +| **Dosya Formatı** | `.op` — JSON tabanlı, insan tarafından okunabilir, Git dostu | ## Proje Yapısı @@ -304,21 +311,21 @@ openpencil/ ## Klavye Kısayolları -| Tuş | İşlem | | Tuş | İşlem | -| --- | --- | --- | --- | --- | -| `V` | Seç | | `Cmd+S` | Kaydet | -| `R` | Dikdörtgen | | `Cmd+Z` | Geri Al | -| `O` | Elips | | `Cmd+Shift+Z` | Yeniden Yap | -| `L` | Çizgi | | `Cmd+C/X/V/D` | Kopyala/Kes/Yapıştır/Çoğalt | -| `T` | Metin | | `Cmd+G` | Grupla | -| `F` | Frame | | `Cmd+Shift+G` | Grubu Çöz | -| `P` | Kalem aracı | | `Cmd+Shift+E` | Dışa Aktar | -| `H` | El (kaydır) | | `Cmd+Shift+C` | Kod paneli | -| `Del` | Sil | | `Cmd+Shift+V` | Değişkenler paneli | -| `[ / ]` | Yeniden sırala | | `Cmd+J` | AI sohbet | -| Oklar | 1px kaydır | | `Cmd+,` | Ajan ayarları | -| `Cmd+Alt+U` | Boolean birleştir | | `Cmd+Alt+S` | Boolean çıkar | -| `Cmd+Alt+I` | Boolean kesiştir | | | | +| Tuş | İşlem | | Tuş | İşlem | +| ----------- | ----------------- | --- | ------------- | --------------------------- | +| `V` | Seç | | `Cmd+S` | Kaydet | +| `R` | Dikdörtgen | | `Cmd+Z` | Geri Al | +| `O` | Elips | | `Cmd+Shift+Z` | Yeniden Yap | +| `L` | Çizgi | | `Cmd+C/X/V/D` | Kopyala/Kes/Yapıştır/Çoğalt | +| `T` | Metin | | `Cmd+G` | Grupla | +| `F` | Frame | | `Cmd+Shift+G` | Grubu Çöz | +| `P` | Kalem aracı | | `Cmd+Shift+E` | Dışa Aktar | +| `H` | El (kaydır) | | `Cmd+Shift+C` | Kod paneli | +| `Del` | Sil | | `Cmd+Shift+V` | Değişkenler paneli | +| `[ / ]` | Yeniden sırala | | `Cmd+J` | AI sohbet | +| Oklar | 1px kaydır | | `Cmd+,` | Ajan ayarları | +| `Cmd+Alt+U` | Boolean birleştir | | `Cmd+Alt+S` | Boolean çıkar | +| `Cmd+Alt+I` | Boolean kesiştir | | | | ## Betikler diff --git a/README.vi.md b/README.vi.md index ff36f435..f34cead6 100644 --- a/README.vi.md +++ b/README.vi.md @@ -124,15 +124,15 @@ bun run electron:dev Có nhiều biến thể image khác nhau — chọn loại phù hợp với nhu cầu của bạn: -| Image | Kích thước | Bao gồm | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | Chỉ ứng dụng web | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | Tất cả công cụ CLI | +| Image | Kích thước | Bao gồm | +| ---------------------------- | ---------- | -------------------- | +| `openpencil:latest` | ~226 MB | Chỉ ứng dụng web | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | Tất cả công cụ CLI | **Chạy (chỉ web):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## Thiết kế thuần AI **Từ Prompt đến Giao diện** + - **Văn bản thành thiết kế** — mô tả một trang, nhận kết quả được tạo ra trên canvas theo thời gian thực với hiệu ứng streaming - **Orchestrator** — phân rã các trang phức tạp thành các tác vụ con không gian để tạo song song - **Chỉnh sửa thiết kế** — chọn các phần tử, sau đó mô tả thay đổi bằng ngôn ngữ tự nhiên @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **Hỗ trợ Đa tác nhân** -| Tác nhân | Cài đặt | -| --- | --- | +| Tác nhân | Cài đặt | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------ | | **Tích hợp sẵn (9+ nhà cung cấp)** | Chọn từ các preset nhà cung cấp với bộ chuyển đổi khu vực — Anthropic, OpenAI, Google, DeepSeek và nhiều hơn | -| **Claude Code** | Không cần cấu hình — sử dụng Claude Agent SDK với OAuth cục bộ | -| **Codex CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) | -| **OpenCode** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) | -| **GitHub Copilot** | `copilot login` rồi kết nối trong Cài đặt tác nhân (`Cmd+,`) | -| **Gemini CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) | +| **Claude Code** | Không cần cấu hình — sử dụng Claude Agent SDK với OAuth cục bộ | +| **Codex CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) | +| **OpenCode** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) | +| **GitHub Copilot** | `copilot login` rồi kết nối trong Cài đặt tác nhân (`Cmd+,`) | +| **Gemini CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) | **Hồ sơ Năng lực Mô hình** — tự động thích ứng prompt, chế độ thinking và thời gian chờ theo từng cấp mô hình. Mô hình cấp đầy đủ (Claude) nhận prompt hoàn chỉnh; cấp tiêu chuẩn (GPT-4o, Gemini, DeepSeek) tắt thinking; cấp cơ bản (MiniMax, Qwen, Llama, Mistral) nhận prompt JSON lồng nhau đơn giản hóa để đảm bảo độ tin cậy tối đa. **i18n** — Bản địa hóa giao diện đầy đủ bằng 15 ngôn ngữ: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia. **Máy chủ MCP** + - Máy chủ MCP tích hợp sẵn — cài đặt một cú nhấp vào Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI - Tự động phát hiện Node.js — nếu chưa cài đặt, tự động chuyển sang HTTP transport và khởi động MCP HTTP server - Tự động hóa thiết kế từ terminal: đọc, tạo và chỉnh sửa các tệp `.op` qua bất kỳ tác nhân tương thích MCP nào @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - Hỗ trợ nhiều trang — tạo, đổi tên, sắp xếp lại và nhân bản trang qua các công cụ MCP **Tạo mã nguồn** + - React + Tailwind CSS, HTML + CSS, CSS Variables - Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native @@ -229,6 +232,7 @@ Hỗ trợ ba phương thức nhập liệu: chuỗi inline, `@filepath` (đọc ## Tính năng **Canvas và Vẽ** + - Canvas vô hạn với pan, zoom, hướng dẫn căn chỉnh thông minh và snapping - Hình chữ nhật, Hình ellipse, Đường thẳng, Đa giác, Bút (Bezier), Frame, Văn bản - Phép toán Boolean — hợp nhất, trừ, giao nhau với thanh công cụ ngữ cảnh @@ -237,15 +241,18 @@ Hỗ trợ ba phương thức nhập liệu: chuỗi inline, `@filepath` (đọc - Tài liệu nhiều trang với điều hướng bằng tab **Hệ thống Thiết kế** + - Biến thiết kế — token màu sắc, số, chuỗi với tham chiếu `$variable` - Hỗ trợ đa chủ đề — nhiều trục, mỗi trục có các biến thể (Sáng/Tối, Thu gọn/Thoải mái) - Hệ thống component — các component có thể tái sử dụng với instances và overrides - Đồng bộ CSS — thuộc tính tùy chỉnh tự động tạo, `var(--name)` trong đầu ra mã **Nhập từ Figma** + - Nhập tệp `.fig` với layout, fills, strokes, effects, văn bản, hình ảnh và vector được bảo toàn **Ứng dụng Desktop** + - macOS, Windows và Linux gốc qua Electron - Liên kết tệp `.op` — nhấp đúp để mở, khóa phiên bản đơn - Tự động cập nhật từ GitHub Releases @@ -253,17 +260,17 @@ Hỗ trợ ba phương thức nhập liệu: chuỗi inline, `@filepath` (đọc ## Công nghệ -| | | -| --- | --- | -| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **Canvas** | CanvasKit/Skia (WASM, tăng tốc GPU) | -| **Trạng thái** | Zustand v5 | -| **Máy chủ** | Nitro | -| **Desktop** | Electron 35 | -| **CLI** | `op` — điều khiển từ terminal, batch design DSL, xuất mã | -| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **Runtime** | Bun · Vite 7 | -| **Định dạng tệp** | `.op` — dựa trên JSON, dễ đọc, thân thiện với Git | +| | | +| ----------------- | -------------------------------------------------------------------------------- | +| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **Canvas** | CanvasKit/Skia (WASM, tăng tốc GPU) | +| **Trạng thái** | Zustand v5 | +| **Máy chủ** | Nitro | +| **Desktop** | Electron 35 | +| **CLI** | `op` — điều khiển từ terminal, batch design DSL, xuất mã | +| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **Runtime** | Bun · Vite 7 | +| **Định dạng tệp** | `.op` — dựa trên JSON, dễ đọc, thân thiện với Git | ## Cấu trúc dự án @@ -304,21 +311,21 @@ openpencil/ ## Phím tắt -| Phím | Hành động | | Phím | Hành động | -| --- | --- | --- | --- | --- | -| `V` | Chọn | | `Cmd+S` | Lưu | -| `R` | Hình chữ nhật | | `Cmd+Z` | Hoàn tác | -| `O` | Hình ellipse | | `Cmd+Shift+Z` | Làm lại | -| `L` | Đường thẳng | | `Cmd+C/X/V/D` | Sao chép/Cắt/Dán/Nhân bản | -| `T` | Văn bản | | `Cmd+G` | Nhóm | -| `F` | Frame | | `Cmd+Shift+G` | Bỏ nhóm | -| `P` | Công cụ bút | | `Cmd+Shift+E` | Xuất | -| `H` | Tay (pan) | | `Cmd+Shift+C` | Bảng mã | -| `Del` | Xóa | | `Cmd+Shift+V` | Bảng biến | -| `[ / ]` | Sắp xếp lại | | `Cmd+J` | AI chat | -| Mũi tên | Dịch chuyển 1px | | `Cmd+,` | Cài đặt tác nhân | -| `Cmd+Alt+U` | Hợp nhất Boolean | | `Cmd+Alt+S` | Trừ Boolean | -| `Cmd+Alt+I` | Giao nhau Boolean | | | | +| Phím | Hành động | | Phím | Hành động | +| ----------- | ----------------- | --- | ------------- | ------------------------- | +| `V` | Chọn | | `Cmd+S` | Lưu | +| `R` | Hình chữ nhật | | `Cmd+Z` | Hoàn tác | +| `O` | Hình ellipse | | `Cmd+Shift+Z` | Làm lại | +| `L` | Đường thẳng | | `Cmd+C/X/V/D` | Sao chép/Cắt/Dán/Nhân bản | +| `T` | Văn bản | | `Cmd+G` | Nhóm | +| `F` | Frame | | `Cmd+Shift+G` | Bỏ nhóm | +| `P` | Công cụ bút | | `Cmd+Shift+E` | Xuất | +| `H` | Tay (pan) | | `Cmd+Shift+C` | Bảng mã | +| `Del` | Xóa | | `Cmd+Shift+V` | Bảng biến | +| `[ / ]` | Sắp xếp lại | | `Cmd+J` | AI chat | +| Mũi tên | Dịch chuyển 1px | | `Cmd+,` | Cài đặt tác nhân | +| `Cmd+Alt+U` | Hợp nhất Boolean | | `Cmd+Alt+S` | Trừ Boolean | +| `Cmd+Alt+I` | Giao nhau Boolean | | | | ## Scripts diff --git a/README.zh-TW.md b/README.zh-TW.md index dd50d4f1..581c974b 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -124,15 +124,15 @@ bun run electron:dev 提供多種映像檔變體 — 選擇適合您需求的版本: -| 映像檔 | 大小 | 包含 | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | 僅 Web 應用程式 | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | 所有 CLI 工具 | +| 映像檔 | 大小 | 包含 | +| ---------------------------- | ------- | -------------------- | +| `openpencil:latest` | ~226 MB | 僅 Web 應用程式 | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | 所有 CLI 工具 | **執行(僅 Web):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## AI 原生設計 **提示詞生成 UI** + - **文字轉設計** — 描述一個頁面,即時以串流動畫在畫布上生成 - **編排器** — 將複雜頁面分解為空間子任務,支援並行生成 - **設計修改** — 選取元素後,以自然語言描述變更 @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **多智能體支援** -| 智能體 | 設定方式 | -| --- | --- | +| 智能體 | 設定方式 | +| --------------------- | --------------------------------------------------------------------- | | **內建(9+ 提供商)** | 從提供商預設中選擇並切換區域 — Anthropic、OpenAI、Google、DeepSeek 等 | -| **Claude Code** | 無需設定 — 使用 Claude Agent SDK 本地 OAuth | -| **Codex CLI** | 在 Agent 設定中連接(`Cmd+,`) | -| **OpenCode** | 在 Agent 設定中連接(`Cmd+,`) | -| **GitHub Copilot** | 執行 `copilot login` 後在 Agent 設定中連接(`Cmd+,`) | -| **Gemini CLI** | 在 Agent 設定中連接(`Cmd+,`) | +| **Claude Code** | 無需設定 — 使用 Claude Agent SDK 本地 OAuth | +| **Codex CLI** | 在 Agent 設定中連接(`Cmd+,`) | +| **OpenCode** | 在 Agent 設定中連接(`Cmd+,`) | +| **GitHub Copilot** | 執行 `copilot login` 後在 Agent 設定中連接(`Cmd+,`) | +| **Gemini CLI** | 在 Agent 設定中連接(`Cmd+,`) | **模型能力設定檔** — 自動依據模型層級調整提示詞、思考模式和逾時設定。完整層級模型(Claude)獲得完整提示詞;標準層級(GPT-4o、Gemini、DeepSeek)停用思考模式;基礎層級(MiniMax、Qwen、Llama、Mistral)獲得精簡巢狀 JSON 提示詞,確保最大可靠性。 **國際化** — 完整介面本地化,支援 15 種語言:English、简体中文、繁體中文、日本語、한국어、Français、Español、Deutsch、Português、Русский、हिन्दी、Türkçe、ไทย、Tiếng Việt、Bahasa Indonesia。 **MCP 伺服器** + - 內建 MCP 伺服器 — 一鍵安裝至 Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI - 自動偵測 Node.js — 若未安裝則自動回退到 HTTP 傳輸模式並啟動 MCP HTTP 伺服器 - 從終端機進行設計自動化:透過任意 MCP 相容的智能體讀取、建立和修改 `.op` 檔案 @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - 多頁面支援 — 透過 MCP 工具建立、重新命名、重新排序和複製頁面 **程式碼生成** + - React + Tailwind CSS、HTML + CSS、CSS Variables - Vue、Svelte、Flutter、SwiftUI、Jetpack Compose、React Native @@ -229,6 +232,7 @@ cat design.dsl | op design - # 從 stdin 管道輸入 ## 功能特色 **畫布與繪圖** + - 無限畫布,支援平移、縮放、智慧對齊參考線和吸附 - 矩形、橢圓、直線、多邊形、鋼筆(貝茲曲線)、Frame、文字 - 布林運算 — 聯合、減去、交集,搭配上下文工具列 @@ -237,15 +241,18 @@ cat design.dsl | op design - # 從 stdin 管道輸入 - 多頁面文件,支援分頁導覽 **設計系統** + - 設計變數 — 顏色、數字、字串令牌,支援 `$variable` 參照 - 多主題支援 — 多個主題軸,每個軸有多個變體(亮色/暗色、緊湊/舒適) - 元件系統 — 可重複使用元件,支援實體和覆寫 - CSS 同步 — 自動生成自訂屬性,程式碼輸出中使用 `var(--name)` **Figma 匯入** + - 匯入 `.fig` 檔案,保留版面配置、填色、筆觸、效果、文字、圖片和向量圖形 **桌面應用程式** + - 透過 Electron 支援原生 macOS、Windows 和 Linux - `.op` 檔案關聯 — 雙擊即可開啟,支援單一實體鎖定 - 從 GitHub Releases 自動更新 @@ -253,17 +260,17 @@ cat design.dsl | op design - # 從 stdin 管道輸入 ## 技術堆疊 -| | | -| --- | --- | -| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **畫布** | CanvasKit/Skia(WASM、GPU 加速) | -| **狀態管理** | Zustand v5 | -| **伺服器** | Nitro | -| **桌面端** | Electron 35 | -| **CLI** | `op` — 終端機控制、批次設計 DSL、程式碼匯出 | -| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **執行環境** | Bun · Vite 7 | -| **檔案格式** | `.op` — 基於 JSON,人類可讀,對 Git 友好 | +| | | +| ------------ | -------------------------------------------------------------------------------- | +| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **畫布** | CanvasKit/Skia(WASM、GPU 加速) | +| **狀態管理** | Zustand v5 | +| **伺服器** | Nitro | +| **桌面端** | Electron 35 | +| **CLI** | `op` — 終端機控制、批次設計 DSL、程式碼匯出 | +| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **執行環境** | Bun · Vite 7 | +| **檔案格式** | `.op` — 基於 JSON,人類可讀,對 Git 友好 | ## 專案結構 @@ -304,21 +311,21 @@ openpencil/ ## 鍵盤快捷鍵 -| 按鍵 | 操作 | | 按鍵 | 操作 | -| --- | --- | --- | --- | --- | -| `V` | 選取 | | `Cmd+S` | 儲存 | -| `R` | 矩形 | | `Cmd+Z` | 復原 | -| `O` | 橢圓 | | `Cmd+Shift+Z` | 重做 | -| `L` | 直線 | | `Cmd+C/X/V/D` | 複製/剪下/貼上/重複 | -| `T` | 文字 | | `Cmd+G` | 群組 | -| `F` | Frame | | `Cmd+Shift+G` | 解散群組 | -| `P` | 鋼筆工具 | | `Cmd+Shift+E` | 匯出 | -| `H` | 手形(平移) | | `Cmd+Shift+C` | 程式碼面板 | -| `Del` | 刪除 | | `Cmd+Shift+V` | 變數面板 | -| `[ / ]` | 調整圖層順序 | | `Cmd+J` | AI 聊天 | -| 方向鍵 | 微移 1px | | `Cmd+,` | 智能體設定 | -| `Cmd+Alt+U` | 布林聯合 | | `Cmd+Alt+S` | 布林減去 | -| `Cmd+Alt+I` | 布林交集 | | | | +| 按鍵 | 操作 | | 按鍵 | 操作 | +| ----------- | ------------ | --- | ------------- | ------------------- | +| `V` | 選取 | | `Cmd+S` | 儲存 | +| `R` | 矩形 | | `Cmd+Z` | 復原 | +| `O` | 橢圓 | | `Cmd+Shift+Z` | 重做 | +| `L` | 直線 | | `Cmd+C/X/V/D` | 複製/剪下/貼上/重複 | +| `T` | 文字 | | `Cmd+G` | 群組 | +| `F` | Frame | | `Cmd+Shift+G` | 解散群組 | +| `P` | 鋼筆工具 | | `Cmd+Shift+E` | 匯出 | +| `H` | 手形(平移) | | `Cmd+Shift+C` | 程式碼面板 | +| `Del` | 刪除 | | `Cmd+Shift+V` | 變數面板 | +| `[ / ]` | 調整圖層順序 | | `Cmd+J` | AI 聊天 | +| 方向鍵 | 微移 1px | | `Cmd+,` | 智能體設定 | +| `Cmd+Alt+U` | 布林聯合 | | `Cmd+Alt+S` | 布林減去 | +| `Cmd+Alt+I` | 布林交集 | | | | ## 指令碼命令 diff --git a/README.zh.md b/README.zh.md index efe5514b..30c81d00 100644 --- a/README.zh.md +++ b/README.zh.md @@ -124,15 +124,15 @@ bun run electron:dev 提供多个镜像变体 — 按需选择: -| 镜像 | 大小 | 包含 | -| --- | --- | --- | -| `openpencil:latest` | ~226 MB | 仅 Web 应用 | -| `openpencil-claude:latest` | — | + Claude Code CLI | -| `openpencil-codex:latest` | — | + Codex CLI | -| `openpencil-opencode:latest` | — | + OpenCode CLI | -| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | -| `openpencil-gemini:latest` | — | + Gemini CLI | -| `openpencil-full:latest` | ~1 GB | 全部 CLI 工具 | +| 镜像 | 大小 | 包含 | +| ---------------------------- | ------- | -------------------- | +| `openpencil:latest` | ~226 MB | 仅 Web 应用 | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | +| `openpencil-full:latest` | ~1 GB | 全部 CLI 工具 | **运行(仅 Web):** @@ -173,6 +173,7 @@ docker build --target full -t openpencil-full . ## AI 原生设计 **提示词生成 UI** + - **文字转设计** — 描述一个页面,实时以流式动画在画布上生成 - **编排器** — 将复杂页面分解为空间子任务,支持并行生成 - **设计修改** — 选中元素后,用自然语言描述更改 @@ -180,20 +181,21 @@ docker build --target full -t openpencil-full . **多智能体支持** -| 智能体 | 配置方式 | -| --- | --- | +| 智能体 | 配置方式 | +| --------------------- | --------------------------------------------------------------------- | | **内置(9+ 提供商)** | 从提供商预设中选择并切换区域 — Anthropic、OpenAI、Google、DeepSeek 等 | -| **Claude Code** | 无需配置 — 使用 Claude Agent SDK 本地 OAuth | -| **Codex CLI** | 在 Agent 设置中连接(`Cmd+,`) | -| **OpenCode** | 在 Agent 设置中连接(`Cmd+,`) | -| **GitHub Copilot** | 运行 `copilot login` 后在 Agent 设置中连接(`Cmd+,`) | -| **Gemini CLI** | 在 Agent 设置中连接(`Cmd+,`) | +| **Claude Code** | 无需配置 — 使用 Claude Agent SDK 本地 OAuth | +| **Codex CLI** | 在 Agent 设置中连接(`Cmd+,`) | +| **OpenCode** | 在 Agent 设置中连接(`Cmd+,`) | +| **GitHub Copilot** | 运行 `copilot login` 后在 Agent 设置中连接(`Cmd+,`) | +| **Gemini CLI** | 在 Agent 设置中连接(`Cmd+,`) | **模型能力配置** — 自动根据模型层级适配提示词、思考模式和超时时间。完整层级模型(Claude)获得完整提示词;标准层级模型(GPT-4o、Gemini、DeepSeek)关闭思考模式;基础层级模型(MiniMax、Qwen、Llama、Mistral)使用简化的嵌套 JSON 提示词以确保最大可靠性。 **国际化** — 完整界面本地化,支持 15 种语言:English、简体中文、繁體中文、日本語、한국어、Français、Español、Deutsch、Português、Русский、हिन्दी、Türkçe、ไทย、Tiếng Việt、Bahasa Indonesia。 **MCP 服务器** + - 内置 MCP 服务器 — 一键安装到 Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI - 自动检测 Node.js — 若未安装则自动回退到 HTTP 传输模式并启动 MCP HTTP 服务器 - 从终端进行设计自动化:通过任意 MCP 兼容的智能体读取、创建和修改 `.op` 文件 @@ -202,6 +204,7 @@ docker build --target full -t openpencil-full . - 多页面支持 — 通过 MCP 工具创建、重命名、重新排序和复制页面 **代码生成** + - React + Tailwind CSS、HTML + CSS、CSS Variables - Vue、Svelte、Flutter、SwiftUI、Jetpack Compose、React Native @@ -229,6 +232,7 @@ cat design.dsl | op design - # 从 stdin 管道输入 ## 功能特性 **画布与绘图** + - 无限画布,支持平移、缩放、智能对齐参考线和吸附 - 矩形、椭圆、直线、多边形、钢笔(贝塞尔)、Frame、文本 - 布尔运算 — 联合、减去、交集,配合上下文工具栏 @@ -237,15 +241,18 @@ cat design.dsl | op design - # 从 stdin 管道输入 - 多页面文档,支持标签页导航 **设计系统** + - 设计变量 — 颜色、数字、字符串令牌,支持 `$variable` 引用 - 多主题支持 — 多个主题轴,每个轴有多个变体(浅色/深色、紧凑/舒适) - 组件系统 — 可复用组件,支持实例和覆盖 - CSS 同步 — 自动生成自定义属性,代码输出中使用 `var(--name)` **Figma 导入** + - 导入 `.fig` 文件,保留布局、填充、描边、效果、文本、图片和矢量图形 **桌面应用** + - 通过 Electron 支持原生 macOS、Windows 和 Linux - `.op` 文件关联 — 双击即可打开,单实例锁定 - 从 GitHub Releases 自动更新 @@ -253,17 +260,17 @@ cat design.dsl | op design - # 从 stdin 管道输入 ## 技术栈 -| | | -| --- | --- | -| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | -| **画布** | CanvasKit/Skia(WASM, GPU 加速) | -| **状态管理** | Zustand v5 | -| **服务器** | Nitro | -| **桌面端** | Electron 35 | -| **CLI** | `op` — 终端控制、批量设计 DSL、代码导出 | -| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | -| **运行时** | Bun · Vite 7 | -| **文件格式** | `.op` — 基于 JSON,人类可读,对 Git 友好 | +| | | +| ------------ | -------------------------------------------------------------------------------- | +| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next | +| **画布** | CanvasKit/Skia(WASM, GPU 加速) | +| **状态管理** | Zustand v5 | +| **服务器** | Nitro | +| **桌面端** | Electron 35 | +| **CLI** | `op` — 终端控制、批量设计 DSL、代码导出 | +| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | +| **运行时** | Bun · Vite 7 | +| **文件格式** | `.op` — 基于 JSON,人类可读,对 Git 友好 | ## 项目结构 @@ -304,21 +311,21 @@ openpencil/ ## 键盘快捷键 -| 按键 | 操作 | | 按键 | 操作 | -| --- | --- | --- | --- | --- | -| `V` | 选择 | | `Cmd+S` | 保存 | -| `R` | 矩形 | | `Cmd+Z` | 撤销 | -| `O` | 椭圆 | | `Cmd+Shift+Z` | 重做 | -| `L` | 直线 | | `Cmd+C/X/V/D` | 复制/剪切/粘贴/重复 | -| `T` | 文本 | | `Cmd+G` | 编组 | -| `F` | Frame | | `Cmd+Shift+G` | 取消编组 | -| `P` | 钢笔工具 | | `Cmd+Shift+E` | 导出 | -| `H` | 手形(平移) | | `Cmd+Shift+C` | 代码面板 | -| `Del` | 删除 | | `Cmd+Shift+V` | 变量面板 | -| `[ / ]` | 调整层级顺序 | | `Cmd+J` | AI 聊天 | -| 方向键 | 微移 1px | | `Cmd+,` | 智能体设置 | -| `Cmd+Alt+U` | 布尔联合 | | `Cmd+Alt+S` | 布尔减去 | -| `Cmd+Alt+I` | 布尔交集 | | | | +| 按键 | 操作 | | 按键 | 操作 | +| ----------- | ------------ | --- | ------------- | ------------------- | +| `V` | 选择 | | `Cmd+S` | 保存 | +| `R` | 矩形 | | `Cmd+Z` | 撤销 | +| `O` | 椭圆 | | `Cmd+Shift+Z` | 重做 | +| `L` | 直线 | | `Cmd+C/X/V/D` | 复制/剪切/粘贴/重复 | +| `T` | 文本 | | `Cmd+G` | 编组 | +| `F` | Frame | | `Cmd+Shift+G` | 取消编组 | +| `P` | 钢笔工具 | | `Cmd+Shift+E` | 导出 | +| `H` | 手形(平移) | | `Cmd+Shift+C` | 代码面板 | +| `Del` | 删除 | | `Cmd+Shift+V` | 变量面板 | +| `[ / ]` | 调整层级顺序 | | `Cmd+J` | AI 聊天 | +| 方向键 | 微移 1px | | `Cmd+,` | 智能体设置 | +| `Cmd+Alt+U` | 布尔联合 | | `Cmd+Alt+S` | 布尔减去 | +| `Cmd+Alt+I` | 布尔交集 | | | | ## 脚本命令 diff --git a/apps/cli/README.de.md b/apps/cli/README.de.md index a9d9674a..3f84570b 100644 --- a/apps/cli/README.de.md +++ b/apps/cli/README.de.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil Das CLI erkennt und startet die OpenPencil-Desktop-App automatisch auf allen Plattformen: -| Plattform | Erkannte Installationspfade | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS pro Benutzer (`%LOCALAPPDATA%`), systemweit (`%PROGRAMFILES%`), portabel | +| Plattform | Erkannte Installationspfade | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | NSIS pro Benutzer (`%LOCALAPPDATA%`), systemweit (`%PROGRAMFILES%`), portabel | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## Verwendung diff --git a/apps/cli/README.es.md b/apps/cli/README.es.md index 789a7b85..9a02adae 100644 --- a/apps/cli/README.es.md +++ b/apps/cli/README.es.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil El CLI detecta y lanza automaticamente la aplicacion de escritorio OpenPencil en todas las plataformas: -| Plataforma | Rutas de instalacion detectadas | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS por usuario (`%LOCALAPPDATA%`), por maquina (`%PROGRAMFILES%`), portable | +| Plataforma | Rutas de instalacion detectadas | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | NSIS por usuario (`%LOCALAPPDATA%`), por maquina (`%PROGRAMFILES%`), portable | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## Uso diff --git a/apps/cli/README.fr.md b/apps/cli/README.fr.md index 4566c25f..c948368e 100644 --- a/apps/cli/README.fr.md +++ b/apps/cli/README.fr.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil Le CLI detecte et lance automatiquement l'application de bureau OpenPencil sur toutes les plateformes : -| Plateforme | Chemins d'installation detectes | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS par utilisateur (`%LOCALAPPDATA%`), par machine (`%PROGRAMFILES%`), portable | +| Plateforme | Chemins d'installation detectes | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | NSIS par utilisateur (`%LOCALAPPDATA%`), par machine (`%PROGRAMFILES%`), portable | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## Utilisation diff --git a/apps/cli/README.hi.md b/apps/cli/README.hi.md index 643cafb5..8c594a73 100644 --- a/apps/cli/README.hi.md +++ b/apps/cli/README.hi.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil CLI सभी प्लेटफ़ॉर्म पर OpenPencil डेस्कटॉप ऐप को स्वचालित रूप से पहचानता और लॉन्च करता है: -| प्लेटफ़ॉर्म | पहचाने गए इंस्टॉलेशन पथ | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS प्रति-उपयोगकर्ता (`%LOCALAPPDATA%`), प्रति-मशीन (`%PROGRAMFILES%`), पोर्टेबल | +| प्लेटफ़ॉर्म | पहचाने गए इंस्टॉलेशन पथ | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | NSIS प्रति-उपयोगकर्ता (`%LOCALAPPDATA%`), प्रति-मशीन (`%PROGRAMFILES%`), पोर्टेबल | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## उपयोग diff --git a/apps/cli/README.id.md b/apps/cli/README.id.md index a52ec0eb..9a874a57 100644 --- a/apps/cli/README.id.md +++ b/apps/cli/README.id.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil CLI secara otomatis mendeteksi dan meluncurkan aplikasi desktop OpenPencil di semua platform: -| Platform | Jalur instalasi yang terdeteksi | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS per-pengguna (`%LOCALAPPDATA%`), per-mesin (`%PROGRAMFILES%`), portabel | +| Platform | Jalur instalasi yang terdeteksi | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | NSIS per-pengguna (`%LOCALAPPDATA%`), per-mesin (`%PROGRAMFILES%`), portabel | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## Penggunaan diff --git a/apps/cli/README.ja.md b/apps/cli/README.ja.md index a24cfffe..d7bda1e1 100644 --- a/apps/cli/README.ja.md +++ b/apps/cli/README.ja.md @@ -17,8 +17,8 @@ CLI はすべてのプラットフォームで OpenPencil デスクトップア | プラットフォーム | 検出されるインストールパス | | ---------------- | ------------------------------------------------------------------------------------------------------- | | **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS ユーザー単位 (`%LOCALAPPDATA%`)、マシン単位 (`%PROGRAMFILES%`)、ポータブル | -| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | +| **Windows** | NSIS ユーザー単位 (`%LOCALAPPDATA%`)、マシン単位 (`%PROGRAMFILES%`)、ポータブル | +| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## 使い方 diff --git a/apps/cli/README.ko.md b/apps/cli/README.ko.md index b600c950..0277de31 100644 --- a/apps/cli/README.ko.md +++ b/apps/cli/README.ko.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil CLI는 모든 플랫폼에서 OpenPencil 데스크톱 앱을 자동으로 감지하고 실행합니다: -| 플랫폼 | 감지되는 설치 경로 | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS 사용자별 (`%LOCALAPPDATA%`), 시스템 전체 (`%PROGRAMFILES%`), 포터블 | +| 플랫폼 | 감지되는 설치 경로 | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | NSIS 사용자별 (`%LOCALAPPDATA%`), 시스템 전체 (`%PROGRAMFILES%`), 포터블 | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## 사용법 diff --git a/apps/cli/README.md b/apps/cli/README.md index 896638ef..c021beb0 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil The CLI automatically detects and launches the OpenPencil desktop app on all platforms: -| Platform | Installation paths detected | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS per-user (`%LOCALAPPDATA%`), per-machine (`%PROGRAMFILES%`), portable | +| Platform | Installation paths detected | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | NSIS per-user (`%LOCALAPPDATA%`), per-machine (`%PROGRAMFILES%`), portable | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## Usage diff --git a/apps/cli/README.pt.md b/apps/cli/README.pt.md index a7d7f3ff..943b6a19 100644 --- a/apps/cli/README.pt.md +++ b/apps/cli/README.pt.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil A CLI detecta e inicia automaticamente o aplicativo desktop OpenPencil em todas as plataformas: -| Plataforma | Caminhos de instalacao detectados | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS por usuario (`%LOCALAPPDATA%`), por maquina (`%PROGRAMFILES%`), portatil | +| Plataforma | Caminhos de instalacao detectados | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | NSIS por usuario (`%LOCALAPPDATA%`), por maquina (`%PROGRAMFILES%`), portatil | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## Uso diff --git a/apps/cli/README.ru.md b/apps/cli/README.ru.md index 2933c626..a543207b 100644 --- a/apps/cli/README.ru.md +++ b/apps/cli/README.ru.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil CLI автоматически обнаруживает и запускает настольное приложение OpenPencil на всех платформах: -| Платформа | Обнаруживаемые пути установки | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS для пользователя (`%LOCALAPPDATA%`), для машины (`%PROGRAMFILES%`), портативная версия | +| Платформа | Обнаруживаемые пути установки | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | NSIS для пользователя (`%LOCALAPPDATA%`), для машины (`%PROGRAMFILES%`), портативная версия | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## Использование diff --git a/apps/cli/README.th.md b/apps/cli/README.th.md index 43df37bf..6badcada 100644 --- a/apps/cli/README.th.md +++ b/apps/cli/README.th.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil CLI จะตรวจจับและเปิดแอปเดสก์ท็อป OpenPencil โดยอัตโนมัติบนทุกแพลตฟอร์ม: -| แพลตฟอร์ม | เส้นทางการติดตั้งที่ตรวจพบ | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS ต่อผู้ใช้ (`%LOCALAPPDATA%`), ต่อเครื่อง (`%PROGRAMFILES%`), แบบพกพา | +| แพลตฟอร์ม | เส้นทางการติดตั้งที่ตรวจพบ | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | NSIS ต่อผู้ใช้ (`%LOCALAPPDATA%`), ต่อเครื่อง (`%PROGRAMFILES%`), แบบพกพา | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## การใช้งาน diff --git a/apps/cli/README.tr.md b/apps/cli/README.tr.md index 4bddc287..6206eb11 100644 --- a/apps/cli/README.tr.md +++ b/apps/cli/README.tr.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil CLI, tum platformlarda OpenPencil masaustu uygulamasini otomatik olarak algilar ve baslatir: -| Platform | Algilanan kurulum yollari | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | Kullanici basina NSIS (`%LOCALAPPDATA%`), makine basina (`%PROGRAMFILES%`), tasinabilir | +| Platform | Algilanan kurulum yollari | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | Kullanici basina NSIS (`%LOCALAPPDATA%`), makine basina (`%PROGRAMFILES%`), tasinabilir | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## Kullanim diff --git a/apps/cli/README.vi.md b/apps/cli/README.vi.md index 2cfbfd7a..8dfcbc19 100644 --- a/apps/cli/README.vi.md +++ b/apps/cli/README.vi.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil CLI tự động phát hiện và khởi chạy ứng dụng desktop OpenPencil trên tất cả các nền tảng: -| Nền tảng | Đường dẫn cài đặt được phát hiện | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS theo người dùng (`%LOCALAPPDATA%`), theo máy (`%PROGRAMFILES%`), di động | +| Nền tảng | Đường dẫn cài đặt được phát hiện | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | NSIS theo người dùng (`%LOCALAPPDATA%`), theo máy (`%PROGRAMFILES%`), di động | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## Sử dụng diff --git a/apps/cli/README.zh-TW.md b/apps/cli/README.zh-TW.md index 3402d04b..9eaec99c 100644 --- a/apps/cli/README.zh-TW.md +++ b/apps/cli/README.zh-TW.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil CLI 會自動偵測並啟動所有平台上的 OpenPencil 桌面應用程式: -| 平台 | 偵測的安裝路徑 | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`、`~/Applications/OpenPencil.app` | -| **Windows** | NSIS 使用者安裝(`%LOCALAPPDATA%`)、全域安裝(`%PROGRAMFILES%`)、可攜版 | +| 平台 | 偵測的安裝路徑 | +| ----------- | -------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`、`~/Applications/OpenPencil.app` | +| **Windows** | NSIS 使用者安裝(`%LOCALAPPDATA%`)、全域安裝(`%PROGRAMFILES%`)、可攜版 | | **Linux** | `/usr/bin`、`/usr/local/bin`、`~/.local/bin`、AppImage(`~/Applications`、`~/Downloads`)、Snap、Flatpak | ## 使用方式 diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 9af6996c..4aa38898 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil CLI 会自动检测并启动各平台上的 OpenPencil 桌面应用: -| 平台 | 检测的安装路径 | -| ----------- | --------------------------------------------------------------------------------------------------- | -| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | -| **Windows** | NSIS 用户级 (`%LOCALAPPDATA%`)、系统级 (`%PROGRAMFILES%`)、便携版 | +| 平台 | 检测的安装路径 | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` | +| **Windows** | NSIS 用户级 (`%LOCALAPPDATA%`)、系统级 (`%PROGRAMFILES%`)、便携版 | | **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak | ## 用法 diff --git a/apps/cli/package.json b/apps/cli/package.json index 8dd68a12..a4aec6fa 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,20 +1,24 @@ { "name": "@zseven-w/openpencil", - "version": "0.6.0", + "version": "0.7.0", "description": "CLI for OpenPencil — control the design tool from your terminal", + "license": "MIT", "author": { "name": "ZSeven-W", "email": "xkayshen@gmail.com" }, - "license": "MIT", - "type": "module", "bin": { "op": "dist/openpencil-cli.cjs" }, "files": [ "dist" ], + "type": "module", "scripts": { - "compile": "cd ../web && esbuild ../cli/src/index.ts --bundle --platform=node --target=node20 --outfile=../cli/dist/openpencil-cli.cjs --format=cjs --sourcemap --alias:@=src --alias:@zseven-w/pen-types=../../packages/pen-types/src --alias:@zseven-w/pen-core=../../packages/pen-core/src --alias:@zseven-w/pen-codegen=../../packages/pen-codegen/src --alias:@zseven-w/pen-figma=../../packages/pen-figma/src --alias:@zseven-w/pen-renderer=../../packages/pen-renderer/src --alias:@zseven-w/pen-sdk=../../packages/pen-sdk/src --define:import.meta.env={} --external:canvas --external:paper" + "compile": "cd ../.. && bun run cli:compile" + }, + "dependencies": { + "@zseven-w/pen-figma": "workspace:*", + "@zseven-w/pen-mcp": "workspace:*" } } diff --git a/apps/cli/src/commands/app.ts b/apps/cli/src/commands/app.ts index 26d49aa7..e4b68405 100644 --- a/apps/cli/src/commands/app.ts +++ b/apps/cli/src/commands/app.ts @@ -1,35 +1,32 @@ -import { getAppInfo } from '../connection' -import { startDesktop, startWeb, stopApp } from '../launcher' -import { output, outputError } from '../output' +import { getAppInfo } from '../connection'; +import { startDesktop, startWeb, stopApp } from '../launcher'; +import { output, outputError } from '../output'; -export async function cmdStart(flags: { - desktop?: boolean - web?: boolean -}): Promise { +export async function cmdStart(flags: { desktop?: boolean; web?: boolean }): Promise { try { - let result: { port: number; pid: number } + let result: { port: number; pid: number }; if (flags.web) { - result = await startWeb() + result = await startWeb(); } else { - result = await startDesktop() + result = await startDesktop(); } - output({ ok: true, ...result, url: `http://127.0.0.1:${result.port}` }) + output({ ok: true, ...result, url: `http://127.0.0.1:${result.port}` }); } catch (err) { - outputError((err as Error).message) + outputError((err as Error).message); } } export async function cmdStop(): Promise { - const stopped = await stopApp() + const stopped = await stopApp(); if (stopped) { - output({ ok: true, message: 'OpenPencil stopped' }) + output({ ok: true, message: 'OpenPencil stopped' }); } else { - output({ ok: true, message: 'No running instance found' }) + output({ ok: true, message: 'No running instance found' }); } } export async function cmdStatus(): Promise { - const info = await getAppInfo() + const info = await getAppInfo(); if (info) { output({ running: true, @@ -37,8 +34,8 @@ export async function cmdStatus(): Promise { pid: info.pid, url: info.url, uptime: Math.floor((Date.now() - info.timestamp) / 1000), - }) + }); } else { - output({ running: false }) + output({ running: false }); } } diff --git a/apps/cli/src/commands/codegen.ts b/apps/cli/src/commands/codegen.ts new file mode 100644 index 00000000..d537c097 --- /dev/null +++ b/apps/cli/src/commands/codegen.ts @@ -0,0 +1,120 @@ +// apps/cli/src/commands/codegen.ts + +import { resolveDocPath, getSyncUrl } from '@zseven-w/pen-mcp'; +import { outputError, resolveArg, parseJsonArg } from '../output'; + +interface GlobalFlags { + file?: string; + page?: string; +} + +async function requireSyncUrl(): Promise { + const syncUrl = await getSyncUrl(); + if (!syncUrl) { + outputError('No running OpenPencil instance found. Start the app first with: op start'); + } + return syncUrl; +} + +export async function cmdCodegenPlan(args: string[], flags: GlobalFlags): Promise { + const raw = await resolveArg(args[0]); + if (!raw) { + outputError('Usage: op codegen:plan '); + } + const plan = await parseJsonArg(raw); + + const syncUrl = await requireSyncUrl(); + if (!syncUrl) return; + + try { + const res = await fetch(`${syncUrl}/api/mcp/codegen/plan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + plan, + filePath: flags.file ? resolveDocPath(flags.file) : undefined, + pageId: flags.page, + }), + }); + if (!res.ok) { + outputError(await res.text()); + } + process.stdout.write(JSON.stringify(await res.json(), null, 2)); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err)); + } +} + +export async function cmdCodegenSubmit(args: string[], _flags: GlobalFlags): Promise { + const planId = args[0]; + const raw = await resolveArg(args[1]); + if (!planId || !raw) { + outputError('Usage: op codegen:submit '); + } + const result = await parseJsonArg(raw); + + const syncUrl = await requireSyncUrl(); + if (!syncUrl) return; + + try { + const res = await fetch(`${syncUrl}/api/mcp/codegen/submit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ planId, result }), + }); + if (!res.ok) { + outputError(await res.text()); + } + process.stdout.write(JSON.stringify(await res.json(), null, 2)); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err)); + } +} + +export async function cmdCodegenAssemble( + args: string[], + flags: GlobalFlags & { framework?: string }, +): Promise { + const planId = args[0]; + if (!planId) { + outputError('Usage: op codegen:assemble [--framework react]'); + } + const framework = flags.framework || 'react'; + + const syncUrl = await requireSyncUrl(); + if (!syncUrl) return; + + try { + const res = await fetch( + `${syncUrl}/api/mcp/codegen/assemble/${encodeURIComponent(planId)}?framework=${encodeURIComponent(framework)}`, + ); + if (!res.ok) { + outputError(await res.text()); + } + process.stdout.write(JSON.stringify(await res.json(), null, 2)); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err)); + } +} + +export async function cmdCodegenClean(args: string[], _flags: GlobalFlags): Promise { + const planId = args[0]; + if (!planId) { + outputError('Usage: op codegen:clean '); + } + + const syncUrl = await requireSyncUrl(); + if (!syncUrl) return; + + try { + const res = await fetch(`${syncUrl}/api/mcp/codegen/plan/${encodeURIComponent(planId)}`, { + method: 'DELETE', + }); + if (!res.ok) { + outputError(await res.text()); + } + process.stdout.write(JSON.stringify(await res.json(), null, 2)); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err)); + } +} diff --git a/apps/cli/src/commands/design.ts b/apps/cli/src/commands/design.ts index 30cedc0e..2f244bdf 100644 --- a/apps/cli/src/commands/design.ts +++ b/apps/cli/src/commands/design.ts @@ -1,70 +1,67 @@ -import { handleBatchDesign } from '@/mcp/tools/batch-design' -import { handleDesignSkeleton } from '@/mcp/tools/design-skeleton' -import { handleDesignContent } from '@/mcp/tools/design-content' -import { handleDesignRefine } from '@/mcp/tools/design-refine' -import { output, outputError, parseJsonArg, resolveArg } from '../output' +import { handleBatchDesign } from '@zseven-w/pen-mcp'; +import { handleDesignSkeleton } from '@zseven-w/pen-mcp'; +import { handleDesignContent } from '@zseven-w/pen-mcp'; +import { handleDesignRefine } from '@zseven-w/pen-mcp'; +import { output, outputError, parseJsonArg, resolveArg } from '../output'; interface GlobalFlags { - file?: string - page?: string + file?: string; + page?: string; } export async function cmdDesign( args: string[], flags: GlobalFlags & { postProcess?: boolean; canvasWidth?: string }, ): Promise { - const operations = await resolveArg(args[0]) + const operations = await resolveArg(args[0]); const result = await handleBatchDesign({ filePath: flags.file, operations, postProcess: flags.postProcess !== false, canvasWidth: flags.canvasWidth ? parseInt(flags.canvasWidth, 10) : undefined, pageId: flags.page, - }) - output(result) + }); + output(result); } -export async function cmdDesignSkeleton( - args: string[], - flags: GlobalFlags, -): Promise { - const json = (await parseJsonArg(args[0])) as Record +export async function cmdDesignSkeleton(args: string[], flags: GlobalFlags): Promise { + const json = (await parseJsonArg(args[0])) as Record; const result = await handleDesignSkeleton({ filePath: flags.file, rootFrame: json.rootFrame as any, sections: json.sections as any, pageId: flags.page, - }) - output(result) + }); + output(result); } export async function cmdDesignContent( args: string[], flags: GlobalFlags & { canvasWidth?: string }, ): Promise { - const sectionId = args[0] - if (!sectionId) outputError('Usage: openpencil design:content ') - const json = (await parseJsonArg(args[1])) as Record + const sectionId = args[0]; + if (!sectionId) outputError('Usage: openpencil design:content '); + const json = (await parseJsonArg(args[1])) as Record; const result = await handleDesignContent({ filePath: flags.file, sectionId, children: json.children as any, canvasWidth: flags.canvasWidth ? parseInt(flags.canvasWidth, 10) : undefined, pageId: flags.page, - }) - output(result) + }); + output(result); } export async function cmdDesignRefine( - args: string[], + _args: string[], flags: GlobalFlags & { rootId?: string; canvasWidth?: string }, ): Promise { - if (!flags.rootId) outputError('Usage: openpencil design:refine --root-id ') + if (!flags.rootId) outputError('Usage: openpencil design:refine --root-id '); const result = await handleDesignRefine({ filePath: flags.file, rootId: flags.rootId!, canvasWidth: flags.canvasWidth ? parseInt(flags.canvasWidth, 10) : undefined, pageId: flags.page, - }) - output(result) + }); + output(result); } diff --git a/apps/cli/src/commands/document.ts b/apps/cli/src/commands/document.ts index c64adb4e..795d4c1c 100644 --- a/apps/cli/src/commands/document.ts +++ b/apps/cli/src/commands/document.ts @@ -1,41 +1,44 @@ -import { handleOpenDocument } from '@/mcp/tools/open-document' -import { handleBatchGet } from '@/mcp/tools/batch-get' -import { handleGetSelection } from '@/mcp/tools/get-selection' -import { openDocument, saveDocument, resolveDocPath } from '@/mcp/document-manager' -import { output, outputError } from '../output' +import { handleOpenDocument } from '@zseven-w/pen-mcp'; +import { handleBatchGet } from '@zseven-w/pen-mcp'; +import { handleGetSelection } from '@zseven-w/pen-mcp'; +import { openDocument, saveDocument, resolveDocPath } from '@zseven-w/pen-mcp'; +import { output, outputError } from '../output'; interface GlobalFlags { - file?: string - page?: string + file?: string; + page?: string; } export async function cmdOpen(args: string[], flags: GlobalFlags): Promise { - const result = await handleOpenDocument({ filePath: flags.file ?? args[0] }) - output(result) + const result = await handleOpenDocument({ filePath: flags.file ?? args[0] }); + output(result); } export async function cmdSave(args: string[], flags: GlobalFlags): Promise { - const target = args[0] - if (!target) outputError('Usage: openpencil save ') - const doc = await openDocument(resolveDocPath(flags.file)) - await saveDocument(target, doc) - output({ ok: true, filePath: target }) + const target = args[0]; + if (!target) outputError('Usage: openpencil save '); + const doc = await openDocument(resolveDocPath(flags.file)); + await saveDocument(target, doc); + output({ ok: true, filePath: target }); } -export async function cmdGet(args: string[], flags: GlobalFlags & { - type?: string - name?: string - id?: string - depth?: string - parent?: string -}): Promise { - const patterns: { type?: string; name?: string }[] = [] +export async function cmdGet( + _args: string[], + flags: GlobalFlags & { + type?: string; + name?: string; + id?: string; + depth?: string; + parent?: string; + }, +): Promise { + const patterns: { type?: string; name?: string }[] = []; if (flags.type || flags.name) { - patterns.push({ type: flags.type, name: flags.name }) + patterns.push({ type: flags.type, name: flags.name }); } - const nodeIds: string[] = [] - if (flags.id) nodeIds.push(flags.id) + const nodeIds: string[] = []; + if (flags.id) nodeIds.push(flags.id); const result = await handleBatchGet({ filePath: flags.file, @@ -44,14 +47,14 @@ export async function cmdGet(args: string[], flags: GlobalFlags & { parentId: flags.parent, readDepth: flags.depth ? parseInt(flags.depth, 10) : undefined, pageId: flags.page, - }) - output(result) + }); + output(result); } export async function cmdSelection(flags: GlobalFlags & { depth?: string }): Promise { const result = await handleGetSelection({ filePath: flags.file, readDepth: flags.depth ? parseInt(flags.depth, 10) : undefined, - }) - output(result) + }); + output(result); } diff --git a/apps/cli/src/commands/export.ts b/apps/cli/src/commands/export.ts deleted file mode 100644 index 2142884b..00000000 --- a/apps/cli/src/commands/export.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { openDocument, resolveDocPath } from '@/mcp/document-manager' -import { - generateReactFromDocument, - generateHTMLFromDocument, - generateVueFromDocument, - generateSvelteFromDocument, - generateFlutterFromDocument, - generateSwiftUIFromDocument, - generateComposeFromDocument, - generateReactNativeFromDocument, - generateCSSVariables, -} from '@zseven-w/pen-codegen' -import { writeFile } from 'node:fs/promises' -import { output, outputError } from '../output' - -type GeneratorResult = string | { html: string; css: string } - -const GENERATORS: Record GeneratorResult> = { - react: generateReactFromDocument, - html: generateHTMLFromDocument, - vue: generateVueFromDocument, - svelte: generateSvelteFromDocument, - flutter: generateFlutterFromDocument, - swiftui: generateSwiftUIFromDocument, - compose: generateComposeFromDocument, - rn: generateReactNativeFromDocument, - 'react-native': generateReactNativeFromDocument, - css: (doc: any) => generateCSSVariables(doc.variables ?? {}), -} - -function resultToString(result: GeneratorResult): string { - if (typeof result === 'string') return result - // HTML generator returns { html, css } - const parts: string[] = [] - if (result.css) parts.push(``) - parts.push(result.html) - return parts.join('\n\n') -} - -export async function cmdExport( - args: string[], - flags: { file?: string; out?: string }, -): Promise { - const format = args[0] - if (!format) { - outputError( - `Usage: op export [--out file]\nFormats: ${Object.keys(GENERATORS).join(', ')}`, - ) - } - const generator = GENERATORS[format] - if (!generator) { - outputError(`Unknown format: "${format}". Available: ${Object.keys(GENERATORS).join(', ')}`) - } - - const filePath = resolveDocPath(flags.file) - const doc = await openDocument(filePath) - const result = generator(doc) - const code = resultToString(result) - - if (flags.out) { - await writeFile(flags.out, code, 'utf-8') - output({ ok: true, format, file: flags.out, length: code.length }) - } else { - process.stdout.write(code) - } -} diff --git a/apps/cli/src/commands/import.ts b/apps/cli/src/commands/import.ts index 2dfea3fa..1fc981f0 100644 --- a/apps/cli/src/commands/import.ts +++ b/apps/cli/src/commands/import.ts @@ -1,48 +1,50 @@ -import { handleImportSvg } from '@/mcp/tools/import-svg' -import { openDocument, saveDocument, resolveDocPath } from '@/mcp/document-manager' -import { parseFigFile, figmaAllPagesToPenDocument } from '@zseven-w/pen-figma' -import { readFile } from 'node:fs/promises' -import { output, outputError } from '../output' +import { handleImportSvg } from '@zseven-w/pen-mcp'; +import { saveDocument } from '@zseven-w/pen-mcp'; +import { parseFigFile, figmaAllPagesToPenDocument } from '@zseven-w/pen-figma'; +import { readFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { output, outputError } from '../output'; interface GlobalFlags { - file?: string - page?: string + file?: string; + page?: string; } export async function cmdImportSvg( args: string[], flags: GlobalFlags & { parent?: string }, ): Promise { - const svgPath = args[0] - if (!svgPath) outputError('Usage: op import:svg ') + const svgPath = args[0]; + if (!svgPath) outputError('Usage: op import:svg '); const result = await handleImportSvg({ filePath: flags.file, svgPath, parent: flags.parent ?? null, pageId: flags.page, - }) - output(result) + }); + output(result); } export async function cmdImportFigma( args: string[], flags: GlobalFlags & { out?: string }, ): Promise { - const figPath = args[0] - if (!figPath) outputError('Usage: op import:figma [--out output.op]') + const figPath = args[0]; + if (!figPath) outputError('Usage: op import:figma [--out output.op]'); - const buf = await readFile(figPath) - const figFile = parseFigFile(new Uint8Array(buf)) - const doc = figmaAllPagesToPenDocument(figFile) + const buf = await readFile(figPath); + const figFile = parseFigFile(buf.buffer as ArrayBuffer); + const { document: doc, warnings } = figmaAllPagesToPenDocument(figFile, basename(figPath)); - const outPath = flags.out ?? figPath.replace(/\.fig$/, '.op') - await saveDocument(outPath, doc) + const outPath = flags.out ?? figPath.replace(/\.fig$/, '.op'); + await saveDocument(outPath, doc); output({ ok: true, filePath: outPath, pageCount: doc.pages?.length ?? 1, nodeCount: doc.pages - ? doc.pages.reduce((s, p) => s + p.children.length, 0) + ? doc.pages.reduce((s: number, p: { children: unknown[] }) => s + p.children.length, 0) : doc.children.length, - }) + warnings, + }); } diff --git a/apps/cli/src/commands/layout.ts b/apps/cli/src/commands/layout.ts index 9f48229c..01fa113c 100644 --- a/apps/cli/src/commands/layout.ts +++ b/apps/cli/src/commands/layout.ts @@ -1,10 +1,10 @@ -import { handleSnapshotLayout } from '@/mcp/tools/snapshot-layout' -import { handleFindEmptySpace } from '@/mcp/tools/find-empty-space' -import { output, outputError } from '../output' +import { handleSnapshotLayout } from '@zseven-w/pen-mcp'; +import { handleFindEmptySpace } from '@zseven-w/pen-mcp'; +import { output } from '../output'; interface GlobalFlags { - file?: string - page?: string + file?: string; + page?: string; } export async function cmdLayout( @@ -15,8 +15,8 @@ export async function cmdLayout( parentId: flags.parent, maxDepth: flags.depth ? parseInt(flags.depth, 10) : undefined, pageId: flags.page, - }) - output(result) + }); + output(result); } export async function cmdFindSpace( @@ -25,9 +25,9 @@ export async function cmdFindSpace( const result = await handleFindEmptySpace({ filePath: flags.file, direction: (flags.direction as 'right' | 'bottom' | 'left' | 'top') ?? 'right', - width: flags.width ? parseInt(flags.width, 10) : undefined, - height: flags.height ? parseInt(flags.height, 10) : undefined, + width: flags.width ? parseInt(flags.width, 10) : 400, + height: flags.height ? parseInt(flags.height, 10) : 300, pageId: flags.page, - }) - output(result) + }); + output(result); } diff --git a/apps/cli/src/commands/nodes.ts b/apps/cli/src/commands/nodes.ts index 5315001c..12ac811a 100644 --- a/apps/cli/src/commands/nodes.ts +++ b/apps/cli/src/commands/nodes.ts @@ -5,104 +5,101 @@ import { handleMoveNode, handleCopyNode, handleReplaceNode, -} from '@/mcp/tools/node-crud' -import { output, outputError, parseJsonArg } from '../output' +} from '@zseven-w/pen-mcp'; +import { output, outputError, parseJsonArg } from '../output'; interface GlobalFlags { - file?: string - page?: string + file?: string; + page?: string; } export async function cmdInsert( args: string[], flags: GlobalFlags & { parent?: string; index?: string; postProcess?: boolean }, ): Promise { - const data = (await parseJsonArg(args[0])) as Record + const data = (await parseJsonArg(args[0])) as Record; const result = await handleInsertNode({ filePath: flags.file, parent: flags.parent ?? null, data, postProcess: flags.postProcess, pageId: flags.page, - }) - output(result) + }); + output(result); } export async function cmdUpdate( args: string[], flags: GlobalFlags & { postProcess?: boolean }, ): Promise { - const nodeId = args[0] - if (!nodeId) outputError('Usage: openpencil update ') - const data = (await parseJsonArg(args[1])) as Record + const nodeId = args[0]; + if (!nodeId) outputError('Usage: openpencil update '); + const data = (await parseJsonArg(args[1])) as Record; const result = await handleUpdateNode({ filePath: flags.file, nodeId, data, postProcess: flags.postProcess, pageId: flags.page, - }) - output(result) + }); + output(result); } -export async function cmdDelete( - args: string[], - flags: GlobalFlags, -): Promise { - const nodeId = args[0] - if (!nodeId) outputError('Usage: openpencil delete ') +export async function cmdDelete(args: string[], flags: GlobalFlags): Promise { + const nodeId = args[0]; + if (!nodeId) outputError('Usage: openpencil delete '); const result = await handleDeleteNode({ filePath: flags.file, nodeId, pageId: flags.page, - }) - output(result) + }); + output(result); } export async function cmdMove( args: string[], flags: GlobalFlags & { parent?: string; index?: string }, ): Promise { - const nodeId = args[0] - if (!nodeId) outputError('Usage: openpencil move --parent ') + const nodeId = args[0]; + if (!nodeId) outputError('Usage: openpencil move --parent '); const result = await handleMoveNode({ filePath: flags.file, nodeId, parent: flags.parent ?? null, index: flags.index ? parseInt(flags.index, 10) : undefined, pageId: flags.page, - }) - output(result) + }); + output(result); } export async function cmdCopy( args: string[], flags: GlobalFlags & { parent?: string }, ): Promise { - const sourceId = args[0] - if (!sourceId) outputError('Usage: openpencil copy [--parent ]') + const sourceId = args[0]; + if (!sourceId) outputError('Usage: openpencil copy [--parent ]'); const result = await handleCopyNode({ filePath: flags.file, sourceId, parent: flags.parent ?? null, pageId: flags.page, - }) - output(result) + }); + output(result); } export async function cmdReplace( args: string[], flags: GlobalFlags & { postProcess?: boolean }, ): Promise { - const nodeId = args[0] - if (!nodeId) outputError('Usage: openpencil replace ') - const data = (await parseJsonArg(args[1])) as Record + const nodeId = args[0]; + if (!nodeId) outputError('Usage: openpencil replace '); + const data = (await parseJsonArg(args[1])) as Record; const result = await handleReplaceNode({ filePath: flags.file, nodeId, data, postProcess: flags.postProcess, pageId: flags.page, - }) - output(result) + }); + output(result); } diff --git a/apps/cli/src/commands/pages.ts b/apps/cli/src/commands/pages.ts index 0c2e7443..fef0cd58 100644 --- a/apps/cli/src/commands/pages.ts +++ b/apps/cli/src/commands/pages.ts @@ -4,21 +4,21 @@ import { handleRenamePage, handleReorderPage, handleDuplicatePage, -} from '@/mcp/tools/pages' -import { handleOpenDocument } from '@/mcp/tools/open-document' -import { output, outputError } from '../output' +} from '@zseven-w/pen-mcp'; +import { handleOpenDocument } from '@zseven-w/pen-mcp'; +import { output, outputError } from '../output'; interface GlobalFlags { - file?: string + file?: string; } export async function cmdPageList(flags: GlobalFlags): Promise { - const result = await handleOpenDocument({ filePath: flags.file }) + const result = await handleOpenDocument({ filePath: flags.file }); output({ pages: result.document.pages ?? [ { id: 'default', name: 'Page 1', childCount: result.document.childCount }, ], - }) + }); } export async function cmdPageAdd( @@ -28,60 +28,48 @@ export async function cmdPageAdd( const result = await handleAddPage({ filePath: flags.file, name: flags.name ?? args[0], - }) - output(result) + }); + output(result); } -export async function cmdPageRemove( - args: string[], - flags: GlobalFlags, -): Promise { - const pageId = args[0] - if (!pageId) outputError('Usage: op page remove ') +export async function cmdPageRemove(args: string[], flags: GlobalFlags): Promise { + const pageId = args[0]; + if (!pageId) outputError('Usage: op page remove '); const result = await handleRemovePage({ filePath: flags.file, pageId, - }) - output(result) + }); + output(result); } -export async function cmdPageRename( - args: string[], - flags: GlobalFlags, -): Promise { - const [pageId, name] = args - if (!pageId || !name) outputError('Usage: op page rename ') +export async function cmdPageRename(args: string[], flags: GlobalFlags): Promise { + const [pageId, name] = args; + if (!pageId || !name) outputError('Usage: op page rename '); const result = await handleRenamePage({ filePath: flags.file, pageId, name, - }) - output(result) + }); + output(result); } -export async function cmdPageReorder( - args: string[], - flags: GlobalFlags, -): Promise { - const [pageId, indexStr] = args - if (!pageId || !indexStr) outputError('Usage: op page reorder ') +export async function cmdPageReorder(args: string[], flags: GlobalFlags): Promise { + const [pageId, indexStr] = args; + if (!pageId || !indexStr) outputError('Usage: op page reorder '); const result = await handleReorderPage({ filePath: flags.file, pageId, index: parseInt(indexStr, 10), - }) - output(result) + }); + output(result); } -export async function cmdPageDuplicate( - args: string[], - flags: GlobalFlags, -): Promise { - const pageId = args[0] - if (!pageId) outputError('Usage: op page duplicate ') +export async function cmdPageDuplicate(args: string[], flags: GlobalFlags): Promise { + const pageId = args[0]; + if (!pageId) outputError('Usage: op page duplicate '); const result = await handleDuplicatePage({ filePath: flags.file, pageId, - }) - output(result) + }); + output(result); } diff --git a/apps/cli/src/commands/read-nodes.ts b/apps/cli/src/commands/read-nodes.ts new file mode 100644 index 00000000..99f3d34f --- /dev/null +++ b/apps/cli/src/commands/read-nodes.ts @@ -0,0 +1,46 @@ +// apps/cli/src/commands/read-nodes.ts + +import { resolveDocPath, getSyncUrl } from '@zseven-w/pen-mcp'; +import { output, outputError } from '../output'; + +interface Flags { + file?: string; + page?: string; + depth?: string; + vars?: boolean; +} + +export async function cmdReadNodes(args: string[], flags: Flags): Promise { + const nodeIds = args[0] + ? args[0] + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined; + const depth = flags.depth !== undefined ? parseInt(flags.depth, 10) : undefined; + + const syncUrl = await getSyncUrl(); + if (!syncUrl) { + outputError('No running OpenPencil instance found. Start the app first with: op start'); + } + + try { + const res = await fetch(`${syncUrl}/api/mcp/read-nodes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + nodeIds, + depth, + pageId: flags.page, + filePath: flags.file ? resolveDocPath(flags.file) : undefined, + includeVariables: flags.vars, + }), + }); + if (!res.ok) { + outputError(await res.text()); + } + output(await res.json()); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err)); + } +} diff --git a/apps/cli/src/commands/variables.ts b/apps/cli/src/commands/variables.ts index 1fe0e92c..b8b866a3 100644 --- a/apps/cli/src/commands/variables.ts +++ b/apps/cli/src/commands/variables.ts @@ -1,81 +1,75 @@ -import { handleGetVariables, handleSetVariables, handleSetThemes } from '@/mcp/tools/variables' +import { handleGetVariables, handleSetVariables, handleSetThemes } from '@zseven-w/pen-mcp'; import { handleSaveThemePreset, handleLoadThemePreset, handleListThemePresets, -} from '@/mcp/tools/theme-presets' -import { output, outputError, parseJsonArg } from '../output' +} from '@zseven-w/pen-mcp'; +import { output, outputError, parseJsonArg } from '../output'; interface GlobalFlags { - file?: string + file?: string; } export async function cmdVars(flags: GlobalFlags): Promise { - const result = await handleGetVariables({ filePath: flags.file }) - output(result) + const result = await handleGetVariables({ filePath: flags.file }); + output(result); } export async function cmdVarsSet( args: string[], flags: GlobalFlags & { replace?: boolean }, ): Promise { - const data = (await parseJsonArg(args[0])) as Record + const data = (await parseJsonArg(args[0])) as Record; const result = await handleSetVariables({ filePath: flags.file, variables: data as any, replace: flags.replace, - }) - output(result) + }); + output(result); } export async function cmdThemes(flags: GlobalFlags): Promise { - const result = await handleGetVariables({ filePath: flags.file }) - output({ themes: result.themes }) + const result = await handleGetVariables({ filePath: flags.file }); + output({ themes: result.themes }); } export async function cmdThemesSet( args: string[], flags: GlobalFlags & { replace?: boolean }, ): Promise { - const data = (await parseJsonArg(args[0])) as Record + const data = (await parseJsonArg(args[0])) as Record; const result = await handleSetThemes({ filePath: flags.file, themes: data as any, replace: flags.replace, - }) - output(result) + }); + output(result); } -export async function cmdThemeSave( - args: string[], - flags: GlobalFlags, -): Promise { - const presetPath = args[0] - if (!presetPath) outputError('Usage: op theme:save ') +export async function cmdThemeSave(args: string[], flags: GlobalFlags): Promise { + const presetPath = args[0]; + if (!presetPath) outputError('Usage: op theme:save '); const result = await handleSaveThemePreset({ filePath: flags.file, presetPath, - }) - output(result) + }); + output(result); } -export async function cmdThemeLoad( - args: string[], - flags: GlobalFlags, -): Promise { - const presetPath = args[0] - if (!presetPath) outputError('Usage: op theme:load ') +export async function cmdThemeLoad(args: string[], flags: GlobalFlags): Promise { + const presetPath = args[0]; + if (!presetPath) outputError('Usage: op theme:load '); const result = await handleLoadThemePreset({ filePath: flags.file, presetPath, - }) - output(result) + }); + output(result); } export async function cmdThemeList(args: string[]): Promise { - if (!args[0]) outputError('Usage: op theme:list ') + if (!args[0]) outputError('Usage: op theme:list '); const result = await handleListThemePresets({ directory: args[0], - }) - output(result) + }); + output(result); } diff --git a/apps/cli/src/connection.ts b/apps/cli/src/connection.ts index e9c27eb8..cc655290 100644 --- a/apps/cli/src/connection.ts +++ b/apps/cli/src/connection.ts @@ -1,89 +1,87 @@ /** Port file discovery and app health check. */ -import { readFile, unlink } from 'node:fs/promises' -import { join } from 'node:path' -import { homedir } from 'node:os' +import { readFile, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; -const PORT_FILE_DIR = '.openpencil' -const PORT_FILE_NAME = '.port' -const PORT_FILE_PATH = join(homedir(), PORT_FILE_DIR, PORT_FILE_NAME) -const APP_BASE_URLS = ['http://127.0.0.1', 'http://localhost'] +const PORT_FILE_DIR = '.openpencil'; +const PORT_FILE_NAME = '.port'; +const PORT_FILE_PATH = join(homedir(), PORT_FILE_DIR, PORT_FILE_NAME); +const APP_BASE_URLS = ['http://127.0.0.1', 'http://localhost']; async function getReachableAppUrl(port: number): Promise { for (const baseUrl of APP_BASE_URLS) { - const url = `${baseUrl}:${port}/api/mcp/server` + const url = `${baseUrl}:${port}/api/mcp/server`; for (let attempt = 0; attempt < 5; attempt++) { try { const res = await fetch(url, { signal: AbortSignal.timeout(500), - }) - if (res.ok) return `${baseUrl}:${port}` + }); + if (res.ok) return `${baseUrl}:${port}`; } catch { // App may still be starting, or the port file may be stale. } if (attempt < 4) { - await new Promise((resolve) => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 200)); } } } - return null + return null; } function isPidAlive(pid: number): boolean { try { - process.kill(pid, 0) - return true + process.kill(pid, 0); + return true; } catch (err: unknown) { - return (err as NodeJS.ErrnoException).code === 'EPERM' + return (err as NodeJS.ErrnoException).code === 'EPERM'; } } export interface AppInfo { - port: number - pid: number - timestamp: number - url: string + port: number; + pid: number; + timestamp: number; + url: string; } /** Read port file and return app info, or null if no running instance. */ export async function getAppInfo(): Promise { try { - const raw = await readFile(PORT_FILE_PATH, 'utf-8') + const raw = await readFile(PORT_FILE_PATH, 'utf-8'); const { port, pid, timestamp } = JSON.parse(raw) as { - port: number - pid: number - timestamp: number - } - const url = await getReachableAppUrl(port) + port: number; + pid: number; + timestamp: number; + }; + const url = await getReachableAppUrl(port); if (url) { - return { port, pid, timestamp, url } + return { port, pid, timestamp, url }; } if (!isPidAlive(pid)) { try { - await unlink(PORT_FILE_PATH) + await unlink(PORT_FILE_PATH); } catch { // Ignore stale port file cleanup failures. } - return null + return null; } - return null + return null; } catch { - return null + return null; } } /** Get app URL or throw if not running. */ export async function requireApp(): Promise { - const info = await getAppInfo() + const info = await getAppInfo(); if (!info) { - throw new Error( - 'No running OpenPencil instance found. Run `openpencil start` first.', - ) + throw new Error('No running OpenPencil instance found. Run `openpencil start` first.'); } - return info.url + return info.url; } /** Quick check if app is running. */ export async function isAppRunning(): Promise { - return (await getAppInfo()) !== null + return (await getAppInfo()) !== null; } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index e63df065..27526db4 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,34 +1,34 @@ #!/usr/bin/env node -import pkg from '../package.json' -import { setPretty, output, outputError } from './output' +import pkg from '../package.json'; +import { setPretty, output, outputError } from './output'; // --- Arg parsing --- interface ParsedArgs { - command: string - positionals: string[] - flags: Record + command: string; + positionals: string[]; + flags: Record; } function parseArgs(argv: string[]): ParsedArgs { - const args = argv.slice(2) - const positionals: string[] = [] - const flags: Record = {} + const args = argv.slice(2); + const positionals: string[] = []; + const flags: Record = {}; for (let i = 0; i < args.length; i++) { - const arg = args[i] + const arg = args[i]; if (arg.startsWith('--')) { - const key = arg.slice(2) - const next = args[i + 1] + const key = arg.slice(2); + const next = args[i + 1]; if (next && !next.startsWith('--')) { - flags[key] = next - i++ + flags[key] = next; + i++; } else { - flags[key] = true + flags[key] = true; } } else { - positionals.push(arg) + positionals.push(arg); } } @@ -36,7 +36,7 @@ function parseArgs(argv: string[]): ParsedArgs { command: positionals[0] ?? '', positionals: positionals.slice(1), flags, - } + }; } // --- Help --- @@ -70,9 +70,15 @@ Design: op design:content op design:refine --root-id -Export: - op export [--out file] - Formats: react, html, vue, svelte, flutter, swiftui, compose, rn, css +Read Nodes: + op read-nodes [ids] [--depth N] [--vars] [--page P] [--file F] + ids: comma-separated node IDs (omit to read all) + +Codegen Pipeline: + op codegen:plan + op codegen:submit + op codegen:assemble [--framework react] + op codegen:clean Variables & Themes: op vars Get variables @@ -110,64 +116,63 @@ Global Flags: --pretty Human-readable JSON output --help Show this help --version Show version -` +`; // --- Main --- async function main(): Promise { - const { command, positionals, flags } = parseArgs(process.argv) + const { command, positionals, flags } = parseArgs(process.argv); - if (flags.pretty) setPretty(true) + if (flags.pretty) setPretty(true); if (flags.help || command === 'help') { - process.stdout.write(HELP) - return + process.stdout.write(HELP); + return; } if (flags.version || command === 'version') { - output({ version: pkg.version }) - return + output({ version: pkg.version }); + return; } if (!command) { - process.stdout.write(HELP) - return + process.stdout.write(HELP); + return; } const globalFlags = { file: flags.file as string | undefined, page: flags.page as string | undefined, - } - + }; switch (command) { // --- App --- case 'start': { - const { cmdStart } = await import('./commands/app') - await cmdStart({ desktop: !!flags.desktop, web: !!flags.web }) - break + const { cmdStart } = await import('./commands/app'); + await cmdStart({ desktop: !!flags.desktop, web: !!flags.web }); + break; } case 'stop': { - const { cmdStop } = await import('./commands/app') - await cmdStop() - break + const { cmdStop } = await import('./commands/app'); + await cmdStop(); + break; } case 'status': { - const { cmdStatus } = await import('./commands/app') - await cmdStatus() - break + const { cmdStatus } = await import('./commands/app'); + await cmdStatus(); + break; } // --- Document --- case 'open': { - const { cmdOpen } = await import('./commands/document') - await cmdOpen(positionals, globalFlags) - break + const { cmdOpen } = await import('./commands/document'); + await cmdOpen(positionals, globalFlags); + break; } case 'save': { - const { cmdSave } = await import('./commands/document') - await cmdSave(positionals, globalFlags) - break + const { cmdSave } = await import('./commands/document'); + await cmdSave(positionals, globalFlags); + break; } case 'get': { - const { cmdGet } = await import('./commands/document') + const { cmdGet } = await import('./commands/document'); await cmdGet(positionals, { ...globalFlags, type: flags.type as string | undefined, @@ -175,230 +180,259 @@ async function main(): Promise { id: flags.id as string | undefined, depth: flags.depth as string | undefined, parent: flags.parent as string | undefined, - }) - break + }); + break; } case 'selection': { - const { cmdSelection } = await import('./commands/document') - await cmdSelection({ ...globalFlags, depth: flags.depth as string | undefined }) - break + const { cmdSelection } = await import('./commands/document'); + await cmdSelection({ ...globalFlags, depth: flags.depth as string | undefined }); + break; } // --- Nodes --- case 'insert': { - const { cmdInsert } = await import('./commands/nodes') + const { cmdInsert } = await import('./commands/nodes'); await cmdInsert(positionals, { ...globalFlags, parent: flags.parent as string | undefined, index: flags.index as string | undefined, postProcess: !!flags['post-process'], - }) - break + }); + break; } case 'update': { - const { cmdUpdate } = await import('./commands/nodes') + const { cmdUpdate } = await import('./commands/nodes'); await cmdUpdate(positionals, { ...globalFlags, postProcess: !!flags['post-process'], - }) - break + }); + break; } case 'delete': { - const { cmdDelete } = await import('./commands/nodes') - await cmdDelete(positionals, globalFlags) - break + const { cmdDelete } = await import('./commands/nodes'); + await cmdDelete(positionals, globalFlags); + break; } case 'move': { - const { cmdMove } = await import('./commands/nodes') + const { cmdMove } = await import('./commands/nodes'); await cmdMove(positionals, { ...globalFlags, parent: flags.parent as string | undefined, index: flags.index as string | undefined, - }) - break + }); + break; } case 'copy': { - const { cmdCopy } = await import('./commands/nodes') + const { cmdCopy } = await import('./commands/nodes'); await cmdCopy(positionals, { ...globalFlags, parent: flags.parent as string | undefined, - }) - break + }); + break; } case 'replace': { - const { cmdReplace } = await import('./commands/nodes') + const { cmdReplace } = await import('./commands/nodes'); await cmdReplace(positionals, { ...globalFlags, postProcess: !!flags['post-process'], - }) - break + }); + break; } // --- Design --- case 'design': { - const { cmdDesign } = await import('./commands/design') + const { cmdDesign } = await import('./commands/design'); await cmdDesign(positionals, { ...globalFlags, postProcess: flags['post-process'] !== false ? true : undefined, canvasWidth: flags['canvas-width'] as string | undefined, - }) - break + }); + break; } case 'design:skeleton': { - const { cmdDesignSkeleton } = await import('./commands/design') - await cmdDesignSkeleton(positionals, globalFlags) - break + const { cmdDesignSkeleton } = await import('./commands/design'); + await cmdDesignSkeleton(positionals, globalFlags); + break; } case 'design:content': { - const { cmdDesignContent } = await import('./commands/design') + const { cmdDesignContent } = await import('./commands/design'); await cmdDesignContent(positionals, { ...globalFlags, canvasWidth: flags['canvas-width'] as string | undefined, - }) - break + }); + break; } case 'design:refine': { - const { cmdDesignRefine } = await import('./commands/design') + const { cmdDesignRefine } = await import('./commands/design'); await cmdDesignRefine(positionals, { ...globalFlags, rootId: flags['root-id'] as string | undefined, canvasWidth: flags['canvas-width'] as string | undefined, - }) - break + }); + break; } - // --- Export --- - case 'export': { - const { cmdExport } = await import('./commands/export') - await cmdExport(positionals, { + // --- Read Nodes --- + case 'read-nodes': { + const { cmdReadNodes } = await import('./commands/read-nodes'); + await cmdReadNodes(positionals, { file: globalFlags.file, - out: flags.out as string | undefined, - }) - break + page: globalFlags.page, + depth: flags.depth as string | undefined, + vars: !!flags.vars, + }); + break; + } + + // --- Codegen Pipeline --- + case 'codegen:plan': { + const { cmdCodegenPlan } = await import('./commands/codegen'); + await cmdCodegenPlan(positionals, globalFlags); + break; + } + case 'codegen:submit': { + const { cmdCodegenSubmit } = await import('./commands/codegen'); + await cmdCodegenSubmit(positionals, globalFlags); + break; + } + case 'codegen:assemble': { + const { cmdCodegenAssemble } = await import('./commands/codegen'); + await cmdCodegenAssemble(positionals, { + ...globalFlags, + framework: flags.framework as string | undefined, + }); + break; + } + case 'codegen:clean': { + const { cmdCodegenClean } = await import('./commands/codegen'); + await cmdCodegenClean(positionals, globalFlags); + break; } // --- Variables & Themes --- case 'vars': { - const { cmdVars } = await import('./commands/variables') - await cmdVars(globalFlags) - break + const { cmdVars } = await import('./commands/variables'); + await cmdVars(globalFlags); + break; } case 'vars:set': { - const { cmdVarsSet } = await import('./commands/variables') - await cmdVarsSet(positionals, { ...globalFlags, replace: !!flags.replace }) - break + const { cmdVarsSet } = await import('./commands/variables'); + await cmdVarsSet(positionals, { ...globalFlags, replace: !!flags.replace }); + break; } case 'themes': { - const { cmdThemes } = await import('./commands/variables') - await cmdThemes(globalFlags) - break + const { cmdThemes } = await import('./commands/variables'); + await cmdThemes(globalFlags); + break; } case 'themes:set': { - const { cmdThemesSet } = await import('./commands/variables') - await cmdThemesSet(positionals, { ...globalFlags, replace: !!flags.replace }) - break + const { cmdThemesSet } = await import('./commands/variables'); + await cmdThemesSet(positionals, { ...globalFlags, replace: !!flags.replace }); + break; } case 'theme:save': { - const { cmdThemeSave } = await import('./commands/variables') - await cmdThemeSave(positionals, globalFlags) - break + const { cmdThemeSave } = await import('./commands/variables'); + await cmdThemeSave(positionals, globalFlags); + break; } case 'theme:load': { - const { cmdThemeLoad } = await import('./commands/variables') - await cmdThemeLoad(positionals, globalFlags) - break + const { cmdThemeLoad } = await import('./commands/variables'); + await cmdThemeLoad(positionals, globalFlags); + break; } case 'theme:list': { - const { cmdThemeList } = await import('./commands/variables') - await cmdThemeList(positionals) - break + const { cmdThemeList } = await import('./commands/variables'); + await cmdThemeList(positionals); + break; } // --- Pages --- case 'page': { - const subCmd = positionals[0] - const subArgs = positionals.slice(1) + const subCmd = positionals[0]; + const subArgs = positionals.slice(1); switch (subCmd) { case 'list': { - const { cmdPageList } = await import('./commands/pages') - await cmdPageList(globalFlags) - break + const { cmdPageList } = await import('./commands/pages'); + await cmdPageList(globalFlags); + break; } case 'add': { - const { cmdPageAdd } = await import('./commands/pages') - await cmdPageAdd(subArgs, { ...globalFlags, name: flags.name as string | undefined }) - break + const { cmdPageAdd } = await import('./commands/pages'); + await cmdPageAdd(subArgs, { ...globalFlags, name: flags.name as string | undefined }); + break; } case 'remove': { - const { cmdPageRemove } = await import('./commands/pages') - await cmdPageRemove(subArgs, globalFlags) - break + const { cmdPageRemove } = await import('./commands/pages'); + await cmdPageRemove(subArgs, globalFlags); + break; } case 'rename': { - const { cmdPageRename } = await import('./commands/pages') - await cmdPageRename(subArgs, globalFlags) - break + const { cmdPageRename } = await import('./commands/pages'); + await cmdPageRename(subArgs, globalFlags); + break; } case 'reorder': { - const { cmdPageReorder } = await import('./commands/pages') - await cmdPageReorder(subArgs, globalFlags) - break + const { cmdPageReorder } = await import('./commands/pages'); + await cmdPageReorder(subArgs, globalFlags); + break; } case 'duplicate': { - const { cmdPageDuplicate } = await import('./commands/pages') - await cmdPageDuplicate(subArgs, globalFlags) - break + const { cmdPageDuplicate } = await import('./commands/pages'); + await cmdPageDuplicate(subArgs, globalFlags); + break; } default: - outputError(`Unknown page subcommand: "${subCmd}". Use: list, add, remove, rename, reorder, duplicate`) + outputError( + `Unknown page subcommand: "${subCmd}". Use: list, add, remove, rename, reorder, duplicate`, + ); } - break + break; } // --- Import --- case 'import:svg': { - const { cmdImportSvg } = await import('./commands/import') + const { cmdImportSvg } = await import('./commands/import'); await cmdImportSvg(positionals, { ...globalFlags, parent: flags.parent as string | undefined, - }) - break + }); + break; } case 'import:figma': { - const { cmdImportFigma } = await import('./commands/import') + const { cmdImportFigma } = await import('./commands/import'); await cmdImportFigma(positionals, { ...globalFlags, out: flags.out as string | undefined, - }) - break + }); + break; } // --- Layout --- case 'layout': { - const { cmdLayout } = await import('./commands/layout') + const { cmdLayout } = await import('./commands/layout'); await cmdLayout({ ...globalFlags, parent: flags.parent as string | undefined, depth: flags.depth as string | undefined, - }) - break + }); + break; } case 'find-space': { - const { cmdFindSpace } = await import('./commands/layout') + const { cmdFindSpace } = await import('./commands/layout'); await cmdFindSpace({ ...globalFlags, direction: flags.direction as string | undefined, width: flags.width as string | undefined, height: flags.height as string | undefined, - }) - break + }); + break; } default: - outputError(`Unknown command: "${command}". Run "op --help" for usage.`) + outputError(`Unknown command: "${command}". Run "op --help" for usage.`); } } main().catch((err) => { - outputError(err instanceof Error ? err.message : String(err)) -}) + outputError(err instanceof Error ? err.message : String(err)); +}); diff --git a/apps/cli/src/launcher.ts b/apps/cli/src/launcher.ts index 63ce9a19..92b90dbb 100644 --- a/apps/cli/src/launcher.ts +++ b/apps/cli/src/launcher.ts @@ -1,97 +1,101 @@ /** Start/stop OpenPencil app from the CLI. */ -import { spawn, fork, execSync } from 'node:child_process' -import { createServer } from 'node:net' -import { writeFile, unlink, mkdir } from 'node:fs/promises' -import { join, dirname } from 'node:path' -import { homedir } from 'node:os' -import { existsSync } from 'node:fs' -import { getAppInfo } from './connection' +import { spawn, fork, execSync } from 'node:child_process'; +import { createServer } from 'node:net'; +import { writeFile, unlink, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { existsSync } from 'node:fs'; +import { getAppInfo } from './connection'; -const IS_WIN = process.platform === 'win32' -const PORT_FILE_DIR = join(homedir(), '.openpencil') -const PORT_FILE_PATH = join(PORT_FILE_DIR, '.port') +const IS_WIN = process.platform === 'win32'; +const PORT_FILE_DIR = join(homedir(), '.openpencil'); +const PORT_FILE_PATH = join(PORT_FILE_DIR, '.port'); function getFreePort(): Promise { return new Promise((resolve, reject) => { - const server = createServer() + const server = createServer(); server.listen(0, '127.0.0.1', () => { - const addr = server.address() + const addr = server.address(); if (addr && typeof addr === 'object') { - const { port } = addr - server.close(() => resolve(port)) + const { port } = addr; + server.close(() => resolve(port)); } else { - reject(new Error('Failed to get free port')) + reject(new Error('Failed to get free port')); } - }) - server.on('error', reject) - }) + }); + server.on('error', reject); + }); } async function waitForPortFile(timeoutMs = 15_000): Promise<{ port: number; pid: number }> { - const start = Date.now() + const start = Date.now(); while (Date.now() - start < timeoutMs) { - const info = await getAppInfo() - if (info) return { port: info.port, pid: info.pid } - await new Promise((r) => setTimeout(r, 300)) + const info = await getAppInfo(); + if (info) return { port: info.port, pid: info.pid }; + await new Promise((r) => setTimeout(r, 300)); } - throw new Error('Timeout waiting for OpenPencil to start') + throw new Error('Timeout waiting for OpenPencil to start'); } /** Find the installed desktop app binary. */ function findDesktopBinary(): string | null { - const candidates: string[] = [] + const candidates: string[] = []; if (process.platform === 'darwin') { - candidates.push('/Applications/OpenPencil.app/Contents/MacOS/OpenPencil') + candidates.push('/Applications/OpenPencil.app/Contents/MacOS/OpenPencil'); candidates.push( join(homedir(), 'Applications', 'OpenPencil.app', 'Contents', 'MacOS', 'OpenPencil'), - ) + ); } else if (process.platform === 'win32') { - const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local') - const programFiles = process.env.PROGRAMFILES ?? 'C:\\Program Files' - const programFilesX86 = process.env['PROGRAMFILES(X86)'] ?? 'C:\\Program Files (x86)' + const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'); + const programFiles = process.env.PROGRAMFILES ?? 'C:\\Program Files'; + const programFilesX86 = process.env['PROGRAMFILES(X86)'] ?? 'C:\\Program Files (x86)'; // NSIS per-user install (default) - candidates.push(join(localAppData, 'Programs', 'openpencil', 'OpenPencil.exe')) + candidates.push(join(localAppData, 'Programs', 'openpencil', 'OpenPencil.exe')); // NSIS per-machine install - candidates.push(join(programFiles, 'OpenPencil', 'OpenPencil.exe')) - candidates.push(join(programFilesX86, 'OpenPencil', 'OpenPencil.exe')) + candidates.push(join(programFiles, 'OpenPencil', 'OpenPencil.exe')); + candidates.push(join(programFilesX86, 'OpenPencil', 'OpenPencil.exe')); // Portable — same directory as CLI - candidates.push(join(__dirname, '..', 'OpenPencil.exe')) + candidates.push(join(__dirname, '..', 'OpenPencil.exe')); } else { // Linux — AppImage, deb, snap, flatpak, manual - candidates.push('/usr/bin/openpencil') - candidates.push('/usr/local/bin/openpencil') - candidates.push(join(homedir(), '.local', 'bin', 'openpencil')) + candidates.push('/usr/bin/openpencil'); + candidates.push('/usr/local/bin/openpencil'); + candidates.push(join(homedir(), '.local', 'bin', 'openpencil')); // AppImage in common download locations const appImageDirs = [ join(homedir(), 'Applications'), join(homedir(), 'Downloads'), join(homedir(), '.local', 'share', 'applications'), - ] + ]; for (const dir of appImageDirs) { // Match OpenPencil*.AppImage (version may vary) try { if (existsSync(dir)) { - const files = require('node:fs').readdirSync(dir) as string[] + const files = require('node:fs').readdirSync(dir) as string[]; const appImage = files.find( (f: string) => f.startsWith('OpenPencil') && f.endsWith('.AppImage'), - ) - if (appImage) candidates.push(join(dir, appImage)) + ); + if (appImage) candidates.push(join(dir, appImage)); } - } catch { /* skip */ } + } catch { + /* skip */ + } } // Snap - candidates.push('/snap/bin/openpencil') + candidates.push('/snap/bin/openpencil'); // Flatpak - candidates.push('/var/lib/flatpak/exports/bin/dev.openpencil.app') - candidates.push(join(homedir(), '.local', 'share', 'flatpak', 'exports', 'bin', 'dev.openpencil.app')) + candidates.push('/var/lib/flatpak/exports/bin/dev.openpencil.app'); + candidates.push( + join(homedir(), '.local', 'share', 'flatpak', 'exports', 'bin', 'dev.openpencil.app'), + ); } for (const path of candidates) { - if (existsSync(path)) return path + if (existsSync(path)) return path; } - return null + return null; } /** Find the Nitro server entry relative to CLI's location. */ @@ -102,46 +106,46 @@ function findServerEntry(): string | null { join(__dirname, '..', '..', '..', 'out', 'web', 'server', 'index.mjs'), join(__dirname, '..', '..', 'out', 'web', 'server', 'index.mjs'), join(__dirname, '..', 'server', 'index.mjs'), // when bundled in Electron resources - ] + ]; for (const path of candidates) { - if (existsSync(path)) return path + if (existsSync(path)) return path; } - return null + return null; } export async function startDesktop(): Promise<{ port: number; pid: number }> { - const info = await getAppInfo() - if (info) return { port: info.port, pid: info.pid } + const info = await getAppInfo(); + if (info) return { port: info.port, pid: info.pid }; - const binary = findDesktopBinary() + const binary = findDesktopBinary(); if (!binary) { throw new Error( 'OpenPencil desktop app not found. Install it or use `op start --web` for the web server.', - ) + ); } const child = spawn(binary, [], { detached: true, stdio: 'ignore', ...(IS_WIN ? { windowsHide: true, shell: false } : {}), - }) - child.unref() + }); + child.unref(); - return waitForPortFile() + return waitForPortFile(); } export async function startWeb(): Promise<{ port: number; pid: number }> { - const info = await getAppInfo() - if (info) return { port: info.port, pid: info.pid } + const info = await getAppInfo(); + if (info) return { port: info.port, pid: info.pid }; - const entry = findServerEntry() + const entry = findServerEntry(); if (!entry) { throw new Error( 'Nitro server not found. Run `bun run build` first, or use `op start --desktop`.', - ) + ); } - const port = await getFreePort() + const port = await getFreePort(); const child = fork(entry, [], { detached: true, @@ -154,45 +158,47 @@ export async function startWeb(): Promise<{ port: number; pid: number }> { NITRO_HOST: '127.0.0.1', NITRO_PORT: String(port), }, - }) - child.unref() + }); + child.unref(); // Write port file (the Nitro plugin also writes it, but write early for faster discovery) - await mkdir(PORT_FILE_DIR, { recursive: true }) + await mkdir(PORT_FILE_DIR, { recursive: true }); await writeFile( PORT_FILE_PATH, JSON.stringify({ port, pid: child.pid, timestamp: Date.now() }), 'utf-8', - ) + ); - return { port, pid: child.pid! } + return { port, pid: child.pid! }; } export async function stopApp(): Promise { - const info = await getAppInfo() - if (!info) return false + const info = await getAppInfo(); + if (!info) return false; try { if (IS_WIN) { // Windows: SIGTERM is not supported, use taskkill for graceful shutdown - execSync(`taskkill /PID ${info.pid}`, { stdio: 'ignore' }) + execSync(`taskkill /PID ${info.pid}`, { stdio: 'ignore' }); } else { - process.kill(info.pid, 'SIGTERM') + process.kill(info.pid, 'SIGTERM'); } } catch { // already dead — try force kill on Windows if (IS_WIN) { try { - execSync(`taskkill /F /PID ${info.pid}`, { stdio: 'ignore' }) - } catch { /* ignore */ } + execSync(`taskkill /F /PID ${info.pid}`, { stdio: 'ignore' }); + } catch { + /* ignore */ + } } } try { - await unlink(PORT_FILE_PATH) + await unlink(PORT_FILE_PATH); } catch { // ignore } - return true + return true; } diff --git a/apps/cli/src/output.ts b/apps/cli/src/output.ts index 672c4883..131292dd 100644 --- a/apps/cli/src/output.ts +++ b/apps/cli/src/output.ts @@ -1,37 +1,33 @@ /** Output formatting and process exit helpers for the CLI. */ -import { readFile } from 'node:fs/promises' +import { readFile } from 'node:fs/promises'; -let prettyMode = false +let prettyMode = false; export function setPretty(v: boolean): void { - prettyMode = v + prettyMode = v; } export function output(data: unknown): void { - const json = prettyMode - ? JSON.stringify(data, null, 2) - : JSON.stringify(data) - process.stdout.write(json + '\n') + const json = prettyMode ? JSON.stringify(data, null, 2) : JSON.stringify(data); + process.stdout.write(json + '\n'); } export function outputSuccess(data: Record = {}): void { - output({ ok: true, ...data }) + output({ ok: true, ...data }); } export function outputError(message: string, code = 1): never { - process.stderr.write( - JSON.stringify({ error: message }) + '\n', - ) - process.exit(code) + process.stderr.write(JSON.stringify({ error: message }) + '\n'); + process.exit(code); } /** Read all of stdin when not a TTY (piped input). */ export async function readStdin(): Promise { - if (process.stdin.isTTY) return '' - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) chunks.push(chunk as Buffer) - return Buffer.concat(chunks).toString('utf-8') + if (process.stdin.isTTY) return ''; + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString('utf-8'); } /** @@ -43,31 +39,31 @@ export async function readStdin(): Promise { */ export async function resolveArg(arg: string | undefined): Promise { if (arg === '-') { - const stdin = await readStdin() - if (!stdin.trim()) outputError('No data received from stdin.') - return stdin.trim() + const stdin = await readStdin(); + if (!stdin.trim()) outputError('No data received from stdin.'); + return stdin.trim(); } if (arg && arg.startsWith('@')) { - const filePath = arg.slice(1) + const filePath = arg.slice(1); try { - return (await readFile(filePath, 'utf-8')).trim() + return (await readFile(filePath, 'utf-8')).trim(); } catch { - outputError(`Cannot read file: ${filePath}`) + outputError(`Cannot read file: ${filePath}`); } } - if (arg) return arg + if (arg) return arg; // No explicit arg — try stdin if piped - const stdin = await readStdin() - if (stdin.trim()) return stdin.trim() - outputError('No data provided. Pass as argument, @filepath, or pipe via stdin.') + const stdin = await readStdin(); + if (stdin.trim()) return stdin.trim(); + outputError('No data provided. Pass as argument, @filepath, or pipe via stdin.'); } /** Parse JSON from a CLI positional arg, @filepath, or stdin. */ export async function parseJsonArg(arg: string | undefined): Promise { - const raw = await resolveArg(arg) + const raw = await resolveArg(arg); try { - return JSON.parse(raw) + return JSON.parse(raw); } catch { - outputError(`Invalid JSON: ${raw.slice(0, 200)}...`) + outputError(`Invalid JSON: ${raw.slice(0, 200)}...`); } } diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 137a94bb..8beebe39 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -1,19 +1,11 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, "esModuleInterop": true, - "skipLibCheck": true, "resolveJsonModule": true, "declaration": false, "outDir": "dist", - "rootDir": "src", - "paths": { - "@/*": ["../../apps/web/src/*"] - } + "rootDir": "src" }, - "include": ["src/**/*.ts"], - "references": [] + "include": ["src/**/*.ts"] } diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md index cacb0697..79278559 100644 --- a/apps/desktop/CLAUDE.md +++ b/apps/desktop/CLAUDE.md @@ -29,6 +29,7 @@ BUILD_TARGET=electron bun run build ## File Association `.op` files are registered as OpenPencil documents via `fileAssociations` in `electron-builder.yml`: + - macOS: `open-file` app event handles double-click/drag - Windows/Linux: `requestSingleInstanceLock` + `second-instance` event forwards CLI args to existing window diff --git a/apps/desktop/__tests__/dev-utils.test.ts b/apps/desktop/__tests__/dev-utils.test.ts new file mode 100644 index 00000000..cdd23657 --- /dev/null +++ b/apps/desktop/__tests__/dev-utils.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'bun:test'; + +import { + getDevServerConflictMessage, + getElectronBinaryPath, + getElectronSpawnEnv, +} from '../dev-utils'; + +describe('getElectronBinaryPath', () => { + it('uses the packaged electron.exe inside dist on Windows', () => { + expect(getElectronBinaryPath('C:/repo', 'win32')).toBe( + 'C:/repo/node_modules/electron/dist/electron.exe', + ); + }); + + it('uses the .bin shim on non-Windows platforms', () => { + expect(getElectronBinaryPath('/repo', 'darwin')).toBe('/repo/node_modules/.bin/electron'); + }); + + it('removes ELECTRON_RUN_AS_NODE when launching Electron', () => { + const env = getElectronSpawnEnv({ + PATH: 'x', + ELECTRON_RUN_AS_NODE: '1', + FOO: 'bar', + }); + + expect(env).toEqual({ + PATH: 'x', + FOO: 'bar', + }); + }); + + it('detects when port 3000 is occupied by a non-Vite server', () => { + expect( + getDevServerConflictMessage( + { + baseReachable: true, + viteClientReachable: false, + viteClientStatus: 404, + }, + 3000, + ), + ).toContain('Port 3000 is responding'); + }); + + it('does not report a conflict when the Vite client is reachable', () => { + expect( + getDevServerConflictMessage( + { + baseReachable: true, + viteClientReachable: true, + viteClientStatus: 200, + }, + 3000, + ), + ).toBeNull(); + }); +}); diff --git a/apps/desktop/__tests__/unsaved-changes-dialog.test.ts b/apps/desktop/__tests__/unsaved-changes-dialog.test.ts new file mode 100644 index 00000000..804ace2c --- /dev/null +++ b/apps/desktop/__tests__/unsaved-changes-dialog.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'bun:test'; + +import { + buildUnsavedChangesDialogOptions, + mapUnsavedChangesResponse, +} from '../unsaved-changes-dialog'; + +describe('unsaved changes dialog helpers', () => { + it('builds a three-button yes/no/cancel dialog', () => { + expect( + buildUnsavedChangesDialogOptions({ + yesLabel: '是', + noLabel: '否', + cancelLabel: '取消', + message: '关闭前是否要保存更改?', + detail: '如果不保存,您的更改将会丢失。', + }), + ).toMatchObject({ + type: 'question', + buttons: ['是', '否', '取消'], + defaultId: 0, + cancelId: 2, + message: '关闭前是否要保存更改?', + detail: '如果不保存,您的更改将会丢失。', + }); + }); + + it('maps button responses to save/discard/cancel actions', () => { + expect(mapUnsavedChangesResponse(0)).toBe('save'); + expect(mapUnsavedChangesResponse(1)).toBe('discard'); + expect(mapUnsavedChangesResponse(2)).toBe('cancel'); + expect(mapUnsavedChangesResponse(99)).toBe('cancel'); + }); +}); diff --git a/apps/desktop/app-menu.ts b/apps/desktop/app-menu.ts index 66ca811f..5ecce3a1 100644 --- a/apps/desktop/app-menu.ts +++ b/apps/desktop/app-menu.ts @@ -1,12 +1,32 @@ -import { app, BrowserWindow, Menu } from 'electron' +import { app, BrowserWindow, Menu } from 'electron'; function sendMenuAction(action: string): void { - const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] - win?.webContents.send('menu:action', action) + const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + win?.webContents.send('menu:action', action); +} + +/** Recent files submenu — rebuilt each time the menu opens. */ +function buildRecentFilesSubmenu(): Electron.MenuItemConstructorOptions[] { + const recent = app.isReady() ? ((global as any).__recentFiles ?? []) : []; + if (recent.length === 0) { + return [{ label: 'No Recent Files', enabled: false }]; + } + const items: Electron.MenuItemConstructorOptions[] = recent.map( + (entry: { fileName: string; filePath: string }) => ({ + label: entry.fileName, + click: () => sendMenuAction(`open-recent:${entry.filePath}`), + }), + ); + items.push({ type: 'separator' }); + items.push({ + label: 'Clear Recent Files', + click: () => sendMenuAction('clear-recent-files'), + }); + return items; } export function buildAppMenu(): void { - const isMac = process.platform === 'darwin' + const isMac = process.platform === 'darwin'; const template: Electron.MenuItemConstructorOptions[] = [ // macOS app menu @@ -43,21 +63,44 @@ export function buildAppMenu(): void { accelerator: 'CmdOrCtrl+O', click: () => sendMenuAction('open'), }, + { + label: 'Open Recent', + submenu: buildRecentFilesSubmenu(), + }, { type: 'separator' }, { label: 'Save', accelerator: 'CmdOrCtrl+S', click: () => sendMenuAction('save'), }, + { + label: 'Save As\u2026', + accelerator: 'CmdOrCtrl+Shift+S', + click: () => sendMenuAction('save-as'), + }, + { type: 'separator' }, + { + label: 'Export Image\u2026', + // Use Cmd+Shift+P (P = Print/PDF/Picture). Cmd+Shift+E was being + // swallowed at the OS level by Chinese IMEs / system tools on + // macOS before reaching the renderer. + // + // `registerAccelerator: false` keeps the hint visible in the menu + // but tells Electron NOT to register it with the OS — the + // keystroke is handled by the renderer's capture-phase document + // keydown listener in editor-layout.tsx, which avoids HMR/IPC + // fragility. + accelerator: 'CmdOrCtrl+Shift+P', + registerAccelerator: false, + click: () => sendMenuAction('export-image'), + }, { type: 'separator' }, { label: 'Import Figma\u2026', accelerator: 'CmdOrCtrl+Shift+F', click: () => sendMenuAction('import-figma'), }, - ...(!isMac - ? [{ type: 'separator' as const }, { role: 'quit' as const }] - : []), + ...(!isMac ? [{ type: 'separator' as const }, { role: 'quit' as const }] : []), ], }, @@ -88,10 +131,15 @@ export function buildAppMenu(): void { { label: 'View', submenu: [ - { role: 'reload' }, - { role: 'forceReload' }, - { role: 'toggleDevTools' }, - { type: 'separator' }, + // Reload / Force Reload / DevTools are dev-only — hide in packaged builds. + ...(app.isPackaged + ? [] + : [ + { role: 'reload' as const }, + { role: 'forceReload' as const }, + { role: 'toggleDevTools' as const }, + { type: 'separator' as const }, + ]), { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, @@ -107,14 +155,11 @@ export function buildAppMenu(): void { { role: 'minimize' }, { role: 'zoom' }, ...(isMac - ? [ - { type: 'separator' as const }, - { role: 'front' as const }, - ] + ? [{ type: 'separator' as const }, { role: 'front' as const }] : [{ role: 'close' as const }]), ], }, - ] + ]; - Menu.setApplicationMenu(Menu.buildFromTemplate(template)) + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); } diff --git a/apps/desktop/auto-updater.ts b/apps/desktop/auto-updater.ts index 2a75769d..29664b50 100644 --- a/apps/desktop/auto-updater.ts +++ b/apps/desktop/auto-updater.ts @@ -1,12 +1,12 @@ -import { app, BrowserWindow } from 'electron' -import { autoUpdater } from 'electron-updater' -import type { NsisUpdater } from 'electron-updater' -import { GitHubProvider } from 'electron-updater/out/providers/GitHubProvider' -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' -import { GITHUB_OWNER, GITHUB_REPO } from './constants' +import { app, BrowserWindow } from 'electron'; +import { autoUpdater } from 'electron-updater'; +import type { NsisUpdater } from 'electron-updater'; +import { GitHubProvider } from 'electron-updater/out/providers/GitHubProvider'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { GITHUB_OWNER, GITHUB_REPO } from './constants'; -const execFileAsync = promisify(execFile) +const execFileAsync = promisify(execFile); // --------------------------------------------------------------------------- // Types @@ -20,64 +20,64 @@ export type UpdaterStatus = | 'downloading' | 'downloaded' | 'not-available' - | 'error' + | 'error'; export interface UpdaterState { - status: UpdaterStatus - currentVersion: string - latestVersion?: string - downloadProgress?: number - releaseDate?: string - error?: string + status: UpdaterStatus; + currentVersion: string; + latestVersion?: string; + downloadProgress?: number; + releaseDate?: string; + error?: string; } // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- -const isDev = !app.isPackaged +const isDev = !app.isPackaged; let updaterState: UpdaterState = { status: isDev ? 'disabled' : 'idle', currentVersion: app.getVersion(), -} +}; -let autoUpdateEnabled = true -let updateCheckTimer: ReturnType | null = null -let lastUpdateCheckAt = 0 +let autoUpdateEnabled = true; +let updateCheckTimer: ReturnType | null = null; +let lastUpdateCheckAt = 0; const MacGitHubUpdateProvider = class { constructor(options: unknown, updater: unknown, runtimeOptions: unknown) { - const provider = new (GitHubProvider as any)(options, updater, runtimeOptions) as any + const provider = new (GitHubProvider as any)(options, updater, runtimeOptions) as any; if (process.platform === 'darwin') { provider.getDefaultChannelName = () => - process.arch === 'arm64' ? 'latest-mac-arm64' : 'latest-mac' - provider.getCustomChannelName = (channel: string) => channel + process.arch === 'arm64' ? 'latest-mac-arm64' : 'latest-mac'; + provider.getCustomChannelName = (channel: string) => channel; } - return provider + return provider; } -} +}; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- export function getUpdaterState(): UpdaterState { - return updaterState + return updaterState; } export function getAutoUpdateEnabled(): boolean { - return autoUpdateEnabled + return autoUpdateEnabled; } export function setAutoUpdateEnabled(enabled: boolean): void { - autoUpdateEnabled = enabled + autoUpdateEnabled = enabled; } export function broadcastUpdaterState(): void { for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) { - win.webContents.send('updater:state', updaterState) + win.webContents.send('updater:state', updaterState); } } } @@ -87,48 +87,51 @@ export function setUpdaterState(next: Partial): void { ...updaterState, ...next, currentVersion: app.getVersion(), - } - broadcastUpdaterState() + }; + broadcastUpdaterState(); } export async function checkForAppUpdates(force = false): Promise { - if (isDev) return + if (isDev) return; - const now = Date.now() + const now = Date.now(); if (!force && now - lastUpdateCheckAt < 60 * 1000) { - return + return; } - lastUpdateCheckAt = now + lastUpdateCheckAt = now; try { - await autoUpdater.checkForUpdates() + await autoUpdater.checkForUpdates(); } catch (err) { - const error = err instanceof Error ? err.message : String(err) - setUpdaterState({ status: 'error', error }) + const error = err instanceof Error ? err.message : String(err); + setUpdaterState({ status: 'error', error }); } } export function clearUpdateTimer(): void { if (updateCheckTimer) { - clearInterval(updateCheckTimer) - updateCheckTimer = null + clearInterval(updateCheckTimer); + updateCheckTimer = null; } } export function startUpdateTimer(): void { - if (updateCheckTimer) return - updateCheckTimer = setInterval(() => { - void checkForAppUpdates(false) - }, 60 * 60 * 1000) - updateCheckTimer.unref() + if (updateCheckTimer) return; + updateCheckTimer = setInterval( + () => { + void checkForAppUpdates(false); + }, + 60 * 60 * 1000, + ); + updateCheckTimer.unref(); } export function quitAndInstall(): boolean { if (!isDev && updaterState.status === 'downloaded') { - autoUpdater.quitAndInstall() - return true + autoUpdater.quitAndInstall(); + return true; } - return false + return false; } // --------------------------------------------------------------------------- @@ -136,7 +139,7 @@ export function quitAndInstall(): boolean { // --------------------------------------------------------------------------- export function setupAutoUpdater(): void { - if (isDev) return + if (isDev) return; if (process.platform === 'darwin') { // macOS needs a custom provider to select arm64 vs x64 channel @@ -146,7 +149,7 @@ export function setupAutoUpdater(): void { owner: GITHUB_OWNER, repo: GITHUB_REPO, releaseType: 'release', - } as any) + } as any); } else { // Windows/Linux: use standard GitHub provider (reads from electron-builder.yml publish config) autoUpdater.setFeedURL({ @@ -154,12 +157,12 @@ export function setupAutoUpdater(): void { owner: GITHUB_OWNER, repo: GITHUB_REPO, releaseType: 'release', - }) + }); } - autoUpdater.autoDownload = true - autoUpdater.autoInstallOnAppQuit = true - autoUpdater.allowPrerelease = true + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.allowPrerelease = true; // Windows: custom signature verification for self-signed certificate. // The default verifier requires the cert to be in the Windows trusted root @@ -167,38 +170,44 @@ export function setupAutoUpdater(): void { // the publisher name from the Authenticode signature — it just skips the // trust chain check. This is NOT disabling verification. if (process.platform === 'win32') { - const nsisUpdater = autoUpdater as NsisUpdater + const nsisUpdater = autoUpdater as NsisUpdater; nsisUpdater.verifyUpdateCodeSignature = async ( publisherNames: string[], tempUpdateFile: string, ): Promise => { try { - const { stdout } = await execFileAsync('powershell.exe', [ - '-NoProfile', '-NonInteractive', '-Command', - `(Get-AuthenticodeSignature '${tempUpdateFile.replace(/'/g, "''")}').SignerCertificate.Subject`, - ], { timeout: 30_000 }) + const { stdout } = await execFileAsync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `(Get-AuthenticodeSignature '${tempUpdateFile.replace(/'/g, "''")}').SignerCertificate.Subject`, + ], + { timeout: 30_000 }, + ); - const subject = stdout.trim() + const subject = stdout.trim(); if (!subject) { - return 'The update file is not signed.' + return 'The update file is not signed.'; } for (const name of publisherNames) { if (subject.includes(name)) { - return null // Publisher name matches — verification passed + return null; // Publisher name matches — verification passed } } - return `Publisher mismatch. Expected: ${publisherNames.join(', ')}. Got: ${subject}` + return `Publisher mismatch. Expected: ${publisherNames.join(', ')}. Got: ${subject}`; } catch (err) { - return `Signature verification failed: ${err instanceof Error ? err.message : String(err)}` + return `Signature verification failed: ${err instanceof Error ? err.message : String(err)}`; } - } + }; } autoUpdater.on('checking-for-update', () => { - setUpdaterState({ status: 'checking', error: undefined, downloadProgress: undefined }) - }) + setUpdaterState({ status: 'checking', error: undefined, downloadProgress: undefined }); + }); autoUpdater.on('update-available', (info) => { setUpdaterState({ @@ -206,16 +215,16 @@ export function setupAutoUpdater(): void { latestVersion: info.version, releaseDate: info.releaseDate, error: undefined, - }) - }) + }); + }); autoUpdater.on('download-progress', (progress) => { setUpdaterState({ status: 'downloading', downloadProgress: Math.round(progress.percent), error: undefined, - }) - }) + }); + }); autoUpdater.on('update-downloaded', (info) => { setUpdaterState({ @@ -224,8 +233,8 @@ export function setupAutoUpdater(): void { releaseDate: info.releaseDate, downloadProgress: 100, error: undefined, - }) - }) + }); + }); autoUpdater.on('update-not-available', (info) => { setUpdaterState({ @@ -233,22 +242,22 @@ export function setupAutoUpdater(): void { latestVersion: info.version, downloadProgress: undefined, error: undefined, - }) - }) + }); + }); autoUpdater.on('error', (err) => { setUpdaterState({ status: 'error', error: err?.message ?? String(err), - }) - }) + }); + }); if (autoUpdateEnabled) { // Delay first check until app startup work is done. setTimeout(() => { - void checkForAppUpdates(true) - }, 5000) + void checkForAppUpdates(true); + }, 5000); - startUpdateTimer() + startUpdateTimer(); } } diff --git a/apps/desktop/constants.ts b/apps/desktop/constants.ts index 605efdd5..33b2552d 100644 --- a/apps/desktop/constants.ts +++ b/apps/desktop/constants.ts @@ -3,30 +3,30 @@ */ // GitHub publish target — used by auto-updater feed URL -export const GITHUB_OWNER = 'ZSeven-W' -export const GITHUB_REPO = 'openpencil' +export const GITHUB_OWNER = 'ZSeven-W'; +export const GITHUB_REPO = 'openpencil'; // Port file for MCP sync discovery -export const PORT_FILE_DIR_NAME = '.openpencil' -export const PORT_FILE_NAME = '.port' +export const PORT_FILE_DIR_NAME = '.openpencil'; +export const PORT_FILE_NAME = '.port'; // Dev server -export const VITE_DEV_PORT = 3000 +export const VITE_DEV_PORT = 3000; // Window defaults -export const WINDOW_WIDTH = 1440 -export const WINDOW_HEIGHT = 900 -export const WINDOW_MIN_WIDTH = 1024 -export const WINDOW_MIN_HEIGHT = 600 -export const TITLEBAR_OVERLAY_HEIGHT = 36 -export const MACOS_TRAFFIC_LIGHT_POSITION = { x: 16, y: 11 } +export const WINDOW_WIDTH = 1440; +export const WINDOW_HEIGHT = 900; +export const WINDOW_MIN_WIDTH = 1024; +export const WINDOW_MIN_HEIGHT = 600; +export const TITLEBAR_OVERLAY_HEIGHT = 36; +export const MACOS_TRAFFIC_LIGHT_POSITION = { x: 16, y: 11 }; // CSS padding for window controls (px) -export const MACOS_TRAFFIC_LIGHT_PAD = 74 -export const WIN_CONTROLS_PAD = 140 -export const LINUX_CONTROLS_PAD = 140 +export const MACOS_TRAFFIC_LIGHT_PAD = 74; +export const WIN_CONTROLS_PAD = 140; +export const LINUX_CONTROLS_PAD = 140; // Nitro server -export const NITRO_HOST = '127.0.0.1' -export const NITRO_FALLBACK_TIMEOUT_WIN = 6000 -export const NITRO_FALLBACK_TIMEOUT_DEFAULT = 3000 +export const NITRO_HOST = '127.0.0.1'; +export const NITRO_FALLBACK_TIMEOUT_WIN = 6000; +export const NITRO_FALLBACK_TIMEOUT_DEFAULT = 3000; diff --git a/apps/desktop/dev-utils.ts b/apps/desktop/dev-utils.ts new file mode 100644 index 00000000..e537c79f --- /dev/null +++ b/apps/desktop/dev-utils.ts @@ -0,0 +1,45 @@ +import { join } from 'node:path'; + +export function getElectronBinaryPath( + root: string, + platform: NodeJS.Platform = process.platform, +): string { + if (platform === 'win32') { + return toForwardSlashes(join(root, 'node_modules', 'electron', 'dist', 'electron.exe')); + } + + return toForwardSlashes(join(root, 'node_modules', '.bin', 'electron')); +} + +function toForwardSlashes(path: string): string { + return path.replace(/\\/g, '/'); +} + +export function getElectronSpawnEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const next = { ...env }; + delete next.ELECTRON_RUN_AS_NODE; + return next; +} + +interface DevServerProbeResult { + baseReachable: boolean; + viteClientReachable: boolean; + viteClientStatus: number | null; +} + +export function getDevServerConflictMessage( + probe: DevServerProbeResult, + port: number, +): string | null { + if (probe.viteClientReachable) return null; + if (probe.baseReachable && probe.viteClientStatus === 404) { + return [ + `Port ${port} is responding, but it is not serving the Vite dev client.`, + 'A stale production server is likely still running on that port', + '(for example `bun run ./out/web/server/index.mjs`).', + 'Stop it and retry the Electron dev launcher.', + ].join(' '); + } + + return null; +} diff --git a/apps/desktop/dev.ts b/apps/desktop/dev.ts index bc51c916..1fa6219c 100644 --- a/apps/desktop/dev.ts +++ b/apps/desktop/dev.ts @@ -7,34 +7,101 @@ * 4. Launch Electron pointing at the dev server */ -import { spawn, execSync, type ChildProcess } from 'node:child_process' -import { build } from 'esbuild' -import { join } from 'node:path' -import { compileSkills } from '../../packages/pen-ai-skills/vite-plugin-skills' +import { spawn, execSync, type ChildProcess } from 'node:child_process'; +import { build } from 'esbuild'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { compileSkills } from '../../packages/pen-ai-skills/vite-plugin-skills'; +import { + getDevServerConflictMessage, + getElectronBinaryPath, + getElectronSpawnEnv, +} from './dev-utils'; -const DESKTOP_DIR = import.meta.dirname -const ROOT = join(DESKTOP_DIR, '..', '..') -const VITE_DEV_PORT = 3000 +const DESKTOP_DIR = import.meta.dirname; +const ROOT = join(DESKTOP_DIR, '..', '..'); +const WEB_DIR = join(ROOT, 'apps', 'web'); +const VITE_DEV_PORT = 3000; +const GENERATED_SKILL_REGISTRY = join( + ROOT, + 'packages', + 'pen-ai-skills', + 'src', + '_generated', + 'skill-registry.ts', +); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -async function waitForServer( - url: string, +async function waitForViteServer( + baseUrl: string, + vite: ChildProcess, + port: number, timeoutMs = 30_000, ): Promise { - const start = Date.now() + const start = Date.now(); + let viteExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + const handleExit = (code: number | null, signal: NodeJS.Signals | null) => { + viteExit = { code, signal }; + }; + + vite.once('exit', handleExit); while (Date.now() - start < timeoutMs) { + let baseReachable = false; + let viteClientReachable = false; + let viteClientStatus: number | null = null; + try { - const res = await fetch(url) - if (res.ok || res.status < 500) return + const res = await fetch(baseUrl, { + signal: AbortSignal.timeout(500), + }); + baseReachable = res.ok || res.status < 500; } catch { // server not ready yet } - await new Promise((r) => setTimeout(r, 500)) + + try { + const res = await fetch(`${baseUrl}/@vite/client`, { + signal: AbortSignal.timeout(500), + }); + viteClientStatus = res.status; + viteClientReachable = res.ok; + if (viteClientReachable) { + vite.off('exit', handleExit); + return; + } + } catch { + // Vite client not ready yet. + } + + const conflict = getDevServerConflictMessage( + { + baseReachable, + viteClientReachable, + viteClientStatus, + }, + port, + ); + if (conflict) { + vite.off('exit', handleExit); + throw new Error(conflict); + } + + if (viteExit) { + vite.off('exit', handleExit); + const detail = viteExit.signal + ? `signal ${viteExit.signal}` + : `exit code ${viteExit.code ?? 'unknown'}`; + throw new Error(`Vite dev server exited before becoming ready (${detail}).`); + } + + await new Promise((r) => setTimeout(r, 500)); } - throw new Error(`Timeout waiting for ${url}`) + + vite.off('exit', handleExit); + throw new Error(`Timeout waiting for Vite dev server on ${baseUrl}`); } async function compileElectron(): Promise { @@ -47,7 +114,7 @@ async function compileElectron(): Promise { outdir: join(ROOT, 'out', 'desktop'), outExtension: { '.js': '.cjs' }, format: 'cjs' as const, - } + }; await Promise.all([ build({ @@ -58,9 +125,9 @@ async function compileElectron(): Promise { ...common, entryPoints: [join(DESKTOP_DIR, 'preload.ts')], }), - ]) + ]); - console.log('[electron-dev] Electron files compiled') + console.log('[electron-dev] Electron files compiled'); } // --------------------------------------------------------------------------- @@ -69,73 +136,100 @@ async function compileElectron(): Promise { async function main(): Promise { // 1. Start Vite dev server - console.log('[electron-dev] Starting Vite dev server...') - const vite = spawn('bun', ['--bun', 'run', 'dev'], { - cwd: ROOT, + console.log('[electron-dev] Starting Vite dev server...'); + // Launch Vite directly on Windows. Spawning through `bun run dev` can tear + // down the inner `vite.exe` process after startup, leaving Electron with a + // ready log but no live dev server to connect to. + const vite = spawn('bun', ['--bun', 'vite', 'dev', '--port', String(VITE_DEV_PORT)], { + cwd: WEB_DIR, stdio: 'inherit', env: { ...process.env }, - }) + }); + + const stopVite = () => { + if (process.platform === 'win32' && vite.pid) { + try { + execSync(`taskkill /pid ${vite.pid} /T /F`, { stdio: 'ignore' }); + } catch { + /* ignore */ + } + return; + } + + vite.kill(); + }; // Ensure cleanup on exit const cleanup = () => { - if (process.platform === 'win32' && vite.pid) { - try { - execSync(`taskkill /pid ${vite.pid} /T /F`, { stdio: 'ignore' }) - } catch { /* ignore */ } - } else { - vite.kill() - } - process.exit() - } - process.on('SIGINT', cleanup) - process.on('SIGTERM', cleanup) + stopVite(); + process.exit(); + }; + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); // 2. Wait for Vite to be ready - console.log(`[electron-dev] Waiting for Vite on port ${VITE_DEV_PORT}...`) - await waitForServer(`http://localhost:${VITE_DEV_PORT}`) - console.log('[electron-dev] Vite is ready') + console.log(`[electron-dev] Waiting for Vite on port ${VITE_DEV_PORT}...`); + try { + await waitForViteServer(`http://localhost:${VITE_DEV_PORT}`, vite, VITE_DEV_PORT); + } catch (error) { + stopVite(); + throw error; + } + console.log('[electron-dev] Vite is ready'); // 3. Compile MCP server + Electron files - compileSkills(join(ROOT, 'packages', 'pen-ai-skills')) - console.log('[electron-dev] Compiling MCP server...') + try { + compileSkills(join(ROOT, 'packages', 'pen-ai-skills')); + } catch (err) { + if (!existsSync(GENERATED_SKILL_REGISTRY)) { + throw err; + } + console.warn('[electron-dev] Skill registry refresh failed, using existing generated registry'); + console.warn(err); + } + console.log('[electron-dev] Compiling MCP server...'); await build({ platform: 'node', bundle: true, sourcemap: true, target: 'node20', format: 'cjs', - entryPoints: [join(ROOT, 'apps', 'web', 'src', 'mcp', 'server.ts')], + entryPoints: [join(ROOT, 'packages', 'pen-mcp', 'src', 'server.ts')], outfile: join(ROOT, 'out', 'mcp-server.cjs'), - alias: { '@': join(ROOT, 'apps', 'web', 'src') }, + alias: { + '@zseven-w/pen-types': join(ROOT, 'packages', 'pen-types', 'src'), + '@zseven-w/pen-core': join(ROOT, 'packages', 'pen-core', 'src'), + '@zseven-w/pen-figma': join(ROOT, 'packages', 'pen-figma', 'src'), + '@zseven-w/pen-renderer': join(ROOT, 'packages', 'pen-renderer', 'src'), + '@zseven-w/pen-sdk': join(ROOT, 'packages', 'pen-sdk', 'src'), + '@zseven-w/pen-ai-skills': join(ROOT, 'packages', 'pen-ai-skills', 'src'), + '@zseven-w/pen-mcp': join(ROOT, 'packages', 'pen-mcp', 'src'), + '@zseven-w/pen-engine': join(ROOT, 'packages', 'pen-engine', 'src'), + '@zseven-w/pen-react': join(ROOT, 'packages', 'pen-react', 'src'), + }, define: { 'import.meta.env': '{}' }, external: ['canvas', 'paper'], - }) - console.log('[electron-dev] MCP server compiled') + }); + console.log('[electron-dev] MCP server compiled'); - await compileElectron() + await compileElectron(); // 4. Launch Electron - console.log('[electron-dev] Starting Electron...') - const electronBin = join(ROOT, 'node_modules', '.bin', 'electron') - const electron = spawn(electronBin, [join(ROOT, 'out', 'desktop', 'main.cjs')], { + console.log('[electron-dev] Starting Electron...'); + const electronBin = getElectronBinaryPath(ROOT); + const electron = spawn(electronBin, [ROOT], { cwd: ROOT, stdio: 'inherit', - env: { ...process.env }, - }) as ChildProcess + env: getElectronSpawnEnv(process.env), + }) as ChildProcess; electron.on('exit', () => { - if (process.platform === 'win32' && vite.pid) { - try { - execSync(`taskkill /pid ${vite.pid} /T /F`, { stdio: 'ignore' }) - } catch { /* ignore */ } - } else { - vite.kill() - } - process.exit() - }) + stopVite(); + process.exit(); + }); } main().catch((err) => { - console.error(err) - process.exit(1) -}) + console.error(err); + process.exit(1); +}); diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 892ffb57..f5fa1b32 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -9,8 +9,8 @@ directories: files: - package.json - out/desktop/**/* - - "!**/node_modules/**" - - "!**/.git/**" + - '!**/node_modules/**' + - '!**/.git/**' extraResources: - from: out/web/server @@ -25,7 +25,7 @@ extraResources: mac: category: public.app-category.graphics-design icon: apps/desktop/build/icon.icns - artifactName: "${productName}-${version}-${arch}-mac.${ext}" + artifactName: '${productName}-${version}-${arch}-mac.${ext}' target: - dmg - zip @@ -34,17 +34,17 @@ mac: notarize: true dmg: - title: "${productName} ${version}" + title: '${productName} ${version}' win: icon: apps/desktop/build/icon.ico - artifactName: "${productName}-${version}-${arch}-win.${ext}" + artifactName: '${productName}-${version}-${arch}-win.${ext}' target: - nsis - portable nsis: - artifactName: "${productName}-${version}-${arch}-win-setup.${ext}" + artifactName: '${productName}-${version}-${arch}-win-setup.${ext}' oneClick: false perMachine: false allowToChangeInstallationDirectory: true @@ -54,7 +54,7 @@ nsis: linux: icon: apps/desktop/build/icon.png category: Graphics - artifactName: "${productName}-${version}-${arch}-linux.${ext}" + artifactName: '${productName}-${version}-${arch}-linux.${ext}' desktop: entry: | [Desktop Entry] diff --git a/apps/desktop/git/__tests__/auth-store.test.ts b/apps/desktop/git/__tests__/auth-store.test.ts new file mode 100644 index 00000000..52cc62c3 --- /dev/null +++ b/apps/desktop/git/__tests__/auth-store.test.ts @@ -0,0 +1,127 @@ +// apps/desktop/git/__tests__/auth-store.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fsp } from 'node:fs'; +import { join } from 'node:path'; +import { + createAuthStore, + createInMemoryBackend, + createUnavailableBackend, + type AuthStore, +} from '../auth-store'; +import { mkTempDir } from './test-helpers'; + +describe('auth-store (in-memory backend)', () => { + let temp: { dir: string; dispose: () => Promise }; + let store: AuthStore; + let filePath: string; + + beforeEach(async () => { + temp = await mkTempDir(); + filePath = join(temp.dir, 'git-auth.bin'); + store = createAuthStore({ filePath, backend: createInMemoryBackend() }); + }); + + afterEach(async () => { + await temp.dispose(); + }); + + it('round-trips a token credential through set + get', async () => { + await store.set('github.com', { + kind: 'token', + username: 'kay', + token: 'ghp_abc123', + }); + const got = await store.get('github.com'); + expect(got).toEqual({ kind: 'token', username: 'kay', token: 'ghp_abc123' }); + }); + + it('round-trips an SSH credential', async () => { + await store.set('git@github.com', { kind: 'ssh', keyId: 'key-1' }); + const got = await store.get('git@github.com'); + expect(got).toEqual({ kind: 'ssh', keyId: 'key-1' }); + }); + + it('returns null for an unknown host', async () => { + const got = await store.get('not-stored.example.com'); + expect(got).toBeNull(); + }); + + it('list returns all stored hosts', async () => { + await store.set('github.com', { kind: 'token', username: 'a', token: 't1' }); + await store.set('gitlab.com', { kind: 'token', username: 'b', token: 't2' }); + const hosts = (await store.list()).sort(); + expect(hosts).toEqual(['github.com', 'gitlab.com']); + }); + + it('clear removes one host without affecting others', async () => { + await store.set('github.com', { kind: 'token', username: 'a', token: 't1' }); + await store.set('gitlab.com', { kind: 'token', username: 'b', token: 't2' }); + await store.clear('github.com'); + expect(await store.get('github.com')).toBeNull(); + expect(await store.get('gitlab.com')).not.toBeNull(); + }); + + it('persists across new store instances backed by the same file', async () => { + await store.set('github.com', { kind: 'token', username: 'kay', token: 't' }); + // New instance, same file + same backend type. + const store2 = createAuthStore({ filePath, backend: createInMemoryBackend() }); + const got = await store2.get('github.com'); + expect(got?.kind).toBe('token'); + }); +}); + +describe('auth-store (unavailable backend → plaintext fallback)', () => { + let temp: { dir: string; dispose: () => Promise }; + + beforeEach(async () => { + temp = await mkTempDir(); + }); + + afterEach(async () => { + await temp.dispose(); + }); + + it('writes a plaintext file with the marker header when safeStorage is unavailable from the start', async () => { + const filePath = join(temp.dir, 'git-auth.bin'); + const store = createAuthStore({ filePath, backend: createUnavailableBackend() }); + await store.set('github.com', { kind: 'token', username: 'kay', token: 't' }); + + const bytes = await fsp.readFile(filePath, 'utf-8'); + expect(bytes.startsWith('__OPENPENCIL_AUTH_PLAINTEXT_V1__')).toBe(true); + const body = bytes.slice('__OPENPENCIL_AUTH_PLAINTEXT_V1__'.length); + const obj = JSON.parse(body); + expect(obj['github.com'].kind).toBe('token'); + }); + + it('locks the store when an existing encrypted file is read with no decryption key — refuses writes to avoid data loss', async () => { + const filePath = join(temp.dir, 'git-auth.bin'); + // Step 1: write an encrypted file via the in-memory backend. + const enc = createAuthStore({ filePath, backend: createInMemoryBackend() }); + await enc.set('github.com', { kind: 'token', username: 'kay', token: 'precious-pat' }); + await enc.set('gitlab.com', { kind: 'token', username: 'kay', token: 'also-precious' }); + + // Step 2: open a fresh store pointing at the same file but with an + // unavailable backend. The encrypted bytes are NOT plaintext-marked, so + // the store should detect them and lock. + const locked = createAuthStore({ filePath, backend: createUnavailableBackend() }); + + // Reads return empty (locked) but do not throw. + expect(await locked.get('github.com')).toBeNull(); + expect(await locked.list()).toEqual([]); + + // Writes throw the lock error. + await expect( + locked.set('newhost.com', { kind: 'token', username: 'a', token: 'b' }), + ).rejects.toThrow(/locked/); + await expect(locked.clear('github.com')).rejects.toThrow(/locked/); + + // Critical: the original encrypted file is unchanged. + const bytesAfter = await fsp.readFile(filePath, 'utf-8'); + expect(bytesAfter.startsWith('__OPENPENCIL_AUTH_PLAINTEXT_V1__')).toBe(false); + // And a new instance with the in-memory backend can still read both + // original credentials. + const recovered = createAuthStore({ filePath, backend: createInMemoryBackend() }); + expect((await recovered.get('github.com'))?.kind).toBe('token'); + expect((await recovered.get('gitlab.com'))?.kind).toBe('token'); + }); +}); diff --git a/apps/desktop/git/__tests__/git-engine.test.ts b/apps/desktop/git/__tests__/git-engine.test.ts new file mode 100644 index 00000000..ffbba209 --- /dev/null +++ b/apps/desktop/git/__tests__/git-engine.test.ts @@ -0,0 +1,2487 @@ +// apps/desktop/git/__tests__/git-engine.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { join, resolve } from 'node:path'; +import { writeFile } from 'node:fs/promises'; +import { promises as fsp } from 'node:fs'; +import { execFile, execFileSync } from 'node:child_process'; +import { promisify } from 'node:util'; +import { + engineDetect, + engineInit, + engineOpen, + engineBindTrackedFile, + engineListCandidates, + engineClose, + engineStatus, + engineLog, + engineCommit, + engineRestore, + enginePromote, + engineBranchList, + engineBranchCreate, + engineBranchSwitch, + engineBranchDelete, + engineFetch, + enginePull, + enginePush, + engineDiff, + engineBranchMerge, + engineResolveConflict, + engineApplyMerge, + engineAbortMerge, + engineRemoteGet, + engineRemoteSet, +} from '../git-engine'; +import { clearAllSessions, sessionCount } from '../repo-session'; +import { mkTempDir, writeOpFile, mkSubdir } from './test-helpers'; + +const execFileAsync = promisify(execFile); + +// Synchronous availability probe at module load — same pattern as +// git-sys-real.test.ts. vitest's it.skipIf() reads its predicate at +// test-collection time, before any beforeEach hook has run. +let systemGitAvailable: boolean; +try { + execFileSync('git', ['--version'], { stdio: 'ignore', timeout: 5000 }); + systemGitAvailable = true; +} catch { + systemGitAvailable = false; +} + +describe('git-engine', () => { + let temp: { dir: string; dispose: () => Promise }; + + beforeEach(async () => { + temp = await mkTempDir(); + clearAllSessions(); + }); + + afterEach(async () => { + clearAllSessions(); + await temp.dispose(); + }); + + describe('engineDetect', () => { + it("returns { mode: 'none' } for an .op file with no surrounding repo", async () => { + const opFile = await writeOpFile(temp.dir, 'orphan.op'); + const result = await engineDetect(opFile); + expect(result.mode).toBe('none'); + expect(sessionCount()).toBe(0); + }); + + it('registers a session and auto-binds the file when a single-file repo exists', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + // Use engineInit to create the repo first. + const initResult = await engineInit(opFile); + // Drop the init session so detect re-allocates a fresh one. + clearAllSessions(); + + const result = await engineDetect(opFile); + expect(result.mode).toBe('single-file'); + if (result.mode === 'none') throw new Error('unreachable'); + expect(result.trackedFilePath).toBe(resolve(opFile)); + expect(result.engineKind).toBe('iso'); + expect(result.gitdir).toBe(initResult.gitdir); + expect(sessionCount()).toBe(1); + }); + + it('detects a folder-mode repo when the .op file lives inside a parent .git', async () => { + const repoRoot = await mkSubdir(temp.dir, 'project'); + const dotGit = await mkSubdir(repoRoot, '.git'); + await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + const opFile = await writeOpFile(repoRoot, 'design.op'); + + const result = await engineDetect(opFile); + expect(result.mode).toBe('folder'); + if (result.mode === 'none') throw new Error('unreachable'); + expect(result.rootPath).toBe(resolve(repoRoot)); + expect(result.trackedFilePath).toBe(resolve(opFile)); + }); + }); + + describe('engineInit', () => { + it('initializes a single-file repo, auto-binds, and returns a single candidate', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const result = await engineInit(opFile); + + expect(result.mode).toBe('single-file'); + expect(result.rootPath).toBe(temp.dir); + expect(result.trackedFilePath).toBe(resolve(opFile)); + expect(result.candidates).toHaveLength(1); + expect(result.candidates[0].relativePath).toBe('login.op'); + expect(result.candidates[0].milestoneCount).toBe(0); + expect(result.candidates[0].autosaveCount).toBe(0); + expect(sessionCount()).toBe(1); + }); + }); + + describe('engineOpen + walk + bind', () => { + it('engineOpen on a folder-mode repo discovers all .op files and sets candidates', async () => { + const repoRoot = await mkSubdir(temp.dir, 'project'); + const dotGit = await mkSubdir(repoRoot, '.git'); + await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + await writeOpFile(repoRoot, 'a.op'); + await writeOpFile(repoRoot, 'b.op'); + await mkSubdir(repoRoot, 'subdir'); + await writeOpFile(join(repoRoot, 'subdir'), 'c.op'); + + const result = await engineOpen(repoRoot); + expect(result.mode).toBe('folder'); + expect(result.candidates).toHaveLength(3); + const rels = result.candidates.map((c) => c.relativePath).sort(); + expect(rels).toEqual(['a.op', 'b.op', join('subdir', 'c.op')]); + }); + + it('engineOpen auto-binds when currentFilePath is inside the repo', async () => { + const repoRoot = await mkSubdir(temp.dir, 'project'); + const dotGit = await mkSubdir(repoRoot, '.git'); + await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + const opFile = await writeOpFile(repoRoot, 'design.op'); + + const result = await engineOpen(repoRoot, opFile); + expect(result.trackedFilePath).toBe(resolve(opFile)); + }); + + it('engineOpen auto-binds the only candidate when currentFilePath is omitted', async () => { + const repoRoot = await mkSubdir(temp.dir, 'project'); + const dotGit = await mkSubdir(repoRoot, '.git'); + await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + const opFile = await writeOpFile(repoRoot, 'design.op'); + + const result = await engineOpen(repoRoot); + expect(result.trackedFilePath).toBe(resolve(opFile)); + }); + + it('engineOpen leaves trackedFilePath null when multiple candidates exist and no currentFilePath', async () => { + const repoRoot = await mkSubdir(temp.dir, 'project'); + const dotGit = await mkSubdir(repoRoot, '.git'); + await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + await writeOpFile(repoRoot, 'a.op'); + await writeOpFile(repoRoot, 'b.op'); + + const result = await engineOpen(repoRoot); + expect(result.trackedFilePath).toBeNull(); + }); + + it('engineBindTrackedFile updates the session and rejects paths outside the repo', async () => { + const repoRoot = await mkSubdir(temp.dir, 'project'); + const dotGit = await mkSubdir(repoRoot, '.git'); + await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + await writeOpFile(repoRoot, 'a.op'); + await writeOpFile(repoRoot, 'b.op'); + const result = await engineOpen(repoRoot); + + const a = join(repoRoot, 'a.op'); + const bound = await engineBindTrackedFile(result.repoId, a); + expect(bound.trackedFilePath).toBe(resolve(a)); + + // Outside path is rejected. + await expect( + engineBindTrackedFile(result.repoId, join(temp.dir, 'outside.op')), + ).rejects.toMatchObject({ name: 'GitError', code: 'open-failed' }); + }); + + it('engineListCandidates re-walks the worktree and includes newly-added files', async () => { + const repoRoot = await mkSubdir(temp.dir, 'project'); + const dotGit = await mkSubdir(repoRoot, '.git'); + await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + await writeOpFile(repoRoot, 'a.op'); + const result = await engineOpen(repoRoot); + expect(result.candidates).toHaveLength(1); + + // Add a new file outside OpenPencil and refresh. + await writeOpFile(repoRoot, 'b.op'); + const fresh = await engineListCandidates(result.repoId); + expect(fresh).toHaveLength(2); + }); + + it('engineClose removes the session and subsequent operations throw no-file', async () => { + const repoRoot = await mkSubdir(temp.dir, 'project'); + const dotGit = await mkSubdir(repoRoot, '.git'); + await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + await writeOpFile(repoRoot, 'a.op'); + const result = await engineOpen(repoRoot); + + engineClose(result.repoId); + await expect(engineListCandidates(result.repoId)).rejects.toMatchObject({ + name: 'GitError', + code: 'no-file', + }); + }); + }); + + describe('engineStatus', () => { + it('reports branch=main and workingDirty=true on a fresh single-file repo', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const result = await engineInit(opFile); + const status = await engineStatus(result.repoId); + expect(status.trackedFilePath).toBe(resolve(opFile)); + // currentBranch reads HEAD's symbolic ref which initSingleFile sets to + // refs/heads/main, so branch='main' even before any commit exists. + expect(status.branch).toBe('main'); + expect(status.workingDirty).toBe(true); + expect(status.otherFilesDirty).toBe(0); + expect(status.mergeInProgress).toBe(false); + expect(status.ahead).toBe(0); + expect(status.behind).toBe(0); + }); + + it('after a milestone commit, workingDirty=false; after a disk edit, workingDirty=true', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + const session = (await import('../repo-session')).getSession(result.repoId)!; + const { commitFile, setRef } = await import('../git-iso'); + + const { hash } = await commitFile({ + handle: session.handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + // Force the autosave ref to the same commit (this is what engineCommit + // milestone path will do in Task 8). + await setRef({ + handle: session.handle, + ref: 'refs/openpencil/autosaves/main', + value: hash, + }); + + const cleanStatus = await engineStatus(result.repoId); + expect(cleanStatus.branch).toBe('main'); + expect(cleanStatus.workingDirty).toBe(false); + + // Mutate the file on disk. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r2' }], + }); + const dirtyStatus = await engineStatus(result.repoId); + expect(dirtyStatus.workingDirty).toBe(true); + }); + + it('folder mode: a modified tracked .gitignore counts toward otherFilesDirty', async () => { + // Build a folder-mode repo with two tracked files: design.op and .gitignore. + const repoRoot = temp.dir; // use temp.dir directly so the gitdir is .git here + const opFile = await writeOpFile(repoRoot, 'design.op'); + const gitignorePath = join(repoRoot, '.gitignore'); + await fsp.writeFile(gitignorePath, 'node_modules\n', 'utf-8'); + + // Init a folder-mode repo via isomorphic-git directly (engineInit only + // does single-file mode). + const isoGit = await import('isomorphic-git'); + const fsMod = await import('node:fs'); + await isoGit.init({ + fs: fsMod, + dir: repoRoot, + defaultBranch: 'main', + }); + // Stage and commit BOTH files in one commit so both are in the heads tree. + await isoGit.add({ fs: fsMod, dir: repoRoot, filepath: 'design.op' }); + await isoGit.add({ fs: fsMod, dir: repoRoot, filepath: '.gitignore' }); + await isoGit.commit({ + fs: fsMod, + dir: repoRoot, + message: 'initial', + author: { name: 't', email: 't@example.com' }, + }); + + // Open via the engine and bind design.op as the tracked file. + const result = await engineOpen(repoRoot, opFile); + expect(result.trackedFilePath).toBe(resolve(opFile)); + + // Clean state: both files match the tree → otherFilesDirty=0. + const cleanStatus = await engineStatus(result.repoId); + expect(cleanStatus.otherFilesDirty).toBe(0); + expect(cleanStatus.otherFilesPaths).toEqual([]); + + // Modify .gitignore on disk and re-check. + await fsp.writeFile(gitignorePath, 'node_modules\n.DS_Store\n', 'utf-8'); + const dirtyStatus = await engineStatus(result.repoId); + expect(dirtyStatus.otherFilesDirty).toBe(1); + expect(dirtyStatus.otherFilesPaths).toEqual(['.gitignore']); + }); + + it('folder mode: a tracked file deleted from disk counts toward otherFilesDirty', async () => { + const repoRoot = temp.dir; + const opFile = await writeOpFile(repoRoot, 'design.op'); + const notesPath = join(repoRoot, 'notes.md'); + await fsp.writeFile(notesPath, '# notes\n', 'utf-8'); + + const isoGit = await import('isomorphic-git'); + const fsMod = await import('node:fs'); + await isoGit.init({ fs: fsMod, dir: repoRoot, defaultBranch: 'main' }); + await isoGit.add({ fs: fsMod, dir: repoRoot, filepath: 'design.op' }); + await isoGit.add({ fs: fsMod, dir: repoRoot, filepath: 'notes.md' }); + await isoGit.commit({ + fs: fsMod, + dir: repoRoot, + message: 'initial', + author: { name: 't', email: 't@example.com' }, + }); + + const result = await engineOpen(repoRoot, opFile); + + // Delete notes.md from disk; the heads tree still has it. + await fsp.unlink(notesPath); + + const status = await engineStatus(result.repoId); + expect(status.otherFilesDirty).toBe(1); + expect(status.otherFilesPaths).toEqual(['notes.md']); + }); + }); + + describe('engineLog', () => { + it("returns milestone-kind entries when querying ref: 'main'", async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + const session = (await import('../repo-session')).getSession(result.repoId)!; + const { commitFile, setRef } = await import('../git-iso'); + + const { hash: h1 } = await commitFile({ + handle: session.handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await setRef({ + handle: session.handle, + ref: 'refs/openpencil/autosaves/main', + value: h1, + }); + + const log = await engineLog(result.repoId, { ref: 'main', limit: 10 }); + expect(log).toHaveLength(1); + expect(log[0].kind).toBe('milestone'); + expect(log[0].hash).toBe(h1); + }); + + it("autosave commits are kind='autosave' when queried via ref: 'autosaves'", async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + const session = (await import('../repo-session')).getSession(result.repoId)!; + const { commitFile, setRef } = await import('../git-iso'); + + // Milestone first. + const { hash: m1 } = await commitFile({ + handle: session.handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'milestone', + author: { name: 't', email: 't@example.com' }, + }); + await setRef({ + handle: session.handle, + ref: 'refs/openpencil/autosaves/main', + value: m1, + }); + // Then an autosave on top. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r2' }], + }); + const { hash: a1 } = await commitFile({ + handle: session.handle, + filepath: 'login.op', + ref: 'refs/openpencil/autosaves/main', + message: 'auto', + author: { name: 't', email: 't@example.com' }, + }); + + const log = await engineLog(result.repoId, { ref: 'autosaves', limit: 10 }); + // log[0] = a1 (autosave), log[1] = m1 (milestone, reachable as parent) + expect(log[0].hash).toBe(a1); + expect(log[0].kind).toBe('autosave'); + expect(log[1].hash).toBe(m1); + expect(log[1].kind).toBe('milestone'); + }); + + it('respects the limit parameter', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + const session = (await import('../repo-session')).getSession(result.repoId)!; + const { commitFile, setRef } = await import('../git-iso'); + + // Make 3 milestones. + for (let i = 0; i < 3; i++) { + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: `r${i}` }], + }); + const { hash } = await commitFile({ + handle: session.handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: `m${i}`, + author: { name: 't', email: 't@example.com' }, + }); + await setRef({ + handle: session.handle, + ref: 'refs/openpencil/autosaves/main', + value: hash, + }); + } + + const log = await engineLog(result.repoId, { ref: 'main', limit: 2 }); + expect(log).toHaveLength(2); + }); + }); + + describe('engineCommit', () => { + it('milestone commit advances both heads and autosaves to the same hash', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + const { hash } = await engineCommit(result.repoId, { + kind: 'milestone', + message: 'first milestone', + author: { name: 't', email: 't@example.com' }, + }); + + const session = (await import('../repo-session')).getSession(result.repoId)!; + const isoGit = await import('isomorphic-git'); + const fsMod = await import('node:fs'); + const headsTip = await isoGit.resolveRef({ + fs: fsMod, + gitdir: session.handle.gitdir, + ref: 'refs/heads/main', + }); + const autoTip = await isoGit.resolveRef({ + fs: fsMod, + gitdir: session.handle.gitdir, + ref: 'refs/openpencil/autosaves/main', + }); + expect(headsTip).toBe(hash); + expect(autoTip).toBe(hash); + }); + + it('autosave commit advances only the autosaves ref, leaving heads behind', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + // First a milestone. + const { hash: m1 } = await engineCommit(result.repoId, { + kind: 'milestone', + message: 'milestone', + author: { name: 't', email: 't@example.com' }, + }); + // Mutate and autosave. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r2' }], + }); + const { hash: a1 } = await engineCommit(result.repoId, { + kind: 'autosave', + message: 'auto', + author: { name: 't', email: 't@example.com' }, + }); + expect(a1).not.toBe(m1); + + const session = (await import('../repo-session')).getSession(result.repoId)!; + const isoGit = await import('isomorphic-git'); + const fsMod = await import('node:fs'); + const headsTip = await isoGit.resolveRef({ + fs: fsMod, + gitdir: session.handle.gitdir, + ref: 'refs/heads/main', + }); + const autoTip = await isoGit.resolveRef({ + fs: fsMod, + gitdir: session.handle.gitdir, + ref: 'refs/openpencil/autosaves/main', + }); + expect(headsTip).toBe(m1); // unchanged + expect(autoTip).toBe(a1); // advanced + }); + + it('a second milestone after autosaves abandons the autosave chain', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + + // milestone, autosave, autosave + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'm1', + author: { name: 't', email: 't@example.com' }, + }); + await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] }); + await engineCommit(result.repoId, { + kind: 'autosave', + message: 'a1', + author: { name: 't', email: 't@example.com' }, + }); + await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r3' }] }); + await engineCommit(result.repoId, { + kind: 'autosave', + message: 'a2', + author: { name: 't', email: 't@example.com' }, + }); + + // Now a second milestone. + const { hash: m2 } = await engineCommit(result.repoId, { + kind: 'milestone', + message: 'm2', + author: { name: 't', email: 't@example.com' }, + }); + + // The heads ref should now show 2 milestones (m2 → m1). + const headsLog = await engineLog(result.repoId, { ref: 'main', limit: 10 }); + expect(headsLog).toHaveLength(2); + expect(headsLog[0].hash).toBe(m2); + expect(headsLog[0].kind).toBe('milestone'); + expect(headsLog[1].kind).toBe('milestone'); + + // The autosaves ref now points at m2 (force-updated), so its log walks + // m2 → m1. The intermediate autosaves a1, a2 are unreachable. + const autoLog = await engineLog(result.repoId, { ref: 'autosaves', limit: 10 }); + expect(autoLog).toHaveLength(2); + expect(autoLog[0].hash).toBe(m2); + }); + + it("throws 'commit-empty' when committing the same content twice", async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const result = await engineInit(opFile); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await expect( + engineCommit(result.repoId, { + kind: 'milestone', + message: 'second', + author: { name: 't', email: 't@example.com' }, + }), + ).rejects.toMatchObject({ name: 'GitError', code: 'commit-empty' }); + }); + + it('engineCommit autosave is a no-op when disk content matches tip blob', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + // Create an initial milestone so there is a headsRef parent. + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'initial', + author: { name: 't', email: 't@example.com' }, + }); + // Mutate the file and autosave — creates the autosave tip. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r2' }], + }); + const { hash: first } = await engineCommit(result.repoId, { + kind: 'autosave', + message: 'auto-1', + author: { name: 't', email: 't@example.com' }, + }); + // Second autosave with identical disk content — must be a no-op. + const { hash: second } = await engineCommit(result.repoId, { + kind: 'autosave', + message: 'auto-2', + author: { name: 't', email: 't@example.com' }, + }); + expect(second).toBe(first); + }); + + it('engineCommit autosave creates a new commit when disk content changed', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + // Initial milestone. + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'initial', + author: { name: 't', email: 't@example.com' }, + }); + // First autosave with mutated content. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r2' }], + }); + const { hash: first } = await engineCommit(result.repoId, { + kind: 'autosave', + message: 'auto-1', + author: { name: 't', email: 't@example.com' }, + }); + // Mutate again, then autosave — content differs, so a new commit is expected. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r3' }], + }); + const { hash: second } = await engineCommit(result.repoId, { + kind: 'autosave', + message: 'auto-2', + author: { name: 't', email: 't@example.com' }, + }); + expect(second).not.toBe(first); + }); + }); + + describe('engineRestore + enginePromote', () => { + it('engineRestore writes a previous commit to disk without recording a new commit', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + const { hash: m1 } = await engineCommit(result.repoId, { + kind: 'milestone', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + // Mutate. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r2' }], + }); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'second', + author: { name: 't', email: 't@example.com' }, + }); + const logBefore = await engineLog(result.repoId, { ref: 'main', limit: 10 }); + + // Restore to first milestone. + await engineRestore(result.repoId, m1); + const restored = await fsp.readFile(opFile, 'utf-8'); + expect(JSON.parse(restored).children[0].id).toBe('r1'); + + // History unchanged (restore doesn't commit). + const logAfter = await engineLog(result.repoId, { ref: 'main', limit: 10 }); + expect(logAfter).toHaveLength(logBefore.length); + }); + + it('enginePromote reads an autosave, writes it, and records a milestone', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + // Initial milestone. + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'm1', + author: { name: 't', email: 't@example.com' }, + }); + // Autosave with new content. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r2' }], + }); + const { hash: a1 } = await engineCommit(result.repoId, { + kind: 'autosave', + message: 'auto', + author: { name: 't', email: 't@example.com' }, + }); + + // The user keeps editing and ends up at r3 in memory, but on disk is + // currently r2 (last autosave). Promote a1 to a milestone. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r3' }], + }); + const { hash: m2 } = await enginePromote(result.repoId, a1, 'promoted to milestone', { + name: 't', + email: 't@example.com', + }); + expect(m2).not.toBe(a1); + + // Working tree should now hold r2 (the promoted autosave content), not r3. + const onDisk = await fsp.readFile(opFile, 'utf-8'); + expect(JSON.parse(onDisk).children[0].id).toBe('r2'); + + // Heads ref now has 2 milestones (m1, m2). + const headsLog = await engineLog(result.repoId, { ref: 'main', limit: 10 }); + expect(headsLog).toHaveLength(2); + expect(headsLog[0].hash).toBe(m2); + expect(headsLog[0].message.trim()).toBe('promoted to milestone'); + }); + + it('engineRestore throws no-file when session has no trackedFilePath', async () => { + const repoRoot = await mkSubdir(temp.dir, 'project'); + const dotGit = await mkSubdir(repoRoot, '.git'); + await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + await writeOpFile(repoRoot, 'a.op'); + await writeOpFile(repoRoot, 'b.op'); + const result = await engineOpen(repoRoot); // multiple candidates → no auto-bind + + await expect(engineRestore(result.repoId, 'deadbeef')).rejects.toMatchObject({ + name: 'GitError', + code: 'no-file', + }); + }); + }); + + describe('branch operations', () => { + it('engineBranchList returns the current branch decorated with lastCommit', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + + const list = await engineBranchList(result.repoId); + expect(list).toHaveLength(1); + expect(list[0].name).toBe('main'); + expect(list[0].isCurrent).toBe(true); + expect(list[0].lastCommit?.message).toBe('first'); + }); + + it('engineBranchCreate adds a new branch from current HEAD', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await engineBranchCreate(result.repoId, { name: 'feature-x' }); + + const list = await engineBranchList(result.repoId); + const names = list.map((b) => b.name).sort(); + expect(names).toEqual(['feature-x', 'main']); + }); + + it('engineBranchSwitch updates the working tree to the target branch tip', async () => { + // Mirrors Phase 1b's switchBranch test structure: commit r1 on main, + // branch off, commit r2 on feature-x (working tree currently r2), then + // switch to main and verify disk reverts to r1. This is the cleanest + // observable test because filepaths-scoped checkout reads the target + // ref's tree and writes it to disk. + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await engineBranchCreate(result.repoId, { name: 'feature-x' }); + + // Mutate the working tree, then commit r2 onto feature-x via the + // underlying primitive (the engine commits to current HEAD only). + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r2' }], + }); + const session = (await import('../repo-session')).getSession(result.repoId)!; + const { commitFile } = await import('../git-iso'); + await commitFile({ + handle: session.handle, + filepath: 'login.op', + ref: 'refs/heads/feature-x', + message: 'feature change', + author: { name: 't', email: 't@example.com' }, + }); + + // Working tree currently has r2 (from the writeOpFile above). + // Switch to main and verify the file reverts to r1. + await engineBranchSwitch(result.repoId, 'main'); + const onDisk = await fsp.readFile(opFile, 'utf-8'); + expect(JSON.parse(onDisk).children[0].id).toBe('r1'); + }); + + it('engineBranchDelete removes a non-current branch', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await engineBranchCreate(result.repoId, { name: 'feature-x' }); + await engineBranchDelete(result.repoId, 'feature-x'); + + const list = await engineBranchList(result.repoId); + expect(list.map((b) => b.name)).toEqual(['main']); + }); + + it('engineBranchDelete refuses to delete the current branch', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await expect(engineBranchDelete(result.repoId, 'main')).rejects.toMatchObject({ + name: 'GitError', + code: 'branch-current', + }); + }); + + it('engineBranchDelete refuses an unmerged branch without force and succeeds with force=true', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const result = await engineInit(opFile); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'base', + author: { name: 't', email: 't@example.com' }, + }); + await engineBranchCreate(result.repoId, { name: 'feature-x' }); + + // Switch to the new branch, modify the tracked file, and commit so + // feature-x has a commit main does not see. + await engineBranchSwitch(result.repoId, 'feature-x'); + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r2' }], + }); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'feature only', + author: { name: 't', email: 't@example.com' }, + }); + await engineBranchSwitch(result.repoId, 'main'); + + await expect(engineBranchDelete(result.repoId, 'feature-x')).rejects.toMatchObject({ + name: 'GitError', + code: 'branch-unmerged', + }); + + await expect( + engineBranchDelete(result.repoId, 'feature-x', { force: true }), + ).resolves.toBeUndefined(); + + const list = await engineBranchList(result.repoId); + expect(list.map((b) => b.name)).toEqual(['main']); + }); + }); + + describe('shouldUseSys dispatch', () => { + it('routes git@ and ssh:// URLs to sys', async () => { + const { shouldUseSys } = await import('../git-engine'); + expect(shouldUseSys('git@github.com:user/repo.git', undefined)).toBe(true); + expect(shouldUseSys('ssh://git@github.com/user/repo.git', undefined)).toBe(true); + }); + + it('routes https URLs to iso', async () => { + const { shouldUseSys } = await import('../git-engine'); + expect(shouldUseSys('https://github.com/user/repo.git', undefined)).toBe(false); + }); + + it('routes auth.kind=ssh to sys regardless of URL', async () => { + const { shouldUseSys } = await import('../git-engine'); + expect(shouldUseSys('https://github.com/user/repo.git', { kind: 'ssh', keyId: 'k1' })).toBe( + true, + ); + }); + }); + + describe('parseHost', () => { + it('extracts the host from https/http/ssh URLs', async () => { + const { parseHost } = await import('../git-engine'); + expect(parseHost('https://github.com/user/repo.git')).toBe('github.com'); + expect(parseHost('http://gitlab.example.com:8080/u/r')).toBe('gitlab.example.com'); + expect(parseHost('ssh://git@github.com:22/user/repo.git')).toBe('github.com'); + }); + + it('extracts the host from SCP-style git URLs', async () => { + const { parseHost } = await import('../git-engine'); + expect(parseHost('git@github.com:user/repo.git')).toBe('github.com'); + expect(parseHost('user@gitlab.com:org/project.git')).toBe('gitlab.com'); + }); + + it('returns null for unparseable inputs', async () => { + const { parseHost } = await import('../git-engine'); + expect(parseHost('/local/path/to/bare.git')).toBeNull(); + expect(parseHost('not a url at all')).toBeNull(); + }); + }); + + describe('resolveAuthForRemote', () => { + it('returns the explicit auth argument unchanged when provided', async () => { + const { resolveAuthForRemote, setAuthStore } = await import('../git-engine'); + // No store needed; explicit auth wins. + setAuthStore(null); + const explicit = { kind: 'token' as const, username: 'a', token: 'b' }; + const got = await resolveAuthForRemote('https://github.com/u/r.git', explicit); + expect(got).toBe(explicit); + }); + + it('falls back to the stored credential when explicit auth is omitted', async () => { + const { resolveAuthForRemote, setAuthStore } = await import('../git-engine'); + const { createAuthStore, createInMemoryBackend } = await import('../auth-store'); + const filePath = join(temp.dir, 'auth.bin'); + const store = createAuthStore({ filePath, backend: createInMemoryBackend() }); + await store.set('github.com', { kind: 'token', username: 'kay', token: 'stored-pat' }); + setAuthStore(store); + + const got = await resolveAuthForRemote('https://github.com/u/r.git', undefined); + expect(got).toEqual({ kind: 'token', username: 'kay', token: 'stored-pat' }); + + // Cleanup so the singleton doesn't leak into other tests. + setAuthStore(null); + }); + + it('returns undefined when no explicit auth, no store, and no host match', async () => { + const { resolveAuthForRemote, setAuthStore } = await import('../git-engine'); + setAuthStore(null); + expect(await resolveAuthForRemote('https://unknown.com/u/r.git', undefined)).toBeUndefined(); + expect(await resolveAuthForRemote(null, undefined)).toBeUndefined(); + }); + }); + + describe('engineFetch + enginePull (system git gated)', () => { + async function setupClonePair() { + const remoteDir = join(temp.dir, 'remote.git'); + const aDir = join(temp.dir, 'a'); + const bDir = join(temp.dir, 'b'); + + await execFileAsync('git', ['init', '--bare', remoteDir]); + await execFileAsync('git', ['clone', remoteDir, aDir]); + await execFileAsync('git', ['-C', aDir, 'checkout', '-b', 'main']); + await fsp.writeFile(join(aDir, 'design.op'), '{"version":"1.0.0","children":[{"id":"r1"}]}'); + await execFileAsync('git', ['-C', aDir, 'add', '.']); + await execFileAsync( + 'git', + ['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'one'], + {}, + ); + await execFileAsync('git', ['-C', aDir, 'push', '-u', 'origin', 'main']); + await execFileAsync('git', ['clone', remoteDir, bDir]); + return { remoteDir, aDir, bDir }; + } + + it.skipIf(!systemGitAvailable)( + 'engineFetch reports behind=1 after upstream advances', + async () => { + const { aDir, bDir } = await setupClonePair(); + // a commits and pushes a new commit + await fsp.writeFile( + join(aDir, 'design.op'), + '{"version":"1.0.0","children":[{"id":"r2"}]}', + ); + await execFileAsync('git', ['-C', aDir, 'add', '.']); + await execFileAsync( + 'git', + ['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'two'], + {}, + ); + await execFileAsync('git', ['-C', aDir, 'push']); + + // Open b via the engine and fetch. + const result = await engineOpen(bDir, join(bDir, 'design.op')); + const fetchResult = await engineFetch(result.repoId); + expect(fetchResult).toEqual({ ahead: 0, behind: 1 }); + }, + ); + + it.skipIf(!systemGitAvailable)('enginePull fast-forwards a clean clone', async () => { + const { aDir, bDir } = await setupClonePair(); + // a pushes a new commit + await fsp.writeFile(join(aDir, 'design.op'), '{"version":"1.0.0","children":[{"id":"r2"}]}'); + await execFileAsync('git', ['-C', aDir, 'add', '.']); + await execFileAsync( + 'git', + ['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'two'], + {}, + ); + await execFileAsync('git', ['-C', aDir, 'push']); + + const result = await engineOpen(bDir, join(bDir, 'design.op')); + const pullResult = await enginePull(result.repoId); + expect(pullResult.result).toBe('fast-forward'); + + // Verify b's design.op now has r2. + const content = await fsp.readFile(join(bDir, 'design.op'), 'utf-8'); + expect(JSON.parse(content).children[0].id).toBe('r2'); + }); + + it.skipIf(!systemGitAvailable)( + 'enginePull in folder mode enters merge workflow when histories diverge (Phase 7a)', + async () => { + const { aDir, bDir } = await setupClonePair(); + // Both a and b commit divergently with conflicting fill values on node 'r1'. + await fsp.writeFile( + join(aDir, 'design.op'), + JSON.stringify({ + version: '1.0.0', + children: [ + { + id: 'r1', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#0000ff' }], + }, + ], + }), + ); + await execFileAsync('git', ['-C', aDir, 'add', '.']); + await execFileAsync( + 'git', + ['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'a: blue'], + {}, + ); + await execFileAsync('git', ['-C', aDir, 'push']); + + await fsp.writeFile( + join(bDir, 'design.op'), + JSON.stringify({ + version: '1.0.0', + children: [ + { + id: 'r1', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#00ff00' }], + }, + ], + }), + ); + await execFileAsync('git', ['-C', bDir, 'add', '.']); + await execFileAsync( + 'git', + ['-C', bDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'b: green'], + {}, + ); + + const result = await engineOpen(bDir, join(bDir, 'design.op')); + // Phase 7a: folder-mode divergent pull should now enter the merge workflow, + // returning 'conflict' (or 'merge' if pen-core finds no semantic conflicts). + const pullResult = await enginePull(result.repoId); + expect(['conflict', 'merge', 'fast-forward']).toContain(pullResult.result); + + // design.op on disk must be readable JSON (not conflict markers). + const onDisk = await fsp.readFile(join(bDir, 'design.op'), 'utf-8'); + expect(() => JSON.parse(onDisk)).not.toThrow(); + }, + ); + }); + + describe('enginePush (system git gated)', () => { + it.skipIf(!systemGitAvailable)( + 'enginePush succeeds on a clean local clone with new commits', + async () => { + const remoteDir = join(temp.dir, 'remote.git'); + const cloneDir = join(temp.dir, 'clone'); + + await execFileAsync('git', ['init', '--bare', remoteDir]); + await execFileAsync('git', ['clone', remoteDir, cloneDir]); + await execFileAsync('git', ['-C', cloneDir, 'checkout', '-b', 'main']); + await fsp.writeFile( + join(cloneDir, 'design.op'), + '{"version":"1.0.0","children":[{"id":"r1"}]}', + ); + await execFileAsync('git', ['-C', cloneDir, 'add', '.']); + await execFileAsync( + 'git', + [ + '-C', + cloneDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'first', + ], + {}, + ); + // First push needs --set-upstream; we use the underlying execFile for it, + // then enginePush handles subsequent pushes. + await execFileAsync('git', ['-C', cloneDir, 'push', '-u', 'origin', 'main']); + + // Make another commit, then push via enginePush. + await fsp.writeFile( + join(cloneDir, 'design.op'), + '{"version":"1.0.0","children":[{"id":"r2"}]}', + ); + await execFileAsync('git', ['-C', cloneDir, 'add', '.']); + await execFileAsync( + 'git', + [ + '-C', + cloneDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'second', + ], + {}, + ); + + const result = await engineOpen(cloneDir, join(cloneDir, 'design.op')); + const pushResult = await enginePush(result.repoId); + expect(pushResult.result).toBe('ok'); + + // Verify the bare remote saw the second commit. + const { stdout } = await execFileAsync('git', [ + '-C', + remoteDir, + 'log', + '--oneline', + 'main', + ]); + expect(stdout).toContain('second'); + }, + ); + }); + + describe('engineDiff', () => { + it('returns 0/0/0/0 summary for two identical commits', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }], + }); + const result = await engineInit(opFile); + const { hash: h1 } = await engineCommit(result.repoId, { + kind: 'milestone', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + + const diff = await engineDiff(result.repoId, h1, h1); + expect(diff.summary).toEqual({ + framesChanged: 0, + nodesAdded: 0, + nodesRemoved: 0, + nodesModified: 0, + }); + expect(diff.patches).toEqual([]); + }); + + it('reports nodes added/removed between two commits', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }], + }); + const result = await engineInit(opFile); + const { hash: h1 } = await engineCommit(result.repoId, { + kind: 'milestone', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + + // Add a node and commit again. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }, + { id: 'b', type: 'rectangle', x: 5, y: 5, width: 1, height: 1 }, + ], + }); + const { hash: h2 } = await engineCommit(result.repoId, { + kind: 'milestone', + message: 'second', + author: { name: 't', email: 't@example.com' }, + }); + + const diff = await engineDiff(result.repoId, h1, h2); + expect(diff.summary.nodesAdded).toBe(1); + expect(diff.summary.nodesRemoved).toBe(0); + expect(diff.patches.some((p) => p.op === 'add' && p.nodeId === 'b')).toBe(true); + }); + }); + + describe('engineBranchMerge', () => { + it('clean merge of disjoint changes produces a merge commit', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }], + }); + const result = await engineInit(opFile); + // Base commit on main. + const { hash: baseHash } = await engineCommit(result.repoId, { + kind: 'milestone', + message: 'base', + author: { name: 't', email: 't@example.com' }, + }); + // Branch off, commit different content on feature. + await engineBranchCreate(result.repoId, { name: 'feature' }); + const session = (await import('../repo-session')).getSession(result.repoId)!; + const { commitFile: cf, setRef: sr } = await import('../git-iso'); + // Theirs adds node 'b'. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }, + { id: 'b', type: 'rectangle', x: 5, y: 5, width: 1, height: 1 }, + ], + }); + const { hash: theirs } = await cf({ + handle: session.handle, + filepath: 'login.op', + ref: 'refs/heads/feature', + message: 'add b', + author: { name: 't', email: 't@example.com' }, + parents: [baseHash], + }); + await sr({ + handle: session.handle, + ref: 'refs/openpencil/autosaves/feature', + value: theirs, + }); + // Restore main's content. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }], + }); + // Ours adds node 'c'. + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }, + { id: 'c', type: 'rectangle', x: 9, y: 9, width: 1, height: 1 }, + ], + }); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'add c', + author: { name: 't', email: 't@example.com' }, + }); + + // Now merge feature into main. + const mergeResult = await engineBranchMerge(result.repoId, 'feature'); + expect(mergeResult.result).toBe('merge'); + expect(mergeResult.conflicts).toBeUndefined(); + + // Working tree should now contain both b and c. + const onDisk = JSON.parse(await fsp.readFile(opFile, 'utf-8')); + const ids = (onDisk.children as Array<{ id: string }>).map((n) => n.id).sort(); + expect(ids).toEqual(['a', 'b', 'c']); + }); + + it('fast-forward when ours is an ancestor of theirs', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }], + }); + const result = await engineInit(opFile); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'base', + author: { name: 't', email: 't@example.com' }, + }); + await engineBranchCreate(result.repoId, { name: 'feature' }); + // Switch to feature and add a commit. + await engineBranchSwitch(result.repoId, 'feature'); + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 }, + { id: 'b', type: 'rectangle', x: 5, y: 5, width: 1, height: 1 }, + ], + }); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'add b on feature', + author: { name: 't', email: 't@example.com' }, + }); + // Switch back to main and merge feature. + await engineBranchSwitch(result.repoId, 'main'); + const mergeResult = await engineBranchMerge(result.repoId, 'feature'); + expect(mergeResult.result).toBe('fast-forward'); + }); + + it('conflict path stashes InflightMerge and returns the bag', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { + id: 'a', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#ff0000' }], + }, + ], + }); + const result = await engineInit(opFile); + const { hash: baseHash } = await engineCommit(result.repoId, { + kind: 'milestone', + message: 'base', + author: { name: 't', email: 't@example.com' }, + }); + + // feature: change to blue + await engineBranchCreate(result.repoId, { name: 'feature' }); + const session = (await import('../repo-session')).getSession(result.repoId)!; + const { commitFile: cf, setRef: sr } = await import('../git-iso'); + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { + id: 'a', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#0000ff' }], + }, + ], + }); + const { hash: theirs } = await cf({ + handle: session.handle, + filepath: 'login.op', + ref: 'refs/heads/feature', + message: 'blue', + author: { name: 't', email: 't@example.com' }, + parents: [baseHash], + }); + await sr({ + handle: session.handle, + ref: 'refs/openpencil/autosaves/feature', + value: theirs, + }); + + // main: change to green (after restoring main's content first) + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { + id: 'a', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#ff0000' }], + }, + ], + }); + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { + id: 'a', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#00ff00' }], + }, + ], + }); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'green', + author: { name: 't', email: 't@example.com' }, + }); + + const mergeResult = await engineBranchMerge(result.repoId, 'feature'); + expect(mergeResult.result).toBe('conflict'); + expect(mergeResult.conflicts).toBeDefined(); + expect(mergeResult.conflicts!.nodeConflicts.length).toBeGreaterThan(0); + + // The session should now hold the InflightMerge. + const refreshed = (await import('../repo-session')).getSession(result.repoId)!; + expect(refreshed.inflightMerge).not.toBeNull(); + expect(refreshed.inflightMerge!.conflictMap.size).toBeGreaterThan(0); + }); + }); + + describe('engineResolveConflict + engineApplyMerge + engineAbortMerge', () => { + // Helper: build a repo with a known conflict in flight. + async function setupConflict() { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { + id: 'a', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#ff0000' }], + }, + ], + }); + const result = await engineInit(opFile); + const { hash: baseHash } = await engineCommit(result.repoId, { + kind: 'milestone', + message: 'base', + author: { name: 't', email: 't@example.com' }, + }); + + await engineBranchCreate(result.repoId, { name: 'feature' }); + const session = (await import('../repo-session')).getSession(result.repoId)!; + const { commitFile: cf, setRef: sr } = await import('../git-iso'); + + // theirs: blue + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { + id: 'a', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#0000ff' }], + }, + ], + }); + const { hash: theirsHash } = await cf({ + handle: session.handle, + filepath: 'login.op', + ref: 'refs/heads/feature', + message: 'blue', + author: { name: 't', email: 't@example.com' }, + parents: [baseHash], + }); + await sr({ + handle: session.handle, + ref: 'refs/openpencil/autosaves/feature', + value: theirsHash, + }); + + // ours: green + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { + id: 'a', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#ff0000' }], + }, + ], + }); + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { + id: 'a', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#00ff00' }], + }, + ], + }); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'green', + author: { name: 't', email: 't@example.com' }, + }); + + const mergeResult = await engineBranchMerge(result.repoId, 'feature'); + if (mergeResult.result !== 'conflict') { + throw new Error('expected conflict result'); + } + return { result, opFile, conflictId: mergeResult.conflicts!.nodeConflicts[0].id }; + } + + it('engineResolveConflict records the choice in session state', async () => { + const { result, conflictId } = await setupConflict(); + await engineResolveConflict(result.repoId, conflictId, { kind: 'theirs' }); + const session = (await import('../repo-session')).getSession(result.repoId)!; + expect(session.inflightMerge!.resolutions.get(conflictId)).toEqual({ kind: 'theirs' }); + }); + + it('engineResolveConflict throws on unknown conflict id', async () => { + const { result } = await setupConflict(); + await expect( + engineResolveConflict(result.repoId, 'node:_:nope', { kind: 'ours' }), + ).rejects.toMatchObject({ name: 'GitError', code: 'engine-crash' }); + }); + + it('engineApplyMerge throws merge-still-conflicted when resolutions are missing', async () => { + const { result } = await setupConflict(); + await expect(engineApplyMerge(result.repoId)).rejects.toMatchObject({ + name: 'GitError', + code: 'merge-still-conflicted', + }); + }); + + it('engineApplyMerge writes the merged doc and creates the merge commit', async () => { + const { result, opFile, conflictId } = await setupConflict(); + await engineResolveConflict(result.repoId, conflictId, { kind: 'theirs' }); + const applied = await engineApplyMerge(result.repoId); + expect(applied.noop).toBe(false); + expect(applied.hash).toMatch(/^[a-f0-9]{40}$/); + + // The working tree should now have theirs's blue rectangle. + const onDisk = JSON.parse(await fsp.readFile(opFile, 'utf-8')); + expect(onDisk.children[0].fill[0].color).toBe('#0000ff'); + + // Session inflightMerge should be cleared. + const session = (await import('../repo-session')).getSession(result.repoId)!; + expect(session.inflightMerge).toBeNull(); + + // The merge commit should have two parents. + const isoGit = await import('isomorphic-git'); + const fsMod = await import('node:fs'); + const commit = await isoGit.readCommit({ + fs: fsMod, + gitdir: session.handle.gitdir, + oid: applied.hash, + }); + expect(commit.commit.parent.length).toBe(2); + }); + + it('engineApplyMerge with no in-flight merge returns noop=true', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [], + }); + const result = await engineInit(opFile); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + const applied = await engineApplyMerge(result.repoId); + expect(applied.noop).toBe(true); + }); + + it('engineAbortMerge clears the in-flight merge state', async () => { + const { result } = await setupConflict(); + await engineAbortMerge(result.repoId); + const session = (await import('../repo-session')).getSession(result.repoId)!; + expect(session.inflightMerge).toBeNull(); + }); + }); + + describe('engineStatus with in-flight merge', () => { + it('mergeInProgress=false on a fresh repo', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [], + }); + const result = await engineInit(opFile); + const status = await engineStatus(result.repoId); + expect(status.mergeInProgress).toBe(false); + expect(status.unresolvedFiles).toEqual([]); + expect(status.conflicts).toBeNull(); + }); + + it('mergeInProgress=true and conflicts populated when a merge is in flight', async () => { + // Build a conflict using the same pattern as the Task 8 setupConflict. + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { + id: 'a', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#ff0000' }], + }, + ], + }); + const result = await engineInit(opFile); + const { hash: baseHash } = await engineCommit(result.repoId, { + kind: 'milestone', + message: 'base', + author: { name: 't', email: 't@example.com' }, + }); + await engineBranchCreate(result.repoId, { name: 'feature' }); + const session = (await import('../repo-session')).getSession(result.repoId)!; + const { commitFile: cf, setRef: sr } = await import('../git-iso'); + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { + id: 'a', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#0000ff' }], + }, + ], + }); + const { hash: theirs } = await cf({ + handle: session.handle, + filepath: 'login.op', + ref: 'refs/heads/feature', + message: 'blue', + author: { name: 't', email: 't@example.com' }, + parents: [baseHash], + }); + await sr({ + handle: session.handle, + ref: 'refs/openpencil/autosaves/feature', + value: theirs, + }); + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'd', + children: [ + { + id: 'a', + type: 'rectangle', + x: 0, + y: 0, + width: 1, + height: 1, + fill: [{ type: 'solid', color: '#00ff00' }], + }, + ], + }); + await engineCommit(result.repoId, { + kind: 'milestone', + message: 'green', + author: { name: 't', email: 't@example.com' }, + }); + const mergeResult = await engineBranchMerge(result.repoId, 'feature'); + expect(mergeResult.result).toBe('conflict'); + + const status = await engineStatus(result.repoId); + expect(status.mergeInProgress).toBe(true); + expect(status.conflicts).not.toBeNull(); + expect(status.conflicts!.nodeConflicts.length).toBeGreaterThan(0); + expect(status.unresolvedFiles).toEqual(['login.op']); + }); + }); + + describe('engineRemoteGet + engineRemoteSet (Phase 6a)', () => { + it('returns { url: null, host: null } when origin is absent', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const init = await engineInit(opFile); + const info = await engineRemoteGet(init.repoId); + expect(info).toEqual({ name: 'origin', url: null, host: null }); + }); + + it('engineRemoteSet adds origin when it does not exist and parses the host', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const init = await engineInit(opFile); + + const result = await engineRemoteSet(init.repoId, 'https://github.com/foo/bar.git'); + expect(result).toEqual({ + name: 'origin', + url: 'https://github.com/foo/bar.git', + host: 'github.com', + }); + + // engineRemoteGet now returns the same thing. + const got = await engineRemoteGet(init.repoId); + expect(got).toEqual(result); + }); + + it('engineRemoteSet updates an existing origin in place', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const init = await engineInit(opFile); + + await engineRemoteSet(init.repoId, 'https://github.com/foo/bar.git'); + const updated = await engineRemoteSet(init.repoId, 'https://gitlab.com/foo/bar.git'); + expect(updated).toEqual({ + name: 'origin', + url: 'https://gitlab.com/foo/bar.git', + host: 'gitlab.com', + }); + + // Read back to confirm there is exactly one origin and it is the new url. + const got = await engineRemoteGet(init.repoId); + expect(got.url).toBe('https://gitlab.com/foo/bar.git'); + }); + + it('engineRemoteSet(null) removes origin and is idempotent', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const init = await engineInit(opFile); + + // Add then remove. + await engineRemoteSet(init.repoId, 'https://github.com/foo/bar.git'); + const removed = await engineRemoteSet(init.repoId, null); + expect(removed).toEqual({ name: 'origin', url: null, host: null }); + + // Removing again must not throw. Note: writeRemoteOrigin() does NOT + // wrap `git.deleteRemote` in a try/catch — this test proves the call + // is naturally idempotent because isomorphic-git implements it as a + // filter over parsed config entries that tolerates an absent section. + // Wrapping the call would silently swallow real I/O errors from + // GitConfigManager.save (EACCES, ENOSPC, etc.), so the lack of a + // catch is load-bearing. + const removedAgain = await engineRemoteSet(init.repoId, null); + expect(removedAgain).toEqual({ name: 'origin', url: null, host: null }); + + // engineRemoteGet confirms origin is gone. + const got = await engineRemoteGet(init.repoId); + expect(got.url).toBeNull(); + }); + + it('parses SCP-style ssh URLs into the host field', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const init = await engineInit(opFile); + + const result = await engineRemoteSet(init.repoId, 'git@github.com:foo/bar.git'); + expect(result.host).toBe('github.com'); + expect(result.url).toBe('git@github.com:foo/bar.git'); + }); + }); + + // --------------------------------------------------------------------------- + // Phase 7a: folder-mode divergent merge (system git gated) + // --------------------------------------------------------------------------- + + /** + * Helper: set up two local folder-mode repos sharing a bare remote. + * Both have an initial commit with design.op. Returns paths for both clones. + */ + async function setupFolderClonePair(): Promise<{ aDir: string; bDir: string }> { + const remoteDir = join(temp.dir, 'remote.git'); + const aDir = join(temp.dir, 'a'); + const bDir = join(temp.dir, 'b'); + + await execFileAsync('git', ['init', '--bare', remoteDir]); + await execFileAsync('git', ['clone', remoteDir, aDir]); + await execFileAsync('git', ['-C', aDir, 'checkout', '-b', 'main']); + await fsp.writeFile( + join(aDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#ff0000' }] }), + ); + await fsp.writeFile(join(aDir, 'README.md'), '# Base\n'); + await execFileAsync('git', ['-C', aDir, 'add', '.']); + await execFileAsync('git', [ + '-C', + aDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'base', + ]); + await execFileAsync('git', ['-C', aDir, 'push', '-u', 'origin', 'main']); + await execFileAsync('git', ['clone', remoteDir, bDir]); + return { aDir, bDir }; + } + + describe('Phase 7a: folder-mode divergent merge (system git gated)', () => { + it.skipIf(!systemGitAvailable)( + 'folder-mode divergent pull returns conflict when .op file conflicts', + async () => { + const { aDir, bDir } = await setupFolderClonePair(); + + // a makes a change to design.op and pushes. + await fsp.writeFile( + join(aDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }), + ); + await execFileAsync('git', ['-C', aDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + aDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'a: blue', + ]); + await execFileAsync('git', ['-C', aDir, 'push']); + + // b makes a divergent change to design.op. + await fsp.writeFile( + join(bDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }), + ); + await execFileAsync('git', ['-C', bDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + bDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'b: green', + ]); + + const bResult = await engineOpen(bDir, join(bDir, 'design.op')); + + // Phase 7a: folder-mode divergent pull should now return conflict (not throw). + const pullResult = await enginePull(bResult.repoId); + expect(pullResult.result).toBe('conflict'); + expect(pullResult.conflicts).toBeDefined(); + expect(pullResult.conflicts!.nodeConflicts.length).toBeGreaterThan(0); + + // design.op on disk must be readable JSON (not conflict markers). + const onDisk = await fsp.readFile(join(bDir, 'design.op'), 'utf-8'); + expect(() => JSON.parse(onDisk)).not.toThrow(); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'folder-mode divergent pull returns conflict-non-op when only non-.op files conflict', + async () => { + const { aDir, bDir } = await setupFolderClonePair(); + + // a changes only README.md and pushes. + await fsp.writeFile(join(aDir, 'README.md'), '# From A\n'); + await execFileAsync('git', ['-C', aDir, 'add', 'README.md']); + await execFileAsync('git', [ + '-C', + aDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'a: readme', + ]); + await execFileAsync('git', ['-C', aDir, 'push']); + + // b also changes README.md (divergently) but leaves design.op alone. + await fsp.writeFile(join(bDir, 'README.md'), '# From B\n'); + await execFileAsync('git', ['-C', bDir, 'add', 'README.md']); + await execFileAsync('git', [ + '-C', + bDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'b: readme', + ]); + + const bResult = await engineOpen(bDir, join(bDir, 'design.op')); + + const pullResult = await enginePull(bResult.repoId); + expect(pullResult.result).toBe('conflict-non-op'); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'engineStatus reports mergeInProgress from on-disk MERGE_HEAD after session close/reopen', + async () => { + const { aDir, bDir } = await setupFolderClonePair(); + + // Create divergent commits in a and b. + await fsp.writeFile( + join(aDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }), + ); + await execFileAsync('git', ['-C', aDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + aDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'a', + ]); + await execFileAsync('git', ['-C', aDir, 'push']); + + await fsp.writeFile( + join(bDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }), + ); + await execFileAsync('git', ['-C', bDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + bDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'b', + ]); + + // First session: open and pull (enters conflict). + const session1 = await engineOpen(bDir, join(bDir, 'design.op')); + await enginePull(session1.repoId); + await engineClose(session1.repoId); + + // Clear in-memory sessions (simulate panel close/reopen). + clearAllSessions(); + + // Second session: reopen — must detect on-disk merge state. + const session2 = await engineOpen(bDir, join(bDir, 'design.op')); + const status = await engineStatus(session2.repoId); + expect(status.mergeInProgress).toBe(true); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'engineApplyMerge with on-disk merge state resolves .op conflicts and creates merge commit', + async () => { + const { aDir, bDir } = await setupFolderClonePair(); + + await fsp.writeFile( + join(aDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }), + ); + await execFileAsync('git', ['-C', aDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + aDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'a', + ]); + await execFileAsync('git', ['-C', aDir, 'push']); + + await fsp.writeFile( + join(bDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }), + ); + await execFileAsync('git', ['-C', bDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + bDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'b', + ]); + + const bResult = await engineOpen(bDir, join(bDir, 'design.op')); + const pullResult = await enginePull(bResult.repoId); + + expect(pullResult.result).toBe('conflict'); + const conflictId = pullResult.conflicts!.nodeConflicts[0].id; + await engineResolveConflict(bResult.repoId, conflictId, { kind: 'theirs' }); + + const applied = await engineApplyMerge(bResult.repoId); + expect(applied.noop).toBe(false); + expect(applied.hash).toMatch(/^[a-f0-9]{40}$/); + + // design.op must be readable JSON with theirs's fill. + const onDisk = JSON.parse(await fsp.readFile(join(bDir, 'design.op'), 'utf-8')); + expect(onDisk.children[0].fill).toBe('#0000ff'); + + // Session inflightMerge cleared. + const { getSession: gs } = await import('../repo-session'); + const sess = gs(bResult.repoId)!; + expect(sess.inflightMerge).toBeNull(); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'engineApplyMerge throws merge-still-conflicted when non-.op files remain unresolved', + async () => { + const { aDir, bDir } = await setupFolderClonePair(); + + // Both change design.op and README.md. + await fsp.writeFile( + join(aDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }), + ); + await fsp.writeFile(join(aDir, 'README.md'), '# A\n'); + await execFileAsync('git', ['-C', aDir, 'add', '.']); + await execFileAsync('git', [ + '-C', + aDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'a', + ]); + await execFileAsync('git', ['-C', aDir, 'push']); + + await fsp.writeFile( + join(bDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }), + ); + await fsp.writeFile(join(bDir, 'README.md'), '# B\n'); + await execFileAsync('git', ['-C', bDir, 'add', '.']); + await execFileAsync('git', [ + '-C', + bDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'b', + ]); + + const bResult = await engineOpen(bDir, join(bDir, 'design.op')); + const pullResult = await enginePull(bResult.repoId); + expect(pullResult.result).toBe('conflict'); + + // Resolve the .op conflict. + const conflictId = pullResult.conflicts!.nodeConflicts[0].id; + await engineResolveConflict(bResult.repoId, conflictId, { kind: 'ours' }); + + // README.md is still unresolved → must throw merge-still-conflicted. + await expect(engineApplyMerge(bResult.repoId)).rejects.toMatchObject({ + name: 'GitError', + code: 'merge-still-conflicted', + }); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'engineAbortMerge in folder mode aborts on-disk merge state', + async () => { + const { aDir, bDir } = await setupFolderClonePair(); + + await fsp.writeFile( + join(aDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }), + ); + await execFileAsync('git', ['-C', aDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + aDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'a', + ]); + await execFileAsync('git', ['-C', aDir, 'push']); + + await fsp.writeFile( + join(bDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }), + ); + await execFileAsync('git', ['-C', bDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + bDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'b', + ]); + + const bResult = await engineOpen(bDir, join(bDir, 'design.op')); + await enginePull(bResult.repoId); + + await engineAbortMerge(bResult.repoId); + + // Session inflightMerge cleared. + const { getSession: gs } = await import('../repo-session'); + const sess = gs(bResult.repoId)!; + expect(sess.inflightMerge).toBeNull(); + + // On-disk MERGE_HEAD gone. + const { readMergeHead: rmh } = await import('../worktree-merge'); + const mergeHead = await rmh(join(bDir, '.git')); + expect(mergeHead).toBeNull(); + + // design.op is clean JSON. + const onDisk = await fsp.readFile(join(bDir, 'design.op'), 'utf-8'); + expect(() => JSON.parse(onDisk)).not.toThrow(); + }, + ); + + // ------------------------------------------------------------------------- + // Issue 1 (engine assertion): rename conflict is classified as conflict-non-op + // + // When the feature branch renames the tracked .op file, git places it in + // conflict state as "deleted-by-them" (stage 3 missing for the original + // path). The engine detects that stage 3 blob is absent for the tracked + // file and classifies the merge as { result: 'conflict-non-op' } — the user + // must resolve the rename in a terminal. + // ------------------------------------------------------------------------- + it.skipIf(!systemGitAvailable)( + 'engineBranchMerge returns conflict-non-op when theirs renames the tracked .op file', + async () => { + const repoDir = join(temp.dir, 'repo-rename-engine'); + await fsp.mkdir(repoDir, { recursive: true }); + + const g = (...args: string[]) => execFileAsync('git', args, { cwd: repoDir }); + const gc = (...args: string[]) => + execFileAsync('git', ['-c', 'user.name=t', '-c', 'user.email=t@e.com', ...args], { + cwd: repoDir, + }); + + // Base: design.op on main. + await g('init', '-b', 'main'); + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base' }] }), + ); + await g('add', '.'); + await gc('commit', '-m', 'base'); + + // feature branch: rename design.op → design-v2.op AND modify content. + await g('checkout', '-b', 'feature'); + await fsp.rename(join(repoDir, 'design.op'), join(repoDir, 'design-v2.op')); + await fsp.writeFile( + join(repoDir, 'design-v2.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'theirs' }] }), + ); + await g('add', '-A'); + await gc('commit', '-m', 'rename to design-v2.op'); + + // main: make a divergent change on the ORIGINAL design.op. + await g('checkout', 'main'); + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'ours' }] }), + ); + await g('add', '.'); + await gc('commit', '-m', 'ours'); + + // Restore design.op (it was renamed away on feature; main's checkout + // puts it back with 'ours' content). + // Open via engine with design.op as the tracked file. + const result = await engineOpen(repoDir, join(repoDir, 'design.op')); + expect(result.trackedFilePath).toBe(resolve(join(repoDir, 'design.op'))); + + // Also manually set the autosaves ref so the engine can resolve commits. + const isoGit = await import('isomorphic-git'); + const fsMod = await import('node:fs'); + const mainHash = await isoGit.resolveRef({ + fs: fsMod, + dir: repoDir, + ref: 'refs/heads/main', + }); + const { setRef } = await import('../git-iso'); + const session = (await import('../repo-session')).getSession(result.repoId)!; + await setRef({ + handle: session.handle, + ref: 'refs/openpencil/autosaves/main', + value: mainHash, + }); + const featureHash = await isoGit.resolveRef({ + fs: fsMod, + dir: repoDir, + ref: 'refs/heads/feature', + }); + await setRef({ + handle: session.handle, + ref: 'refs/openpencil/autosaves/feature', + value: featureHash, + }); + + // Merge feature into main. + const mergeResult = await engineBranchMerge(result.repoId, 'feature'); + + // ASSERTION: rename conflict → stage 3 blob missing for tracked file + // → classified as conflict-non-op (user must resolve rename in terminal). + expect(mergeResult.result).toBe('conflict-non-op'); + + // Cleanup: abort the merge so the temp directory can be removed cleanly. + await engineAbortMerge(result.repoId); + }, + ); + + // ------------------------------------------------------------------------- + // Issue 2: engineApplyMerge folder-mode noop path + // The `noop: true` branch is reached when inflightMerge is null AND + // MERGE_HEAD is absent (merge was committed externally, e.g. via terminal). + // ------------------------------------------------------------------------- + it.skipIf(!systemGitAvailable)( + 'engineApplyMerge returns noop=true when merge was already committed externally', + async () => { + const { aDir, bDir } = await setupFolderClonePair(); + + // Create a conflict between a and b. + await fsp.writeFile( + join(aDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }), + ); + await execFileAsync('git', ['-C', aDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + aDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'a', + ]); + await execFileAsync('git', ['-C', aDir, 'push']); + + await fsp.writeFile( + join(bDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }), + ); + await execFileAsync('git', ['-C', bDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + bDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'b', + ]); + + // Open b, pull to enter conflict state. + const bResult = await engineOpen(bDir, join(bDir, 'design.op')); + const pullResult = await enginePull(bResult.repoId); + expect(pullResult.result).toBe('conflict'); + + // Externally finalize the merge via sysFinalizeMerge (simulating a user + // resolving the conflict in a terminal). First write a resolved file. + await fsp.writeFile( + join(bDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#ff00ff' }] }), + ); + const { sysStageFile: sf, sysFinalizeMerge: sfm } = await import('../worktree-merge'); + await sf({ cwd: bDir, filepath: 'design.op' }); + await sfm({ + cwd: bDir, + message: 'merge resolved externally', + author: { name: 'External', email: 'ext@test.com' }, + }); + + // Now clear the in-memory inflightMerge to simulate what the engine + // session holds after the external commit (inflightMerge still set from + // pullResult, but MERGE_HEAD is now gone). + // We simulate this by clearing the session's inflightMerge manually. + const { clearInflightMerge } = await import('../repo-session'); + clearInflightMerge(bResult.repoId); + + // engineApplyMerge should detect: no inflightMerge, no MERGE_HEAD → + // return { noop: true } with the current HEAD hash. + const applied = await engineApplyMerge(bResult.repoId); + expect(applied.noop).toBe(true); + expect(applied.hash).toMatch(/^[a-f0-9]{40}$/); + }, + ); + + // ------------------------------------------------------------------------- + // Issue 3: panel reopen without calling status() first (Option A contract) + // + // When MERGE_HEAD is present on disk but session.inflightMerge is null + // (e.g. panel closed and reopened mid-conflict), engineApplyMerge must + // throw a clear, caller-actionable error directing the caller to call + // status() first. This is the deliberate Option A contract. + // + // The Phase 7b renderer always calls refreshStatus() on conflict-panel + // entry, so this path is only hit by direct callers (CLI, test harnesses) + // that skip the status() call. + // ------------------------------------------------------------------------- + it.skipIf(!systemGitAvailable)( + 'engineStatus panel-reopen: returns reopenedMidMerge=true and tracked .op excluded from unresolvedFiles', + async () => { + const { aDir, bDir } = await setupFolderClonePair(); + + // Create a conflict between a and b on design.op. + await fsp.writeFile( + join(aDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }), + ); + await execFileAsync('git', ['-C', aDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + aDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'a', + ]); + await execFileAsync('git', ['-C', aDir, 'push']); + + await fsp.writeFile( + join(bDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }), + ); + await execFileAsync('git', ['-C', bDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + bDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'b', + ]); + + // First session: open and pull (enters conflict). + const session1 = await engineOpen(bDir, join(bDir, 'design.op')); + await enginePull(session1.repoId); + await engineClose(session1.repoId); + + // Simulate panel close/reopen: clear all in-memory sessions. + clearAllSessions(); + + // Second session: reopen — must detect on-disk merge state. + const session2 = await engineOpen(bDir, join(bDir, 'design.op')); + const status = await engineStatus(session2.repoId); + + // I2: must signal degraded panel-reopen mode. + expect(status.reopenedMidMerge).toBe(true); + + // I2: tracked .op file must NOT appear in unresolvedFiles. + // (It is in the git index with stages 1/2/3, but filtering prevents + // the renderer from showing it as a misleading "non-op file".) + const trackedRel = 'design.op'; + expect(status.unresolvedFiles).not.toContain(trackedRel); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'engineApplyMerge throws merge-still-conflicted with actionable message when called without status() after panel reopen', + async () => { + const { aDir, bDir } = await setupFolderClonePair(); + + // Create a conflict in b. + await fsp.writeFile( + join(aDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#0000ff' }] }), + ); + await execFileAsync('git', ['-C', aDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + aDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'a', + ]); + await execFileAsync('git', ['-C', aDir, 'push']); + + await fsp.writeFile( + join(bDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base', fill: '#00ff00' }] }), + ); + await execFileAsync('git', ['-C', bDir, 'add', 'design.op']); + await execFileAsync('git', [ + '-C', + bDir, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e.com', + 'commit', + '-m', + 'b', + ]); + + // Session 1: open and pull (enters conflict — MERGE_HEAD on disk). + const session1 = await engineOpen(bDir, join(bDir, 'design.op')); + await enginePull(session1.repoId); + await engineClose(session1.repoId); + + // Simulate panel close/reopen: clear all in-memory sessions. + clearAllSessions(); + + // Session 2: reopen the same repo. inflightMerge is null (new session), + // but MERGE_HEAD is still on disk. + const session2 = await engineOpen(bDir, join(bDir, 'design.op')); + + // Verify MERGE_HEAD is still on disk (the conflict is still active). + const { readMergeHead: rmh } = await import('../worktree-merge'); + const mergeHead = await rmh(join(bDir, '.git')); + expect(mergeHead).not.toBeNull(); + + // Call engineApplyMerge WITHOUT calling status() first. + // Must throw merge-still-conflicted with a message that guides the caller. + await expect(engineApplyMerge(session2.repoId)).rejects.toMatchObject({ + name: 'GitError', + code: 'merge-still-conflicted', + message: expect.stringMatching(/call status\(\) first/i), + }); + }, + ); + }); +}); diff --git a/apps/desktop/git/__tests__/git-ipc.test.ts b/apps/desktop/git/__tests__/git-ipc.test.ts new file mode 100644 index 00000000..81466e4d --- /dev/null +++ b/apps/desktop/git/__tests__/git-ipc.test.ts @@ -0,0 +1,53 @@ +// apps/desktop/git/__tests__/git-ipc.test.ts +import { describe, it, expect } from 'vitest'; +import { GitError } from '../error'; +import { serializeGitError, GIT_ERROR_MARKER } from '../ipc-handlers'; + +describe('serializeGitError', () => { + it('produces an Error whose message starts with the marker and round-trips fields', () => { + const original = new GitError('commit-empty', 'No changes', { recoverable: true }); + const serialized = serializeGitError(original); + expect(serialized.message.startsWith(GIT_ERROR_MARKER)).toBe(true); + const payload = JSON.parse(serialized.message.slice(GIT_ERROR_MARKER.length)); + expect(payload.code).toBe('commit-empty'); + expect(payload.message).toBe('No changes'); + expect(payload.recoverable).toBe(true); + }); + + it('preserves recoverable=false from the GitError', () => { + const original = new GitError('engine-crash', 'something blew up', { recoverable: false }); + const serialized = serializeGitError(original); + const payload = JSON.parse(serialized.message.slice(GIT_ERROR_MARKER.length)); + expect(payload.recoverable).toBe(false); + }); + + it('the marker is a stable, unique string the renderer can pattern-match', () => { + expect(GIT_ERROR_MARKER).toBe('__GIT_ERROR__'); + }); + + it('serializes auth-related and network error codes', () => { + const codes = [ + 'auth-failed', + 'auth-required', + 'clone-failed', + 'network', + 'pull-non-fast-forward', + ] as const; + for (const code of codes) { + const original = new GitError(code, `${code} test`); + const serialized = serializeGitError(original); + const payload = JSON.parse(serialized.message.slice(GIT_ERROR_MARKER.length)); + expect(payload.code).toBe(code); + } + }); + + it('serializes merge-related error codes', () => { + const codes = ['merge-conflict', 'merge-still-conflicted', 'merge-abort-failed'] as const; + for (const code of codes) { + const original = new GitError(code, `${code} test`); + const serialized = serializeGitError(original); + const payload = JSON.parse(serialized.message.slice(GIT_ERROR_MARKER.length)); + expect(payload.code).toBe(code); + } + }); +}); diff --git a/apps/desktop/git/__tests__/git-iso.test.ts b/apps/desktop/git/__tests__/git-iso.test.ts new file mode 100644 index 00000000..723db3c6 --- /dev/null +++ b/apps/desktop/git/__tests__/git-iso.test.ts @@ -0,0 +1,653 @@ +// apps/desktop/git/__tests__/git-iso.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { + initSingleFile, + openRepo, + commitFile, + readBlobAtCommit, + logForRef, + restoreFileFromCommit, + listBranches, + createBranch, + deleteBranch, + switchBranch, + getCurrentBranch, + setRef, + readBlobOidAt, + findMergeBase, +} from '../git-iso'; +import { detectRepo } from '../repo-detector'; +import { mkTempDir, writeOpFile } from './test-helpers'; + +describe('git-iso', () => { + let temp: { dir: string; dispose: () => Promise }; + + beforeEach(async () => { + temp = await mkTempDir(); + }); + + afterEach(async () => { + await temp.dispose(); + }); + + describe('initSingleFile', () => { + it('creates .op-history/.git/ with HEAD pointing at refs/heads/main', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const handle = await initSingleFile({ filePath: opFile }); + + expect(handle.mode).toBe('single-file'); + expect(handle.dir).toBe(temp.dir); + expect(handle.gitdir).toBe(join(temp.dir, '.op-history', 'login.op.git')); + expect(existsSync(join(handle.gitdir, 'HEAD'))).toBe(true); + }); + + it('is idempotent: re-running init on an existing repo returns the same handle without erroring', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const first = await initSingleFile({ filePath: opFile }); + const second = await initSingleFile({ filePath: opFile }); + expect(second.gitdir).toBe(first.gitdir); + expect(second.dir).toBe(first.dir); + expect(existsSync(join(second.gitdir, 'HEAD'))).toBe(true); + }); + + it('respects the defaultBranch option', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const handle = await initSingleFile({ filePath: opFile, defaultBranch: 'trunk' }); + // isomorphic-git writes HEAD as a symbolic ref string + const fs = await import('node:fs/promises'); + const head = await fs.readFile(join(handle.gitdir, 'HEAD'), 'utf-8'); + expect(head.trim()).toBe('ref: refs/heads/trunk'); + }); + + it('writes core.worktree = ../.. so terminal git can inspect the repo', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const handle = await initSingleFile({ filePath: opFile }); + // Read the on-disk config and verify both core.worktree and core.bare. + const fsp = await import('node:fs/promises'); + const config = await fsp.readFile(join(handle.gitdir, 'config'), 'utf-8'); + expect(config).toMatch(/worktree\s*=\s*\.\.\/\.\./); + expect(config).toMatch(/bare\s*=\s*false/); + }); + }); + + describe('openRepo', () => { + it('opens an existing single-file repo via a successful detection', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + await initSingleFile({ filePath: opFile }); + + const detection = await detectRepo(opFile); + expect(detection.mode).toBe('single-file'); + if (detection.mode !== 'single-file') throw new Error('detection failed'); + + const handle = await openRepo(detection); + expect(handle.mode).toBe('single-file'); + expect(handle.dir).toBe(temp.dir); + }); + }); + + describe('commitFile + readBlobAtCommit', () => { + it('creates a commit on the given ref and the blob round-trips', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + + const { hash } = await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'initial', + author: { name: 'tester', email: 'tester@example.com' }, + }); + expect(hash).toMatch(/^[a-f0-9]{40}$/); + + const content = await readBlobAtCommit({ + handle, + filepath: 'login.op', + commitHash: hash, + }); + const parsed = JSON.parse(content); + expect(parsed.children[0].id).toBe('r1'); + }); + + it('throws commit-empty when committing the same content twice', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const handle = await initSingleFile({ filePath: opFile }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await expect( + commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'second', + author: { name: 't', email: 't@example.com' }, + }), + ).rejects.toMatchObject({ name: 'GitError', code: 'commit-empty' }); + }); + + it('chains commits: each commit has the previous as parent', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + + const { hash: h1 } = await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + + // Mutate the file then commit again. + await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] }); + const { hash: h2 } = await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'second', + author: { name: 't', email: 't@example.com' }, + }); + + expect(h2).not.toBe(h1); + // Verify h1 content is recoverable + const c1 = await readBlobAtCommit({ handle, filepath: 'login.op', commitHash: h1 }); + expect(JSON.parse(c1).children[0].id).toBe('r1'); + // Verify h2 content + const c2 = await readBlobAtCommit({ handle, filepath: 'login.op', commitHash: h2 }); + expect(JSON.parse(c2).children[0].id).toBe('r2'); + }); + }); + + describe('logForRef', () => { + it('returns commits in reverse chronological order (newest first)', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + + const { hash: h1 } = await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] }); + const { hash: h2 } = await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'second', + author: { name: 't', email: 't@example.com' }, + }); + + const log = await logForRef({ handle, ref: 'refs/heads/main', depth: 10 }); + expect(log).toHaveLength(2); + expect(log[0].hash).toBe(h2); + expect(log[0].message).toBe('second\n'); + expect(log[1].hash).toBe(h1); + expect(log[1].message).toBe('first\n'); + }); + + it('returns an empty array for a ref that does not exist', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const handle = await initSingleFile({ filePath: opFile }); + const log = await logForRef({ + handle, + ref: 'refs/openpencil/autosaves/main', + depth: 10, + }); + expect(log).toEqual([]); + }); + + it('respects the depth parameter', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + // Make 3 commits. + for (let i = 0; i < 3; i++) { + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: `r${i}` }], + }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: `commit-${i}`, + author: { name: 't', email: 't@example.com' }, + }); + } + const log = await logForRef({ handle, ref: 'refs/heads/main', depth: 2 }); + expect(log).toHaveLength(2); + }); + }); + + describe('restoreFileFromCommit', () => { + it("writes a previous commit's content to the working tree", async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + const { hash: h1 } = await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + // Mutate + await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] }); + // Restore + await restoreFileFromCommit({ handle, filepath: 'login.op', commitHash: h1 }); + // Verify the working tree file was overwritten + const fsp = await import('node:fs/promises'); + const restored = await fsp.readFile(opFile, 'utf-8'); + expect(JSON.parse(restored).children[0].id).toBe('r1'); + }); + + it('does NOT create a new commit (the caller is responsible)', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + const { hash: h1 } = await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'second', + author: { name: 't', email: 't@example.com' }, + }); + const logBefore = await logForRef({ handle, ref: 'refs/heads/main', depth: 10 }); + + await restoreFileFromCommit({ handle, filepath: 'login.op', commitHash: h1 }); + const logAfter = await logForRef({ handle, ref: 'refs/heads/main', depth: 10 }); + expect(logAfter).toHaveLength(logBefore.length); // unchanged + }); + }); + + describe('branch operations', () => { + it('listBranches returns empty for a fresh repo with no commits', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const handle = await initSingleFile({ filePath: opFile }); + const branches = await listBranches({ handle }); + expect(branches).toEqual([]); // no commits = no branches + }); + + it('listBranches returns the branch after a commit creates it', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + const branches = await listBranches({ handle }); + expect(branches).toContain('main'); + }); + + it('createBranch from current HEAD adds a new branch', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await createBranch({ handle, name: 'feature-x' }); + const branches = await listBranches({ handle }); + expect(branches).toContain('feature-x'); + expect(branches).toContain('main'); + }); + + it('createBranch throws branch-exists when the name is taken', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await createBranch({ handle, name: 'feature-x' }); + await expect(createBranch({ handle, name: 'feature-x' })).rejects.toMatchObject({ + name: 'GitError', + code: 'branch-exists', + }); + }); + + it('deleteBranch throws branch-current when deleting the active branch', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await expect(deleteBranch({ handle, name: 'main' })).rejects.toMatchObject({ + name: 'GitError', + code: 'branch-current', + }); + }); + + it('deleteBranch removes a non-active branch', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + await createBranch({ handle, name: 'feature-x' }); + await deleteBranch({ handle, name: 'feature-x' }); + const branches = await listBranches({ handle }); + expect(branches).not.toContain('feature-x'); + expect(branches).toContain('main'); + }); + + it('deleteBranch throws branch-unmerged for an unmerged branch without force', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'base', + author: { name: 't', email: 't@example.com' }, + }); + await createBranch({ handle, name: 'feature-x' }); + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r2' }], + }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/feature-x', + message: 'feature only', + author: { name: 't', email: 't@example.com' }, + }); + await expect(deleteBranch({ handle, name: 'feature-x' })).rejects.toMatchObject({ + name: 'GitError', + code: 'branch-unmerged', + }); + }); + + it('deleteBranch removes an unmerged branch when force=true', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'base', + author: { name: 't', email: 't@example.com' }, + }); + await createBranch({ handle, name: 'feature-x' }); + await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r2' }], + }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/feature-x', + message: 'feature only', + author: { name: 't', email: 't@example.com' }, + }); + await deleteBranch({ handle, name: 'feature-x', force: true }); + const branches = await listBranches({ handle }); + expect(branches).toEqual(['main']); + }); + + it('deleteBranch treats a fast-forward-merged branch as merged (equal OID tips)', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'base', + author: { name: 't', email: 't@example.com' }, + }); + // Branch off main with no additional commits: feature-ff tip === main tip. + await createBranch({ handle, name: 'feature-ff' }); + // Without the equal-OID short-circuit in isBranchMergedAnywhere this would + // throw branch-unmerged because isomorphic-git's isDescendent returns + // false when oid === ancestor. + await deleteBranch({ handle, name: 'feature-ff' }); + const branches = await listBranches({ handle }); + expect(branches).toEqual(['main']); + }); + + it('getCurrentBranch returns the active branch name', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + const current = await getCurrentBranch({ handle }); + expect(current).toBe('main'); + }); + + it('switchBranch updates the working tree file to the target branch tip', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + // Branch off, then commit something different on the new branch. + await createBranch({ handle, name: 'feature-x' }); + await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] }); + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/feature-x', + message: 'feature change', + author: { name: 't', email: 't@example.com' }, + }); + // Switch back to main and verify the file content reverts. + await switchBranch({ handle, name: 'main', filepath: 'login.op' }); + const fsp = await import('node:fs/promises'); + const content = await fsp.readFile(opFile, 'utf-8'); + expect(JSON.parse(content).children[0].id).toBe('r1'); + }); + }); + + describe('setRef + readBlobOidAt', () => { + it('setRef creates a new ref pointing at the given commit and force-overwrites an existing one', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + const { hash: h1 } = await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + + // Create a brand-new ref pointing at h1. + await setRef({ handle, ref: 'refs/openpencil/autosaves/main', value: h1 }); + const log1 = await logForRef({ + handle, + ref: 'refs/openpencil/autosaves/main', + depth: 10, + }); + expect(log1).toHaveLength(1); + expect(log1[0].hash).toBe(h1); + + // Make a second milestone, then force the autosave ref to it (overwrite). + await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] }); + const { hash: h2 } = await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'second', + author: { name: 't', email: 't@example.com' }, + }); + await setRef({ handle, ref: 'refs/openpencil/autosaves/main', value: h2 }); + const log2 = await logForRef({ + handle, + ref: 'refs/openpencil/autosaves/main', + depth: 10, + }); + // The autosave ref now jumps to h2 — its log walks h2 → h1 (parent chain). + expect(log2[0].hash).toBe(h2); + }); + + it('readBlobOidAt returns the blob OID at the ref tip and null for missing ref/file', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + + // Before any commit, the ref doesn't exist → null. + const before = await readBlobOidAt({ + handle, + ref: 'refs/heads/main', + filepath: 'login.op', + }); + expect(before).toBeNull(); + + await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'first', + author: { name: 't', email: 't@example.com' }, + }); + + // After commit, the OID is a 40-char hex string. + const after = await readBlobOidAt({ + handle, + ref: 'refs/heads/main', + filepath: 'login.op', + }); + expect(after).toMatch(/^[a-f0-9]{40}$/); + + // Asking for a file that's not in the commit's tree → null. + const missing = await readBlobOidAt({ + handle, + ref: 'refs/heads/main', + filepath: 'does-not-exist.op', + }); + expect(missing).toBeNull(); + }); + }); + + describe('findMergeBase', () => { + it('returns the common ancestor of two divergent branches', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + children: [{ id: 'r1' }], + }); + const handle = await initSingleFile({ filePath: opFile }); + + const { hash: base } = await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/main', + message: 'base', + author: { name: 't', email: 't@example.com' }, + }); + + // Branch off and commit on each branch. + await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] }); + const { hash: ours } = await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/feature-a', + message: 'ours', + author: { name: 't', email: 't@example.com' }, + parents: [base], + }); + await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r3' }] }); + const { hash: theirs } = await commitFile({ + handle, + filepath: 'login.op', + ref: 'refs/heads/feature-b', + message: 'theirs', + author: { name: 't', email: 't@example.com' }, + parents: [base], + }); + + const mergeBase = await findMergeBase({ handle, oid1: ours, oid2: theirs }); + expect(mergeBase).toBe(base); + }); + }); +}); diff --git a/apps/desktop/git/__tests__/git-sys-real.test.ts b/apps/desktop/git/__tests__/git-sys-real.test.ts new file mode 100644 index 00000000..ee988603 --- /dev/null +++ b/apps/desktop/git/__tests__/git-sys-real.test.ts @@ -0,0 +1,703 @@ +// apps/desktop/git/__tests__/git-sys-real.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fsp } from 'node:fs'; +import { join } from 'node:path'; +import { execFile, execFileSync } from 'node:child_process'; +import { promisify } from 'node:util'; +import { sysClone, sysFetch, sysPush, sysAheadBehind, mapSysError } from '../git-sys'; +import { + sysMergeNoCommit, + sysListUnresolved, + readMergeHead, + sysShowStageBlob, + sysRestoreOurs, + sysStageFile, + sysFinalizeMerge, + sysAbortMerge, + sysReadHead, +} from '../worktree-merge'; +import { mkTempDir } from './test-helpers'; + +const execFileAsync = promisify(execFile); + +// Synchronous availability probe at module load. We can't use the async +// isSystemGitAvailable() because vitest's it.skipIf() reads its predicate at +// test-collection time, before any beforeEach hook has run. +let systemGitAvailable: boolean; +try { + execFileSync('git', ['--version'], { stdio: 'ignore', timeout: 5000 }); + systemGitAvailable = true; +} catch { + systemGitAvailable = false; +} + +describe('git-sys real (gated on system git)', () => { + let temp: { dir: string; dispose: () => Promise }; + + beforeEach(async () => { + temp = await mkTempDir(); + }); + + afterEach(async () => { + if (temp) await temp.dispose(); + }); + + it.skipIf(!systemGitAvailable)('clones a local bare remote', async () => { + const remoteDir = join(temp.dir, 'remote.git'); + const sourceDir = join(temp.dir, 'source'); + const cloneDir = join(temp.dir, 'clone'); + + // Set up: bare remote, source repo with one commit, push source → remote. + await execFileAsync('git', ['init', '--bare', remoteDir]); + await fsp.mkdir(sourceDir, { recursive: true }); + await execFileAsync('git', ['init', '-b', 'main', sourceDir]); + await fsp.writeFile(join(sourceDir, 'README.md'), '# test\n'); + await execFileAsync('git', ['add', '.'], { cwd: sourceDir }); + await execFileAsync( + 'git', + ['-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'init'], + { cwd: sourceDir }, + ); + await execFileAsync('git', ['remote', 'add', 'origin', remoteDir], { cwd: sourceDir }); + await execFileAsync('git', ['push', 'origin', 'main'], { cwd: sourceDir }); + + // Now clone via sysClone. + await sysClone({ url: remoteDir, dest: cloneDir }); + + // Verify the clone has the README. + const content = await fsp.readFile(join(cloneDir, 'README.md'), 'utf-8'); + expect(content).toBe('# test\n'); + }); + + it.skipIf(!systemGitAvailable)('fetch updates remote-tracking refs', async () => { + const remoteDir = join(temp.dir, 'remote.git'); + const aDir = join(temp.dir, 'a'); + const bDir = join(temp.dir, 'b'); + + await execFileAsync('git', ['init', '--bare', remoteDir]); + // a: clone, commit, push + await execFileAsync('git', ['clone', remoteDir, aDir]); + await execFileAsync('git', ['-C', aDir, 'checkout', '-b', 'main']); + await fsp.writeFile(join(aDir, 'one.txt'), '1'); + await execFileAsync('git', ['-C', aDir, 'add', '.']); + await execFileAsync( + 'git', + ['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'one'], + {}, + ); + await execFileAsync('git', ['-C', aDir, 'push', '-u', 'origin', 'main']); + + // b: clone the same remote (now has main with one.txt) + await execFileAsync('git', ['clone', remoteDir, bDir]); + + // a commits another file and pushes + await fsp.writeFile(join(aDir, 'two.txt'), '2'); + await execFileAsync('git', ['-C', aDir, 'add', '.']); + await execFileAsync( + 'git', + ['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'two'], + {}, + ); + await execFileAsync('git', ['-C', aDir, 'push']); + + // b's ahead/behind before fetch should be 0/0 (b doesn't know about the new commit yet). + const before = await sysAheadBehind({ cwd: bDir, branch: 'main' }); + expect(before).toEqual({ ahead: 0, behind: 0 }); + + // Fetch updates b's remote-tracking ref. + await sysFetch({ cwd: bDir }); + const after = await sysAheadBehind({ cwd: bDir, branch: 'main' }); + expect(after).toEqual({ ahead: 0, behind: 1 }); + }); + + it.skipIf(!systemGitAvailable)('push to local bare remote succeeds', async () => { + const remoteDir = join(temp.dir, 'remote.git'); + const cloneDir = join(temp.dir, 'clone'); + + await execFileAsync('git', ['init', '--bare', remoteDir]); + await execFileAsync('git', ['clone', remoteDir, cloneDir]); + await execFileAsync('git', ['-C', cloneDir, 'checkout', '-b', 'main']); + await fsp.writeFile(join(cloneDir, 'a.txt'), 'a'); + await execFileAsync('git', ['-C', cloneDir, 'add', '.']); + await execFileAsync( + 'git', + ['-C', cloneDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'a'], + {}, + ); + + await sysPush({ cwd: cloneDir, branch: 'main' }); + + // Verify the bare remote has main pointing at the clone's commit. + const { stdout: remoteHead } = await execFileAsync('git', [ + '-C', + remoteDir, + 'rev-parse', + 'main', + ]); + const { stdout: cloneHead } = await execFileAsync('git', ['-C', cloneDir, 'rev-parse', 'HEAD']); + expect(remoteHead.trim()).toBe(cloneHead.trim()); + }); + + it.skipIf(!systemGitAvailable)('push non-fast-forward is rejected', async () => { + const remoteDir = join(temp.dir, 'remote.git'); + const aDir = join(temp.dir, 'a'); + const bDir = join(temp.dir, 'b'); + + await execFileAsync('git', ['init', '--bare', remoteDir]); + // a: seed remote with one commit + await execFileAsync('git', ['clone', remoteDir, aDir]); + await execFileAsync('git', ['-C', aDir, 'checkout', '-b', 'main']); + await fsp.writeFile(join(aDir, 'one.txt'), '1'); + await execFileAsync('git', ['-C', aDir, 'add', '.']); + await execFileAsync( + 'git', + ['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'one'], + {}, + ); + await execFileAsync('git', ['-C', aDir, 'push', '-u', 'origin', 'main']); + + // b: clone, then a pushes a 2nd commit + await execFileAsync('git', ['clone', remoteDir, bDir]); + await fsp.writeFile(join(aDir, 'two.txt'), '2'); + await execFileAsync('git', ['-C', aDir, 'add', '.']); + await execFileAsync( + 'git', + ['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'two'], + {}, + ); + await execFileAsync('git', ['-C', aDir, 'push']); + + // b makes a divergent commit and tries to push → rejected. + await fsp.writeFile(join(bDir, 'b.txt'), 'b'); + await execFileAsync('git', ['-C', bDir, 'add', '.']); + await execFileAsync( + 'git', + ['-C', bDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'b'], + {}, + ); + await expect(sysPush({ cwd: bDir, branch: 'main' })).rejects.toMatchObject({ + name: 'GitError', + code: 'push-rejected', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 7a: worktree-merge real-git spike tests +// --------------------------------------------------------------------------- + +describe('worktree-merge real-git spike (gated on system git)', () => { + let temp: { dir: string; dispose: () => Promise }; + + beforeEach(async () => { + temp = await mkTempDir(); + }); + + afterEach(async () => { + if (temp) await temp.dispose(); + }); + + /** + * Helper: create a repo with two divergent branches, each modifying the + * tracked .op file (and optionally a README.md side file). + */ + async function setupDivergentRepo(opts: { + withReadme?: boolean; + readmeConflict?: boolean; + }): Promise<{ repoDir: string; gitdir: string }> { + const repoDir = join(temp.dir, 'repo'); + await fsp.mkdir(repoDir, { recursive: true }); + + const g = (...args: string[]) => execFileAsync('git', args, { cwd: repoDir }); + const gc = (...args: string[]) => + execFileAsync('git', ['-c', 'user.name=t', '-c', 'user.email=t@e.com', ...args], { + cwd: repoDir, + }); + + await g('init', '-b', 'main'); + // sysMergeNoCommit reads identity during git's internal bookkeeping; + // make tests self-sufficient for machines without global git config. + await g('config', 'user.name', 'Test'); + await g('config', 'user.email', 'test@test.com'); + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base' }] }), + ); + if (opts.withReadme) { + await fsp.writeFile(join(repoDir, 'README.md'), '# Base\n'); + } + await g('add', '.'); + await gc('commit', '-m', 'base'); + + // Branch off: feature changes + await g('checkout', '-b', 'feature'); + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'theirs' }] }), + ); + if (opts.withReadme && opts.readmeConflict) { + await fsp.writeFile(join(repoDir, 'README.md'), '# Feature\n'); + } + await g('add', '.'); + await gc('commit', '-m', 'theirs'); + + // Return to main: ours changes + await g('checkout', 'main'); + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'ours' }] }), + ); + if (opts.withReadme && opts.readmeConflict) { + await fsp.writeFile(join(repoDir, 'README.md'), '# Main\n'); + } + await g('add', '.'); + await gc('commit', '-m', 'ours'); + + const gitdir = join(repoDir, '.git'); + return { repoDir, gitdir }; + } + + it.skipIf(!systemGitAvailable)( + 'sysMergeNoCommit returns conflict and MERGE_HEAD is set', + async () => { + const { repoDir, gitdir } = await setupDivergentRepo({}); + + const result = await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' }); + expect(result.kind).toBe('conflict'); + + const mergeHead = await readMergeHead(gitdir); + expect(mergeHead).not.toBeNull(); + expect(mergeHead).toMatch(/^[a-f0-9]{40}$/); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'sysListUnresolved lists the tracked .op file as unresolved', + async () => { + const { repoDir } = await setupDivergentRepo({}); + await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' }); + + const unresolved = await sysListUnresolved({ cwd: repoDir }); + expect(unresolved).toContain('design.op'); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'sysListUnresolved lists both .op and README when both conflict', + async () => { + const { repoDir } = await setupDivergentRepo({ withReadme: true, readmeConflict: true }); + await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' }); + + const unresolved = await sysListUnresolved({ cwd: repoDir }); + expect(unresolved).toContain('design.op'); + expect(unresolved).toContain('README.md'); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'sysShowStageBlob reads base/ours/theirs from the index', + async () => { + const { repoDir } = await setupDivergentRepo({}); + await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' }); + + const base = await sysShowStageBlob({ cwd: repoDir, stage: 1, filepath: 'design.op' }); + const ours = await sysShowStageBlob({ cwd: repoDir, stage: 2, filepath: 'design.op' }); + const theirs = await sysShowStageBlob({ cwd: repoDir, stage: 3, filepath: 'design.op' }); + + expect(JSON.parse(base!).children[0].id).toBe('base'); + expect(JSON.parse(ours!).children[0].id).toBe('ours'); + expect(JSON.parse(theirs!).children[0].id).toBe('theirs'); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'sysRestoreOurs writes readable JSON and keeps MERGE_HEAD alive', + async () => { + const { repoDir, gitdir } = await setupDivergentRepo({}); + await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' }); + + await sysRestoreOurs({ cwd: repoDir, filepath: 'design.op' }); + + // File on disk is now readable JSON. + const content = await fsp.readFile(join(repoDir, 'design.op'), 'utf-8'); + expect(() => JSON.parse(content)).not.toThrow(); + expect(JSON.parse(content).children[0].id).toBe('ours'); + + // MERGE_HEAD is still set. + const mergeHead = await readMergeHead(gitdir); + expect(mergeHead).not.toBeNull(); + + // File is still listed as unresolved in the index. + const unresolved = await sysListUnresolved({ cwd: repoDir }); + expect(unresolved).toContain('design.op'); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'sysStageFile marks file as resolved so sysListUnresolved no longer includes it', + async () => { + const { repoDir } = await setupDivergentRepo({}); + await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' }); + + // Write the final content and stage it. + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'resolved' }] }), + ); + await sysStageFile({ cwd: repoDir, filepath: 'design.op' }); + + const unresolved = await sysListUnresolved({ cwd: repoDir }); + expect(unresolved).not.toContain('design.op'); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'sysFinalizeMerge creates a 2-parent merge commit and clears MERGE_HEAD', + async () => { + const { repoDir, gitdir } = await setupDivergentRepo({}); + const headBefore = await sysReadHead({ cwd: repoDir }); + await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' }); + + // Resolve the conflict. + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'resolved' }] }), + ); + await sysStageFile({ cwd: repoDir, filepath: 'design.op' }); + + const mergeCommit = await sysFinalizeMerge({ + cwd: repoDir, + message: 'Merge feature into main', + author: { name: 'Test', email: 'test@test.com' }, + }); + + expect(mergeCommit).toMatch(/^[a-f0-9]{40}$/); + expect(mergeCommit).not.toBe(headBefore); + + // MERGE_HEAD is gone. + const mergeHead = await readMergeHead(gitdir); + expect(mergeHead).toBeNull(); + + // Verify 2-parent commit via git cat-file. + const catResult = await execFileAsync('git', ['cat-file', '-p', 'HEAD'], { cwd: repoDir }); + const parentLines = catResult.stdout.split('\n').filter((line) => line.startsWith('parent ')); + expect(parentLines).toHaveLength(2); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'sysAbortMerge restores working tree and clears MERGE_HEAD', + async () => { + const { repoDir, gitdir } = await setupDivergentRepo({}); + const headBefore = await sysReadHead({ cwd: repoDir }); + await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' }); + + await sysAbortMerge({ cwd: repoDir }); + + // MERGE_HEAD is gone. + const mergeHead = await readMergeHead(gitdir); + expect(mergeHead).toBeNull(); + + // HEAD is unchanged. + const headAfter = await sysReadHead({ cwd: repoDir }); + expect(headAfter).toBe(headBefore); + + // design.op is the ours version (clean JSON, no conflict markers). + const content = await fsp.readFile(join(repoDir, 'design.op'), 'utf-8'); + expect(() => JSON.parse(content)).not.toThrow(); + expect(JSON.parse(content).children[0].id).toBe('ours'); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'sysAbortMerge is idempotent when no merge is in progress', + async () => { + const { repoDir } = await setupDivergentRepo({}); + // No merge started — abort should not throw. + await expect(sysAbortMerge({ cwd: repoDir })).resolves.toBeUndefined(); + }, + ); + + // --------------------------------------------------------------------------- + // Phase 7a spike scenario 3: rename conflict + // Documents what git actually does when the tracked .op file is RENAMED + // on the feature branch while also being modified on both branches. + // + // Setup: + // base: design.op (base content) + // main: design.op (modified — id: 'ours') + // feature: design-v2.op (renamed + modified — id: 'theirs') + // + // Expected git behavior after `git merge --no-commit --no-ff feature`: + // - exitCode 1 (conflict) + // - `git ls-files -u` lists BOTH "design.op" (deleted-by-them, stages 1+2) + // AND "design-v2.op" (added-by-them, stage 3 only) + // - stage 3 blob for "design.op" is absent (file was renamed away on theirs) + // - stage 1/2 blobs for "design-v2.op" are absent (file is new on theirs) + // + // Engine implication (verified in the engine test below): + // Since stage 3 blob for the tracked "design.op" is missing, the engine + // falls through to { result: 'conflict-non-op' }. This is CORRECT because + // we cannot perform a semantic merge when theirs renamed the tracked file. + // --------------------------------------------------------------------------- + it.skipIf(!systemGitAvailable)( + 'spike scenario 3: rename conflict — sysListUnresolved reports both old and new name', + async () => { + const repoDir = join(temp.dir, 'repo-rename'); + await fsp.mkdir(repoDir, { recursive: true }); + + const g = (...args: string[]) => execFileAsync('git', args, { cwd: repoDir }); + const gc = (...args: string[]) => + execFileAsync('git', ['-c', 'user.name=t', '-c', 'user.email=t@e.com', ...args], { + cwd: repoDir, + }); + + await g('init', '-b', 'main'); + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base' }] }), + ); + await g('add', '.'); + await gc('commit', '-m', 'base'); + + // feature branch: rename design.op → design-v2.op and modify content. + await g('checkout', '-b', 'feature'); + await fsp.rename(join(repoDir, 'design.op'), join(repoDir, 'design-v2.op')); + // Overwrite the new name with different content so there's a real content diff. + await fsp.writeFile( + join(repoDir, 'design-v2.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'theirs' }] }), + ); + await g('add', '-A'); + await gc('commit', '-m', 'rename to design-v2.op'); + + // main branch: modify design.op in place (divergent from the feature rename). + await g('checkout', 'main'); + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'ours' }] }), + ); + await g('add', '.'); + await gc('commit', '-m', 'ours'); + + // Attempt merge — expect conflict. + const mergeResult = await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' }); + expect(mergeResult.kind).toBe('conflict'); + + // SPIKE FINDING: git reports the rename as a conflict by listing the old + // path ("design.op") and possibly the new path ("design-v2.op") as unresolved. + // The exact set depends on git version and rename detection thresholds. + const unresolved = await sysListUnresolved({ cwd: repoDir }); + + // The original tracked file must appear in the unresolved list because + // git detects a rename conflict involving it. + expect(unresolved).toContain('design.op'); + + // Stage 3 blob for the ORIGINAL path must be absent (theirs renamed it away). + const stage3Original = await sysShowStageBlob({ + cwd: repoDir, + stage: 3, + filepath: 'design.op', + }); + expect(stage3Original).toBeNull(); + + // Stage 2 blob for the ORIGINAL path (ours) must be present. + const stage2Original = await sysShowStageBlob({ + cwd: repoDir, + stage: 2, + filepath: 'design.op', + }); + expect(stage2Original).not.toBeNull(); + expect(JSON.parse(stage2Original!).children[0].id).toBe('ours'); + + // CONCLUSION: the engine checks for all three stages of trackedRel. + // When stage 3 is null, it returns { result: 'conflict-non-op' }. + // This is correct: the user must resolve the rename in a terminal. + }, + ); + + // --------------------------------------------------------------------------- + // Phase 7a spike scenario 4: dirty working tree behavior + // + // The plan says the renderer-side `withCleanWorkingTree` gate should block + // merge attempts when the tracked file has uncommitted changes. This test + // documents what git *actually does* when that gate is bypassed — establishing + // the engine-layer contract: "the engine trusts callers to gate dirty trees; + // if they don't, here is what git does." + // + // Spike setup: + // - Both branches have a divergent commit on design.op (true 3-way merge + // scenario, not a fast-forward), so git must merge the working tree. + // - The working tree has an ADDITIONAL uncommitted change on top of the + // committed ours version. + // + // Spike finding: + // git merge --no-commit --no-ff with dirty tracked files that the merge + // would touch exits with a non-zero code and a "local changes would be + // overwritten" message. sysMergeNoCommit sees exit code != 0 and != 1, + // so it throws a GitError('engine-crash'). The dirty content is NOT + // silently lost or overwritten. + // + // This confirms that the renderer gate is the right place for the check: + // the engine will throw (not silently corrupt) if called with a dirty tree. + // --------------------------------------------------------------------------- + it.skipIf(!systemGitAvailable)( + 'spike scenario 4: sysMergeNoCommit throws engine-crash when dirty tracked file would be overwritten', + async () => { + const repoDir = join(temp.dir, 'repo-dirty'); + await fsp.mkdir(repoDir, { recursive: true }); + + const g = (...args: string[]) => execFileAsync('git', args, { cwd: repoDir }); + const gc = (...args: string[]) => + execFileAsync('git', ['-c', 'user.name=t', '-c', 'user.email=t@e.com', ...args], { + cwd: repoDir, + }); + + // Base commit on main. + await g('init', '-b', 'main'); + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'base' }] }), + ); + await g('add', '.'); + await gc('commit', '-m', 'base'); + + // feature branch: modify design.op and commit. + await g('checkout', '-b', 'feature'); + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'theirs' }] }), + ); + await g('add', '.'); + await gc('commit', '-m', 'theirs'); + + // main: ALSO make a divergent commit (creates a true 3-way merge). + await g('checkout', 'main'); + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'ours' }] }), + ); + await g('add', '.'); + await gc('commit', '-m', 'ours'); + + // Now dirty the tracked file AFTER committing (uncommitted working-tree change). + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'dirty-uncommitted' }] }), + ); + // Do NOT stage or commit — file is now dirty. + + // SPIKE: attempt the merge. Git detects the dirty tracked file would be + // overwritten by the merge and exits with a non-zero code OTHER than 1 + // (typically exit code 1 but with a "would be overwritten" message, or + // exit code 128 on some git versions). Either way, sysMergeNoCommit + // either throws or returns { kind: 'conflict' } (if git wrote markers). + // + // The critical contract: the dirty content is NEVER silently discarded. + let threwOrConflict = false; + try { + const mergeResult = await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' }); + // If sysMergeNoCommit did not throw, git returned exit code 0 or 1. + // Exit code 1 means it entered a conflict state — verify the dirty + // content is preserved in conflict markers or the file is unresolved. + if (mergeResult.kind === 'conflict') { + threwOrConflict = true; + const unresolved = await sysListUnresolved({ cwd: repoDir }); + // design.op must be listed — dirty working tree + merge conflict. + expect(unresolved).toContain('design.op'); + // The working tree file should contain conflict markers (not clean JSON). + const raw = await fsp.readFile(join(repoDir, 'design.op'), 'utf-8'); + // Either it has conflict markers OR it's valid JSON (git preserved ours). + // In both cases the content must not be silently replaced with theirs. + const hasConflictMarkers = raw.includes('<<<<<<<') || raw.includes('>>>>>>>'); + const isReadableJson = (() => { + try { + JSON.parse(raw); + return true; + } catch { + return false; + } + })(); + expect(hasConflictMarkers || isReadableJson).toBe(true); + } + // If kind === 'clean', the dirty content was identical to what the merge + // would produce — the merge happened to be a no-op for this file. + } catch (err) { + // sysMergeNoCommit threw a GitError — git refused the merge entirely. + // This is the most common outcome when dirty files would be overwritten. + threwOrConflict = true; + // The error should be an engine-crash (non-0/non-1 exit code from git). + const e = err as { name?: string; code?: string }; + expect(e.name).toBe('GitError'); + expect(e.code).toBe('engine-crash'); + } + + // CONTRACT: either git threw (refused) or entered conflict state. + // It must NOT silently overwrite the dirty content with theirs. + expect(threwOrConflict).toBe(true); + }, + ); + + it.skipIf(!systemGitAvailable)( + 'full workflow: tracked .op conflict + non-.op conflict, then finalize', + async () => { + const { repoDir, gitdir } = await setupDivergentRepo({ + withReadme: true, + readmeConflict: true, + }); + await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' }); + + // Confirm both conflict. + const unresolved = await sysListUnresolved({ cwd: repoDir }); + expect(unresolved).toContain('design.op'); + expect(unresolved).toContain('README.md'); + + // Resolve .op by writing final merged content and staging. + await fsp.writeFile( + join(repoDir, 'design.op'), + JSON.stringify({ version: '1.0.0', children: [{ id: 'merged' }] }), + ); + await sysStageFile({ cwd: repoDir, filepath: 'design.op' }); + + // .op is resolved; README still unresolved. + const afterOp = await sysListUnresolved({ cwd: repoDir }); + expect(afterOp).not.toContain('design.op'); + expect(afterOp).toContain('README.md'); + + // Resolve README (take ours). + await sysRestoreOurs({ cwd: repoDir, filepath: 'README.md' }); + await sysStageFile({ cwd: repoDir, filepath: 'README.md' }); + + // All resolved. + const afterAll = await sysListUnresolved({ cwd: repoDir }); + expect(afterAll).toHaveLength(0); + + // Finalize. + const mergeCommit = await sysFinalizeMerge({ + cwd: repoDir, + message: 'Merge feature: mixed conflict', + author: { name: 'Test', email: 'test@test.com' }, + }); + expect(mergeCommit).toMatch(/^[a-f0-9]{40}$/); + const mergeHead = await readMergeHead(gitdir); + expect(mergeHead).toBeNull(); + }, + ); +}); + +describe('mapSysError', () => { + it('maps known stderr substrings to GitError codes', () => { + expect(mapSysError('Authentication failed for ...')).toBe('auth-failed'); + expect(mapSysError('Permission denied (publickey).')).toBe('auth-failed'); + expect(mapSysError('Repository not found')).toBe('clone-failed'); + expect( + mapSysError("destination path 'foo' already exists and is not an empty directory."), + ).toBe('clone-target-exists'); + expect(mapSysError("Couldn't resolve host 'github.com'")).toBe('network'); + expect(mapSysError('Connection timed out')).toBe('timeout'); + expect(mapSysError('Updates were rejected because ...')).toBe('push-rejected'); + expect(mapSysError('not possible to fast-forward, aborting.')).toBe('pull-non-fast-forward'); + expect(mapSysError('fatal: not a git repository')).toBe('not-a-repo'); + expect(mapSysError('something completely unexpected')).toBe('engine-crash'); + }); +}); diff --git a/apps/desktop/git/__tests__/git-sys.test.ts b/apps/desktop/git/__tests__/git-sys.test.ts new file mode 100644 index 00000000..a5b8678f --- /dev/null +++ b/apps/desktop/git/__tests__/git-sys.test.ts @@ -0,0 +1,72 @@ +// apps/desktop/git/__tests__/git-sys.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { isSystemGitAvailable, __resetSystemGitCache, getSystemAuthor } from '../git-sys'; + +describe('git-sys', () => { + beforeEach(() => { + __resetSystemGitCache(); + }); + + it('isSystemGitAvailable returns a boolean and caches the result', async () => { + const first = await isSystemGitAvailable(); + expect(typeof first).toBe('boolean'); + // Second call should hit the cache and return the same value. + const second = await isSystemGitAvailable(); + expect(second).toBe(first); + }); +}); + +describe('getSystemAuthor (injected exec)', () => { + // These tests use the injected-exec seam to stay deterministic and avoid + // depending on whatever user.name/user.email happen to be configured on the + // host running the suite. The seam short-circuits isSystemGitAvailable and + // runGit entirely, so we exercise only the parse/validate/catch logic. + + it('returns parsed name/email on success', async () => { + const calls: string[][] = []; + const fakeExec = async (args: string[]) => { + calls.push(args); + if (args[2] === 'user.name') return { stdout: 'Alice\n', stderr: '' }; + if (args[2] === 'user.email') return { stdout: 'alice@example.com\n', stderr: '' }; + return { stdout: '', stderr: '' }; + }; + + const result = await getSystemAuthor(fakeExec); + + expect(result).toEqual({ name: 'Alice', email: 'alice@example.com' }); + expect(calls).toHaveLength(2); + expect(calls[0]).toEqual(['config', '--get', 'user.name']); + expect(calls[1]).toEqual(['config', '--get', 'user.email']); + }); + + it('returns null when git throws (e.g. key not set)', async () => { + const calls: string[][] = []; + const fakeExec = async (args: string[]) => { + calls.push(args); + // Simulate `git config --get user.name` exiting non-zero when unset. + throw new Error('git config --get user.name failed: exit code 1'); + }; + + const result = await getSystemAuthor(fakeExec); + + expect(result).toBeNull(); + // First call throws, so the second call never happens. + expect(calls).toHaveLength(1); + }); + + it('returns null when either value is empty/whitespace', async () => { + const calls: string[][] = []; + const fakeExec = async (args: string[]) => { + calls.push(args); + if (args[2] === 'user.name') return { stdout: 'Bob\n', stderr: '' }; + if (args[2] === 'user.email') return { stdout: ' \n', stderr: '' }; + return { stdout: '', stderr: '' }; + }; + + const result = await getSystemAuthor(fakeExec); + + expect(result).toBeNull(); + // Both calls happen because validation is post-fetch. + expect(calls).toHaveLength(2); + }); +}); diff --git a/apps/desktop/git/__tests__/merge-orchestrator.test.ts b/apps/desktop/git/__tests__/merge-orchestrator.test.ts new file mode 100644 index 00000000..10067c43 --- /dev/null +++ b/apps/desktop/git/__tests__/merge-orchestrator.test.ts @@ -0,0 +1,280 @@ +// apps/desktop/git/__tests__/merge-orchestrator.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkTempDir, writeOpFile } from './test-helpers'; +import { initSingleFile, commitFile, type IsoRepoHandle } from '../git-iso'; +import { runMerge, applyResolutions } from '../merge-orchestrator'; +import { buildConflictBag } from '../merge-session'; +import type { PenDocument } from '@zseven-w/pen-types'; +import { mergeDocuments } from '@zseven-w/pen-core'; + +const author = { name: 't', email: 't@example.com' }; + +async function commitDocument( + handle: IsoRepoHandle, + doc: PenDocument, + ref: string, + message: string, + parents?: string[], +): Promise { + // Write to disk first (commitFile reads from disk, not from the input). + const fsp = await import('node:fs/promises'); + const path = `${handle.dir}/login.op`; + await fsp.writeFile(path, JSON.stringify(doc), 'utf-8'); + const { hash } = await commitFile({ + handle, + filepath: 'login.op', + ref, + message, + author, + parents, + }); + return hash; +} + +describe('merge-orchestrator', () => { + let temp: { dir: string; dispose: () => Promise }; + let handle: IsoRepoHandle; + + beforeEach(async () => { + temp = await mkTempDir(); + const opFile = await writeOpFile(temp.dir, 'login.op', { + version: '1.0.0', + name: 'login', + children: [], + }); + handle = await initSingleFile({ filePath: opFile }); + }); + + afterEach(async () => { + await temp.dispose(); + }); + + it('runMerge produces an empty conflict bag for a clean merge', async () => { + const base: PenDocument = { + version: '1.0.0', + name: 'doc', + children: [{ id: 'rect-1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 } as never], + }; + const ours: PenDocument = { + version: '1.0.0', + name: 'doc', + children: [ + { id: 'rect-1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 } as never, + { id: 'rect-2', type: 'rectangle', x: 20, y: 0, width: 10, height: 10 } as never, + ], + }; + const theirs: PenDocument = { + version: '1.0.0', + name: 'doc', + children: [ + { id: 'rect-1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 } as never, + { id: 'rect-3', type: 'rectangle', x: 40, y: 0, width: 10, height: 10 } as never, + ], + }; + + const baseHash = await commitDocument(handle, base, 'refs/heads/main', 'base'); + const oursHash = await commitDocument(handle, ours, 'refs/heads/feature-a', 'ours', [baseHash]); + const theirsHash = await commitDocument(handle, theirs, 'refs/heads/feature-b', 'theirs', [ + baseHash, + ]); + + const merged = await runMerge({ + handle, + filepath: 'login.op', + oursCommit: oursHash, + theirsCommit: theirsHash, + baseCommit: baseHash, + }); + + expect(merged.bag.nodeConflicts).toHaveLength(0); + expect(merged.bag.docFieldConflicts).toHaveLength(0); + expect(merged.conflictMap.size).toBe(0); + }); + + it('runMerge surfaces a node conflict when both sides modify the same field', async () => { + const base: PenDocument = { + version: '1.0.0', + name: 'doc', + children: [ + { + id: 'rect-1', + type: 'rectangle', + x: 0, + y: 0, + width: 10, + height: 10, + fill: [{ type: 'solid', color: '#ff0000' }], + } as never, + ], + }; + const ours: PenDocument = { + version: '1.0.0', + name: 'doc', + children: [ + { + id: 'rect-1', + type: 'rectangle', + x: 0, + y: 0, + width: 10, + height: 10, + fill: [{ type: 'solid', color: '#00ff00' }], + } as never, + ], + }; + const theirs: PenDocument = { + version: '1.0.0', + name: 'doc', + children: [ + { + id: 'rect-1', + type: 'rectangle', + x: 0, + y: 0, + width: 10, + height: 10, + fill: [{ type: 'solid', color: '#0000ff' }], + } as never, + ], + }; + + const baseHash = await commitDocument(handle, base, 'refs/heads/main', 'base'); + const oursHash = await commitDocument(handle, ours, 'refs/heads/feature-a', 'ours', [baseHash]); + const theirsHash = await commitDocument(handle, theirs, 'refs/heads/feature-b', 'theirs', [ + baseHash, + ]); + + const merged = await runMerge({ + handle, + filepath: 'login.op', + oursCommit: oursHash, + theirsCommit: theirsHash, + baseCommit: baseHash, + }); + expect(merged.bag.nodeConflicts.length).toBeGreaterThan(0); + const c = merged.bag.nodeConflicts[0]; + expect(c.id).toBe('node:_:rect-1'); + expect(c.reason).toBe('both-modified-same-field'); + }); + + it('applyResolutions with kind=ours leaves the merged tree unchanged', () => { + const base: PenDocument = { version: '1.0.0', name: 'd', children: [] }; + const ours: PenDocument = { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 } as never], + }; + const theirs: PenDocument = { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 5, y: 5, width: 1, height: 1 } as never], + }; + const result = mergeDocuments({ base, ours, theirs }); + const { conflictMap } = buildConflictBag(result); + + const out = applyResolutions({ + merged: result.merged, + conflictMap, + resolutions: new Map([['node:_:a', { kind: 'ours' }]]), + }); + // pen-core's merge places ours as the placeholder, so the result is + // identical to ours. + expect(((out.children ?? [])[0] as { x: number }).x).toBe(0); + }); + + it('applyResolutions with kind=theirs replaces the node with the theirs version', () => { + const base: PenDocument = { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 } as never], + }; + const ours: PenDocument = { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 5, y: 5, width: 1, height: 1 } as never], + }; + const theirs: PenDocument = { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 9, y: 9, width: 1, height: 1 } as never], + }; + const result = mergeDocuments({ base, ours, theirs }); + const { conflictMap } = buildConflictBag(result); + expect(result.nodeConflicts.length).toBeGreaterThan(0); + + const out = applyResolutions({ + merged: result.merged, + conflictMap, + resolutions: new Map([['node:_:a', { kind: 'theirs' }]]), + }); + expect(((out.children ?? [])[0] as { x: number }).x).toBe(9); + }); + + it('applyResolutions with kind=manual-node replaces the node with the user-supplied version', () => { + const base: PenDocument = { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 } as never], + }; + const ours: PenDocument = { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 5, y: 5, width: 1, height: 1 } as never], + }; + const theirs: PenDocument = { + version: '1.0.0', + name: 'd', + children: [{ id: 'a', type: 'rectangle', x: 9, y: 9, width: 1, height: 1 } as never], + }; + const result = mergeDocuments({ base, ours, theirs }); + const { conflictMap } = buildConflictBag(result); + + const manualNode = { + id: 'a', + type: 'rectangle', + x: 100, + y: 100, + width: 1, + height: 1, + } as never; + const out = applyResolutions({ + merged: result.merged, + conflictMap, + resolutions: new Map([['node:_:a', { kind: 'manual-node', node: manualNode }]]), + }); + expect(((out.children ?? [])[0] as { x: number }).x).toBe(100); + }); + + it('applyResolutions handles a doc-field conflict via setDocFieldByPath', () => { + // Synthesize a doc-field conflict directly. We don't go through pen-core + // because constructing a real variable conflict requires more PenDocument + // boilerplate than this test needs to verify the path-set helper. + const merged: PenDocument = { + version: '1.0.0', + name: 'd', + children: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...({ variables: { 'color-1': { value: 'red' } } } as any), + }; + const conflictMap = new Map([ + [ + 'field:variables:variables.color-1.value', + { + field: 'variables' as const, + path: 'variables.color-1.value', + base: 'red', + ours: 'green', + theirs: 'blue', + }, + ], + ]); + + const out = applyResolutions({ + merged, + conflictMap, + resolutions: new Map([['field:variables:variables.color-1.value', { kind: 'theirs' }]]), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((out as any).variables['color-1'].value).toBe('blue'); + }); +}); diff --git a/apps/desktop/git/__tests__/merge-session.test.ts b/apps/desktop/git/__tests__/merge-session.test.ts new file mode 100644 index 00000000..6b3098a2 --- /dev/null +++ b/apps/desktop/git/__tests__/merge-session.test.ts @@ -0,0 +1,94 @@ +// apps/desktop/git/__tests__/merge-session.test.ts +import { describe, it, expect } from 'vitest'; +import { + encodeNodeConflictId, + encodeDocFieldConflictId, + parseConflictId, + buildConflictBag, +} from '../merge-session'; +import type { NodeConflict, DocFieldConflict, MergeResult } from '@zseven-w/pen-core'; + +describe('conflict id codec', () => { + it('encodes node conflicts with the page-id placeholder for legacy single-page docs', () => { + const a: NodeConflict = { + pageId: 'page-1', + nodeId: 'rect-7', + reason: 'both-modified-same-field', + base: null, + ours: null, + theirs: null, + }; + expect(encodeNodeConflictId(a)).toBe('node:page-1:rect-7'); + + const b: NodeConflict = { + pageId: null, + nodeId: 'frame-root', + reason: 'both-modified-same-field', + base: null, + ours: null, + theirs: null, + }; + expect(encodeNodeConflictId(b)).toBe('node:_:frame-root'); + }); + + it('encodes doc-field conflicts with field:path format', () => { + const a: DocFieldConflict = { + field: 'variables', + path: 'variables.color-1.value', + base: null, + ours: null, + theirs: null, + }; + expect(encodeDocFieldConflictId(a)).toBe('field:variables:variables.color-1.value'); + }); + + it('parses node and field ids back into structured form', () => { + expect(parseConflictId('node:page-1:rect-7')).toEqual({ + kind: 'node', + pageId: 'page-1', + nodeId: 'rect-7', + }); + expect(parseConflictId('node:_:frame-root')).toEqual({ + kind: 'node', + pageId: null, + nodeId: 'frame-root', + }); + expect(parseConflictId('field:variables:variables.color-1.value')).toEqual({ + kind: 'field', + field: 'variables', + path: 'variables.color-1.value', + }); + }); + + it('buildConflictBag attaches ids and builds the lookup map in one pass', () => { + const merged: MergeResult = { + merged: { version: '1.0.0', name: 'doc', children: [] }, + nodeConflicts: [ + { + pageId: null, + nodeId: 'frame-root', + reason: 'both-modified-same-field', + base: null, + ours: null, + theirs: null, + }, + ], + docFieldConflicts: [ + { + field: 'variables', + path: 'variables.color-1.value', + base: 'red', + ours: 'blue', + theirs: 'green', + }, + ], + }; + const { bag, conflictMap } = buildConflictBag(merged); + expect(bag.nodeConflicts).toHaveLength(1); + expect(bag.nodeConflicts[0].id).toBe('node:_:frame-root'); + expect(bag.docFieldConflicts[0].id).toBe('field:variables:variables.color-1.value'); + expect(conflictMap.size).toBe(2); + expect(conflictMap.get('node:_:frame-root')).toBeDefined(); + expect(conflictMap.get('field:variables:variables.color-1.value')).toBeDefined(); + }); +}); diff --git a/apps/desktop/git/__tests__/repo-detector.test.ts b/apps/desktop/git/__tests__/repo-detector.test.ts new file mode 100644 index 00000000..24aae563 --- /dev/null +++ b/apps/desktop/git/__tests__/repo-detector.test.ts @@ -0,0 +1,88 @@ +// apps/desktop/git/__tests__/repo-detector.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { detectRepo } from '../repo-detector'; +import { mkTempDir, writeOpFile, mkSubdir } from './test-helpers'; + +describe('detectRepo', () => { + let temp: { dir: string; dispose: () => Promise }; + + beforeEach(async () => { + temp = await mkTempDir(); + }); + + afterEach(async () => { + await temp.dispose(); + }); + + it('returns single-file mode when .op-history/.git/HEAD exists adjacent', async () => { + const opFile = await writeOpFile(temp.dir, 'login.op'); + const gitdir = await mkSubdir(temp.dir, '.op-history', 'login.op.git'); + await writeFile(join(gitdir, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + + const result = await detectRepo(opFile); + expect(result.mode).toBe('single-file'); + if (result.mode === 'single-file') { + expect(result.rootPath).toBe(resolve(temp.dir)); + expect(result.gitdir).toBe(resolve(gitdir)); + } + }); + + it('returns folder mode when the file lives inside a directory containing .git/HEAD', async () => { + const repoRoot = await mkSubdir(temp.dir, 'repo'); + const dotGit = await mkSubdir(repoRoot, '.git'); + await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + const opFile = await writeOpFile(repoRoot, 'design.op'); + + const result = await detectRepo(opFile); + expect(result.mode).toBe('folder'); + if (result.mode === 'folder') { + expect(result.rootPath).toBe(resolve(repoRoot)); + expect(result.gitdir).toBe(resolve(dotGit)); + } + }); + + it('returns folder mode when the file is nested several levels inside a parent git repo', async () => { + const repoRoot = await mkSubdir(temp.dir, 'project'); + const dotGit = await mkSubdir(repoRoot, '.git'); + await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + const designsDir = await mkSubdir(repoRoot, 'designs', 'login'); + const opFile = await writeOpFile(designsDir, 'login.op'); + + const result = await detectRepo(opFile); + expect(result.mode).toBe('folder'); + if (result.mode === 'folder') { + expect(result.rootPath).toBe(resolve(repoRoot)); + } + }); + + it('prefers single-file mode when both single-file and parent folder repos exist', async () => { + // Set up: a parent .git AND a sibling .op-history. Spec says single-file wins. + const repoRoot = await mkSubdir(temp.dir, 'project'); + const parentGit = await mkSubdir(repoRoot, '.git'); + await writeFile(join(parentGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + const opFile = await writeOpFile(repoRoot, 'login.op'); + const singleGitdir = await mkSubdir(repoRoot, '.op-history', 'login.op.git'); + await writeFile(join(singleGitdir, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8'); + + const result = await detectRepo(opFile); + expect(result.mode).toBe('single-file'); + if (result.mode === 'single-file') { + expect(result.gitdir).toBe(resolve(singleGitdir)); + } + }); + + it('returns none when no repository is found anywhere up the parent chain', async () => { + const opFile = await writeOpFile(temp.dir, 'orphan.op'); + const result = await detectRepo(opFile); + expect(result.mode).toBe('none'); + }); + + it('does not blow up when given a file path with non-existent parent directories', async () => { + // The walk-up should still produce a 'none' result, not throw. + const fakePath = join(temp.dir, 'does-not-exist', 'nested', 'fake.op'); + const result = await detectRepo(fakePath); + expect(result.mode).toBe('none'); + }); +}); diff --git a/apps/desktop/git/__tests__/repo-session.test.ts b/apps/desktop/git/__tests__/repo-session.test.ts new file mode 100644 index 00000000..4d4367d3 --- /dev/null +++ b/apps/desktop/git/__tests__/repo-session.test.ts @@ -0,0 +1,122 @@ +// apps/desktop/git/__tests__/repo-session.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { + registerSession, + getSession, + updateTrackedFile, + updateCandidates, + unregisterSession, + clearAllSessions, + sessionCount, + setInflightMerge, + clearInflightMerge, +} from '../repo-session'; +import type { IsoRepoHandle } from '../git-iso'; + +const stubHandle: IsoRepoHandle = { + dir: '/tmp/stub', + gitdir: '/tmp/stub/.git', + mode: 'folder', +}; + +describe('repo-session', () => { + beforeEach(() => { + clearAllSessions(); + }); + + it('registerSession allocates a unique repoId and getSession round-trips', () => { + const a = registerSession({ + handle: stubHandle, + trackedFilePath: '/tmp/stub/a.op', + candidateFiles: [], + engineKind: 'iso', + }); + const b = registerSession({ + handle: stubHandle, + trackedFilePath: '/tmp/stub/b.op', + candidateFiles: [], + engineKind: 'iso', + }); + expect(a.repoId).not.toBe(b.repoId); + expect(getSession(a.repoId)?.trackedFilePath).toBe('/tmp/stub/a.op'); + expect(getSession(b.repoId)?.trackedFilePath).toBe('/tmp/stub/b.op'); + expect(sessionCount()).toBe(2); + }); + + it('updateTrackedFile mutates the session and returns true; unknown id returns false', () => { + const s = registerSession({ + handle: stubHandle, + trackedFilePath: null, + candidateFiles: [], + engineKind: 'iso', + }); + expect(updateTrackedFile(s.repoId, '/tmp/stub/picked.op')).toBe(true); + expect(getSession(s.repoId)?.trackedFilePath).toBe('/tmp/stub/picked.op'); + expect(updateTrackedFile('not-a-real-id', '/tmp/x.op')).toBe(false); + }); + + it('updateCandidates replaces the cached candidate list', () => { + const s = registerSession({ + handle: stubHandle, + trackedFilePath: null, + candidateFiles: [], + engineKind: 'iso', + }); + expect( + updateCandidates(s.repoId, [ + { + path: '/tmp/stub/a.op', + relativePath: 'a.op', + milestoneCount: 0, + autosaveCount: 0, + lastCommitAt: null, + lastCommitMessage: null, + }, + ]), + ).toBe(true); + expect(getSession(s.repoId)?.candidateFiles).toHaveLength(1); + }); + + it('unregisterSession removes the entry and getSession returns undefined', () => { + const s = registerSession({ + handle: stubHandle, + trackedFilePath: null, + candidateFiles: [], + engineKind: 'iso', + }); + expect(unregisterSession(s.repoId)).toBe(true); + expect(getSession(s.repoId)).toBeUndefined(); + expect(unregisterSession(s.repoId)).toBe(false); // already gone + }); + + it('setInflightMerge and clearInflightMerge mutate the session', () => { + const s = registerSession({ + handle: stubHandle, + trackedFilePath: '/tmp/stub/a.op', + candidateFiles: [], + engineKind: 'iso', + }); + expect(getSession(s.repoId)?.inflightMerge).toBeNull(); + + // Minimal InflightMerge stub (types cast bypasses full shape — the test + // just exercises the registry mutators, not the merge logic). + const merge = { + oursCommit: 'a'.repeat(40), + theirsCommit: 'b'.repeat(40), + baseCommit: 'c'.repeat(40), + mergeResult: { + merged: { version: '1.0.0', name: 'd', children: [] }, + nodeConflicts: [], + docFieldConflicts: [], + }, + conflictMap: new Map(), + resolutions: new Map(), + defaultMessage: 'Merge', + }; + expect(setInflightMerge(s.repoId, merge as never)).toBe(true); + expect(getSession(s.repoId)?.inflightMerge).toBe(merge); + + expect(clearInflightMerge(s.repoId)).toBe(true); + expect(getSession(s.repoId)?.inflightMerge).toBeNull(); + }); +}); diff --git a/apps/desktop/git/__tests__/ssh-keys.test.ts b/apps/desktop/git/__tests__/ssh-keys.test.ts new file mode 100644 index 00000000..7fefdc4d --- /dev/null +++ b/apps/desktop/git/__tests__/ssh-keys.test.ts @@ -0,0 +1,75 @@ +// apps/desktop/git/__tests__/ssh-keys.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fsp } from 'node:fs'; +import { join } from 'node:path'; +import { createSshKeyManager, type SshKeyManager } from '../ssh-keys'; +import { mkTempDir } from './test-helpers'; + +describe('ssh-keys', () => { + let temp: { dir: string; dispose: () => Promise }; + let manager: SshKeyManager; + + beforeEach(async () => { + temp = await mkTempDir(); + manager = createSshKeyManager({ sshDir: join(temp.dir, 'ssh') }); + }); + + afterEach(async () => { + await temp.dispose(); + }); + + it('generate produces a valid OpenSSH ed25519 public key with a SHA256 fingerprint', async () => { + const info = await manager.generate({ host: 'github.com', comment: 'kay@laptop' }); + expect(info.publicKey.startsWith('ssh-ed25519 ')).toBe(true); + expect(info.publicKey.endsWith('kay@laptop')).toBe(true); + expect(info.fingerprint.startsWith('SHA256:')).toBe(true); + expect(info.privateKeyPath).toMatch(/\.pem$/); + + // The private key file exists with mode 0600. + const stat = await fsp.stat(info.privateKeyPath); + // mode bits include the file type, mask for permissions only. + expect(stat.mode & 0o777).toBe(0o600); + }); + + it('list returns all generated keys', async () => { + const a = await manager.generate({ host: 'github.com', comment: 'a' }); + const b = await manager.generate({ host: 'gitlab.com', comment: 'b' }); + const all = await manager.list(); + const ids = all.map((k) => k.id).sort(); + expect(ids).toEqual([a.id, b.id].sort()); + }); + + it('delete removes the key from the index and unlinks the private file', async () => { + const info = await manager.generate({ host: 'github.com', comment: 'k' }); + await manager.delete(info.id); + const all = await manager.list(); + expect(all).toHaveLength(0); + await expect(fsp.access(info.privateKeyPath)).rejects.toThrow(); + }); + + it('delete is idempotent: deleting an unknown key is a no-op', async () => { + await expect(manager.delete('not-a-real-id')).resolves.toBeUndefined(); + }); + + it('getPrivateKeyPath returns the absolute path for an existing key and throws for unknown', async () => { + const info = await manager.generate({ host: 'github.com', comment: 'k' }); + expect(await manager.getPrivateKeyPath(info.id)).toBe(info.privateKeyPath); + await expect(manager.getPrivateKeyPath('not-real')).rejects.toThrow(/SSH key/); + }); + + it('import takes an existing PEM private key file and stores a copy', async () => { + // First generate one to get a real PEM file we can use as the "external" source. + const seeded = await manager.generate({ host: 'github.com', comment: 'seed' }); + // Now create a fresh manager (different sshDir) and import the seeded private key. + const otherDir = join(temp.dir, 'other-ssh'); + const other = createSshKeyManager({ sshDir: otherDir }); + const imported = await other.import({ + privateKeyPath: seeded.privateKeyPath, + host: 'gitlab.com', + }); + expect(imported.publicKey.startsWith('ssh-ed25519 ')).toBe(true); + expect(imported.fingerprint).toBe(seeded.fingerprint); // same key → same fingerprint + expect(imported.privateKeyPath).not.toBe(seeded.privateKeyPath); // copied to new location + expect(imported.privateKeyPath.startsWith(otherDir)).toBe(true); + }); +}); diff --git a/apps/desktop/git/__tests__/test-helpers.ts b/apps/desktop/git/__tests__/test-helpers.ts new file mode 100644 index 00000000..34a92223 --- /dev/null +++ b/apps/desktop/git/__tests__/test-helpers.ts @@ -0,0 +1,51 @@ +// apps/desktop/git/__tests__/test-helpers.ts +// +// Shared test utilities for the desktop git layer tests. Each test creates +// a fresh temp dir, runs its operation, and cleans up via the returned +// disposer. This keeps tests isolated and parallel-safe. + +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Create a fresh temp directory under the OS temp path. Returns the path + * and a disposer that recursively removes it. Always pair the call with + * `try { ... } finally { await dispose(); }`. + */ +export async function mkTempDir(prefix = 'op-git-test-'): Promise<{ + dir: string; + dispose: () => Promise; +}> { + const dir = await mkdtemp(join(tmpdir(), prefix)); + return { + dir, + dispose: async () => { + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +/** + * Write a stub `.op` file (a tiny PenDocument JSON) into a directory. + * Returns the absolute file path. + */ +export async function writeOpFile( + dir: string, + name: string, + content: object = { version: '1.0.0', children: [] }, +): Promise { + const path = join(dir, name); + await writeFile(path, JSON.stringify(content), 'utf-8'); + return path; +} + +/** + * Create a nested directory structure under a temp root. + * Useful for setting up "file inside parent git repo" scenarios. + */ +export async function mkSubdir(root: string, ...segments: string[]): Promise { + const dir = join(root, ...segments); + await mkdir(dir, { recursive: true }); + return dir; +} diff --git a/apps/desktop/git/auth-store.ts b/apps/desktop/git/auth-store.ts new file mode 100644 index 00000000..0538ed0c --- /dev/null +++ b/apps/desktop/git/auth-store.ts @@ -0,0 +1,198 @@ +// apps/desktop/git/auth-store.ts +// +// Encrypted credential store backed by Electron safeStorage. The whole +// credential map is encrypted as a single blob and persisted to disk on +// every mutation. We don't shard per-host because the map is tiny (a +// handful of hosts at most) and atomic single-file writes are simpler. +// +// Tests inject a fake EncryptionBackend so they don't need a live Electron +// process. + +import { promises as fsp } from 'node:fs'; +import { join } from 'node:path'; + +export type AuthCreds = + | { kind: 'token'; username: string; token: string } + | { kind: 'ssh'; keyId: string }; + +export interface EncryptionBackend { + isAvailable(): boolean; + encrypt(plain: string): Buffer | string; + decrypt(cipher: Buffer | string): string; +} + +export interface AuthStore { + set(host: string, creds: AuthCreds): Promise; + get(host: string): Promise; + clear(host: string): Promise; + list(): Promise; +} + +interface AuthStoreOpts { + filePath: string; + backend: EncryptionBackend; +} + +const PLAINTEXT_HEADER = '__OPENPENCIL_AUTH_PLAINTEXT_V1__'; + +/** + * Build an AuthStore around a file path and an encryption backend. The + * default factory at the bottom of this file uses Electron's safeStorage; + * tests use createInMemoryBackend() instead. + */ +export function createAuthStore(opts: AuthStoreOpts): AuthStore { + const { filePath, backend } = opts; + let cache: Map | null = null; + let warnedNoEncryption = false; + // Set when we detect an encrypted blob on disk but the encryption backend + // is unavailable. While locked, all reads return empty AND all writes throw + // — we refuse to overwrite the encrypted file with plaintext (which would + // destroy the user's stored credentials). + let lockedOut = false; + + async function load(): Promise> { + if (cache) return cache; + try { + const bytes = await fsp.readFile(filePath); + let json: string; + // Plaintext file (from a previous run without encryption available)? + // Detect via the header marker. + const head = bytes + .slice(0, Math.min(PLAINTEXT_HEADER.length, bytes.length)) + .toString('utf-8'); + if (head === PLAINTEXT_HEADER) { + json = bytes.slice(PLAINTEXT_HEADER.length).toString('utf-8'); + } else if (backend.isAvailable()) { + json = backend.decrypt(bytes); + } else { + // Encrypted blob exists but no key. Lock the store: subsequent + // writes will throw rather than silently destroying the encrypted + // file by overwriting it with plaintext. + if (!warnedNoEncryption) { + console.warn( + '[git/auth-store] Encrypted credential file exists but safeStorage is unavailable. ' + + 'Refusing to read or modify until encryption is restored (e.g. install libsecret on Linux).', + ); + warnedNoEncryption = true; + } + lockedOut = true; + cache = new Map(); + return cache; + } + const obj = JSON.parse(json) as Record; + cache = new Map(Object.entries(obj)); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + cache = new Map(); + } else { + throw err; + } + } + return cache; + } + + async function save(map: Map): Promise { + if (lockedOut) { + throw new Error( + 'auth-store is locked: encrypted credential file exists but safeStorage is unavailable. ' + + 'Restore the encryption backend before modifying credentials to avoid data loss.', + ); + } + + const obj: Record = {}; + for (const [host, creds] of map) obj[host] = creds; + const json = JSON.stringify(obj); + + if (backend.isAvailable()) { + const encrypted = backend.encrypt(json); + const buf = Buffer.isBuffer(encrypted) ? encrypted : Buffer.from(encrypted); + await fsp.writeFile(filePath, buf, { mode: 0o600 }); + } else { + if (!warnedNoEncryption) { + console.warn( + '[git/auth-store] safeStorage unavailable; persisting credentials in plaintext (file mode 0600). Install libsecret for encryption.', + ); + warnedNoEncryption = true; + } + await fsp.writeFile(filePath, PLAINTEXT_HEADER + json, { mode: 0o600 }); + } + } + + return { + async set(host, creds) { + const map = await load(); + map.set(host, creds); + await save(map); + }, + async get(host) { + const map = await load(); + return map.get(host) ?? null; + }, + async clear(host) { + const map = await load(); + map.delete(host); + await save(map); + }, + async list() { + const map = await load(); + return [...map.keys()]; + }, + }; +} + +/** + * In-memory backend used by tests. Encrypt/decrypt are no-ops that wrap + * the input in a marker so we can verify the round-trip happened. + */ +export function createInMemoryBackend(): EncryptionBackend { + return { + isAvailable: () => true, + encrypt: (plain) => Buffer.from('MEMENC:' + plain, 'utf-8'), + decrypt: (cipher) => { + const s = Buffer.isBuffer(cipher) ? cipher.toString('utf-8') : cipher; + if (!s.startsWith('MEMENC:')) throw new Error('not memenc'); + return s.slice('MEMENC:'.length); + }, + }; +} + +/** + * Test-only helper: build an unavailable backend that always returns false + * for isAvailable() so tests can exercise the plaintext fallback. + */ +export function createUnavailableBackend(): EncryptionBackend { + return { + isAvailable: () => false, + encrypt: () => { + throw new Error('not available'); + }, + decrypt: () => { + throw new Error('not available'); + }, + }; +} + +/** + * Default factory: build an AuthStore that uses the real Electron safeStorage + * and the standard userData git-auth.bin location. Imported by ipc-handlers.ts. + * + * NOTE: This factory must NOT be called at module load time — Electron's + * safeStorage is only available after `app.whenReady()`. ipc-handlers.ts + * calls this lazily inside setupGitIPC(). + */ +export function createDefaultAuthStore(): AuthStore { + // Lazy require so tests don't pull in Electron. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const electron = require('electron'); + const userDataDir: string = electron.app.getPath('userData'); + const filePath = join(userDataDir, 'git-auth.bin'); + const backend: EncryptionBackend = { + isAvailable: () => electron.safeStorage.isEncryptionAvailable(), + encrypt: (plain) => electron.safeStorage.encryptString(plain), + decrypt: (cipher) => { + const buf = Buffer.isBuffer(cipher) ? cipher : Buffer.from(cipher); + return electron.safeStorage.decryptString(buf); + }, + }; + return createAuthStore({ filePath, backend }); +} diff --git a/apps/desktop/git/error.ts b/apps/desktop/git/error.ts new file mode 100644 index 00000000..eb160a7b --- /dev/null +++ b/apps/desktop/git/error.ts @@ -0,0 +1,74 @@ +// apps/desktop/git/error.ts +// +// Unified error type for the desktop git layer. Phase 1b only emits a subset +// of these codes; Phase 2 will throw the rest (auth, network, merge, etc.) +// without modifying this file. + +/** + * The complete error code union. New codes should land here, not in callsites, + * so the renderer's error matrix in the spec stays in sync with the reality. + */ +export type GitErrorCode = + // Phase 1b emits these: + | 'init-failed' + | 'open-failed' + | 'not-a-repo' + | 'commit-empty' + | 'branch-exists' + | 'branch-current' + | 'branch-unmerged' + | 'engine-crash' + // Phase 2 will emit these (declared here for forward-compat): + | 'no-file' + | 'clone-failed' + | 'clone-target-exists' + | 'clone-network' + | 'auth-required' + | 'auth-failed' + | 'auth-token-invalid' + | 'network' + | 'timeout' + | 'commit-author-missing' + | 'pull-non-fast-forward' + | 'push-rejected' + | 'push-no-remote' + | 'branch-switch-dirty' + | 'merge-conflict' + | 'merge-conflict-non-op' + | 'merge-still-conflicted' + | 'merge-abort-failed' + | 'restore-dirty' + | 'ssh-not-supported-iso' + | 'ssh-key-missing' + | 'concurrent-busy' + | 'external-modified' + | 'save-required'; + +export class GitError extends Error { + readonly code: GitErrorCode; + readonly recoverable: boolean; + readonly detail?: unknown; + + constructor( + code: GitErrorCode, + message: string, + opts: { recoverable?: boolean; detail?: unknown; cause?: unknown } = {}, + ) { + super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined); + this.name = 'GitError'; + this.code = code; + this.recoverable = opts.recoverable ?? true; + if (opts.detail !== undefined) this.detail = opts.detail; + } +} + +/** + * Type guard for catching GitError specifically (since `instanceof` across + * realms can be flaky in tests, this is a defensive backup). + */ +export function isGitError(err: unknown): err is GitError { + return ( + err instanceof GitError || + (typeof err === 'object' && err !== null && (err as { name?: string }).name === 'GitError') + ); +} diff --git a/apps/desktop/git/git-engine.ts b/apps/desktop/git/git-engine.ts new file mode 100644 index 00000000..0601735d --- /dev/null +++ b/apps/desktop/git/git-engine.ts @@ -0,0 +1,2010 @@ +// apps/desktop/git/git-engine.ts +// +// Engine adapter — the single backend interface for the git layer. Each +// IPC handler is a one-line forward to one of the exported `engineX` fns +// below. The engine owns: +// +// - repoId allocation (via repoSession) +// - dual-ref milestone semantics (heads + autosaves) +// - workingDirty detection via blob OID comparison +// - candidate file walking for the needs-tracked-file picker +// - the iso vs sys decision (Phase 2a: always 'iso') +// +// It does NOT own: +// - low-level git primitives (those live in git-iso.ts) +// - IPC serialization (that's ipc-handlers.ts) +// - clone/fetch/push/auth/SSH/merge (Phase 2b/2c) +// +// FILE SIZE DEBT (Phase 7a): This file is ~1982 lines — approximately 2.5× +// the 800-line guideline. The Phase 7a addition (engineBranchMergeFolderMode) +// could not move to worktree-merge.ts because it needs session state +// (repoSession, setInflightMerge) and ref-resolution helpers that live here; +// worktree-merge.ts is intentionally kept as a pure shell-wrapper boundary +// with no session coupling. Decomposition is deferred to a future phase — +// revisit when this file crosses ~2100 lines or when session state is extracted. + +import { resolve, basename, relative, join, sep } from 'node:path'; +import { promises as fsp } from 'node:fs'; +import * as git from 'isomorphic-git'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const httpNode = require('isomorphic-git/http/node') as typeof import('isomorphic-git/http/node'); +import * as fs from 'node:fs'; + +import { GitError, type GitErrorCode } from './error'; +import { detectRepo, type RepoDetection } from './repo-detector'; +import { + initSingleFile, + openRepo, + commitFile, + readBlobAtCommit, + logForRef, + restoreFileFromCommit, + listBranches, + createBranch, + deleteBranch, + switchBranch, + getCurrentBranch, + setRef, + readBlobOidAt, + findMergeBase, + writeRemoteOrigin, + type IsoRepoHandle, + type CommitMetaIso, +} from './git-iso'; +import { + isSystemGitAvailable, + sysClone, + sysFetch, + sysPush, + sysAheadBehind, + buildSshCommand, +} from './git-sys'; +import { + sysMergeNoCommit, + sysListUnresolved, + readMergeHead, + sysShowStageBlob, + sysRestoreOurs, + sysStageFile, + sysFinalizeMerge, + sysAbortMerge, +} from './worktree-merge'; +import { + registerSession, + getSession, + updateTrackedFile, + updateCandidates, + unregisterSession, + setInflightMerge, + clearInflightMerge, + type RepoSession, + type CandidateFileInfo, +} from './repo-session'; +import type { AuthCreds, AuthStore } from './auth-store'; +import type { SshKeyManager } from './ssh-keys'; +import { diffDocuments, mergeDocuments, type NodePatch } from '@zseven-w/pen-core'; +import type { PenDocument } from '@zseven-w/pen-types'; +import { runMerge, applyResolutions } from './merge-orchestrator'; +import { buildConflictBag, type ConflictBag, type ConflictResolution } from './merge-session'; + +// --------------------------------------------------------------------------- +// Public types — these are the wire shapes returned to the IPC layer. +// They mirror the spec's IPC contract section. +// --------------------------------------------------------------------------- + +export interface RepoOpenInfo { + repoId: string; + mode: 'single-file' | 'folder'; + rootPath: string; + gitdir: string; + engineKind: 'iso' | 'sys'; + trackedFilePath: string | null; + candidates: CandidateFileInfo[]; +} + +export interface CommitMeta { + hash: string; + parentHashes: string[]; + message: string; + author: { name: string; email: string; timestamp: number }; + kind: 'milestone' | 'autosave'; +} + +export interface BranchInfo { + name: string; + isCurrent: boolean; + ahead: number; // always 0 in Phase 2a (no remote tracking) + behind: number; // always 0 in Phase 2a + lastCommit: { hash: string; message: string; timestamp: number } | null; +} + +/** + * Phase 6a: renderer-visible remote metadata for the single 'origin' remote. + * Mirrors the wire shape declared in apps/web/src/services/git-types.ts. + */ +export interface RemoteInfo { + name: 'origin'; + url: string | null; + host: string | null; +} + +export interface StatusInfo { + /** Current branch from HEAD's symbolic ref. Always populated for normal + * repos — even on a fresh repo with no commits, isomorphic-git's + * currentBranch reads HEAD's symbolic value (e.g. 'main') without + * verifying the heads ref exists. The engine throws 'engine-crash' if + * HEAD is detached, so callers never see undefined here. */ + branch: string; + trackedFilePath: string | null; + workingDirty: boolean; + otherFilesDirty: number; + otherFilesPaths: string[]; + ahead: number; + behind: number; + mergeInProgress: boolean; + unresolvedFiles: string[]; + /** Wire-format conflict bag when an in-flight merge has unresolved + * conflicts. null otherwise. Phase 2c populates this from the session's + * inflightMerge state. */ + conflicts: ConflictBag | null; + /** + * I2: true when the panel was reopened mid-merge — MERGE_HEAD is present + * on disk but session.inflightMerge is null (new session, lost in-memory + * state). The renderer uses this to show an abort-only UI instead of the + * normal conflict resolution view. + */ + reopenedMidMerge: boolean; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Return the session or throw GitError('no-file'). */ +function requireSession(repoId: string): RepoSession { + const s = getSession(repoId); + if (!s) { + throw new GitError('no-file', `Unknown repoId: ${repoId}`, { recoverable: false }); + } + return s; +} + +/** + * Resolve an alias ref name to a fully-qualified ref: + * 'main' → 'refs/heads/' + * 'autosaves' → 'refs/openpencil/autosaves/' + * → 'refs/heads/' + * + * In Phase 2a there's no detached HEAD handling — if currentBranch is null + * we throw 'engine-crash' since the caller should have caught it earlier. + */ +async function getRefAlias( + handle: IsoRepoHandle, + alias: 'main' | 'autosaves' | string, +): Promise { + if (alias === 'main' || alias === 'autosaves') { + const branch = await getCurrentBranch({ handle }); + if (!branch) { + throw new GitError('engine-crash', 'No current branch (HEAD detached?)'); + } + return alias === 'main' ? `refs/heads/${branch}` : `refs/openpencil/autosaves/${branch}`; + } + return `refs/heads/${alias}`; +} + +// --------------------------------------------------------------------------- +// Phase 2b: SSH key manager + auth store singletons + dispatch helpers. +// Singletons are assigned by ipc-handlers.ts at boot via setSshKeyManager() +// and setAuthStore(). Tests inject fakes via the same setters. +// --------------------------------------------------------------------------- + +let sshKeyManager: SshKeyManager | null = null; +let authStore: AuthStore | null = null; + +export function setSshKeyManager(mgr: SshKeyManager | null): void { + sshKeyManager = mgr; +} + +export function setAuthStore(store: AuthStore | null): void { + authStore = store; +} + +async function resolveSshKeyPath(keyId: string): Promise { + if (!sshKeyManager) { + throw new GitError('ssh-key-missing', 'No SSH key manager configured'); + } + try { + return await sshKeyManager.getPrivateKeyPath(keyId); + } catch { + throw new GitError('ssh-key-missing', `SSH key ${keyId} not found`); + } +} + +/** + * Decide whether a network op needs system git. + * + * iso (isomorphic-git) only speaks HTTP/HTTPS. Everything else — SSH transport, + * `file://` URLs, local file paths — must go through sys. SSH key auth always + * forces sys regardless of URL scheme. + * + * - `null` URL (anonymous, no remote info) → iso (default safe path) + * - `http(s)://...` → iso + * - everything else → sys + */ +export function shouldUseSys(url: string | null, auth?: AuthCreds): boolean { + if (auth?.kind === 'ssh') return true; + if (!url) return false; + if (url.startsWith('https://') || url.startsWith('http://')) return false; + return true; +} + +/** + * Extract the hostname from a git remote URL. Handles three formats: + * https://host/path → host + * ssh://git@host:22/path → host + * git@host:user/repo.git → host (the SCP-style SSH form) + * + * Returns null for unparseable URLs (e.g. local file paths used in tests). + */ +export function parseHost(url: string): string | null { + if (url.startsWith('https://') || url.startsWith('http://') || url.startsWith('ssh://')) { + try { + return new URL(url).hostname || null; + } catch { + return null; + } + } + // SCP-style: user@host:path + const m = url.match(/^[^@\s]+@([^:\s]+):/); + if (m) return m[1]; + return null; +} + +/** + * Look up the remote URL configured for `` (default 'origin') on + * the given handle. Uses isomorphic-git's listRemotes which only reads + * .git/config — no network. Returns null if the remote isn't configured + * or the gitdir is unreadable. + */ +export async function getRemoteUrl( + handle: IsoRepoHandle, + remote = 'origin', +): Promise { + try { + const remotes = await git.listRemotes({ fs, gitdir: handle.gitdir }); + const r = remotes.find((x) => x.remote === remote); + return r?.url ?? null; + } catch { + return null; + } +} + +/** + * Pick the auth credentials to use for a remote operation. Order of + * precedence: + * 1. The explicit `auth` argument passed to the IPC call (highest) + * 2. A credential previously stored in auth-store, keyed by the URL's host + * 3. undefined → anonymous / let iso fail with auth-required + * + * This is the SINGLE place auth resolution happens. Every network engine fn + * (clone/fetch/pull/push) calls it before deciding iso vs sys and before + * passing creds into iso's onAuth callback. + */ +export async function resolveAuthForRemote( + url: string | null, + explicit?: AuthCreds, +): Promise { + if (explicit) return explicit; + if (!authStore || !url) return undefined; + const host = parseHost(url); + if (!host) return undefined; + return (await authStore.get(host)) ?? undefined; +} + +/** + * Map an iso (isomorphic-git) error to a GitErrorCode. iso errors carry + * a `.code` property and sometimes a `.data.statusCode` for HTTP failures. + */ +function mapIsoError(err: unknown): GitErrorCode { + const e = err as { code?: string; data?: { statusCode?: number }; message?: string }; + const status = e.data?.statusCode; + if (status === 401 || status === 403) return 'auth-failed'; + if (status === 404) return 'clone-failed'; + if ( + e.code === 'UrlParseError' || + e.code === 'EAI_AGAIN' || + (e.message ?? '').includes('ENOTFOUND') + ) { + return 'network'; + } + if (e.code === 'PushRejectedError') return 'push-rejected'; + if (e.code === 'MergeNotSupportedError' || e.code === 'FastForwardError') { + return 'pull-non-fast-forward'; + } + return 'engine-crash'; +} + +// --------------------------------------------------------------------------- +// Public engine fns +// --------------------------------------------------------------------------- + +/** + * Discover whether the given .op file lives inside a git repo. If found, + * register a session and auto-bind the file path. If not found, return + * { mode: 'none' } and allocate no session. + * + * The candidate file walk is NOT performed here for the 'none' branch. + * For the 'single-file' branch, candidates is always [opFile] (a single-file + * repo can only contain one .op file by definition). For the 'folder' branch, + * we run the full walk so the picker has data ready. + */ +export async function engineDetect(filePath: string): Promise<{ mode: 'none' } | RepoOpenInfo> { + const detection = await detectRepo(filePath); + if (detection.mode === 'none') { + return { mode: 'none' }; + } + const handle = await openRepo(detection); + const candidates = + detection.mode === 'single-file' + ? [await buildSingleFileCandidate(handle, filePath)] + : await walkCandidates(handle); + const session = registerSession({ + handle, + trackedFilePath: resolve(filePath), + candidateFiles: candidates, + engineKind: 'iso', + }); + return toOpenInfo(session); +} + +/** + * Initialize a fresh single-file repo at .op-history/.git next to + * the file. Auto-binds the file as the tracked file. + */ +export async function engineInit(filePath: string): Promise { + const handle = await initSingleFile({ filePath }); + const candidates = [await buildSingleFileCandidate(handle, filePath)]; + const session = registerSession({ + handle, + trackedFilePath: resolve(filePath), + candidateFiles: candidates, + engineKind: 'iso', + }); + return toOpenInfo(session); +} + +// --------------------------------------------------------------------------- +// Internal helpers (continued) +// --------------------------------------------------------------------------- + +function toOpenInfo(session: RepoSession): RepoOpenInfo { + return { + repoId: session.repoId, + mode: session.handle.mode, + rootPath: session.handle.dir, + gitdir: session.handle.gitdir, + engineKind: session.engineKind, + trackedFilePath: session.trackedFilePath, + candidates: session.candidateFiles, + }; +} + +/** + * Build a single CandidateFileInfo for a single-file repo. Counts come from + * the heads + autosaves refs for the current branch (or zeros if no commits + * yet). Used by engineDetect/Init in single-file mode. + */ +async function buildSingleFileCandidate( + handle: IsoRepoHandle, + filePath: string, +): Promise { + const abs = resolve(filePath); + const rel = basename(abs); + return computeCandidateMeta(handle, abs, rel); +} + +/** + * Compute the candidate metadata (counts + last commit) for one file by + * walking the heads and autosaves refs of the current branch and checking + * blob presence at each commit's tree. Used by both single-file and folder + * walks. + */ +async function computeCandidateMeta( + handle: IsoRepoHandle, + absPath: string, + relativePath: string, +): Promise { + const branch = await getCurrentBranch({ handle }); + if (!branch) { + return { + path: absPath, + relativePath, + milestoneCount: 0, + autosaveCount: 0, + lastCommitAt: null, + lastCommitMessage: null, + }; + } + + const headsRef = `refs/heads/${branch}`; + const autoRef = `refs/openpencil/autosaves/${branch}`; + const headsLog = await logForRef({ handle, ref: headsRef, depth: 10000 }); + const autoLog = await logForRef({ handle, ref: autoRef, depth: 10000 }); + + // Count commits whose tree contains this file's relativePath. We probe by + // calling readBlob and catching misses. + const milestoneCount = await countCommitsTouching(handle, headsLog, relativePath); + const autosaveCount = await countCommitsTouching(handle, autoLog, relativePath); + + // Most recent touching commit across both refs determines lastCommitAt. + let lastCommitAt: number | null = null; + let lastCommitMessage: string | null = null; + for (const c of [...headsLog, ...autoLog]) { + if (lastCommitAt === null || c.author.timestamp > lastCommitAt) { + // Verify the file is present at this commit before counting it as the latest. + try { + await git.readBlob({ + fs, + gitdir: handle.gitdir, + oid: c.hash, + filepath: relativePath, + }); + lastCommitAt = c.author.timestamp; + lastCommitMessage = c.message.trim(); + } catch { + // not in this commit + } + } + } + + return { + path: absPath, + relativePath, + milestoneCount, + autosaveCount, + lastCommitAt, + lastCommitMessage, + }; +} + +async function countCommitsTouching( + handle: IsoRepoHandle, + commits: CommitMetaIso[], + filepath: string, +): Promise { + let n = 0; + for (const c of commits) { + try { + await git.readBlob({ + fs, + gitdir: handle.gitdir, + oid: c.hash, + filepath, + }); + n++; + } catch { + // file not in this commit's tree + } + } + return n; +} + +/** + * Walk the worktree for *.op files (and *.pen, since the editor accepts both). + * Skips: + * - dotfiles and dotdirs (.git, .op-history, .DS_Store, ...) + * - node_modules + * - any directory the user can't read (logged + ignored) + * + * Returns CandidateFileInfo[] sorted by lastCommitAt descending (most + * recently-touched first), then by relativePath ascending as a stable tiebreak. + */ +async function walkCandidates(handle: IsoRepoHandle): Promise { + const root = handle.dir; + const found: string[] = []; // absolute paths + + async function recurse(dir: string): Promise { + let entries; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + if (entry.name === 'node_modules') continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + await recurse(full); + } else if (entry.isFile()) { + const lower = entry.name.toLowerCase(); + if (lower.endsWith('.op') || lower.endsWith('.pen')) { + found.push(full); + } + } + } + } + + await recurse(root); + + const metas: CandidateFileInfo[] = []; + for (const abs of found) { + const rel = relative(root, abs); + metas.push(await computeCandidateMeta(handle, abs, rel)); + } + + metas.sort((a, b) => { + const aTs = a.lastCommitAt ?? -1; + const bTs = b.lastCommitAt ?? -1; + if (aTs !== bTs) return bTs - aTs; // most recent first + return a.relativePath.localeCompare(b.relativePath); + }); + + return metas; +} + +/** + * Open an existing repo at `repoPath`. If `currentFilePath` is provided AND + * the file lives inside the repo's worktree, the file is auto-bound as the + * tracked file. Otherwise the session's trackedFilePath is left null and + * the renderer must call engineBindTrackedFile. + * + * Phase 2a only handles already-existing folder-mode repos here. Single-file + * repos enter the engine via engineDetect (which knows the .op file path + * from the start). + */ +export async function engineOpen( + repoPath: string, + currentFilePath?: string, +): Promise { + const absRepo = resolve(repoPath); + let detection: RepoDetection; + try { + // detectRepo expects a file path, not a dir. Probe with a dummy file inside. + detection = await detectRepo(join(absRepo, '__probe__.op')); + } catch (err) { + throw new GitError('open-failed', `Failed to probe ${absRepo}`, { cause: err }); + } + if (detection.mode !== 'folder') { + throw new GitError('not-a-repo', `${absRepo} is not a folder-mode git repository`); + } + const handle = await openRepo(detection); + + // Decide auto-binding. + let trackedFilePath: string | null = null; + if (currentFilePath) { + const absCurrent = resolve(currentFilePath); + if (isInside(handle.dir, absCurrent)) { + trackedFilePath = absCurrent; + } + } + + const candidates = await walkCandidates(handle); + + // If no current file but exactly one candidate exists, auto-bind it for + // convenience. (Spec §"Tracked file binding" allows this single-candidate + // shortcut.) + if (!trackedFilePath && candidates.length === 1) { + trackedFilePath = candidates[0].path; + } + + const session = registerSession({ + handle, + trackedFilePath, + candidateFiles: candidates, + engineKind: 'iso', + }); + return toOpenInfo(session); +} + +/** + * Set or replace the trackedFilePath of a session. Returns the new value. + * Throws 'no-file' for unknown repoId, 'open-failed' if the file is not + * inside the repo's worktree. + */ +export async function engineBindTrackedFile( + repoId: string, + filePath: string, +): Promise<{ trackedFilePath: string }> { + const session = requireSession(repoId); + const abs = resolve(filePath); + if (!isInside(session.handle.dir, abs)) { + throw new GitError('open-failed', `${abs} is not inside repo ${session.handle.dir}`); + } + updateTrackedFile(repoId, abs); + return { trackedFilePath: abs }; +} + +/** + * Re-walk the worktree and refresh the cached candidate list. Returns the + * fresh list. Used by the picker UI when the user adds files outside + * OpenPencil and wants to refresh. + */ +export async function engineListCandidates(repoId: string): Promise { + const session = requireSession(repoId); + const fresh = await walkCandidates(session.handle); + updateCandidates(repoId, fresh); + return fresh; +} + +/** + * Drop the session. The renderer should call this when the user closes the + * file (so memory doesn't grow with stale repoIds). It is also called by + * tests in afterEach via clearAllSessions. + */ +export function engineClose(repoId: string): void { + unregisterSession(repoId); +} + +/** + * Path containment check. Returns true if `child` is `root` itself or a + * descendant. Uses the resolved absolute paths. + */ +function isInside(root: string, child: string): boolean { + const r = resolve(root); + const c = resolve(child); + if (c === r) return true; + return c.startsWith(r + sep); +} + +/** + * Snapshot of the repo's working state. workingDirty is computed by hashing + * the tracked file's on-disk content and comparing it to the blob OID stored + * at the tip of refs/openpencil/autosaves/ (falling back to + * refs/heads/ if the autosave ref doesn't exist yet). + * + * Phase 2a constants (filled in by 2b/2c): + * - ahead/behind: 0 (no remote tracking yet) + * - mergeInProgress: false (no merge orchestration yet) + * - unresolvedFiles: [] (same) + */ +export async function engineStatus(repoId: string): Promise { + const session = requireSession(repoId); + const branch = await getCurrentBranch({ handle: session.handle }); + if (!branch) { + // Detached HEAD — Phase 2a doesn't support this yet. The renderer should + // never get here on a normal repo because initSingleFile and isomorphic-git's + // default init both leave HEAD as a symbolic ref to refs/heads/. + throw new GitError('engine-crash', 'HEAD is detached; Phase 2a does not support this'); + } + + // workingDirty: hash the on-disk file and compare to the autosave-ref tip + // (falling back to heads-ref if autosave doesn't exist yet). On a fresh + // repo with no commits both refs are missing → workingDirty is true. + let workingDirty = false; + if (session.trackedFilePath) { + workingDirty = await isWorkingDirty(session.handle, session.trackedFilePath, branch); + } + + // otherFilesDirty: in single-file mode, always 0. In folder mode, count + // files that differ from refs/heads/'s tree (excluding the tracked + // file itself). The walker takes the union of tree paths AND worktree paths + // so deleted-from-disk tracked files and untracked dotfiles are both counted. + let otherFilesDirty = 0; + let otherFilesPaths: string[] = []; + if (session.handle.mode === 'folder') { + const result = await countOtherDirtyFiles(session.handle, session.trackedFilePath, branch); + otherFilesDirty = result.count; + otherFilesPaths = result.paths; + } + + // ahead/behind: compute via sys git if available; otherwise return 0/0. + // Phase 2b adds this — Phase 2a always returned 0/0. + let ahead = 0; + let behind = 0; + if (await isSystemGitAvailable()) { + try { + const ab = await sysAheadBehind({ cwd: session.handle.dir, branch }); + ahead = ab.ahead; + behind = ab.behind; + } catch { + // No remote tracking → leave as 0/0. + } + } + + // Populate merge fields from two sources (Phase 7a): + // 1. session.inflightMerge — in-memory state for .op-level conflicts + // 2. on-disk MERGE_HEAD — survives session close/reopen, covers + // non-.op conflicts, and reflects terminal-initiated merges + let mergeInProgress = false; + let unresolvedFiles: string[] = []; + let conflicts: ConflictBag | null = null; + let reopenedMidMerge = false; + + if (session.inflightMerge) { + mergeInProgress = true; + // Build the wire-format bag from the in-flight conflict map. + const merged = session.inflightMerge.mergeResult; + conflicts = { + nodeConflicts: merged.nodeConflicts.map((c) => ({ + ...c, + id: `node:${c.pageId ?? '_'}:${c.nodeId}`, + })), + docFieldConflicts: merged.docFieldConflicts.map((c) => ({ + ...c, + id: `field:${c.field}:${c.path}`, + })), + }; + // Tracked .op file is "unresolved" until all conflicts have resolutions. + const totalConflicts = merged.nodeConflicts.length + merged.docFieldConflicts.length; + if (session.inflightMerge.resolutions.size < totalConflicts && session.trackedFilePath) { + unresolvedFiles = [toPosixPath(relative(session.handle.dir, session.trackedFilePath))]; + } + // Also check for non-.op files still unresolved on-disk (mixed conflict). + if (session.handle.mode === 'folder') { + const onDiskUnresolved = (await readMergeHead(session.handle.gitdir)) + ? await sysListUnresolved({ cwd: session.handle.dir }) + : []; + const trackedRel = session.trackedFilePath + ? toPosixPath(relative(session.handle.dir, session.trackedFilePath)) + : null; + for (const p of onDiskUnresolved) { + if (p !== trackedRel && !unresolvedFiles.includes(p)) { + unresolvedFiles.push(p); + } + } + } + } else if (session.handle.mode === 'folder') { + // No in-memory merge, but check on-disk MERGE_HEAD (e.g. after panel + // close/reopen mid-merge, or terminal-initiated merge). + const mergeHead = await readMergeHead(session.handle.gitdir); + if (mergeHead) { + mergeInProgress = true; + // I2: filter the tracked .op file out of unresolvedFiles so the renderer + // does not misleadingly label it as a "non-op file". The tracked file + // appears in the git index with stages 1/2/3 after a conflict, but the + // renderer has no UI to resolve it in the degraded panel-reopen state. + const sysUnresolved = await sysListUnresolved({ cwd: session.handle.dir }); + const trackedRel = session.trackedFilePath + ? toPosixPath(relative(session.handle.dir, session.trackedFilePath)) + : null; + unresolvedFiles = trackedRel ? sysUnresolved.filter((f) => f !== trackedRel) : sysUnresolved; + // Signal the degraded panel-reopen state so the renderer can show + // abort-only UI instead of the normal conflict resolution view. + reopenedMidMerge = true; + } + } + + return { + branch, + trackedFilePath: session.trackedFilePath, + workingDirty, + otherFilesDirty, + otherFilesPaths, + ahead, + behind, + mergeInProgress, + unresolvedFiles, + conflicts, + reopenedMidMerge, + }; +} + +/** + * Compute workingDirty for a tracked file. Hashes the disk content and + * compares to the blob OID at the autosave-ref tip (or heads-ref tip if no + * autosave ref exists yet). + */ +async function isWorkingDirty( + handle: IsoRepoHandle, + trackedFilePath: string, + branch: string, +): Promise { + let bytes: Buffer; + try { + bytes = await fsp.readFile(trackedFilePath); + } catch { + // File missing on disk → treat as dirty so the next save action surfaces + // the I/O error from there. We don't bubble fs errors out of status(). + return true; + } + const { oid: workOid } = await git.hashBlob({ object: bytes }); + + const rel = toPosixPath(relative(handle.dir, trackedFilePath)); + let refOid = await readBlobOidAt({ + handle, + ref: `refs/openpencil/autosaves/${branch}`, + filepath: rel, + }); + if (refOid === null) { + refOid = await readBlobOidAt({ + handle, + ref: `refs/heads/${branch}`, + filepath: rel, + }); + } + if (refOid === null) { + // Neither ref has the file → dirty. + return true; + } + return refOid !== workOid; +} + +/** + * Count files (other than the tracked file) that are dirty relative to the + * heads-ref tip. "Dirty" includes: + * - file present in tree but missing on disk (deleted) + * - file present on disk but missing from tree (untracked) + * - file present in both with different blob OIDs (modified) + * + * The walker takes the UNION of tree paths and worktree paths so all three + * cases are caught. Tracked dotfiles like `.gitignore` are included — only + * `.git/`, `.op-history/`, and `node_modules/` are excluded from the walk. + * + * Returns POSIX-separated relative paths matching git's tree format. + */ +async function countOtherDirtyFiles( + handle: IsoRepoHandle, + trackedFilePath: string | null, + branch: string, +): Promise<{ count: number; paths: string[] }> { + const headsRef = `refs/heads/${branch}`; + let tip: string; + try { + tip = await git.resolveRef({ fs, gitdir: handle.gitdir, ref: headsRef }); + } catch { + // No heads ref yet → no commits exist, so by definition every file on + // disk is "untracked but dirty". For Phase 2a we treat the empty-history + // case as 0 other-dirty (the panel UX shows "no history yet" instead of + // a wall of untracked files). The folder dirty count becomes meaningful + // only after the first milestone. + return { count: 0, paths: [] }; + } + + const treePaths = await listTreePaths(handle, tip); + const diskPaths = await listWorktreePaths(handle.dir); + const all = new Set([...treePaths, ...diskPaths]); + + const trackedRel = trackedFilePath ? toPosixPath(relative(handle.dir, trackedFilePath)) : null; + const dirty: string[] = []; + + for (const rel of all) { + if (trackedRel && rel === trackedRel) continue; + + // diskOid: null if file missing/unreadable on disk. + let diskOid: string | null = null; + try { + const bytes = await fsp.readFile(join(handle.dir, ...rel.split('/'))); + const { oid } = await git.hashBlob({ object: bytes }); + diskOid = oid; + } catch { + diskOid = null; + } + + // treeOid: null if file is not in the heads tree. + let treeOid: string | null = null; + try { + const blob = await git.readBlob({ + fs, + gitdir: handle.gitdir, + oid: tip, + filepath: rel, + }); + treeOid = blob.oid; + } catch { + treeOid = null; + } + + // Dirty if either side missing or hashes differ. + if (diskOid !== treeOid) { + dirty.push(rel); + } + } + + // Stable sort for deterministic UI rendering and tests. + dirty.sort(); + return { count: dirty.length, paths: dirty }; +} + +/** + * Recursively list every blob path inside a commit's root tree. Returns + * POSIX-separated paths matching git's internal format. + */ +async function listTreePaths(handle: IsoRepoHandle, commit: string): Promise { + const out: string[] = []; + await walkTree(handle, commit, '', out); + return out; +} + +async function walkTree( + handle: IsoRepoHandle, + oid: string, + prefix: string, + out: string[], +): Promise { + // git.readTree accepts either a tree OID or a commit OID (in which case it + // resolves to that commit's root tree). + const { tree } = await git.readTree({ fs, gitdir: handle.gitdir, oid }); + for (const entry of tree) { + const path = prefix ? `${prefix}/${entry.path}` : entry.path; + if (entry.type === 'tree') { + await walkTree(handle, entry.oid, path, out); + } else if (entry.type === 'blob') { + out.push(path); + } + } +} + +/** + * Walk the worktree and return every regular file's POSIX-relative path. + * Excludes ONLY `.git/`, `.op-history/`, and `node_modules/`. Tracked dotfiles + * (`.gitignore`, `.editorconfig`, etc.) are included. + */ +async function listWorktreePaths(root: string): Promise { + const out: string[] = []; + async function recurse(dir: string, prefix: string): Promise { + let entries; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name === '.git' || entry.name === '.op-history' || entry.name === 'node_modules') { + continue; + } + const full = join(dir, entry.name); + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await recurse(full, rel); + } else if (entry.isFile()) { + out.push(rel); + } + } + } + await recurse(root, ''); + return out; +} + +/** + * Convert an OS-native relative path to POSIX form so it matches git's + * internal tree format on Windows. + */ +function toPosixPath(p: string): string { + return p.split(sep).join('/'); +} + +/** + * Walk commits from the given ref. The ref alias is resolved by getRefAlias: + * 'main' → refs/heads/ + * 'autosaves' → refs/openpencil/autosaves/ + * → refs/heads/ + * + * Each commit is decorated with `kind`: 'milestone' if its hash is reachable + * from refs/heads/, 'autosave' otherwise. + */ +export async function engineLog( + repoId: string, + opts: { ref: 'main' | 'autosaves' | string; limit: number }, +): Promise { + const session = requireSession(repoId); + const ref = await getRefAlias(session.handle, opts.ref); + const commits = await logForRef({ handle: session.handle, ref, depth: opts.limit }); + + // Build the set of milestone hashes for the current branch so we can label + // each entry. We do this by walking the heads ref to a generous depth. + const branch = await getCurrentBranch({ handle: session.handle }); + const milestoneHashes = new Set(); + if (branch) { + const headsCommits = await logForRef({ + handle: session.handle, + ref: `refs/heads/${branch}`, + depth: 10000, + }); + for (const c of headsCommits) milestoneHashes.add(c.hash); + } + + return commits.map((c) => ({ + hash: c.hash, + parentHashes: c.parentHashes, + message: c.message, + author: c.author, + kind: milestoneHashes.has(c.hash) ? 'milestone' : 'autosave', + })); +} + +/** + * Create a commit on the tracked file. Two kinds: + * + * 'milestone': writes the commit to refs/heads/ (parent = heads + * tip), then force-jumps refs/openpencil/autosaves/ to the new + * hash. Abandons any intermediate autosaves. + * + * 'autosave': writes the commit to refs/openpencil/autosaves/ + * (parent = autosaves tip, which may be a milestone if no autosaves + * since). heads ref is untouched. + * + * Throws GitError 'commit-empty' if the working file content is identical + * to its tip on the relevant ref. Throws 'no-file' for unknown repoId or + * for a session with no trackedFilePath set. + */ +export async function engineCommit( + repoId: string, + opts: { + kind: 'milestone' | 'autosave'; + message: string; + author: { name: string; email: string }; + }, +): Promise<{ hash: string }> { + const session = requireSession(repoId); + if (!session.trackedFilePath) { + throw new GitError('no-file', 'Session has no trackedFilePath; call bindTrackedFile first'); + } + + const branch = await getCurrentBranch({ handle: session.handle }); + if (!branch) { + throw new GitError('engine-crash', 'HEAD is detached; Phase 2a does not support this'); + } + const headsRef = `refs/heads/${branch}`; + const autoRef = `refs/openpencil/autosaves/${branch}`; + + const rel = toPosixPath(relative(session.handle.dir, session.trackedFilePath)); + + if (opts.kind === 'milestone') { + const { hash } = await commitFile({ + handle: session.handle, + filepath: rel, + ref: headsRef, + message: opts.message, + author: opts.author, + }); + await setRef({ handle: session.handle, ref: autoRef, value: hash }); + return { hash }; + } + + // autosave + // + // Phase 4c content-hash debounce: if the tracked file's current disk + // blob matches the autosave tip's blob for the same path, skip the + // commit and return the existing tip hash. This catches the common + // "multi-Cmd+S" / "undo-to-clean" case where the user fires N saves + // with no actual content change. + const tipBlobOid = await readBlobOidAt({ + handle: session.handle, + ref: autoRef, + filepath: rel, + }); + if (tipBlobOid !== null) { + const diskContent = await fsp.readFile(session.trackedFilePath); + const { oid: diskBlobHash } = await git.hashBlob({ object: diskContent }); + if (diskBlobHash === tipBlobOid) { + // No content change since the last autosave — return the existing + // tip commit hash as a no-op. We re-resolve the ref here because + // readBlobOidAt only exposes the blob oid, and the API contract + // requires a commit hash in the return value. + const currentTipHash = await git.resolveRef({ + fs, + gitdir: session.handle.gitdir, + ref: autoRef, + }); + return { hash: currentTipHash }; + } + } + + const { hash } = await commitFile({ + handle: session.handle, + filepath: rel, + ref: autoRef, + message: opts.message, + author: opts.author, + }); + return { hash }; +} + +/** + * Restore the tracked file's content from a specific commit. Writes the + * blob to the working tree but does NOT create a new commit — the engine + * leaves the working tree dirty so the user sees pending changes and can + * decide whether to record a milestone. + */ +export async function engineRestore(repoId: string, commitHash: string): Promise { + const session = requireSession(repoId); + if (!session.trackedFilePath) { + throw new GitError('no-file', 'Session has no trackedFilePath'); + } + const rel = toPosixPath(relative(session.handle.dir, session.trackedFilePath)); + await restoreFileFromCommit({ + handle: session.handle, + filepath: rel, + commitHash, + }); +} + +/** + * Promote an autosave commit to a milestone: + * 1. Read the autosave's blob for the tracked file. + * 2. Write it to the working tree. + * 3. Create a milestone commit (which advances both heads and autosaves). + * + * The result is a clean milestone with the user-supplied message that + * captures the same content as the autosave. The intermediate autosave + * chain is abandoned by the milestone's force-update of the autosave ref. + */ +export async function enginePromote( + repoId: string, + autosaveHash: string, + message: string, + author: { name: string; email: string }, +): Promise<{ hash: string }> { + const session = requireSession(repoId); + if (!session.trackedFilePath) { + throw new GitError('no-file', 'Session has no trackedFilePath'); + } + const rel = toPosixPath(relative(session.handle.dir, session.trackedFilePath)); + + // Step 1+2: read the autosave content and write it to disk. + const content = await readBlobAtCommit({ + handle: session.handle, + filepath: rel, + commitHash: autosaveHash, + }); + await fsp.writeFile(session.trackedFilePath, content, 'utf-8'); + + // Step 3: milestone commit using the engine's normal path. + return engineCommit(repoId, { kind: 'milestone', message, author }); +} + +/** + * List local branches with current-marker and lastCommit metadata. + * ahead/behind are 0 in Phase 2a (no remote tracking yet). + */ +export async function engineBranchList(repoId: string): Promise { + const session = requireSession(repoId); + const names = await listBranches({ handle: session.handle }); + const current = await getCurrentBranch({ handle: session.handle }); + + const out: BranchInfo[] = []; + for (const name of names) { + const log = await logForRef({ + handle: session.handle, + ref: `refs/heads/${name}`, + depth: 1, + }); + const tip = log[0]; + out.push({ + name, + isCurrent: name === current, + ahead: 0, + behind: 0, + lastCommit: tip + ? { + hash: tip.hash, + message: tip.message.trim(), + timestamp: tip.author.timestamp, + } + : null, + }); + } + return out; +} + +/** + * Create a new branch under refs/heads/. If `fromCommit` is omitted, the + * branch is created at the current HEAD. + */ +export async function engineBranchCreate( + repoId: string, + opts: { name: string; fromCommit?: string }, +): Promise { + const session = requireSession(repoId); + await createBranch({ + handle: session.handle, + name: opts.name, + fromCommit: opts.fromCommit, + }); +} + +/** + * Switch HEAD to the given branch. Updates the tracked file in the working + * tree to that branch's tip. Throws 'no-file' if the session has no + * trackedFilePath bound (the panel UI must bind a file before allowing + * branch switches). + */ +export async function engineBranchSwitch(repoId: string, name: string): Promise { + const session = requireSession(repoId); + if (!session.trackedFilePath) { + throw new GitError('no-file', 'Session has no trackedFilePath'); + } + const rel = toPosixPath(relative(session.handle.dir, session.trackedFilePath)); + await switchBranch({ handle: session.handle, name, filepath: rel }); +} + +/** + * Delete a non-current branch. The Phase 1b primitive throws 'branch-current' + * if you try to delete the active branch and 'branch-unmerged' if the + * branch has commits not reachable from any other ref; Phase 5 adds the + * `force` flag so the renderer can retry after a confirm dialog. + */ +export async function engineBranchDelete( + repoId: string, + name: string, + opts: { force?: boolean } = {}, +): Promise { + const session = requireSession(repoId); + await deleteBranch({ handle: session.handle, name, force: opts.force === true }); +} + +/** + * Clone a remote repository. For HTTPS URLs we use isomorphic-git's clone + * with the node http transport. For SSH URLs (or auth.kind === 'ssh') we + * shell out to system git with GIT_SSH_COMMAND configured to use the + * specified private key. + * + * Per spec line 669, clone NEVER auto-binds a tracked file — the renderer + * must call bindTrackedFile before commit/restore work, even if the cloned + * repo happens to contain exactly one .op file. We therefore do NOT call + * engineOpen (which auto-binds the single-candidate case) and instead + * register the session manually with trackedFilePath: null. + */ +export async function engineClone(opts: { + url: string; + dest: string; + auth?: AuthCreds; +}): Promise { + const { url, dest } = opts; + // Resolve auth: explicit > stored-by-host > anonymous. + const resolvedAuth = await resolveAuthForRemote(url, opts.auth); + const useSys = shouldUseSys(url, resolvedAuth); + + if (useSys) { + // SSH path requires system git. + if (!(await isSystemGitAvailable())) { + throw new GitError( + 'ssh-not-supported-iso', + 'SSH transport requires system git; install git or use HTTPS', + ); + } + let env: Record | undefined; + if (resolvedAuth?.kind === 'ssh') { + const keyPath = await resolveSshKeyPath(resolvedAuth.keyId); + env = { GIT_SSH_COMMAND: buildSshCommand(keyPath) }; + } + await sysClone({ url, dest, env }); + } else { + // HTTPS path via iso. onAuth uses the pre-resolved creds; iso may invoke + // it lazily when the server demands authentication. + try { + await git.clone({ + fs, + http: httpNode, + dir: dest, + url, + singleBranch: false, + depth: undefined, // full history + onAuth: () => { + if (resolvedAuth?.kind === 'token') { + return { username: resolvedAuth.username, password: resolvedAuth.token }; + } + return undefined; // anonymous + }, + onAuthFailure: () => { + // Returning undefined cancels the request → iso throws an error. + return undefined; + }, + }); + } catch (err) { + throw new GitError(mapIsoError(err), `Clone failed: ${url}`, { + cause: err, + detail: { url, dest }, + }); + } + } + + // After a successful clone, register the session manually. We bypass + // engineOpen on purpose: spec §"User Experience" line 109 says clone + // ALWAYS lands in needs-tracked-file regardless of how many .op files + // the repo contains. engineOpen auto-binds the single-candidate case, + // which would silently violate that contract. + const detection = await detectRepo(join(dest, '__probe__.op')); + if (detection.mode !== 'folder') { + throw new GitError( + 'clone-failed', + `Cloned repo at ${dest} is not detectable as a folder repository`, + ); + } + const handle = await openRepo(detection); + const candidates = await walkCandidates(handle); + const session = registerSession({ + handle, + trackedFilePath: null, // ALWAYS null after clone, per spec + candidateFiles: candidates, + engineKind: 'iso', + }); + return toOpenInfo(session); +} + +/** + * Phase 6a: read the configured `origin` remote from `.git/config` only. + * No network, no IO outside the gitdir. Returns `{ name: 'origin', url: + * null, host: null }` when origin is absent. + */ +export async function engineRemoteGet(repoId: string): Promise { + const session = requireSession(repoId); + const url = await getRemoteUrl(session.handle, 'origin'); + return { + name: 'origin', + url, + host: url ? parseHost(url) : null, + }; +} + +/** + * Phase 6a: set, update, or remove the single 'origin' remote. + * + * - non-empty `url` → upsert in `.git/config` + * - `null` → remove from `.git/config` (idempotent if absent) + * + * Returns the fresh RemoteInfo so the renderer can update its cached state + * from a single round-trip without a follow-up engineRemoteGet(). + */ +export async function engineRemoteSet(repoId: string, url: string | null): Promise { + const session = requireSession(repoId); + await writeRemoteOrigin({ handle: session.handle, url }); + // Read back through the same getRemoteUrl path to guarantee the renderer + // sees exactly what `.git/config` now holds (handles trailing-slash and + // any other normalization that addRemote may apply). + const stored = await getRemoteUrl(session.handle, 'origin'); + return { + name: 'origin', + url: stored, + host: stored ? parseHost(stored) : null, + }; +} + +/** + * Fetch from origin, updating remote-tracking refs. Returns ahead/behind + * for the current branch after the fetch. + * + * Dispatch policy: read the actual configured `origin` URL from .git/config + * (no network) and use shouldUseSys(remoteUrl, resolvedAuth) to decide iso + * vs sys. This means a repo cloned via SSH always routes to sys for fetch, + * even when the caller doesn't pass an explicit auth argument. + */ +export async function engineFetch( + repoId: string, + auth?: AuthCreds, +): Promise<{ ahead: number; behind: number }> { + const session = requireSession(repoId); + const branch = await getCurrentBranch({ handle: session.handle }); + if (!branch) { + throw new GitError('engine-crash', 'HEAD is detached'); + } + + const remoteUrl = await getRemoteUrl(session.handle); + const resolvedAuth = await resolveAuthForRemote(remoteUrl, auth); + const useSys = shouldUseSys(remoteUrl, resolvedAuth); + + if (useSys) { + if (!(await isSystemGitAvailable())) { + throw new GitError('ssh-not-supported-iso', 'SSH transport requires system git'); + } + let env: Record | undefined; + if (resolvedAuth?.kind === 'ssh') { + const keyPath = await resolveSshKeyPath(resolvedAuth.keyId); + env = { GIT_SSH_COMMAND: buildSshCommand(keyPath) }; + } + await sysFetch({ cwd: session.handle.dir, env }); + } else { + try { + await git.fetch({ + fs, + http: httpNode, + dir: session.handle.dir, + gitdir: session.handle.gitdir, + onAuth: () => { + if (resolvedAuth?.kind === 'token') { + return { username: resolvedAuth.username, password: resolvedAuth.token }; + } + return undefined; + }, + onAuthFailure: () => undefined, + }); + } catch (err) { + throw new GitError(mapIsoError(err), `Fetch failed`, { cause: err }); + } + } + + // Compute ahead/behind. Try sys first (more accurate); fall back to 0/0 + // if sys git isn't available. + if (await isSystemGitAvailable()) { + return sysAheadBehind({ cwd: session.handle.dir, branch }); + } + return { ahead: 0, behind: 0 }; +} + +/** + * Pull from origin. Phase 2c rewrites this to use the full merge path: + * fetch updates refs/remotes/origin/, then engineBranchMerge runs + * the in-process pen-core merge against that ref. Conflicts land in session + * state the same way as a local branchMerge would. + */ +export async function enginePull( + repoId: string, + auth?: AuthCreds, +): Promise<{ + result: 'fast-forward' | 'merge' | 'conflict' | 'conflict-non-op'; + conflicts?: ConflictBag; +}> { + const session = requireSession(repoId); + const branch = await getCurrentBranch({ handle: session.handle }); + if (!branch) { + throw new GitError('engine-crash', 'HEAD is detached'); + } + + // Step 1: fetch from origin so refs/remotes/origin/ is current. + await engineFetch(repoId, auth); + + // Step 2: merge the remote-tracking ref into the current branch using the + // same code path as a local branch merge. + const remoteRef = `refs/remotes/origin/${branch}`; + return engineBranchMerge(repoId, remoteRef); +} + +/** + * Push the current branch to origin. iso for HTTPS, sys for SSH/local. Same + * dispatch + auth-resolution policy as engineFetch. + * + * Phase 2b throws GitError on rejection/auth-fail rather than returning a + * tagged result. The renderer (Phase 3) will translate the GitError code + * back to the spec's `result: 'rejected' | 'auth-failed'` shape if needed. + */ +export async function enginePush(repoId: string, auth?: AuthCreds): Promise<{ result: 'ok' }> { + const session = requireSession(repoId); + const branch = await getCurrentBranch({ handle: session.handle }); + if (!branch) { + throw new GitError('engine-crash', 'HEAD is detached'); + } + + const remoteUrl = await getRemoteUrl(session.handle); + const resolvedAuth = await resolveAuthForRemote(remoteUrl, auth); + const useSys = shouldUseSys(remoteUrl, resolvedAuth); + + if (useSys) { + if (!(await isSystemGitAvailable())) { + throw new GitError('ssh-not-supported-iso', 'SSH transport requires system git'); + } + let env: Record | undefined; + if (resolvedAuth?.kind === 'ssh') { + const keyPath = await resolveSshKeyPath(resolvedAuth.keyId); + env = { GIT_SSH_COMMAND: buildSshCommand(keyPath) }; + } + await sysPush({ cwd: session.handle.dir, branch, env }); + } else { + try { + await git.push({ + fs, + http: httpNode, + dir: session.handle.dir, + gitdir: session.handle.gitdir, + ref: branch, + onAuth: () => { + if (resolvedAuth?.kind === 'token') { + return { username: resolvedAuth.username, password: resolvedAuth.token }; + } + return undefined; + }, + onAuthFailure: () => undefined, + }); + } catch (err) { + throw new GitError(mapIsoError(err), `Push failed`, { cause: err }); + } + } + + return { result: 'ok' }; +} + +/** + * Diff two commits' versions of the tracked file. Returns a NodePatch[] plus + * an aggregated summary. Read-only — does not touch the working tree. + * + * For Phase 2c, requires that the session has a trackedFilePath set; the + * diff is always computed for that single file. (The renderer never asks + * for cross-file diffs in folder mode.) + */ +export async function engineDiff( + repoId: string, + fromCommit: string, + toCommit: string, +): Promise<{ + summary: { + framesChanged: number; + nodesAdded: number; + nodesRemoved: number; + nodesModified: number; + }; + patches: NodePatch[]; +}> { + const session = requireSession(repoId); + if (!session.trackedFilePath) { + throw new GitError('no-file', 'Session has no trackedFilePath'); + } + const rel = toPosixPath(relative(session.handle.dir, session.trackedFilePath)); + + const [fromStr, toStr] = await Promise.all([ + readBlobAtCommit({ handle: session.handle, filepath: rel, commitHash: fromCommit }), + readBlobAtCommit({ handle: session.handle, filepath: rel, commitHash: toCommit }), + ]); + + let from: PenDocument; + let to: PenDocument; + try { + from = JSON.parse(fromStr) as PenDocument; + to = JSON.parse(toStr) as PenDocument; + } catch (err) { + throw new GitError('engine-crash', 'Failed to parse blobs for diff', { + cause: err, + detail: { fromCommit, toCommit }, + }); + } + + const patches = diffDocuments(from, to); + + // Aggregate summary counts. framesChanged is the number of distinct parent + // ids that appear in any patch (lower bound — we don't deeply track frames + // separately from other nodes in 2c). + let nodesAdded = 0; + let nodesRemoved = 0; + let nodesModified = 0; + const framesChanged = new Set(); + for (const p of patches) { + if (p.op === 'add') nodesAdded++; + else if (p.op === 'remove') nodesRemoved++; + else if (p.op === 'modify') nodesModified++; + else if (p.op === 'move') nodesModified++; // move counts as a modification + if (p.parentId) framesChanged.add(p.parentId); + } + + return { + summary: { + framesChanged: framesChanged.size, + nodesAdded, + nodesRemoved, + nodesModified, + }, + patches, + }; +} + +/** + * Folder-mode 3-way merge path. Delegates to system-git worktree helpers + * in worktree-merge.ts. Returns the same result union as engineBranchMerge. + * + * Contract (Phase 7a): + * - fast-forward / clean merge → 'merge' (handled by caller before reaching here) + * - .op conflict (possibly mixed) → 'conflict' with InflightMerge stashed in session + * - non-.op-only conflict → 'conflict-non-op' (no InflightMerge — renderer shows + * "unresolved non-design files" message) + */ +async function engineBranchMergeFolderMode( + repoId: string, + session: RepoSession, + branch: string, + fromBranch: string, + theirsRef: string, + oursRef: string, +): Promise<{ + result: 'fast-forward' | 'merge' | 'conflict' | 'conflict-non-op'; + conflicts?: ConflictBag; +}> { + if (!(await isSystemGitAvailable())) { + throw new GitError( + 'engine-crash', + 'Folder-mode divergent merge requires system git, which is not available', + ); + } + + const mergeResult = await sysMergeNoCommit({ cwd: session.handle.dir, ref: theirsRef }); + + if (mergeResult.kind === 'clean') { + // No conflicts — finalize the merge commit right away. + const hash = await sysFinalizeMerge({ + cwd: session.handle.dir, + message: `Merge ${fromBranch} into ${branch}`, + author: { name: 'OpenPencil', email: 'noreply@openpencil' }, + }); + // Sync the isomorphic-git refs to the new HEAD so the engine stays consistent. + await setRef({ handle: session.handle, ref: oursRef, value: hash }); + await setRef({ + handle: session.handle, + ref: `refs/openpencil/autosaves/${branch}`, + value: hash, + }); + return { result: 'merge' }; + } + + // Conflicts present. Classify them: which files are unresolved? + const unresolved = await sysListUnresolved({ cwd: session.handle.dir }); + const trackedRel = session.trackedFilePath + ? toPosixPath(relative(session.handle.dir, session.trackedFilePath)) + : null; + + const opConflicted = trackedRel !== null && unresolved.includes(trackedRel); + + if (!opConflicted) { + // Only non-.op files have conflicts — return conflict-non-op. + // We do NOT abort: the renderer must surface the non-.op conflicts so + // the user can resolve them in a terminal or external tool. + return { result: 'conflict-non-op' }; + } + + // The tracked .op file is among the conflicts. Read base/ours/theirs from + // the index stages so the pen-core merge can produce a semantic merge result. + const [baseBlob, oursBlob, theirsBlob] = await Promise.all([ + sysShowStageBlob({ cwd: session.handle.dir, stage: 1, filepath: trackedRel! }), + sysShowStageBlob({ cwd: session.handle.dir, stage: 2, filepath: trackedRel! }), + sysShowStageBlob({ cwd: session.handle.dir, stage: 3, filepath: trackedRel! }), + ]); + + if (!baseBlob || !oursBlob || !theirsBlob) { + // One or more index stages are missing for the tracked .op file. + // This covers rename conflicts (e.g. theirs renamed the file so stage :3: is + // null) and delete/add conflicts. In all such cases a semantic 3-way merge + // of the document is impossible, so we surface conflict-non-op and let the + // user resolve it in a terminal. + return { result: 'conflict-non-op' }; + } + + let base: PenDocument; + let ours: PenDocument; + let theirs: PenDocument; + try { + base = JSON.parse(baseBlob); + ours = JSON.parse(oursBlob); + theirs = JSON.parse(theirsBlob); + } catch (err) { + throw new GitError('engine-crash', 'Failed to parse .op blobs during folder-mode merge', { + cause: err, + }); + } + + const opMergeResult = mergeDocuments({ base, ours, theirs }); + const { bag, conflictMap } = buildConflictBag(opMergeResult); + + // Restore the tracked .op file to readable JSON (stage 2 = ours) so the + // renderer can open the document without seeing conflict markers. MERGE_HEAD + // and any non-.op unresolved files remain alive. + await sysRestoreOurs({ cwd: session.handle.dir, filepath: trackedRel! }); + + // Read the commit hashes for InflightMerge. + const oursCommit = await git.resolveRef({ fs, gitdir: session.handle.gitdir, ref: oursRef }); + const theirsCommit = await git.resolveRef({ + fs, + gitdir: session.handle.gitdir, + ref: theirsRef, + }); + const baseCommit = await findMergeBase({ + handle: session.handle, + oid1: oursCommit, + oid2: theirsCommit, + }); + + setInflightMerge(repoId, { + oursCommit, + theirsCommit, + baseCommit, + mergeResult: opMergeResult, + conflictMap, + resolutions: new Map(), + defaultMessage: `Merge ${fromBranch} into ${branch}`, + }); + + return { result: 'conflict', conflicts: bag }; +} + +/** + * Merge another branch into the current branch. Single-file mode uses the + * pen-core merge directly; folder mode uses system-git merge machinery + * (Phase 7a+). + * + * Return shape: + * - { result: 'fast-forward' } — theirs is a descendant of ours, or + * theirs is an ancestor of ours (up-to-date) + * - { result: 'merge' } — clean merge produced new merge commit + * - { result: 'conflict', conflicts } — .op-level conflicts; InflightMerge stashed in session + * - { result: 'conflict-non-op' } — conflicts only in non-.op files + */ +export async function engineBranchMerge( + repoId: string, + fromBranch: string, +): Promise<{ + result: 'fast-forward' | 'merge' | 'conflict' | 'conflict-non-op'; + conflicts?: ConflictBag; +}> { + const session = requireSession(repoId); + const branch = await getCurrentBranch({ handle: session.handle }); + if (!branch) { + throw new GitError('engine-crash', 'HEAD is detached'); + } + + // Resolve the two commit oids. We do this BEFORE the mode check so that + // up-to-date and fast-forward cases work uniformly for both single-file + // and folder mode (FF is just ref movement + checkout; no merge involved). + const oursRef = `refs/heads/${branch}`; + const theirsRef = fromBranch.startsWith('refs/') ? fromBranch : `refs/heads/${fromBranch}`; + let oursCommit: string; + let theirsCommit: string; + try { + oursCommit = await git.resolveRef({ fs, gitdir: session.handle.gitdir, ref: oursRef }); + theirsCommit = await git.resolveRef({ fs, gitdir: session.handle.gitdir, ref: theirsRef }); + } catch (err) { + throw new GitError('engine-crash', `Failed to resolve refs for merge`, { + cause: err, + detail: { oursRef, theirsRef }, + }); + } + + // Find the merge base. Three shortcut cases before we run the 3-way merge: + // 1. ours === theirs → trivially the same commit → no-op. + // 2. base === theirs → theirs is an ancestor of ours → already up to date. + // 3. base === ours → ours is an ancestor of theirs → fast-forward. + const baseCommit = await findMergeBase({ + handle: session.handle, + oid1: oursCommit, + oid2: theirsCommit, + }); + + if (oursCommit === theirsCommit || baseCommit === theirsCommit) { + // Already up to date — theirs has nothing ours doesn't already have. + return { result: 'fast-forward' }; + } + + if (baseCommit === oursCommit) { + // Fast-forward: move heads + autosaves to theirs and update the working + // tree. Works for both single-file and folder mode because git.checkout + // without filepaths updates every changed file from the target ref. + await setRef({ handle: session.handle, ref: oursRef, value: theirsCommit }); + await setRef({ + handle: session.handle, + ref: `refs/openpencil/autosaves/${branch}`, + value: theirsCommit, + }); + try { + await git.checkout({ + fs, + dir: session.handle.dir, + gitdir: session.handle.gitdir, + ref: branch, + force: true, + }); + } catch (err) { + throw new GitError('engine-crash', 'Fast-forward checkout failed', { cause: err }); + } + return { result: 'fast-forward' }; + } + + // From here on we need a true 3-way merge. + if (session.handle.mode === 'folder') { + return engineBranchMergeFolderMode(repoId, session, branch, fromBranch, theirsRef, oursRef); + } + + if (!session.trackedFilePath) { + throw new GitError('no-file', 'Session has no trackedFilePath'); + } + const rel = toPosixPath(relative(session.handle.dir, session.trackedFilePath)); + + // True 3-way merge: load the three blobs and run pen-core merge. + const merge = await runMerge({ + handle: session.handle, + filepath: rel, + oursCommit, + theirsCommit, + baseCommit, + }); + + if (merge.bag.nodeConflicts.length === 0 && merge.bag.docFieldConflicts.length === 0) { + // Clean merge. Write the merged document to disk and create a merge commit. + const mergedJson = JSON.stringify(merge.result.merged); + await fsp.writeFile(session.trackedFilePath, mergedJson, 'utf-8'); + const { hash } = await commitFile({ + handle: session.handle, + filepath: rel, + ref: oursRef, + message: `Merge ${fromBranch} into ${branch}`, + author: { name: 'OpenPencil', email: 'noreply@openpencil' }, + parents: [oursCommit, theirsCommit], + }); + await setRef({ + handle: session.handle, + ref: `refs/openpencil/autosaves/${branch}`, + value: hash, + }); + return { result: 'merge' }; + } + + // Conflicts present. Stash the InflightMerge and return the bag. + setInflightMerge(repoId, { + oursCommit, + theirsCommit, + baseCommit, + mergeResult: merge.result, + conflictMap: merge.conflictMap, + resolutions: new Map(), + defaultMessage: `Merge ${fromBranch} into ${branch}`, + }); + return { result: 'conflict', conflicts: merge.bag }; +} + +/** + * Record the user's choice for a single conflict. The choice is stored in + * the session's InflightMerge.resolutions map; applyMerge consumes the map + * later to produce the final merged document. + * + * Throws 'engine-crash' if the conflictId is unknown for the current + * in-flight merge — that's a programming error since the renderer should + * only ever pass back ids the engine just emitted. + */ +export async function engineResolveConflict( + repoId: string, + conflictId: string, + choice: ConflictResolution, +): Promise { + const session = requireSession(repoId); + if (!session.inflightMerge) { + throw new GitError('engine-crash', 'No in-flight merge for this repo'); + } + if (!session.inflightMerge.conflictMap.has(conflictId)) { + throw new GitError('engine-crash', `Unknown conflict id: ${conflictId}`); + } + session.inflightMerge.resolutions.set(conflictId, choice); +} + +/** + * Finalize the in-flight merge. Applies all accumulated resolutions to the + * merged document, writes the result to disk, creates a merge commit, advances + * both heads + autosaves refs, and clears the session's inflightMerge. + * + * Handles three cases uniformly (Phase 7a): + * 1. .op conflicts only (single-file or folder mode with no non-.op unresolved) + * 2. Mixed .op + non-.op conflicts — throws merge-still-conflicted if non-.op + * files remain unresolved (the user must fix those in a terminal first) + * 3. Merge committed externally (inflightMerge === null and no MERGE_HEAD) + * → returns { noop: true } + */ +export async function engineApplyMerge(repoId: string): Promise<{ hash: string; noop: boolean }> { + const session = requireSession(repoId); + + if (!session.inflightMerge) { + // Check whether an on-disk merge was already committed (e.g. via terminal). + // If MERGE_HEAD is gone, the merge is done → noop. + if (session.handle.mode === 'folder') { + const mergeHead = await readMergeHead(session.handle.gitdir); + if (!mergeHead) { + const branch = await getCurrentBranch({ handle: session.handle }); + if (!branch) throw new GitError('engine-crash', 'HEAD is detached'); + const head = await git.resolveRef({ + fs, + gitdir: session.handle.gitdir, + ref: `refs/heads/${branch}`, + }); + return { hash: head, noop: true }; + } + // MERGE_HEAD present but no inflightMerge in session — conflict was + // detected in a different session (e.g. after panel reopen) but the + // user tried to apply before we re-established the InflightMerge. + throw new GitError( + 'merge-still-conflicted', + 'Merge in progress but no in-flight merge state — call status() first to re-establish', + ); + } + // Single-file mode: no inflightMerge → noop. + const branch = await getCurrentBranch({ handle: session.handle }); + if (!branch) throw new GitError('engine-crash', 'HEAD is detached'); + const head = await git.resolveRef({ + fs, + gitdir: session.handle.gitdir, + ref: `refs/heads/${branch}`, + }); + return { hash: head, noop: true }; + } + + if (!session.trackedFilePath) { + throw new GitError('no-file', 'Session has no trackedFilePath'); + } + + const branch = await getCurrentBranch({ handle: session.handle }); + if (!branch) throw new GitError('engine-crash', 'HEAD is detached'); + + // Verify all .op conflicts have a resolution. + const { conflictMap, resolutions, mergeResult } = session.inflightMerge; + const unresolvedConflicts: string[] = []; + for (const id of conflictMap.keys()) { + if (!resolutions.has(id)) unresolvedConflicts.push(id); + } + if (unresolvedConflicts.length > 0) { + throw new GitError( + 'merge-still-conflicted', + `Cannot apply merge: ${unresolvedConflicts.length} unresolved .op conflicts`, + { detail: { unresolved: unresolvedConflicts } }, + ); + } + + // For folder mode, check whether non-.op files are still unresolved. + if (session.handle.mode === 'folder') { + const trackedRel = toPosixPath(relative(session.handle.dir, session.trackedFilePath)); + const onDiskUnresolved = await sysListUnresolved({ cwd: session.handle.dir }); + const nonOpUnresolved = onDiskUnresolved.filter((p) => p !== trackedRel); + if (nonOpUnresolved.length > 0) { + throw new GitError( + 'merge-still-conflicted', + `Cannot apply merge: ${nonOpUnresolved.length} non-.op file(s) still unresolved`, + { detail: { unresolved: nonOpUnresolved } }, + ); + } + } + + // Build the final document. + const finalDoc = applyResolutions({ + merged: mergeResult.merged, + conflictMap, + resolutions, + }); + + // Write to disk. + const rel = toPosixPath(relative(session.handle.dir, session.trackedFilePath)); + await fsp.writeFile(session.trackedFilePath, JSON.stringify(finalDoc), 'utf-8'); + + let hash: string; + + if (session.handle.mode === 'folder') { + // Folder mode: stage the .op file and finalize with system git. + await sysStageFile({ cwd: session.handle.dir, filepath: rel }); + hash = await sysFinalizeMerge({ + cwd: session.handle.dir, + message: session.inflightMerge.defaultMessage, + author: { name: 'OpenPencil', email: 'noreply@openpencil' }, + }); + // Sync isomorphic-git refs to the new HEAD. + const oursRef = `refs/heads/${branch}`; + await setRef({ handle: session.handle, ref: oursRef, value: hash }); + await setRef({ + handle: session.handle, + ref: `refs/openpencil/autosaves/${branch}`, + value: hash, + }); + } else { + // Single-file mode: use isomorphic-git commit (same as before). + const { oursCommit, theirsCommit } = session.inflightMerge; + const oursRef = `refs/heads/${branch}`; + const commitResult = await commitFile({ + handle: session.handle, + filepath: rel, + ref: oursRef, + message: session.inflightMerge.defaultMessage, + author: { name: 'OpenPencil', email: 'noreply@openpencil' }, + parents: [oursCommit, theirsCommit], + }); + hash = commitResult.hash; + await setRef({ + handle: session.handle, + ref: `refs/openpencil/autosaves/${branch}`, + value: hash, + }); + } + + clearInflightMerge(repoId); + return { hash, noop: false }; +} + +/** + * Abort the in-flight merge. Clears both in-memory session state and on-disk + * merge state (MERGE_HEAD) for folder mode. + * + * In single-file mode, the working tree was never modified by the engine + * during the conflict path, so only session state needs clearing. + * + * In folder mode, `git merge --abort` restores the working tree and index. + */ +export async function engineAbortMerge(repoId: string): Promise { + const session = requireSession(repoId); + + // Clear in-memory state. + clearInflightMerge(repoId); + + // Abort on-disk merge state for folder mode. + if (session.handle.mode === 'folder') { + const mergeHead = await readMergeHead(session.handle.gitdir); + if (mergeHead) { + await sysAbortMerge({ cwd: session.handle.dir }); + } + } +} diff --git a/apps/desktop/git/git-iso.ts b/apps/desktop/git/git-iso.ts new file mode 100644 index 00000000..eed10100 --- /dev/null +++ b/apps/desktop/git/git-iso.ts @@ -0,0 +1,682 @@ +// apps/desktop/git/git-iso.ts +// +// Local git operations using isomorphic-git. This module is namespace-agnostic: +// callers pass ref names (e.g. 'refs/heads/main') as parameters. The engine +// in Phase 2 wraps these primitives with the actual ref naming convention +// for milestones (refs/heads/) and autosaves (refs/openpencil/autosaves/). + +import * as fs from 'node:fs'; +import { dirname, basename, resolve, join } from 'node:path'; +import { mkdir } from 'node:fs/promises'; +import * as git from 'isomorphic-git'; +import { GitError } from './error'; +import type { RepoDetectionFound } from './repo-detector'; + +export interface IsoRepoHandle { + /** worktree (parent dir of .op file in single-file mode; repo root in folder mode) */ + dir: string; + /** absolute path to the gitdir */ + gitdir: string; + mode: 'single-file' | 'folder'; +} + +export interface CommitMetaIso { + hash: string; + parentHashes: string[]; + message: string; + author: { name: string; email: string; timestamp: number }; +} + +export interface InitOptions { + /** absolute path to the .op file (must already exist on disk) */ + filePath: string; + /** branch name to initialize HEAD with; default 'main' */ + defaultBranch?: string; + /** author for the initial empty commit; defaults to 'OpenPencil ' */ + authorName?: string; + authorEmail?: string; +} + +/** + * Initialize a single-file repo at .op-history/.git next to the file. + * Idempotent: if the gitdir already exists with a HEAD, returns the existing + * handle without re-initializing. + * + * The new repo has: + * - HEAD pointing at refs/heads/ + * - `core.worktree = ../..` written into the gitdir's config file so that + * a user running `git -C status` from the terminal sees the + * correct working tree (the parent dir of the .op file). isomorphic-git + * itself doesn't need this — it accepts `dir` explicitly on every call — + * but the spec documents the on-disk shape with this setting and the + * CLI inspection use case demands it. + * - No initial commit (the engine will create one with the file's current + * content via commitFile). + */ +export async function initSingleFile(opts: InitOptions): Promise { + const absFile = resolve(opts.filePath); + const dir = dirname(absFile); + const baseName = basename(absFile); + const gitdir = resolve(dir, '.op-history', `${baseName}.git`); + const defaultBranch = opts.defaultBranch ?? 'main'; + + try { + // If a HEAD already exists, this is a re-init. Return the handle as-is. + if (fs.existsSync(join(gitdir, 'HEAD'))) { + return { dir, gitdir, mode: 'single-file' }; + } + + // Create the parent .op-history/ if needed. + await mkdir(gitdir, { recursive: true }); + + // isomorphic-git's init creates the gitdir layout. We pass `dir` and + // `gitdir` separately so the worktree is the file's parent dir, not + // a sibling of gitdir. + await git.init({ + fs, + dir, + gitdir, + defaultBranch, + bare: false, + }); + + // Explicitly write core.worktree so terminal `git -C ` sees the + // correct working tree. The path is relative to the gitdir: from + // /.op-history/.git → ../.. brings us back to . + // We also force core.bare = false because some isomorphic-git versions + // default it to true when dir != dirname(gitdir). + await git.setConfig({ + fs, + gitdir, + path: 'core.worktree', + value: '../..', + }); + await git.setConfig({ + fs, + gitdir, + path: 'core.bare', + value: false, + }); + + return { dir, gitdir, mode: 'single-file' }; + } catch (err) { + throw new GitError( + 'init-failed', + `Failed to initialize single-file repo for ${opts.filePath}`, + { + cause: err, + detail: { filePath: opts.filePath, gitdir }, + }, + ); + } +} + +/** + * Open an existing repository given a successful detection result. + * This is a thin wrapper that just packages the detection into a handle and + * verifies the gitdir is readable. + */ +export async function openRepo(detection: RepoDetectionFound): Promise { + try { + // Verify the gitdir is readable by reading HEAD. + const head = join(detection.gitdir, 'HEAD'); + if (!fs.existsSync(head)) { + throw new GitError('open-failed', `gitdir has no HEAD: ${detection.gitdir}`); + } + return { + dir: detection.rootPath, + gitdir: detection.gitdir, + mode: detection.mode, + }; + } catch (err) { + if (err instanceof GitError) throw err; + throw new GitError('open-failed', `Failed to open repository at ${detection.rootPath}`, { + cause: err, + }); + } +} + +/** + * Stage the given file and create a commit on the specified ref. The ref + * doesn't have to exist beforehand — if it doesn't, it's created at this + * commit (this is how the engine seeds an empty branch with its first + * autosave). + * + * Throws GitError 'commit-empty' if the working tree state for `filepath` + * is identical to the ref's current tip (no changes to commit). + * + * Implementation note: isomorphic-git's `commit()` accepts a `ref` argument + * and updates that ref directly (instead of HEAD). It also accepts an + * explicit `parent` array. So we don't need any low-level tree-writing + * primitives — just stage, detect-empty, then commit. + */ +export async function commitFile(opts: { + handle: IsoRepoHandle; + filepath: string; + ref: string; + message: string; + author: { name: string; email: string }; + parents?: string[]; +}): Promise<{ hash: string }> { + const { handle, filepath, ref, message, author } = opts; + try { + // Determine parent commit(s). If the caller passed parents explicitly, + // use those. Otherwise look up the current ref tip; if the ref doesn't + // exist, the new commit is a root commit (no parents). + let parents = opts.parents; + if (!parents) { + try { + const tip = await git.resolveRef({ fs, gitdir: handle.gitdir, ref }); + parents = [tip]; + } catch { + parents = []; + } + } + + // Detect "no changes" by comparing the working tree blob hash for + // `filepath` to the blob hash recorded at that path in the parent + // commit's tree. Only valid for linear commits (exactly one parent). + // + // For root commits (no parents) the check doesn't apply. For merge + // commits (2+ parents) the new tree may legitimately match one parent + // but not the others — a merge that was fully resolved in favor of one + // side still records history and must not be rejected. The engine layer + // in Phase 2 owns the higher-level "is this merge meaningful" decision. + if (parents.length === 1) { + const fileBytes = await fs.promises.readFile(resolve(handle.dir, filepath)); + const { oid: workOid } = await git.hashBlob({ object: fileBytes }); + let parentBlobOid: string | undefined; + try { + const { oid } = await git.readBlob({ + fs, + gitdir: handle.gitdir, + oid: parents[0], + filepath, + }); + parentBlobOid = oid; + } catch { + // file didn't exist in parent → not empty, fall through to commit + } + if (parentBlobOid && parentBlobOid === workOid) { + throw new GitError('commit-empty', `No changes to commit for ${filepath} on ${ref}`, { + recoverable: true, + }); + } + } + + // Stage the file. isomorphic-git's add reads the working tree file + // and writes a blob into objects/, plus updates the index. + await git.add({ fs, dir: handle.dir, gitdir: handle.gitdir, filepath }); + + // Create the commit. Passing `ref` makes isomorphic-git update that + // ref instead of HEAD. Passing `parent` overrides the default + // (which would be HEAD's tip). Together this lets us write to e.g. + // refs/openpencil/autosaves/main without touching HEAD or refs/heads/main. + const ts = Math.floor(Date.now() / 1000); + const hash = await git.commit({ + fs, + dir: handle.dir, + gitdir: handle.gitdir, + message, + ref, + parent: parents, + author: { + name: author.name, + email: author.email, + timestamp: ts, + timezoneOffset: 0, + }, + committer: { + name: author.name, + email: author.email, + timestamp: ts, + timezoneOffset: 0, + }, + }); + + return { hash }; + } catch (err) { + if (err instanceof GitError) throw err; + throw new GitError('engine-crash', `commitFile failed for ${filepath} on ${ref}`, { + cause: err, + detail: { filepath, ref }, + }); + } +} + +/** + * Read the contents of a file at a specific commit. Returns the blob as + * a UTF-8 string. Used by `restoreFileFromCommit` and by the engine's + * "promote autosave" flow which needs to read the autosave's tree blob. + */ +export async function readBlobAtCommit(opts: { + handle: IsoRepoHandle; + filepath: string; + commitHash: string; +}): Promise { + const { handle, filepath, commitHash } = opts; + try { + const { blob } = await git.readBlob({ + fs, + gitdir: handle.gitdir, + oid: commitHash, + filepath, + }); + return new TextDecoder('utf-8').decode(blob); + } catch (err) { + throw new GitError('engine-crash', `readBlobAtCommit failed for ${filepath} at ${commitHash}`, { + cause: err, + detail: { filepath, commitHash }, + }); + } +} + +/** + * Walk commits from a ref tip in reverse chronological order. Returns up to + * `depth` commits, oldest-last. If the ref doesn't exist, returns an empty + * array (rather than throwing) — this is convenient for the engine which + * may query autosave refs that haven't been created yet. + */ +export async function logForRef(opts: { + handle: IsoRepoHandle; + ref: string; + depth: number; +}): Promise { + const { handle, ref, depth } = opts; + try { + // Check existence first; missing ref → empty log. + try { + await git.resolveRef({ fs, gitdir: handle.gitdir, ref }); + } catch { + return []; + } + + const commits = await git.log({ + fs, + gitdir: handle.gitdir, + ref, + depth, + }); + + return commits.map((c) => ({ + hash: c.oid, + parentHashes: c.commit.parent, + message: c.commit.message, + author: { + name: c.commit.author.name, + email: c.commit.author.email, + timestamp: c.commit.author.timestamp, + }, + })); + } catch (err) { + throw new GitError('engine-crash', `logForRef failed for ${ref}`, { + cause: err, + detail: { ref }, + }); + } +} + +/** + * Read a file's contents at a commit and write them to the working tree. + * Does NOT create a new commit — that's the caller's responsibility (the + * engine's restore flow runs commitFile afterward to record the restore). + * + * The file is written via Node fs.writeFile, NOT via git checkout, because + * checkout would also update the index and we want the working tree to be + * dirty after a restore so the user sees pending changes. + */ +export async function restoreFileFromCommit(opts: { + handle: IsoRepoHandle; + filepath: string; + commitHash: string; +}): Promise { + const { handle, filepath, commitHash } = opts; + try { + const content = await readBlobAtCommit({ handle, filepath, commitHash }); + const absPath = resolve(handle.dir, filepath); + await fs.promises.writeFile(absPath, content, 'utf-8'); + } catch (err) { + if (err instanceof GitError) throw err; + throw new GitError( + 'engine-crash', + `restoreFileFromCommit failed for ${filepath} at ${commitHash}`, + { + cause: err, + detail: { filepath, commitHash }, + }, + ); + } +} + +/** + * List branches under a ref prefix. The prefix is RELATIVE to refs/, so + * 'heads' lists refs/heads/*, 'openpencil/autosaves' lists + * refs/openpencil/autosaves/*. Returns just the branch names (last segment), + * not the full ref paths. + */ +export async function listBranches(opts: { + handle: IsoRepoHandle; + prefix?: string; +}): Promise { + const { handle } = opts; + const prefix = opts.prefix ?? 'heads'; + try { + if (prefix === 'heads') { + // Use isomorphic-git's high-level API for the common case. + return await git.listBranches({ fs, gitdir: handle.gitdir }); + } + // Custom prefix: walk the gitdir's refs// directory directly. + const refsRoot = join(handle.gitdir, 'refs', prefix); + try { + const entries = await fs.promises.readdir(refsRoot, { withFileTypes: true }); + return entries.filter((e) => e.isFile()).map((e) => e.name); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + } catch (err) { + if (err instanceof GitError) throw err; + throw new GitError('engine-crash', `listBranches failed for prefix ${prefix}`, { + cause: err, + detail: { prefix }, + }); + } +} + +/** + * Create a new branch under refs/heads/. If `fromCommit` is omitted, the + * new branch points at the current HEAD. Throws GitError 'branch-exists' + * if the name is already taken. + */ +export async function createBranch(opts: { + handle: IsoRepoHandle; + name: string; + fromCommit?: string; +}): Promise { + const { handle, name, fromCommit } = opts; + try { + // Check existence first. + const existing = await git.listBranches({ fs, gitdir: handle.gitdir }); + if (existing.includes(name)) { + throw new GitError('branch-exists', `Branch ${name} already exists`); + } + + if (fromCommit) { + // Write the ref directly to the specified commit. + await git.writeRef({ + fs, + gitdir: handle.gitdir, + ref: `refs/heads/${name}`, + value: fromCommit, + force: false, + }); + } else { + // Branch from current HEAD via the high-level API. + await git.branch({ fs, gitdir: handle.gitdir, ref: name, checkout: false }); + } + } catch (err) { + if (err instanceof GitError) throw err; + throw new GitError('engine-crash', `createBranch failed for ${name}`, { + cause: err, + detail: { name, fromCommit }, + }); + } +} + +/** + * Return true if the branch `name` is reachable from the tip of any OTHER + * branch in refs/heads. "Reachable" means either: + * - another branch's tip OID equals `name`'s tip (fast-forward case), OR + * - another branch descends from `name`'s tip (merged-via-merge-commit). + * + * The equal-tip short-circuit is load-bearing: isomorphic-git's + * `git.isDescendent` explicitly returns false when `oid === ancestor` + * (see node_modules/isomorphic-git/index.cjs:12094), so without this + * shortcut a branch that was just fast-forward merged (or a branch that was + * just created from HEAD and never advanced) would be incorrectly flagged + * as unmerged, blocking a legitimate delete. + */ +async function isBranchMergedAnywhere(opts: { + handle: IsoRepoHandle; + name: string; +}): Promise { + const { handle, name } = opts; + const targetOid = await git.resolveRef({ fs, gitdir: handle.gitdir, ref: name }); + const branches = await git.listBranches({ fs, gitdir: handle.gitdir }); + + for (const candidate of branches) { + if (candidate === name) continue; + const candidateOid = await git.resolveRef({ fs, gitdir: handle.gitdir, ref: candidate }); + if (candidateOid === targetOid) return true; // equal tips = merged (fast-forward case) + const merged = await git.isDescendent({ + fs, + dir: handle.dir, + gitdir: handle.gitdir, + oid: candidateOid, + ancestor: targetOid, + depth: -1, + }); + if (merged) return true; + } + + return false; +} + +/** + * Delete a branch under refs/heads/. Throws GitError 'branch-current' if + * the branch is the active HEAD. Throws GitError 'branch-unmerged' if the + * branch's tip is not reachable from any other branch, unless `force` is + * set — in which case the branch is deleted unconditionally. + * + * Note: `isomorphic-git`'s `deleteBranch` has no `force` option of its own, + * so we implement the mergedness check ourselves above the low-level call. + */ +export async function deleteBranch(opts: { + handle: IsoRepoHandle; + name: string; + force?: boolean; +}): Promise { + const { handle, name, force = false } = opts; + try { + const current = await getCurrentBranch({ handle }); + if (current === name) { + throw new GitError('branch-current', `Cannot delete the current branch ${name}`); + } + if (!force) { + const merged = await isBranchMergedAnywhere({ handle, name }); + if (!merged) { + throw new GitError('branch-unmerged', `Branch ${name} has unmerged commits`, { + detail: { name }, + }); + } + } + await git.deleteBranch({ fs, gitdir: handle.gitdir, ref: name }); + } catch (err) { + if (err instanceof GitError) throw err; + throw new GitError('engine-crash', `deleteBranch failed for ${name}`, { + cause: err, + detail: { name, force }, + }); + } +} + +/** + * Switch HEAD to a different branch and update the working tree's tracked + * file to that branch's tip. Uses filepaths-scoped checkout so other files + * in the worktree are untouched. + */ +export async function switchBranch(opts: { + handle: IsoRepoHandle; + name: string; + filepath: string; +}): Promise { + const { handle, name, filepath } = opts; + try { + await git.checkout({ + fs, + dir: handle.dir, + gitdir: handle.gitdir, + ref: name, + filepaths: [filepath], + force: true, + }); + } catch (err) { + throw new GitError('engine-crash', `switchBranch failed for ${name}`, { + cause: err, + detail: { name, filepath }, + }); + } +} + +/** + * Return the current branch name (without the 'refs/heads/' prefix), or + * null if HEAD is detached. + */ +export async function getCurrentBranch(opts: { handle: IsoRepoHandle }): Promise { + const { handle } = opts; + try { + const branch = await git.currentBranch({ fs, gitdir: handle.gitdir, fullname: false }); + return branch ?? null; + } catch (err) { + throw new GitError('engine-crash', `getCurrentBranch failed`, { cause: err }); + } +} + +/** + * Force a ref to point at the given commit hash. Creates the ref if it does + * not exist. Used by the engine's milestone commit flow to jump the + * `refs/openpencil/autosaves/` ref to a freshly written milestone, + * abandoning the intermediate autosave chain. + */ +export async function setRef(opts: { + handle: IsoRepoHandle; + ref: string; + value: string; +}): Promise { + const { handle, ref, value } = opts; + try { + await git.writeRef({ + fs, + gitdir: handle.gitdir, + ref, + value, + force: true, + }); + } catch (err) { + throw new GitError('engine-crash', `setRef failed for ${ref}`, { + cause: err, + detail: { ref, value }, + }); + } +} + +/** + * Look up the blob OID for `filepath` at the tip of `ref`. Returns null if + * the ref doesn't exist OR the file isn't present in that commit's tree. + * Never throws — used by workingDirty detection where missing refs/files are + * a normal case (fresh repo with no commits). + */ +export async function readBlobOidAt(opts: { + handle: IsoRepoHandle; + ref: string; + filepath: string; +}): Promise { + const { handle, ref, filepath } = opts; + let tip: string; + try { + tip = await git.resolveRef({ fs, gitdir: handle.gitdir, ref }); + } catch { + return null; + } + try { + const { oid } = await git.readBlob({ + fs, + gitdir: handle.gitdir, + oid: tip, + filepath, + }); + return oid; + } catch { + return null; + } +} + +/** + * Phase 6a: write the single 'origin' remote in `.git/config`. + * + * - non-empty `url` → `git.addRemote({ remote: 'origin', url, force: true })` + * The `force: true` flag makes this an upsert (add OR update). isomorphic-git + * itself rejects an existing remote without `force`. + * - `null` url → `git.deleteRemote({ remote: 'origin' })`. Naturally + * idempotent: isomorphic-git's `deleteRemote` is implemented as + * `GitConfigManager.deleteSection('remote', 'origin')`, which is a + * filter over parsed config entries and never throws when the section + * is absent. We therefore do NOT wrap it in a try/catch — the only + * failure modes are real I/O errors (EACCES, ENOSPC, etc.) from + * `GitConfigManager.save`, and those must propagate so the UI can + * surface them instead of silently claiming "origin removed" while + * `.git/config` still holds the old url. + * + * This is a LOCAL config mutation only — never opens a network socket. Lives + * in git-iso.ts (not git-sys.ts) per the Phase 6a plan: there is no transport + * decision to make here, so the dispatch helper in git-engine.ts isn't needed. + */ +export async function writeRemoteOrigin(opts: { + handle: IsoRepoHandle; + url: string | null; +}): Promise { + const { handle, url } = opts; + try { + if (url === null) { + await git.deleteRemote({ fs, gitdir: handle.gitdir, remote: 'origin' }); + return; + } + await git.addRemote({ + fs, + gitdir: handle.gitdir, + remote: 'origin', + url, + force: true, // upsert + }); + } catch (err) { + throw new GitError('engine-crash', `writeRemoteOrigin failed`, { + cause: err, + detail: { url }, + }); + } +} + +/** + * Find the merge base (common ancestor) of two commits. Used by the merge + * orchestrator to load the `base` document for 3-way merge. + * + * Throws GitError 'engine-crash' if no merge base exists (unrelated + * histories) — Phase 2c does not support merging unrelated histories. + */ +export async function findMergeBase(opts: { + handle: IsoRepoHandle; + oid1: string; + oid2: string; +}): Promise { + const { handle, oid1, oid2 } = opts; + try { + const oids = await git.findMergeBase({ + fs, + gitdir: handle.gitdir, + oids: [oid1, oid2], + }); + if (!oids || oids.length === 0) { + throw new GitError( + 'engine-crash', + `No merge base for ${oid1} and ${oid2} (unrelated histories)`, + ); + } + return oids[0]; + } catch (err) { + if (err instanceof GitError) throw err; + throw new GitError('engine-crash', `findMergeBase failed`, { + cause: err, + detail: { oid1, oid2 }, + }); + } +} diff --git a/apps/desktop/git/git-sys.ts b/apps/desktop/git/git-sys.ts new file mode 100644 index 00000000..5831727b --- /dev/null +++ b/apps/desktop/git/git-sys.ts @@ -0,0 +1,294 @@ +// apps/desktop/git/git-sys.ts +// +// System git wrapper. Phase 2b makes this real: clone/fetch/push/pull-FF +// run via execFile, returning typed results or throwing GitError on +// recognized failure modes. +// +// All ops accept an optional env map so SSH callers can set +// GIT_SSH_COMMAND='ssh -i -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new' +// without affecting the parent process env. + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { GitError, type GitErrorCode } from './error'; + +const execFileAsync = promisify(execFile); + +let cached: boolean | undefined; + +const DEFAULT_TIMEOUT_MS = 60_000; + +export async function isSystemGitAvailable(): Promise { + if (cached !== undefined) return cached; + try { + await execFileAsync('git', ['--version'], { timeout: 5000 }); + cached = true; + } catch { + cached = false; + } + return cached; +} + +export function __resetSystemGitCache(): void { + cached = undefined; +} + +interface RunOpts { + cwd?: string; + env?: Record; + timeoutMs?: number; +} + +interface RunResult { + stdout: string; + stderr: string; +} + +/** + * Run `git ` with the given env and cwd. On failure, maps stderr to a + * GitError code and throws. Used by all the higher-level fns below. + */ +async function runGit(args: string[], opts: RunOpts = {}): Promise { + const env = { ...process.env, ...opts.env }; + try { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd: opts.cwd, + env, + timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + maxBuffer: 32 * 1024 * 1024, + }); + return { stdout, stderr }; + } catch (err) { + const e = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }; + const stderr = e.stderr ?? ''; + const code = mapSysError(stderr, e); + throw new GitError(code, `git ${args.join(' ')} failed: ${stderr.trim() || e.message}`, { + cause: err, + detail: { args, stderr }, + }); + } +} + +/** + * Map stderr text from a failed `git` invocation to a GitError code. + * Patterns are intentionally simple substring checks — git's error messages + * are not localized and have been stable for many years. + */ +export function mapSysError(stderr: string, err?: { message?: string }): GitErrorCode { + const s = stderr.toLowerCase(); + const msg = (err?.message ?? '').toLowerCase(); + + // Auth failures + if ( + s.includes('authentication failed') || + s.includes('could not read username') || + s.includes('permission denied (publickey)') || + s.includes('access denied') + ) { + return 'auth-failed'; + } + + // Repository missing + if (s.includes('repository not found') || s.includes('does not exist')) { + return 'clone-failed'; + } + + // Clone target already exists + if (s.includes('already exists and is not an empty directory')) { + return 'clone-target-exists'; + } + + // Network failures + if ( + s.includes("couldn't resolve host") || + s.includes('could not resolve hostname') || + s.includes('connection refused') || + s.includes('no route to host') + ) { + return 'network'; + } + + // Timeouts (both git's own and execFile's) + if ( + s.includes('connection timed out') || + s.includes('operation timed out') || + msg.includes('etimedout') + ) { + return 'timeout'; + } + + // Push rejected (non-FF) + if (s.includes('updates were rejected') || s.includes('non-fast-forward')) { + return 'push-rejected'; + } + + // Pull non-FF (we run --ff-only so any divergence yields this) + if (s.includes('not possible to fast-forward')) { + return 'pull-non-fast-forward'; + } + + // Not a repo + if (s.includes('not a git repository') || s.includes('does not appear to be a git repository')) { + return 'not-a-repo'; + } + + return 'engine-crash'; +} + +// --------------------------------------------------------------------------- +// Public ops +// --------------------------------------------------------------------------- + +export async function sysClone(opts: { + url: string; + dest: string; + env?: Record; + timeoutMs?: number; +}): Promise { + await runGit(['clone', opts.url, opts.dest], { + env: opts.env, + timeoutMs: opts.timeoutMs, + }); +} + +export async function sysFetch(opts: { + cwd: string; + remote?: string; + env?: Record; + timeoutMs?: number; +}): Promise { + const remote = opts.remote ?? 'origin'; + await runGit(['fetch', remote], { + cwd: opts.cwd, + env: opts.env, + timeoutMs: opts.timeoutMs, + }); +} + +export async function sysPullFastForward(opts: { + cwd: string; + remote?: string; + branch: string; + env?: Record; + timeoutMs?: number; +}): Promise<{ result: 'fast-forward' | 'up-to-date' }> { + const remote = opts.remote ?? 'origin'; + // --ff-only refuses to merge; if the remote diverged, throws pull-non-fast-forward. + const { stdout } = await runGit(['pull', '--ff-only', remote, opts.branch], { + cwd: opts.cwd, + env: opts.env, + timeoutMs: opts.timeoutMs, + }); + if (stdout.includes('Already up to date')) return { result: 'up-to-date' }; + return { result: 'fast-forward' }; +} + +export async function sysPush(opts: { + cwd: string; + remote?: string; + branch: string; + env?: Record; + timeoutMs?: number; +}): Promise { + const remote = opts.remote ?? 'origin'; + await runGit(['push', remote, opts.branch], { + cwd: opts.cwd, + env: opts.env, + timeoutMs: opts.timeoutMs, + }); +} + +/** + * Compute ahead/behind counts for the current branch vs `/`. + * Returns 0/0 if no remote-tracking ref exists for the branch. + */ +export async function sysAheadBehind(opts: { + cwd: string; + branch: string; + remote?: string; + env?: Record; +}): Promise<{ ahead: number; behind: number }> { + const remote = opts.remote ?? 'origin'; + try { + const { stdout } = await runGit( + ['rev-list', '--left-right', '--count', `${opts.branch}...${remote}/${opts.branch}`], + { cwd: opts.cwd, env: opts.env }, + ); + // Output format: "\t\n" + const [aheadStr, behindStr] = stdout.trim().split(/\s+/); + return { + ahead: parseInt(aheadStr, 10) || 0, + behind: parseInt(behindStr, 10) || 0, + }; + } catch (err) { + // No remote tracking ref → not an error, just zeros. + if (err instanceof GitError && err.code === 'engine-crash') { + return { ahead: 0, behind: 0 }; + } + throw err; + } +} + +/** + * Executor shape used by getSystemAuthor's test seam. Mirrors runGit's return + * type so tests can inject a fake without pulling in node:child_process. + */ +type RunGitExec = (args: string[]) => Promise<{ stdout: string; stderr: string }>; + +/** + * Read `user.name` / `user.email` from the system git config. Used by Phase 4a + * as step 2 of the author identity lookup chain (prefs → sysGit → form). + * + * The optional `injectedExec` parameter is a TEST SEAM: production callers pass + * no args and get the real `runGit`-backed path, while tests inject a fake to + * deterministically exercise both the success and null branches without + * depending on the host machine having a configured global git identity. + * + * Returns null on: + * - system git not available (when not using injected exec) + * - either config value missing / empty / whitespace-only + * - git exec throwing (e.g. key not set, which git reports as exit 1) + * The catch block intentionally swallows errors: "no identity" is a normal + * state, not an operational failure, and the caller treats null as "fall + * through to the next step in the chain". + */ +export async function getSystemAuthor( + injectedExec?: RunGitExec, +): Promise<{ name: string; email: string } | null> { + if (!injectedExec && !(await isSystemGitAvailable())) return null; + + const exec: RunGitExec = injectedExec ?? ((args) => runGit(args, { timeoutMs: 5000 })); + + try { + const nameResult = await exec(['config', '--get', 'user.name']); + const emailResult = await exec(['config', '--get', 'user.email']); + const name = nameResult.stdout.trim(); + const email = emailResult.stdout.trim(); + if (!name || !email) return null; + return { name, email }; + } catch { + return null; + } +} + +/** + * Build the GIT_SSH_COMMAND env value for an SSH key file. Used by the + * engine before invoking sysClone/Fetch/Pull/Push with auth.kind === 'ssh'. + */ +export function buildSshCommand(privateKeyPath: string): string { + // -i: identity file + // -o IdentitiesOnly=yes: don't try ssh-agent identities (avoid prompting) + // -o StrictHostKeyChecking=accept-new: trust on first use, verify thereafter + // -o BatchMode=yes: never prompt; fail fast on missing creds + return [ + 'ssh', + '-i', + JSON.stringify(privateKeyPath), + '-o', + 'IdentitiesOnly=yes', + '-o', + 'StrictHostKeyChecking=accept-new', + '-o', + 'BatchMode=yes', + ].join(' '); +} diff --git a/apps/desktop/git/ipc-handlers.ts b/apps/desktop/git/ipc-handlers.ts new file mode 100644 index 00000000..ede67bda --- /dev/null +++ b/apps/desktop/git/ipc-handlers.ts @@ -0,0 +1,354 @@ +// apps/desktop/git/ipc-handlers.ts +// +// Thin shim: each handler is a one-line forward to a git-engine function. +// Tests call gitIpcHandlers directly. setupGitIPC wires them onto ipcMain. +// +// Error serialization: Electron's structured-clone drops custom Error +// subclasses (only `message` survives reliably). We tag GitError instances +// by stuffing { __gitError, code, message, recoverable } into the message +// field as JSON, prefixed with a marker the renderer detects on receive. + +import { ipcMain } from 'electron'; +import { GitError, isGitError } from './error'; +import { + engineDetect, + engineInit, + engineOpen, + engineBindTrackedFile, + engineListCandidates, + engineClose, + engineStatus, + engineLog, + engineCommit, + engineRestore, + enginePromote, + engineBranchList, + engineBranchCreate, + engineBranchSwitch, + engineBranchDelete, + engineClone, + engineFetch, + enginePull, + enginePush, + engineDiff, + engineBranchMerge, + engineResolveConflict, + engineApplyMerge, + engineAbortMerge, + engineRemoteGet, + engineRemoteSet, + setSshKeyManager, + setAuthStore, +} from './git-engine'; +import type { ConflictResolution } from './merge-session'; +import { createDefaultAuthStore, type AuthCreds, type AuthStore } from './auth-store'; +import { createDefaultSshKeyManager, type SshKeyInfo, type SshKeyManager } from './ssh-keys'; +import { getSystemAuthor as sysGetSystemAuthor } from './git-sys'; + +// --------------------------------------------------------------------------- +// Module-level singletons assigned by setupGitIPC at boot. We require Electron +// to be ready (app.whenReady()) before instantiating because both auth-store +// and ssh-keys lazy-import electron's `app` and `safeStorage`. +// --------------------------------------------------------------------------- + +let authStore: AuthStore | null = null; +let sshKeyManager: SshKeyManager | null = null; + +function requireAuthStore(): AuthStore { + if (!authStore) throw new Error('auth store not initialized; call setupGitIPC() first'); + return authStore; +} + +function requireSshKeyManager(): SshKeyManager { + if (!sshKeyManager) throw new Error('ssh key manager not initialized; call setupGitIPC() first'); + return sshKeyManager; +} + +/** + * Strip the on-disk private key path before returning SSH key info to the + * renderer. The path is backend-only — the renderer never needs it because + * SSH transport is invoked via git-sys with GIT_SSH_COMMAND, never via the + * renderer-side filesystem. + */ +type PublicSshKeyInfo = Omit; + +function stripPrivatePath(info: SshKeyInfo): PublicSshKeyInfo { + const { privateKeyPath: _omit, ...rest } = info; + void _omit; + return rest; +} + +const GIT_ERROR_MARKER = '__GIT_ERROR__'; + +/** + * Serialize a GitError into an Error whose message is JSON-encoded with the + * GIT_ERROR_MARKER prefix. The renderer's git-client (Phase 3) detects the + * marker and rehydrates a GitError on its side. + */ +export function serializeGitError(err: GitError): Error { + const payload = { + code: err.code, + message: err.message, + recoverable: err.recoverable, + }; + return new Error(`${GIT_ERROR_MARKER}${JSON.stringify(payload)}`); +} + +/** + * Wrap a handler so any thrown GitError becomes a serialized Error suitable + * for crossing the IPC boundary. Other errors propagate as-is (their + * `message` survives clone but the stack/type does not). + */ +async function runHandler(fn: () => Promise): Promise { + try { + return await fn(); + } catch (err) { + if (isGitError(err)) { + throw serializeGitError(err as GitError); + } + throw err; + } +} + +// --------------------------------------------------------------------------- +// Direct handler functions — exported so tests can call them without ipcMain. +// Each is a one-line forward to engineX(). Argument shapes mirror the IPC +// contract section of the spec. +// --------------------------------------------------------------------------- + +export const gitIpcHandlers = { + detect: (filePath: string) => engineDetect(filePath), + init: (filePath: string) => engineInit(filePath), + open: (repoPath: string, currentFilePath?: string) => engineOpen(repoPath, currentFilePath), + bindTrackedFile: (repoId: string, filePath: string) => engineBindTrackedFile(repoId, filePath), + listCandidates: (repoId: string) => engineListCandidates(repoId), + close: (repoId: string) => { + engineClose(repoId); + return Promise.resolve(); + }, + status: (repoId: string) => engineStatus(repoId), + log: (repoId: string, opts: { ref: 'main' | 'autosaves' | string; limit: number }) => + engineLog(repoId, opts), + commit: ( + repoId: string, + opts: { + kind: 'milestone' | 'autosave'; + message: string; + author: { name: string; email: string }; + }, + ) => engineCommit(repoId, opts), + restore: (repoId: string, commitHash: string) => engineRestore(repoId, commitHash), + promote: ( + repoId: string, + autosaveHash: string, + message: string, + author: { name: string; email: string }, + ) => enginePromote(repoId, autosaveHash, message, author), + branchList: (repoId: string) => engineBranchList(repoId), + branchCreate: (repoId: string, opts: { name: string; fromCommit?: string }) => + engineBranchCreate(repoId, opts), + branchSwitch: (repoId: string, name: string) => engineBranchSwitch(repoId, name), + branchDelete: (repoId: string, name: string, opts?: { force?: boolean }) => + engineBranchDelete(repoId, name, opts), + + // Phase 2b: remote ops + clone: (opts: { url: string; dest: string; auth?: AuthCreds }) => engineClone(opts), + fetch: (repoId: string, auth?: AuthCreds) => engineFetch(repoId, auth), + pull: (repoId: string, auth?: AuthCreds) => enginePull(repoId, auth), + push: (repoId: string, auth?: AuthCreds) => enginePush(repoId, auth), + + // Phase 6a: remote metadata + config (no network) + remoteGet: (repoId: string) => engineRemoteGet(repoId), + remoteSet: (repoId: string, url: string | null) => engineRemoteSet(repoId, url), + + // Phase 2b: auth + authStore: (host: string, creds: AuthCreds) => requireAuthStore().set(host, creds), + authGet: (host: string) => requireAuthStore().get(host), + authClear: (host: string) => requireAuthStore().clear(host), + + // Phase 2b: ssh keys (privateKeyPath stripped before crossing IPC) + sshListKeys: async (): Promise => { + const all = await requireSshKeyManager().list(); + return all.map(stripPrivatePath); + }, + sshGenerateKey: async (opts: { host: string; comment: string }): Promise => { + const info = await requireSshKeyManager().generate(opts); + return stripPrivatePath(info); + }, + sshImportKey: async (opts: { + privateKeyPath: string; + host: string; + }): Promise => { + const info = await requireSshKeyManager().import(opts); + return stripPrivatePath(info); + }, + sshDeleteKey: (keyId: string) => requireSshKeyManager().delete(keyId), + + // Phase 2c: merge orchestration + diff: (repoId: string, fromCommit: string, toCommit: string) => + engineDiff(repoId, fromCommit, toCommit), + branchMerge: (repoId: string, fromBranch: string) => engineBranchMerge(repoId, fromBranch), + resolveConflict: (repoId: string, conflictId: string, choice: ConflictResolution) => + engineResolveConflict(repoId, conflictId, choice), + applyMerge: (repoId: string) => engineApplyMerge(repoId), + abortMerge: (repoId: string) => engineAbortMerge(repoId), + + // Phase 4a: author identity probe (system git config) + getSystemAuthor: () => sysGetSystemAuthor(), +}; + +// --------------------------------------------------------------------------- +// ipcMain registration. Each channel is registered exactly once. Calling +// setupGitIPC twice would throw on the second call (ipcMain.handle rejects +// duplicate channel names) — main.ts must ensure single invocation. +// --------------------------------------------------------------------------- + +export function setupGitIPC(): void { + // Lazy-instantiate the auth/ssh singletons. These pull in Electron, so they + // must run AFTER app.whenReady() (which is when main.ts calls setupGitIPC). + authStore = createDefaultAuthStore(); + sshKeyManager = createDefaultSshKeyManager(); + // Inject both into the engine so engineClone/Fetch/Pull/Push can: + // - look up stored host credentials when no explicit auth was passed + // - resolve SSH keyIds to private key file paths + setAuthStore(authStore); + setSshKeyManager(sshKeyManager); + + ipcMain.handle('git:detect', (_e, filePath: string) => + runHandler(() => gitIpcHandlers.detect(filePath)), + ); + ipcMain.handle('git:init', (_e, filePath: string) => + runHandler(() => gitIpcHandlers.init(filePath)), + ); + ipcMain.handle('git:open', (_e, repoPath: string, currentFilePath?: string) => + runHandler(() => gitIpcHandlers.open(repoPath, currentFilePath)), + ); + ipcMain.handle('git:bindTrackedFile', (_e, repoId: string, filePath: string) => + runHandler(() => gitIpcHandlers.bindTrackedFile(repoId, filePath)), + ); + ipcMain.handle('git:listCandidates', (_e, repoId: string) => + runHandler(() => gitIpcHandlers.listCandidates(repoId)), + ); + ipcMain.handle('git:close', (_e, repoId: string) => + runHandler(() => gitIpcHandlers.close(repoId)), + ); + ipcMain.handle('git:status', (_e, repoId: string) => + runHandler(() => gitIpcHandlers.status(repoId)), + ); + ipcMain.handle( + 'git:log', + (_e, repoId: string, opts: { ref: 'main' | 'autosaves' | string; limit: number }) => + runHandler(() => gitIpcHandlers.log(repoId, opts)), + ); + ipcMain.handle( + 'git:commit', + ( + _e, + repoId: string, + opts: { + kind: 'milestone' | 'autosave'; + message: string; + author: { name: string; email: string }; + }, + ) => runHandler(() => gitIpcHandlers.commit(repoId, opts)), + ); + ipcMain.handle('git:restore', (_e, repoId: string, commitHash: string) => + runHandler(() => gitIpcHandlers.restore(repoId, commitHash)), + ); + ipcMain.handle( + 'git:promote', + ( + _e, + repoId: string, + autosaveHash: string, + message: string, + author: { name: string; email: string }, + ) => runHandler(() => gitIpcHandlers.promote(repoId, autosaveHash, message, author)), + ); + ipcMain.handle('git:branchList', (_e, repoId: string) => + runHandler(() => gitIpcHandlers.branchList(repoId)), + ); + ipcMain.handle( + 'git:branchCreate', + (_e, repoId: string, opts: { name: string; fromCommit?: string }) => + runHandler(() => gitIpcHandlers.branchCreate(repoId, opts)), + ); + ipcMain.handle('git:branchSwitch', (_e, repoId: string, name: string) => + runHandler(() => gitIpcHandlers.branchSwitch(repoId, name)), + ); + ipcMain.handle( + 'git:branchDelete', + (_e, repoId: string, name: string, opts?: { force?: boolean }) => + runHandler(() => gitIpcHandlers.branchDelete(repoId, name, opts)), + ); + + // ---- Phase 2b: remote ops ------------------------------------------------ + ipcMain.handle('git:clone', (_e, opts: { url: string; dest: string; auth?: AuthCreds }) => + runHandler(() => gitIpcHandlers.clone(opts)), + ); + ipcMain.handle('git:fetch', (_e, repoId: string, auth?: AuthCreds) => + runHandler(() => gitIpcHandlers.fetch(repoId, auth)), + ); + ipcMain.handle('git:pull', (_e, repoId: string, auth?: AuthCreds) => + runHandler(() => gitIpcHandlers.pull(repoId, auth)), + ); + ipcMain.handle('git:push', (_e, repoId: string, auth?: AuthCreds) => + runHandler(() => gitIpcHandlers.push(repoId, auth)), + ); + + // ---- Phase 6a: remote metadata + config --------------------------------- + ipcMain.handle('git:remoteGet', (_e, repoId: string) => + runHandler(() => gitIpcHandlers.remoteGet(repoId)), + ); + ipcMain.handle('git:remoteSet', (_e, repoId: string, url: string | null) => + runHandler(() => gitIpcHandlers.remoteSet(repoId, url)), + ); + + // ---- Phase 2b: auth ------------------------------------------------------ + ipcMain.handle('git:authStore', (_e, host: string, creds: AuthCreds) => + runHandler(() => gitIpcHandlers.authStore(host, creds)), + ); + ipcMain.handle('git:authGet', (_e, host: string) => + runHandler(() => gitIpcHandlers.authGet(host)), + ); + ipcMain.handle('git:authClear', (_e, host: string) => + runHandler(() => gitIpcHandlers.authClear(host)), + ); + + // ---- Phase 2b: ssh keys -------------------------------------------------- + ipcMain.handle('git:sshListKeys', () => runHandler(() => gitIpcHandlers.sshListKeys())); + ipcMain.handle('git:sshGenerateKey', (_e, opts: { host: string; comment: string }) => + runHandler(() => gitIpcHandlers.sshGenerateKey(opts)), + ); + ipcMain.handle('git:sshImportKey', (_e, opts: { privateKeyPath: string; host: string }) => + runHandler(() => gitIpcHandlers.sshImportKey(opts)), + ); + ipcMain.handle('git:sshDeleteKey', (_e, keyId: string) => + runHandler(() => gitIpcHandlers.sshDeleteKey(keyId)), + ); + + // ---- Phase 2c: merge orchestration -------------------------------------- + ipcMain.handle('git:diff', (_e, repoId: string, fromCommit: string, toCommit: string) => + runHandler(() => gitIpcHandlers.diff(repoId, fromCommit, toCommit)), + ); + ipcMain.handle('git:branchMerge', (_e, repoId: string, fromBranch: string) => + runHandler(() => gitIpcHandlers.branchMerge(repoId, fromBranch)), + ); + ipcMain.handle( + 'git:resolveConflict', + (_e, repoId: string, conflictId: string, choice: ConflictResolution) => + runHandler(() => gitIpcHandlers.resolveConflict(repoId, conflictId, choice)), + ); + ipcMain.handle('git:applyMerge', (_e, repoId: string) => + runHandler(() => gitIpcHandlers.applyMerge(repoId)), + ); + ipcMain.handle('git:abortMerge', (_e, repoId: string) => + runHandler(() => gitIpcHandlers.abortMerge(repoId)), + ); + + // Phase 4a: author identity probe + ipcMain.handle('git:getSystemAuthor', () => runHandler(() => gitIpcHandlers.getSystemAuthor())); +} + +/** Exposed for the renderer-side rehydrator (Phase 3). Tests use it too. */ +export { GIT_ERROR_MARKER }; diff --git a/apps/desktop/git/merge-orchestrator.ts b/apps/desktop/git/merge-orchestrator.ts new file mode 100644 index 00000000..4a3d4395 --- /dev/null +++ b/apps/desktop/git/merge-orchestrator.ts @@ -0,0 +1,273 @@ +// apps/desktop/git/merge-orchestrator.ts +// +// Single-file mode merge orchestration. Loads three PenDocument blobs from +// git, calls pen-core's mergeDocuments, and produces the wire-format +// ConflictBag with stable ids. Also applies user resolutions to a merged +// document during applyMerge. + +import { + mergeDocuments, + type MergeResult, + type NodeConflict, + type DocFieldConflict, +} from '@zseven-w/pen-core'; +import type { PenDocument, PenNode } from '@zseven-w/pen-types'; + +import { GitError } from './error'; +import { readBlobAtCommit, type IsoRepoHandle } from './git-iso'; +import { + buildConflictBag, + parseConflictId, + type ConflictBag, + type ConflictResolution, +} from './merge-session'; + +/** + * Load the tracked file's content at three commits, JSON.parse, and run the + * pen-core merge. Returns BOTH the raw MergeResult (so the caller can stash + * it for later applyResolutions) AND the wire-format ConflictBag (so the + * caller can return it across IPC immediately). + */ +export async function runMerge(opts: { + handle: IsoRepoHandle; + filepath: string; + oursCommit: string; + theirsCommit: string; + baseCommit: string; +}): Promise<{ + result: MergeResult; + bag: ConflictBag; + conflictMap: Map; +}> { + const { handle, filepath, oursCommit, theirsCommit, baseCommit } = opts; + + const [oursStr, theirsStr, baseStr] = await Promise.all([ + readBlobAtCommit({ handle, filepath, commitHash: oursCommit }), + readBlobAtCommit({ handle, filepath, commitHash: theirsCommit }), + readBlobAtCommit({ handle, filepath, commitHash: baseCommit }), + ]); + + let ours: PenDocument; + let theirs: PenDocument; + let base: PenDocument; + try { + ours = JSON.parse(oursStr) as PenDocument; + theirs = JSON.parse(theirsStr) as PenDocument; + base = JSON.parse(baseStr) as PenDocument; + } catch (err) { + throw new GitError('engine-crash', `Failed to parse PenDocument blobs for merge`, { + cause: err, + detail: { filepath, oursCommit, theirsCommit, baseCommit }, + }); + } + + const result = mergeDocuments({ base, ours, theirs }); + const { bag, conflictMap } = buildConflictBag(result); + return { result, bag, conflictMap }; +} + +/** + * Apply the user's conflict resolutions to a merged document. Returns a new + * PenDocument with the chosen versions substituted in. Does NOT mutate the + * input merged document. + * + * Resolution semantics: + * - 'ours' → leave the merged tree unchanged at that node/field (pen-core's + * mergeDocuments already places ours as the placeholder for unresolved + * conflicts, so 'ours' is a no-op). + * - 'theirs' → replace the conflicted node/field with the theirs version. + * - 'manual-node' → replace the conflicted node with the user's edited version. + * - 'manual-field' → set the doc-field value to the user's choice. + * + * If a conflict has no resolution in the map, we default to 'ours' (the + * pen-core placeholder). The applyMerge engine fn enforces "all conflicts + * must be resolved" before calling this — but defaulting here makes the + * function safe to call in tests with partial resolution maps. + */ +export function applyResolutions(opts: { + merged: PenDocument; + conflictMap: Map; + resolutions: Map; +}): PenDocument { + const { conflictMap, resolutions } = opts; + // We build a new document by deep-cloning via JSON round-trip. The merge + // result is already a fresh object from pen-core, but we don't want to + // mutate it in case the caller still holds a reference for diagnostics. + let doc = JSON.parse(JSON.stringify(opts.merged)) as PenDocument; + + for (const [id, resolution] of resolutions) { + const parsed = parseConflictId(id); + const conflict = conflictMap.get(id); + if (!conflict) { + throw new GitError('engine-crash', `resolveConflict: unknown id ${id}`); + } + + if (parsed.kind === 'node') { + const nodeConflict = conflict as NodeConflict; + const target = pickNodeForResolution(resolution, nodeConflict); + if (target === null) { + // 'ours' (with ours being null = deleted) — drop the node from the tree. + doc = removeNodeById(doc, parsed.pageId, parsed.nodeId); + } else { + doc = replaceNodeById(doc, parsed.pageId, parsed.nodeId, target); + } + } else { + // doc-field conflict + const fieldConflict = conflict as DocFieldConflict; + const value = pickFieldForResolution(resolution, fieldConflict); + doc = setDocFieldByPath(doc, parsed.field, parsed.path, value); + } + } + + return doc; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function pickNodeForResolution( + resolution: ConflictResolution, + conflict: NodeConflict, +): PenNode | null { + if (resolution.kind === 'ours') return conflict.ours; + if (resolution.kind === 'theirs') return conflict.theirs; + if (resolution.kind === 'manual-node') return resolution.node; + // manual-field is a programming error for node conflicts; treat as ours. + return conflict.ours; +} + +function pickFieldForResolution( + resolution: ConflictResolution, + conflict: DocFieldConflict, +): unknown { + if (resolution.kind === 'ours') return conflict.ours; + if (resolution.kind === 'theirs') return conflict.theirs; + if (resolution.kind === 'manual-field') return resolution.value; + // manual-node is a programming error for doc-field conflicts; treat as ours. + return conflict.ours; +} + +/** + * Replace a node in the document tree by id. Walks both the legacy + * single-page `children` and the multi-page `pages` shape. + * + * NOTE: we do NOT use pen-core's `updateNodeInTree` here because its + * semantics are shallow-merge (`{...oldNode, ...updates}`), which would + * leave stale fields from the old node if the replacement changes type or + * omits properties. Conflict resolution requires wholesale replacement — + * the user's chosen node (from theirs or manual edit) must fully supplant + * the old one with no residual fields leaking through. + */ +function replaceNodeById( + doc: PenDocument, + pageId: string | null, + nodeId: string, + replacement: PenNode, +): PenDocument { + if (doc.pages && pageId !== null) { + return { + ...doc, + pages: doc.pages.map((page) => + page.id === pageId + ? { ...page, children: replaceNodeInArray(page.children, nodeId, replacement) } + : page, + ), + }; + } + // Legacy single-page or null pageId. + return { + ...doc, + children: replaceNodeInArray(doc.children ?? [], nodeId, replacement), + }; +} + +/** + * Recursive tree walker that swaps a node by id with a wholesale replacement. + * Returns a new array — does not mutate the input. + */ +function replaceNodeInArray(nodes: PenNode[], id: string, replacement: PenNode): PenNode[] { + return nodes.map((n) => { + if (n.id === id) return replacement; + if ('children' in n && n.children) { + return { + ...n, + children: replaceNodeInArray(n.children, id, replacement), + } as PenNode; + } + return n; + }); +} + +/** + * Remove a node from the document tree by id. Returns a new document. + */ +function removeNodeById(doc: PenDocument, pageId: string | null, nodeId: string): PenDocument { + if (doc.pages && pageId !== null) { + return { + ...doc, + pages: doc.pages.map((page) => + page.id === pageId + ? { ...page, children: removeNodeFromArray(page.children, nodeId) } + : page, + ), + }; + } + return { + ...doc, + children: removeNodeFromArray(doc.children ?? [], nodeId), + }; +} + +function removeNodeFromArray(nodes: PenNode[], id: string): PenNode[] { + const out: PenNode[] = []; + for (const n of nodes) { + if (n.id === id) continue; + if ('children' in n && n.children) { + out.push({ ...n, children: removeNodeFromArray(n.children, id) } as PenNode); + } else { + out.push(n); + } + } + return out; +} + +/** + * Set a doc-field value by its dotted path. Used for variables/themes/etc. + * The path format matches DocFieldConflict.path (e.g. + * 'variables.color-1.value' or 'pages'). + */ +function setDocFieldByPath( + doc: PenDocument, + field: string, + path: string, + value: unknown, +): PenDocument { + // Split the path into segments. The first segment matches `field`; the + // remaining segments navigate into nested object properties. + const segments = path.split('.'); + if (segments.length === 0 || segments[0] !== field) { + // Top-level field, no nesting (e.g. field === 'name', path === 'name'). + return { ...doc, [field]: value } as unknown as PenDocument; + } + + if (segments.length === 1) { + return { ...doc, [field]: value } as unknown as PenDocument; + } + + // Clone the field value and navigate to the parent of the leaf. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const docAny = doc as any; + const fieldValue = docAny[field]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cloned: any = JSON.parse(JSON.stringify(fieldValue ?? {})); + let cursor = cloned; + for (let i = 1; i < segments.length - 1; i++) { + const seg = segments[i]; + if (cursor[seg] == null) cursor[seg] = {}; + cursor = cursor[seg]; + } + cursor[segments[segments.length - 1]] = value; + + return { ...doc, [field]: cloned } as unknown as PenDocument; +} diff --git a/apps/desktop/git/merge-session.ts b/apps/desktop/git/merge-session.ts new file mode 100644 index 00000000..a607d20d --- /dev/null +++ b/apps/desktop/git/merge-session.ts @@ -0,0 +1,132 @@ +// apps/desktop/git/merge-session.ts +// +// In-flight merge state for a single repo. Stored on RepoSession.inflightMerge +// during the conflict resolution loop. The conflict id codec lives here too +// because it's the only string format both the engine and the tests share. + +import type { MergeResult, NodeConflict, DocFieldConflict } from '@zseven-w/pen-core'; +import type { PenNode } from '@zseven-w/pen-types'; + +/** + * The user's choice for resolving a single conflict. Mirrors the spec's + * ConflictResolution union. + */ +export type ConflictResolution = + | { kind: 'ours' } + | { kind: 'theirs' } + | { kind: 'manual-node'; node: PenNode } // for node conflicts + | { kind: 'manual-field'; value: unknown }; // for doc-field conflicts + +/** + * Wire-format conflict bag returned across IPC. The renderer wraps this in + * Maps for resolution tracking. Each conflict has a stable id that the + * renderer passes back via resolveConflict(). + */ +export interface ConflictBag { + nodeConflicts: Array; + docFieldConflicts: Array; +} + +export interface InflightMerge { + /** The current HEAD commit at the time branchMerge was invoked. */ + oursCommit: string; + /** The branch tip we're merging in. */ + theirsCommit: string; + /** Common ancestor commit. */ + baseCommit: string; + + /** Raw output from pen-core's mergeDocuments. */ + mergeResult: MergeResult; + + /** O(1) lookup of conflict by id. Built once at branchMerge time. */ + conflictMap: Map; + + /** Accumulated user choices. Empty until resolveConflict is called. */ + resolutions: Map; + + /** Default commit message for applyMerge. The renderer can override later. */ + defaultMessage: string; +} + +// --------------------------------------------------------------------------- +// Conflict id codec +// +// Encoding rules (matches spec line 836-841 verbatim): +// Node conflict: `node:${pageId ?? '_'}:${nodeId}` +// Doc-field conflict: `field:${field}:${path}` +// +// Stable, deterministic, both engine and renderer agree. +// --------------------------------------------------------------------------- + +export function encodeNodeConflictId(conflict: NodeConflict): string { + return `node:${conflict.pageId ?? '_'}:${conflict.nodeId}`; +} + +export function encodeDocFieldConflictId(conflict: DocFieldConflict): string { + return `field:${conflict.field}:${conflict.path}`; +} + +export type ParsedConflictId = + | { kind: 'node'; pageId: string | null; nodeId: string } + | { kind: 'field'; field: string; path: string }; + +/** + * Parse a conflict id back into its components. Used by resolveConflict to + * locate the conflict in session state. Throws if the id is malformed — + * callers should treat that as a programming error (the renderer always + * passes back ids the engine just emitted). + */ +export function parseConflictId(id: string): ParsedConflictId { + if (id.startsWith('node:')) { + const rest = id.slice('node:'.length); + const colonIdx = rest.indexOf(':'); + if (colonIdx === -1) { + throw new Error(`Malformed node conflict id: ${id}`); + } + const rawPage = rest.slice(0, colonIdx); + const nodeId = rest.slice(colonIdx + 1); + return { + kind: 'node', + pageId: rawPage === '_' ? null : rawPage, + nodeId, + }; + } + if (id.startsWith('field:')) { + const rest = id.slice('field:'.length); + const colonIdx = rest.indexOf(':'); + if (colonIdx === -1) { + throw new Error(`Malformed field conflict id: ${id}`); + } + return { + kind: 'field', + field: rest.slice(0, colonIdx), + path: rest.slice(colonIdx + 1), + }; + } + throw new Error(`Unknown conflict id prefix: ${id}`); +} + +/** + * Build a wire-format ConflictBag from a MergeResult by attaching ids. Used + * by branchMerge before stashing the InflightMerge in session state. + * + * Returns BOTH the bag AND the conflict map (id → conflict) so the caller + * can hydrate the InflightMerge in one pass without re-walking the result. + */ +export function buildConflictBag(result: MergeResult): { + bag: ConflictBag; + conflictMap: Map; +} { + const conflictMap = new Map(); + const nodeConflicts = result.nodeConflicts.map((c) => { + const id = encodeNodeConflictId(c); + conflictMap.set(id, c); + return { ...c, id }; + }); + const docFieldConflicts = result.docFieldConflicts.map((c) => { + const id = encodeDocFieldConflictId(c); + conflictMap.set(id, c); + return { ...c, id }; + }); + return { bag: { nodeConflicts, docFieldConflicts }, conflictMap }; +} diff --git a/apps/desktop/git/repo-detector.ts b/apps/desktop/git/repo-detector.ts new file mode 100644 index 00000000..c8511885 --- /dev/null +++ b/apps/desktop/git/repo-detector.ts @@ -0,0 +1,89 @@ +// apps/desktop/git/repo-detector.ts +// +// Discover whether a .op file lives inside a git repository, and if so, +// in which mode (single-file or folder). Returns a discriminated union +// the engine can match against without follow-up filesystem checks. + +import { dirname, basename, resolve } from 'node:path'; +import { stat } from 'node:fs/promises'; + +/** + * Result of a successful detection. The shape is the same for both modes + * so the engine can pass it directly to `openRepo` regardless of mode. + */ +export interface RepoDetectionFound { + mode: 'single-file' | 'folder'; + /** worktree root (parent of the .op file in single-file mode; repo root in folder mode) */ + rootPath: string; + /** absolute path to the gitdir */ + gitdir: string; +} + +export type RepoDetection = RepoDetectionFound | { mode: 'none' }; + +/** + * Walk up from the given .op file looking for a tracked repository. + * + * Order of checks (single-file wins per spec): + * 1. /.op-history/.git/HEAD exists + * → single-file mode + * 2. Walk up parent dirs looking for any /.git/HEAD → folder mode + * 3. Otherwise → none + * + * The function never throws on missing files; only on filesystem errors that + * indicate something deeper is wrong (permissions, broken symlinks). Those + * propagate as standard Node errors and are NOT wrapped in GitError — the + * engine layer is responsible for translation. + */ +export async function detectRepo(filePath: string): Promise { + const absFile = resolve(filePath); + const parentDir = dirname(absFile); + const baseName = basename(absFile); + + // 1. Single-file mode check. + const singleGitdir = resolve(parentDir, '.op-history', `${baseName}.git`); + if (await pathExists(resolve(singleGitdir, 'HEAD'))) { + return { + mode: 'single-file', + rootPath: parentDir, + gitdir: singleGitdir, + }; + } + + // 2. Walk up parents looking for a .git directory. + let current = parentDir; + while (true) { + const candidate = resolve(current, '.git'); + if (await pathExists(resolve(candidate, 'HEAD'))) { + return { + mode: 'folder', + rootPath: current, + gitdir: candidate, + }; + } + const parent = dirname(current); + if (parent === current) { + // Reached filesystem root. + break; + } + current = parent; + } + + // 3. No repo found. + return { mode: 'none' }; +} + +/** + * Returns true if `path` exists (file or directory). Returns false on ENOENT. + * Re-throws other errors (permission denied, etc.) so we don't silently + * misbehave. + */ +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw err; + } +} diff --git a/apps/desktop/git/repo-session.ts b/apps/desktop/git/repo-session.ts new file mode 100644 index 00000000..771c4cc2 --- /dev/null +++ b/apps/desktop/git/repo-session.ts @@ -0,0 +1,145 @@ +// apps/desktop/git/repo-session.ts +// +// In-memory registry of open repositories. Each entry is allocated by +// engineDetect/Init/Open and disposed by engineClose. The renderer treats +// repoId as an opaque string and passes it back on every subsequent IPC +// call. +// +// This map is module-level (singleton). It does NOT survive a main-process +// restart, which matches the spec's session-scoped contract for repoId. + +import { randomUUID } from 'node:crypto'; +import type { IsoRepoHandle } from './git-iso'; +import type { InflightMerge } from './merge-session'; + +export interface CandidateFileInfo { + /** absolute path */ + path: string; + /** path relative to RepoSession.handle.dir, used for UI display */ + relativePath: string; + /** # of commits on refs/heads/ that include this file */ + milestoneCount: number; + /** # of commits on refs/openpencil/autosaves/ for this file */ + autosaveCount: number; + /** timestamp (seconds) of the most recent touching commit, null if never */ + lastCommitAt: number | null; + lastCommitMessage: string | null; +} + +export interface RepoSession { + repoId: string; + handle: IsoRepoHandle; + /** Which .op file the panel is currently tracking. May be null right after + * an `open`/`clone` call where no auto-binding happened — the renderer + * must call bindTrackedFile to set it before commit/restore work. */ + trackedFilePath: string | null; + /** Cached candidate list, populated by engineDetect/Init/Open and refreshed + * on demand by engineListCandidates. */ + candidateFiles: CandidateFileInfo[]; + /** Engine kind reported to the renderer. In Phase 2a always 'iso'. */ + engineKind: 'iso' | 'sys'; + /** In-flight merge state. Set by engineBranchMerge when conflicts are + * present, cleared by engineApplyMerge / engineAbortMerge. Null when no + * merge is in progress. */ + inflightMerge: InflightMerge | null; +} + +const sessions = new Map(); + +/** + * Allocate a fresh session for a newly-opened repo. Returns the new repoId. + * The caller fills in trackedFilePath / candidateFiles before returning to + * the IPC layer. + */ +export function registerSession(args: { + handle: IsoRepoHandle; + trackedFilePath: string | null; + candidateFiles: CandidateFileInfo[]; + engineKind: 'iso' | 'sys'; +}): RepoSession { + const repoId = randomUUID(); + const session: RepoSession = { + repoId, + handle: args.handle, + trackedFilePath: args.trackedFilePath, + candidateFiles: args.candidateFiles, + engineKind: args.engineKind, + inflightMerge: null, + }; + sessions.set(repoId, session); + return session; +} + +/** + * Look up an existing session by repoId. Returns undefined if the id is + * unknown — the engine layer translates this to a GitError('no-file'). + */ +export function getSession(repoId: string): RepoSession | undefined { + return sessions.get(repoId); +} + +/** + * Mutate the trackedFilePath of an existing session. Used by + * engineBindTrackedFile. + */ +export function updateTrackedFile(repoId: string, trackedFilePath: string): boolean { + const session = sessions.get(repoId); + if (!session) return false; + session.trackedFilePath = trackedFilePath; + return true; +} + +/** + * Mutate the candidateFiles cache of an existing session. Used by + * engineListCandidates after re-walking the worktree. + */ +export function updateCandidates(repoId: string, candidateFiles: CandidateFileInfo[]): boolean { + const session = sessions.get(repoId); + if (!session) return false; + session.candidateFiles = candidateFiles; + return true; +} + +/** + * Stash an in-flight merge on the session. Used by engineBranchMerge after + * detecting .op-level conflicts. + */ +export function setInflightMerge(repoId: string, merge: InflightMerge): boolean { + const session = sessions.get(repoId); + if (!session) return false; + session.inflightMerge = merge; + return true; +} + +/** + * Clear the in-flight merge. Used by engineApplyMerge (after a successful + * commit) and engineAbortMerge (after the user discards). + */ +export function clearInflightMerge(repoId: string): boolean { + const session = sessions.get(repoId); + if (!session) return false; + session.inflightMerge = null; + return true; +} + +/** + * Drop a session from the registry. Called by engineClose. The handle's + * gitdir/file descriptors are managed by isomorphic-git internally — there's + * nothing to flush here. + */ +export function unregisterSession(repoId: string): boolean { + return sessions.delete(repoId); +} + +/** + * Wipe the entire registry. Used in tests' afterEach to keep sessions from + * leaking between cases. Production code should never call this. + */ +export function clearAllSessions(): void { + sessions.clear(); +} + +/** Test/debug helper: count active sessions. Not part of the IPC surface. */ +export function sessionCount(): number { + return sessions.size; +} diff --git a/apps/desktop/git/ssh-keys.ts b/apps/desktop/git/ssh-keys.ts new file mode 100644 index 00000000..3c43f714 --- /dev/null +++ b/apps/desktop/git/ssh-keys.ts @@ -0,0 +1,183 @@ +// apps/desktop/git/ssh-keys.ts +// +// SSH key management. We generate ed25519 keypairs via node:crypto and +// format the public key as OpenSSH using sshpk. Private keys are stored +// as PEM PKCS#8 with file mode 0600. The metadata index lives at +// /index.json and is rewritten atomically on every mutation. +// +// This module is factory-based so tests can inject a temp directory. + +import { promises as fsp } from 'node:fs'; +import { generateKeyPair, randomUUID } from 'node:crypto'; +import { promisify } from 'node:util'; +import { join, basename } from 'node:path'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const sshpk = require('sshpk') as typeof import('sshpk'); + +const generateKeyPairAsync = promisify(generateKeyPair); + +export interface SshKeyInfo { + id: string; + host: string; + publicKey: string; // OpenSSH single-line format + fingerprint: string; // SHA256 fingerprint with `SHA256:` prefix + comment: string; + /** Absolute path to the PEM private key file. Not exposed via IPC — used + * internally by git-sys when constructing GIT_SSH_COMMAND. */ + privateKeyPath: string; +} + +export interface SshKeyManager { + generate(opts: { host: string; comment: string }): Promise; + import(opts: { privateKeyPath: string; host: string }): Promise; + list(): Promise; + delete(keyId: string): Promise; + /** Resolve a keyId to its private key path. Throws if missing. Used by + * git-sys when invoking SSH transport. Not part of the IPC surface. */ + getPrivateKeyPath(keyId: string): Promise; +} + +export interface SshKeyManagerOpts { + /** Directory where private keys + index.json live. Created on first use. */ + sshDir: string; +} + +const INDEX_FILE = 'index.json'; + +export function createSshKeyManager(opts: SshKeyManagerOpts): SshKeyManager { + const { sshDir } = opts; + + async function ensureDir(): Promise { + await fsp.mkdir(sshDir, { recursive: true, mode: 0o700 }); + } + + async function loadIndex(): Promise { + try { + const bytes = await fsp.readFile(join(sshDir, INDEX_FILE), 'utf-8'); + return JSON.parse(bytes) as SshKeyInfo[]; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + } + + async function saveIndex(keys: SshKeyInfo[]): Promise { + await ensureDir(); + const tmp = join(sshDir, `${INDEX_FILE}.tmp`); + const dest = join(sshDir, INDEX_FILE); + await fsp.writeFile(tmp, JSON.stringify(keys, null, 2), { mode: 0o600 }); + await fsp.rename(tmp, dest); + } + + function computeFingerprint(opensshPublicKey: string): string { + const key = sshpk.parseKey(opensshPublicKey, 'ssh'); + return key.fingerprint('sha256').toString(); + } + + function formatPublicKeyOpenSsh(pemPublic: string, comment: string): string { + const key = sshpk.parseKey(pemPublic, 'pem'); + const ssh = key.toString('ssh'); + // sshpk's ssh format already includes "ssh-ed25519 ..."; append comment. + return `${ssh.trim()} ${comment}`.trim(); + } + + return { + async generate({ host, comment }) { + await ensureDir(); + const { publicKey, privateKey } = await generateKeyPairAsync('ed25519', { + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + + const id = randomUUID(); + const privateKeyPath = join(sshDir, `${id}.pem`); + await fsp.writeFile(privateKeyPath, privateKey, { mode: 0o600 }); + + const sshPublic = formatPublicKeyOpenSsh(publicKey, comment); + const fingerprint = computeFingerprint(sshPublic); + + const info: SshKeyInfo = { + id, + host, + publicKey: sshPublic, + fingerprint, + comment, + privateKeyPath, + }; + const all = await loadIndex(); + all.push(info); + await saveIndex(all); + return info; + }, + + async import({ privateKeyPath, host }) { + // Read the existing private key, derive the public key via sshpk. + const pemBytes = await fsp.readFile(privateKeyPath); + const privateKeyObj = sshpk.parsePrivateKey(pemBytes, 'auto'); + const publicKeyObj = privateKeyObj.toPublic(); + const sshPublic = publicKeyObj.toString('ssh').trim(); + const fingerprint = publicKeyObj.fingerprint('sha256').toString(); + const comment = `imported-${basename(privateKeyPath)}`; + + // Copy the file into our sshDir so the user's original location can + // move freely without breaking us. + const id = randomUUID(); + const destPath = join(sshDir, `${id}.pem`); + await ensureDir(); + // Re-export as PKCS#8 PEM via sshpk so we always store a uniform format. + const pkcs8Pem = privateKeyObj.toString('pkcs8'); + await fsp.writeFile(destPath, pkcs8Pem, { mode: 0o600 }); + + const info: SshKeyInfo = { + id, + host, + publicKey: `${sshPublic} ${comment}`, + fingerprint, + comment, + privateKeyPath: destPath, + }; + const all = await loadIndex(); + all.push(info); + await saveIndex(all); + return info; + }, + + async list() { + return loadIndex(); + }, + + async delete(keyId) { + const all = await loadIndex(); + const idx = all.findIndex((k) => k.id === keyId); + if (idx === -1) return; // already gone — idempotent + const info = all[idx]; + try { + await fsp.unlink(info.privateKeyPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + all.splice(idx, 1); + await saveIndex(all); + }, + + async getPrivateKeyPath(keyId) { + const all = await loadIndex(); + const found = all.find((k) => k.id === keyId); + if (!found) { + throw new Error(`SSH key ${keyId} not found`); + } + return found.privateKeyPath; + }, + }; +} + +/** + * Default factory: builds a manager pointed at /ssh/. Lazy-imports + * Electron so tests don't pull it in. + */ +export function createDefaultSshKeyManager(): SshKeyManager { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const electron = require('electron'); + const userDataDir: string = electron.app.getPath('userData'); + return createSshKeyManager({ sshDir: join(userDataDir, 'ssh') }); +} diff --git a/apps/desktop/git/worktree-merge.ts b/apps/desktop/git/worktree-merge.ts new file mode 100644 index 00000000..3ee4e8fc --- /dev/null +++ b/apps/desktop/git/worktree-merge.ts @@ -0,0 +1,307 @@ +// apps/desktop/git/worktree-merge.ts +// +// System-git helpers for folder-mode merge operations. These are the only +// functions in the git layer that shell out to the system git binary for +// merge state management — everything else uses isomorphic-git. +// +// DESIGN NOTE (Phase 7a spike): +// We use system git's merge machinery because isomorphic-git has no +// equivalent of --no-commit --no-ff merges and cannot write the three-stage +// index entries needed for conflict detection. The exact command sequence +// was chosen after verifying each shape against a live repo: +// +// 1. `git merge --no-commit --no-ff ` — enters merge state; exits 1 +// on conflicts, exits 0 on clean merge (but still --no-commit so we +// can write the tracked file before committing). +// 2. `git ls-files -u` — lists all unresolved paths (all conflict types, +// not just "both modified"), along with stage numbers 1/2/3. +// 3. `git show :1:`, `:2:`, `:3:` — reads base/ours/ +// theirs blobs from the index without touching the working tree. +// 4. `git checkout --ours -- ` — writes the ours version to disk so +// the tracked .op file is readable JSON; file stays "unresolved" in the +// index so MERGE_HEAD and other unresolved files survive. +// 5. `git add ` — marks a file resolved in the index. +// 6. `git commit -m ` — when MERGE_HEAD is present, git +// automatically creates a 2-parent merge commit. +// 7. `git merge --abort` — atomically restores the working tree and index. + +import { execFile } from 'node:child_process'; +import { promises as fsp } from 'node:fs'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { GitError } from './error'; + +const execFileAsync = promisify(execFile); + +const DEFAULT_TIMEOUT_MS = 60_000; + +interface RunOpts { + cwd: string; + env?: Record; + timeoutMs?: number; +} + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** + * Run `git `. Unlike the private runGit in git-sys.ts, this version + * tolerates non-zero exits and returns the exit code so callers can + * distinguish "conflict" from "error" — `git merge` exits 1 on conflicts + * but that is not an error from the caller's perspective. + */ +async function runGitTolerant(args: string[], opts: RunOpts): Promise { + const env = { ...process.env, ...opts.env }; + try { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd: opts.cwd, + env, + timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + maxBuffer: 32 * 1024 * 1024, + }); + return { stdout, stderr, exitCode: 0 }; + } catch (err) { + const e = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string; code?: number }; + // exitCode is the numeric exit code from the child process; undefined if it + // was killed by a signal (which we map to -1). + const exitCode = typeof e.code === 'number' ? e.code : -1; + return { + stdout: e.stdout ?? '', + stderr: e.stderr ?? '', + exitCode, + }; + } +} + +// --------------------------------------------------------------------------- +// Public helpers +// --------------------------------------------------------------------------- + +/** + * Attempt to merge `ref` into the current branch without auto-committing. + * Uses `--no-ff` to always produce a merge commit even for fast-forwards. + * + * Returns: + * - { kind: 'clean' } — merge succeeded with no conflicts; index is staged + * but not committed (MERGE_HEAD is set). + * - { kind: 'conflict' } — one or more conflicts; MERGE_HEAD is set, unresolved + * files remain in the index at conflict stages. + * - throws GitError — on engine-level failures (not available, unknown ref, etc.) + * + * NOTE: some git versions read user identity during merge bookkeeping even with + * --no-commit. Callers must ensure the repo has user.name/user.email configured + * (or inject them via opts.env) — machines without a global git config will fail. + */ +export async function sysMergeNoCommit(opts: { + cwd: string; + ref: string; + env?: Record; +}): Promise<{ kind: 'clean' | 'conflict' }> { + const result = await runGitTolerant(['merge', '--no-commit', '--no-ff', opts.ref], { + cwd: opts.cwd, + env: opts.env, + }); + + if (result.exitCode === 0) return { kind: 'clean' }; + + // Exit code 1 from `git merge` means conflicts. Any other code is an error. + if (result.exitCode === 1) return { kind: 'conflict' }; + + throw new GitError( + 'engine-crash', + `git merge --no-commit failed: ${result.stderr.trim() || result.stdout.trim()}`, + { detail: { ref: opts.ref, exitCode: result.exitCode } }, + ); +} + +/** + * List all unresolved file paths in the current merge state. + * Uses `git ls-files -u` which reports ALL conflict types (both-modified, + * deleted-by-them, etc.), not just `--diff-filter=U` which only reports + * "both modified". Returns deduplicated, sorted paths. + * + * MINIMUM GIT VERSION: `--format=%(path)` requires git ≥ 2.35 (Feb 2022). + * No version check or fallback is provided here — callers must ensure the + * system git is new enough. Document this floor in deployment requirements. + */ +export async function sysListUnresolved(opts: { cwd: string }): Promise { + const result = await runGitTolerant(['ls-files', '-u', '--format=%(path)'], { + cwd: opts.cwd, + }); + + if (result.exitCode !== 0) { + throw new GitError('engine-crash', `git ls-files -u failed: ${result.stderr.trim()}`, { + detail: { exitCode: result.exitCode }, + }); + } + + const paths = result.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + // Deduplicate: each unresolved path appears 2-3 times (one per stage). + return [...new Set(paths)].sort(); +} + +/** + * Detect whether a merge is in progress by checking for MERGE_HEAD in the + * gitdir. Does NOT run git — pure filesystem check. Returns the theirs + * commit hash if in progress, null otherwise. + */ +export async function readMergeHead(gitdir: string): Promise { + const mergeHeadPath = join(gitdir, 'MERGE_HEAD'); + try { + const content = await fsp.readFile(mergeHeadPath, 'utf-8'); + const hash = content.trim(); + if (hash.length === 40) return hash; + return null; + } catch { + return null; + } +} + +/** + * Read the content of a tracked file from the index at a specific stage: + * stage 1 = base (merge-base ancestor) + * stage 2 = ours (HEAD) + * stage 3 = theirs (MERGE_HEAD) + * + * Returns null if the file is not present at that stage (e.g. deleted-by-them + * conflict has no stage 3, only stages 1 and 2). + */ +export async function sysShowStageBlob(opts: { + cwd: string; + stage: 1 | 2 | 3; + filepath: string; +}): Promise { + const stageRef = `:${opts.stage}:${opts.filepath}`; + const result = await runGitTolerant(['show', stageRef], { cwd: opts.cwd }); + + if (result.exitCode === 0) return result.stdout; + + // Non-zero exit means the file doesn't exist at this stage — that is not + // an error, it's a normal state (e.g. deleted-by-them has no :3:). + return null; +} + +/** + * Restore the working-tree content of a tracked file to the "ours" version + * (stage 2) so the renderer can read readable JSON instead of conflict + * markers. The file stays "unresolved" in the index — MERGE_HEAD survives. + * + * The exact behaviour was verified in the Phase 7a spike: + * `git checkout --ours -- ` writes stage 2 to disk and leaves the + * index at conflict stages (1/2/3). `git diff --name-only --diff-filter=U` + * still reports the file as unresolved after this call. + */ +export async function sysRestoreOurs(opts: { cwd: string; filepath: string }): Promise { + const result = await runGitTolerant(['checkout', '--ours', '--', opts.filepath], { + cwd: opts.cwd, + }); + + if (result.exitCode !== 0) { + throw new GitError( + 'engine-crash', + `git checkout --ours failed for ${opts.filepath}: ${result.stderr.trim()}`, + { detail: { filepath: opts.filepath, exitCode: result.exitCode } }, + ); + } +} + +/** + * Stage a file, marking it as resolved in the index. Used after the tracked + * .op file has been written with the final merged document so git accepts the + * merge commit. + */ +export async function sysStageFile(opts: { cwd: string; filepath: string }): Promise { + const result = await runGitTolerant(['add', '--', opts.filepath], { cwd: opts.cwd }); + + if (result.exitCode !== 0) { + throw new GitError( + 'engine-crash', + `git add failed for ${opts.filepath}: ${result.stderr.trim()}`, + { detail: { filepath: opts.filepath, exitCode: result.exitCode } }, + ); + } +} + +/** + * Finalize the merge by creating the merge commit. MERGE_HEAD must be set. + * When MERGE_HEAD is present, git automatically records both parents. + * + * Returns the new merge commit hash. + */ +export async function sysFinalizeMerge(opts: { + cwd: string; + message: string; + author: { name: string; email: string }; + env?: Record; +}): Promise { + const env: Record = { + ...opts.env, + GIT_AUTHOR_NAME: opts.author.name, + GIT_AUTHOR_EMAIL: opts.author.email, + GIT_COMMITTER_NAME: opts.author.name, + GIT_COMMITTER_EMAIL: opts.author.email, + }; + + const result = await runGitTolerant(['commit', '-m', opts.message], { + cwd: opts.cwd, + env, + }); + + if (result.exitCode !== 0) { + throw new GitError( + 'engine-crash', + `git commit (merge finalize) failed: ${result.stderr.trim()}`, + { detail: { exitCode: result.exitCode } }, + ); + } + + // Parse the new commit hash from `git rev-parse HEAD`. + const headResult = await runGitTolerant(['rev-parse', 'HEAD'], { cwd: opts.cwd }); + if (headResult.exitCode !== 0 || !headResult.stdout.trim()) { + throw new GitError('engine-crash', 'Failed to read HEAD after merge commit'); + } + return headResult.stdout.trim(); +} + +/** + * Abort an in-progress merge. Restores the working tree and index to pre-merge + * state. Idempotent: safe to call even if no merge is in progress (git merge + * --abort exits 0 with a warning in that case on modern git versions). + */ +export async function sysAbortMerge(opts: { cwd: string }): Promise { + const result = await runGitTolerant(['merge', '--abort'], { cwd: opts.cwd }); + + // Exit code 0 = success. Exit code 128 with "MERGE_HEAD missing" means there + // was no merge in progress — treat that as idempotent success. + if (result.exitCode === 0) return; + + const msg = (result.stderr + result.stdout).toLowerCase(); + if (msg.includes('merge_head') || msg.includes('no merge in progress')) { + return; // Nothing to abort — already clean. + } + + throw new GitError('merge-abort-failed', `git merge --abort failed: ${result.stderr.trim()}`, { + detail: { exitCode: result.exitCode }, + }); +} + +/** + * Read the current HEAD commit hash. Throws if HEAD cannot be resolved + * (e.g. repo has no commits). + */ +export async function sysReadHead(opts: { cwd: string }): Promise { + const result = await runGitTolerant(['rev-parse', 'HEAD'], { cwd: opts.cwd }); + if (result.exitCode !== 0 || !result.stdout.trim()) { + throw new GitError('engine-crash', `git rev-parse HEAD failed: ${result.stderr.trim()}`, { + detail: { exitCode: result.exitCode }, + }); + } + return result.stdout.trim(); +} diff --git a/apps/desktop/ipc-handlers.ts b/apps/desktop/ipc-handlers.ts index 88dc158c..35c6a33d 100644 --- a/apps/desktop/ipc-handlers.ts +++ b/apps/desktop/ipc-handlers.ts @@ -1,11 +1,7 @@ -import { - ipcMain, - dialog, - type BrowserWindow, -} from 'electron' -import { resolve, extname, sep } from 'node:path' -import { readFile, writeFile } from 'node:fs/promises' -import { app } from 'electron' +import { ipcMain, dialog, type BrowserWindow } from 'electron'; +import { resolve, extname, sep } from 'node:path'; +import { readFile, writeFile } from 'node:fs/promises'; +import { app } from 'electron'; import { getUpdaterState, @@ -16,148 +12,226 @@ import { setUpdaterState, clearUpdateTimer, startUpdateTimer, -} from './auto-updater' -import { getLogDir } from './logger' +} from './auto-updater'; +import { getLogDir } from './logger'; +import { + buildUnsavedChangesDialogOptions, + mapUnsavedChangesResponse, +} from './unsaved-changes-dialog'; interface IpcDeps { - getMainWindow: () => BrowserWindow | null - getPendingFilePath: () => string | null - clearPendingFilePath: () => void - prefsCache: Record - schedulePrefsWrite: () => void - writeAppSettings: (patch: { autoUpdate?: boolean }) => Promise + getMainWindow: () => BrowserWindow | null; + getPendingFilePath: () => string | null; + clearPendingFilePath: () => void; + prefsCache: Record; + schedulePrefsWrite: () => void; + writeAppSettings: (patch: { autoUpdate?: boolean }) => Promise; } export function setupIPC(deps: IpcDeps): void { - const { getMainWindow, getPendingFilePath, clearPendingFilePath, prefsCache, schedulePrefsWrite, writeAppSettings } = deps + const { + getMainWindow, + getPendingFilePath, + clearPendingFilePath, + prefsCache, + schedulePrefsWrite, + writeAppSettings, + } = deps; ipcMain.handle('dialog:openFile', async () => { - const mainWindow = getMainWindow() - if (!mainWindow) return null + const mainWindow = getMainWindow(); + if (!mainWindow) return null; const result = await dialog.showOpenDialog(mainWindow, { title: 'Open .op file', filters: [{ name: 'OpenPencil Files', extensions: ['op', 'pen'] }], properties: ['openFile'], - }) - if (result.canceled || result.filePaths.length === 0) return null - const filePath = result.filePaths[0] - const content = await readFile(filePath, 'utf-8') - return { filePath, content } - }) + }); + if (result.canceled || result.filePaths.length === 0) return null; + const filePath = result.filePaths[0]; + const content = await readFile(filePath, 'utf-8'); + return { filePath, content }; + }); + + ipcMain.handle('dialog:openDirectory', async () => { + const mainWindow = getMainWindow(); + if (!mainWindow) return null; + const result = await dialog.showOpenDialog(mainWindow, { + title: 'Open Git repository folder', + properties: ['openDirectory'], + }); + if (result.canceled || result.filePaths.length === 0) return null; + return result.filePaths[0]; + }); ipcMain.handle( 'dialog:saveFile', async (_event, payload: { content: string; defaultPath?: string }) => { - const mainWindow = getMainWindow() - if (!mainWindow) return null + const mainWindow = getMainWindow(); + if (!mainWindow) return null; const result = await dialog.showSaveDialog(mainWindow, { title: 'Save .op file', defaultPath: payload.defaultPath, filters: [{ name: 'OpenPencil Files', extensions: ['op'] }], - }) - if (result.canceled || !result.filePath) return null - await writeFile(result.filePath, payload.content, 'utf-8') - return result.filePath + }); + if (result.canceled || !result.filePath) return null; + await writeFile(result.filePath, payload.content, 'utf-8'); + return result.filePath; }, - ) + ); ipcMain.handle( 'dialog:saveToPath', async (_event, payload: { filePath: string; content: string }) => { - const resolved = resolve(payload.filePath) + const resolved = resolve(payload.filePath); if (resolved.includes('\0')) { - throw new Error('Invalid file path') + throw new Error('Invalid file path'); } - const ext = extname(resolved).toLowerCase() + const ext = extname(resolved).toLowerCase(); if (ext !== '.op' && ext !== '.pen') { - throw new Error('Only .op and .pen file extensions are allowed') + throw new Error('Only .op and .pen file extensions are allowed'); } - const allowedRoots = [app.getPath('home'), app.getPath('temp')] + const allowedRoots = [app.getPath('home'), app.getPath('temp')]; const inAllowedDir = allowedRoots.some( (root) => resolved === root || resolved.startsWith(root + sep), - ) + ); if (!inAllowedDir) { - throw new Error('File path must be within the user home or temp directory') + throw new Error('File path must be within the user home or temp directory'); } - await writeFile(resolved, payload.content, 'utf-8') - return resolved + await writeFile(resolved, payload.content, 'utf-8'); + return resolved; }, - ) + ); ipcMain.handle('file:getPending', () => { - const filePath = getPendingFilePath() + const filePath = getPendingFilePath(); if (filePath) { - clearPendingFilePath() - return filePath + clearPendingFilePath(); + return filePath; } - return null - }) + return null; + }); ipcMain.handle('file:read', async (_event, filePath: string) => { - const resolved = resolve(filePath) - const ext = extname(resolved).toLowerCase() - if (ext !== '.op' && ext !== '.pen') return null + const resolved = resolve(filePath); + const ext = extname(resolved).toLowerCase(); + if (ext !== '.op' && ext !== '.pen') return null; try { - const content = await readFile(resolved, 'utf-8') - return { filePath: resolved, content } + const content = await readFile(resolved, 'utf-8'); + return { filePath: resolved, content }; } catch { - return null + return null; } - }) + }); + + ipcMain.handle('dialog:openImageFile', async () => { + const mainWindow = getMainWindow(); + if (!mainWindow) return null; + const result = await dialog.showOpenDialog(mainWindow, { + title: 'Open image file', + filters: [ + { + name: 'Image Files', + extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif'], + }, + ], + properties: ['openFile'], + }); + if (result.canceled || result.filePaths.length === 0) return null; + + const filePath = result.filePaths[0]; + return { + filePath, + name: filePath.split(/[\\/]/).pop() ?? 'image', + content: + extname(filePath).toLowerCase() === '.svg' ? await readFile(filePath, 'utf-8') : null, + }; + }); + + ipcMain.handle( + 'dialog:confirmUnsavedChanges', + async ( + _event, + payload: { + message: string; + detail?: string; + yesLabel: string; + noLabel: string; + cancelLabel: string; + }, + ) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return 'cancel'; + const { response } = await dialog.showMessageBox( + mainWindow, + buildUnsavedChangesDialogOptions(payload), + ); + return mapUnsavedChangesResponse(response); + }, + ); // Theme sync for Windows/Linux title bar overlay ipcMain.handle( 'theme:set', (_event, theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => { - const mainWindow = getMainWindow() - if (!mainWindow || mainWindow.isDestroyed()) return - const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux' - if (!isWinOrLinux) return - const isLinux = process.platform === 'linux' - const fallbackBg = theme === 'dark' ? '#111' : '#fff' - const fallbackFg = theme === 'dark' ? '#d4d4d8' : '#3f3f46' + const mainWindow = getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux'; + if (!isWinOrLinux) return; + const isLinux = process.platform === 'linux'; + const fallbackBg = theme === 'dark' ? '#111' : '#fff'; + const fallbackFg = theme === 'dark' ? '#d4d4d8' : '#3f3f46'; mainWindow.setTitleBarOverlay({ - color: isLinux ? (colors?.bg || fallbackBg) : 'rgba(0,0,0,0)', + color: isLinux ? colors?.bg || fallbackBg : 'rgba(0,0,0,0)', symbolColor: colors?.fg || fallbackFg, - }) + }); }, - ) + ); // Renderer preferences - ipcMain.handle('prefs:getAll', () => ({ ...prefsCache })) + ipcMain.handle('prefs:getAll', () => ({ ...prefsCache })); ipcMain.handle('prefs:set', (_event, key: string, value: string) => { - prefsCache[key] = value - schedulePrefsWrite() - }) + prefsCache[key] = value; + schedulePrefsWrite(); + }); ipcMain.handle('prefs:remove', (_event, key: string) => { - delete prefsCache[key] - schedulePrefsWrite() - }) + delete prefsCache[key]; + schedulePrefsWrite(); + }); - ipcMain.handle('log:getDir', () => getLogDir()) + // Recent files sync from renderer → main (for native menu) + ipcMain.on( + 'recent-files:sync', + (_event, files: Array<{ fileName: string; filePath: string }>) => { + (global as any).__recentFiles = files; + // Rebuild menu so "Open Recent" reflects current state + import('./app-menu').then(({ buildAppMenu }) => buildAppMenu()); + }, + ); + + ipcMain.handle('log:getDir', () => getLogDir()); // Updater IPC - ipcMain.handle('updater:getState', () => getUpdaterState()) + ipcMain.handle('updater:getState', () => getUpdaterState()); ipcMain.handle('updater:checkForUpdates', async () => { - await checkForAppUpdates(true) - return getUpdaterState() - }) - ipcMain.handle('updater:quitAndInstall', () => quitAndInstall()) - ipcMain.handle('updater:getAutoCheck', () => getAutoUpdateEnabled()) + await checkForAppUpdates(true); + return getUpdaterState(); + }); + ipcMain.handle('updater:quitAndInstall', () => quitAndInstall()); + ipcMain.handle('updater:getAutoCheck', () => getAutoUpdateEnabled()); ipcMain.handle('updater:setAutoCheck', async (_event, enabled: boolean) => { - setAutoUpdateEnabled(enabled) - await writeAppSettings({ autoUpdate: enabled }) + setAutoUpdateEnabled(enabled); + await writeAppSettings({ autoUpdate: enabled }); if (enabled) { - startUpdateTimer() - setUpdaterState({ status: 'idle' }) + startUpdateTimer(); + setUpdaterState({ status: 'idle' }); } else { - clearUpdateTimer() - setUpdaterState({ status: 'disabled' }) + clearUpdateTimer(); + setUpdaterState({ status: 'disabled' }); } - return enabled - }) + return enabled; + }); } diff --git a/apps/desktop/logger.ts b/apps/desktop/logger.ts index 0cbab7cd..9fe6465d 100644 --- a/apps/desktop/logger.ts +++ b/apps/desktop/logger.ts @@ -12,34 +12,34 @@ * log.warn('message') */ -import { appendFile, readdir, unlink, mkdir, stat } from 'node:fs/promises' -import { join } from 'node:path' +import { appendFile, readdir, unlink, mkdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; -let logDir = '' -let logFilePath = '' -let initialized = false +let logDir = ''; +let logFilePath = ''; +let initialized = false; -const MAX_LOG_DAYS = 7 +const MAX_LOG_DAYS = 7; function timestamp(): string { - return new Date().toISOString() + return new Date().toISOString(); } function todayStamp(): string { - return new Date().toISOString().slice(0, 10) // YYYY-MM-DD + return new Date().toISOString().slice(0, 10); // YYYY-MM-DD } async function writeLine(level: string, msg: string): Promise { - if (!initialized) return - const line = `${timestamp()} [${level}] ${msg}\n` + if (!initialized) return; + const line = `${timestamp()} [${level}] ${msg}\n`; // Also forward to console for dev mode if (level === 'ERROR') { - process.stderr.write(line) + process.stderr.write(line); } else { - process.stdout.write(line) + process.stdout.write(line); } try { - await appendFile(logFilePath, line, 'utf-8') + await appendFile(logFilePath, line, 'utf-8'); } catch { // Disk full or permission error — silently drop } @@ -47,15 +47,15 @@ async function writeLine(level: string, msg: string): Promise { async function cleanOldLogs(): Promise { try { - const files = await readdir(logDir) - const cutoff = Date.now() - MAX_LOG_DAYS * 24 * 60 * 60 * 1000 + const files = await readdir(logDir); + const cutoff = Date.now() - MAX_LOG_DAYS * 24 * 60 * 60 * 1000; for (const file of files) { - if (!file.endsWith('.log')) continue - const filePath = join(logDir, file) + if (!file.endsWith('.log')) continue; + const filePath = join(logDir, file); try { - const s = await stat(filePath) + const s = await stat(filePath); if (s.mtimeMs < cutoff) { - await unlink(filePath) + await unlink(filePath); } } catch { // ignore individual file errors @@ -70,26 +70,32 @@ async function cleanOldLogs(): Promise { * Initialize the logger. Must be called after `app.getPath('userData')` is available. */ export async function initLogger(userDataPath: string): Promise { - logDir = join(userDataPath, 'logs') - logFilePath = join(logDir, `main-${todayStamp()}.log`) + logDir = join(userDataPath, 'logs'); + logFilePath = join(logDir, `main-${todayStamp()}.log`); try { - await mkdir(logDir, { recursive: true }) + await mkdir(logDir, { recursive: true }); } catch { // ignore } - initialized = true - await writeLine('INFO', '--- OpenPencil started ---') + initialized = true; + await writeLine('INFO', '--- OpenPencil started ---'); // Clean old logs in background - cleanOldLogs() + cleanOldLogs(); } /** Get the log directory path (for displaying to users). */ export function getLogDir(): string { - return logDir + return logDir; } export const log = { - info: (msg: string) => { writeLine('INFO', msg) }, - warn: (msg: string) => { writeLine('WARN', msg) }, - error: (msg: string) => { writeLine('ERROR', msg) }, -} + info: (msg: string) => { + writeLine('INFO', msg); + }, + warn: (msg: string) => { + writeLine('WARN', msg); + }, + error: (msg: string) => { + writeLine('ERROR', msg); + }, +}; diff --git a/apps/desktop/main.ts b/apps/desktop/main.ts index 15ec8687..dbafc94e 100644 --- a/apps/desktop/main.ts +++ b/apps/desktop/main.ts @@ -4,15 +4,15 @@ import { ipcMain, dialog, type BrowserWindowConstructorOptions, -} from 'electron' -import { execSync } from 'node:child_process' -import { fork, type ChildProcess } from 'node:child_process' -import { createServer } from 'node:net' -import { join, extname } from 'node:path' -import { homedir } from 'node:os' -import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises' +} from 'electron'; +import { execSync } from 'node:child_process'; +import { fork, type ChildProcess } from 'node:child_process'; +import { createServer } from 'node:net'; +import { join, extname } from 'node:path'; +import { homedir } from 'node:os'; +import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'; -import { buildAppMenu } from './app-menu' +import { buildAppMenu } from './app-menu'; import { PORT_FILE_DIR_NAME, PORT_FILE_NAME, @@ -29,61 +29,66 @@ import { NITRO_HOST, NITRO_FALLBACK_TIMEOUT_WIN, NITRO_FALLBACK_TIMEOUT_DEFAULT, -} from './constants' +} from './constants'; import { setupAutoUpdater, broadcastUpdaterState, setUpdaterState, clearUpdateTimer, setAutoUpdateEnabled, -} from './auto-updater' -import { initLogger, log } from './logger' -import { setupIPC } from './ipc-handlers' +} from './auto-updater'; +import { initLogger, log } from './logger'; +import { setupIPC } from './ipc-handlers'; +import { + buildUnsavedChangesDialogOptions, + mapUnsavedChangesResponse, +} from './unsaved-changes-dialog'; +import { setupGitIPC } from './git/ipc-handlers'; -let mainWindow: BrowserWindow | null = null -let nitroProcess: ChildProcess | null = null -let serverPort = 0 -let pendingFilePath: string | null = null +let mainWindow: BrowserWindow | null = null; +let nitroProcess: ChildProcess | null = null; +let serverPort = 0; +let pendingFilePath: string | null = null; -const isDev = !app.isPackaged +const isDev = !app.isPackaged; // Settings stored in platform-standard app data dir (Electron-managed): // macOS: ~/Library/Application Support/OpenPencil/ // Windows: %APPDATA%\OpenPencil\ // Linux: ~/.config/OpenPencil/ -const SETTINGS_PATH = join(app.getPath('userData'), 'settings.json') -const PREFS_PATH = join(app.getPath('userData'), 'preferences.json') +const SETTINGS_PATH = join(app.getPath('userData'), 'settings.json'); +const PREFS_PATH = join(app.getPath('userData'), 'preferences.json'); // --------------------------------------------------------------------------- // Renderer preferences (replaces localStorage which is origin-scoped) // --------------------------------------------------------------------------- -let prefsCache: Record = {} -let prefsDirty = false -let prefsWriteTimer: ReturnType | null = null +let prefsCache: Record = {}; +let prefsDirty = false; +let prefsWriteTimer: ReturnType | null = null; async function loadPrefs(): Promise { try { - const raw = await readFile(PREFS_PATH, 'utf-8') - prefsCache = JSON.parse(raw) + const raw = await readFile(PREFS_PATH, 'utf-8'); + prefsCache = JSON.parse(raw); } catch { - prefsCache = {} + prefsCache = {}; } } function schedulePrefsWrite(): void { - if (prefsWriteTimer) return - prefsDirty = true + if (prefsWriteTimer) return; + prefsDirty = true; prefsWriteTimer = setTimeout(async () => { - prefsWriteTimer = null - if (!prefsDirty) return - prefsDirty = false + prefsWriteTimer = null; + if (!prefsDirty) return; + prefsDirty = false; try { - await mkdir(app.getPath('userData'), { recursive: true }) - await writeFile(PREFS_PATH, JSON.stringify(prefsCache, null, 2), 'utf-8') + await mkdir(app.getPath('userData'), { recursive: true }); + await writeFile(PREFS_PATH, JSON.stringify(prefsCache, null, 2), 'utf-8'); } catch (err) { - log.error(`[prefs] Failed to write preferences: ${err}`) + log.error(`[prefs] Failed to write preferences: ${err}`); } - }, 500) + }, 500); } // --------------------------------------------------------------------------- @@ -94,52 +99,52 @@ function fixPath(): void { if (process.platform === 'win32') { // Windows GUI apps inherit PATH from the system, but common tool install // dirs (npm global, scoop, cargo, etc.) may be missing in packaged apps. - const home = homedir() + const home = homedir(); const extraDirs = [ - join(home, 'AppData', 'Roaming', 'npm'), // npm global + join(home, 'AppData', 'Roaming', 'npm'), // npm global join(home, 'AppData', 'Local', 'Programs', 'Microsoft VS Code', 'bin'), // VS Code CLI - join(home, '.cargo', 'bin'), // Rust/cargo - join(home, 'scoop', 'shims'), // scoop - join(home, '.bun', 'bin'), // bun - ] - const current = process.env.PATH || '' - const existing = new Set(current.split(';').map((p) => p.toLowerCase())) - const additions = extraDirs.filter((d) => !existing.has(d.toLowerCase())) + join(home, '.cargo', 'bin'), // Rust/cargo + join(home, 'scoop', 'shims'), // scoop + join(home, '.bun', 'bin'), // bun + ]; + const current = process.env.PATH || ''; + const existing = new Set(current.split(';').map((p) => p.toLowerCase())); + const additions = extraDirs.filter((d) => !existing.has(d.toLowerCase())); if (additions.length > 0) { - process.env.PATH = [...additions, current].join(';') + process.env.PATH = [...additions, current].join(';'); } - return + return; } - if (process.platform !== 'darwin' && process.platform !== 'linux') return + if (process.platform !== 'darwin' && process.platform !== 'linux') return; try { - const shell = process.env.SHELL || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash') + const shell = process.env.SHELL || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'); const shellPath = execSync(`${shell} -ilc 'echo -n "$PATH"'`, { encoding: 'utf-8', timeout: 5000, - }).trim() + }).trim(); if (shellPath) { - const current = process.env.PATH || '' + const current = process.env.PATH || ''; process.env.PATH = [...new Set([...shellPath.split(':'), ...current.split(':')])] .filter(Boolean) - .join(':') + .join(':'); } } catch { // Packaged app may not have a login shell — add common tool dirs as fallback - const home = homedir() + const home = homedir(); const fallbackDirs = [ join(home, '.local', 'bin'), join(home, '.cargo', 'bin'), join(home, '.bun', 'bin'), '/usr/local/bin', '/opt/homebrew/bin', - ] - const current = process.env.PATH || '' - const existing = new Set(current.split(':')) - const additions = fallbackDirs.filter((d) => !existing.has(d)) + ]; + const current = process.env.PATH || ''; + const existing = new Set(current.split(':')); + const additions = fallbackDirs.filter((d) => !existing.has(d)); if (additions.length > 0) { - process.env.PATH = [...additions, current].join(':') + process.env.PATH = [...additions, current].join(':'); } } } @@ -149,48 +154,48 @@ function fixPath(): void { // --------------------------------------------------------------------------- interface AppSettings { - autoUpdate?: boolean + autoUpdate?: boolean; } async function readAppSettings(): Promise { try { - const raw = await readFile(SETTINGS_PATH, 'utf-8') - return JSON.parse(raw) + const raw = await readFile(SETTINGS_PATH, 'utf-8'); + return JSON.parse(raw); } catch { - return {} + return {}; } } async function writeAppSettings(patch: Partial): Promise { - const current = await readAppSettings() - const merged = { ...current, ...patch } - await mkdir(app.getPath('userData'), { recursive: true }) - await writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2), 'utf-8') + const current = await readAppSettings(); + const merged = { ...current, ...patch }; + await mkdir(app.getPath('userData'), { recursive: true }); + await writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2), 'utf-8'); } // --------------------------------------------------------------------------- // Port file for MCP sync discovery (~/.openpencil/.port) // --------------------------------------------------------------------------- -const PORT_FILE_DIR = join(homedir(), PORT_FILE_DIR_NAME) -const PORT_FILE_PATH = join(PORT_FILE_DIR, PORT_FILE_NAME) +const PORT_FILE_DIR = join(homedir(), PORT_FILE_DIR_NAME); +const PORT_FILE_PATH = join(PORT_FILE_DIR, PORT_FILE_NAME); async function writePortFile(port: number): Promise { try { - await mkdir(PORT_FILE_DIR, { recursive: true }) + await mkdir(PORT_FILE_DIR, { recursive: true }); await writeFile( PORT_FILE_PATH, JSON.stringify({ port, pid: process.pid, timestamp: Date.now() }), 'utf-8', - ) + ); } catch (err) { - log.error(`[port-file] Failed to write port file: ${err}`) + log.error(`[port-file] Failed to write port file: ${err}`); } } async function cleanupPortFile(): Promise { try { - await unlink(PORT_FILE_PATH) + await unlink(PORT_FILE_PATH); } catch { // Ignore if already removed } @@ -202,27 +207,27 @@ async function cleanupPortFile(): Promise { function getFreePorts(): Promise { return new Promise((resolve, reject) => { - const server = createServer() + const server = createServer(); server.listen(0, NITRO_HOST, () => { - const addr = server.address() + const addr = server.address(); if (addr && typeof addr === 'object') { - const { port } = addr - server.close(() => resolve(port)) + const { port } = addr; + server.close(() => resolve(port)); } else { - reject(new Error('Failed to get free port')) + reject(new Error('Failed to get free port')); } - }) - server.on('error', reject) - }) + }); + server.on('error', reject); + }); } function getServerEntry(): string { if (isDev) { // In dev, the Nitro output lives at out/web/server/index.mjs - return join(app.getAppPath(), 'out', 'web', 'server', 'index.mjs') + return join(app.getAppPath(), 'out', 'web', 'server', 'index.mjs'); } // In production, extraResources copies out/web into the resources folder - return join(process.resourcesPath, 'server', 'index.mjs') + return join(process.resourcesPath, 'server', 'index.mjs'); } // --------------------------------------------------------------------------- @@ -230,8 +235,8 @@ function getServerEntry(): string { // --------------------------------------------------------------------------- async function startNitroServer(): Promise { - const port = await getFreePorts() - const entry = getServerEntry() + const port = await getFreePorts(); + const entry = getServerEntry(); return new Promise((resolve, reject) => { const child = fork(entry, [], { @@ -244,52 +249,53 @@ async function startNitroServer(): Promise { ELECTRON_RESOURCES_PATH: process.resourcesPath, }, stdio: 'pipe', - }) + }); - nitroProcess = child + nitroProcess = child; child.stdout?.on('data', (data: Buffer) => { - const msg = data.toString() - log.info(`[nitro] ${msg.trimEnd()}`) + const msg = data.toString(); + log.info(`[nitro] ${msg.trimEnd()}`); // Resolve once Nitro reports it's listening if (msg.includes('Listening') || msg.includes('ready')) { - resolve(port) + resolve(port); } - }) + }); child.stderr?.on('data', (data: Buffer) => { - log.error(`[nitro:err] ${data.toString().trimEnd()}`) - }) + log.error(`[nitro:err] ${data.toString().trimEnd()}`); + }); - child.on('error', reject) + child.on('error', reject); child.on('exit', (code) => { if (code !== 0 && code !== null) { - log.error(`Nitro exited with code ${code}`) + log.error(`Nitro exited with code ${code}`); } - nitroProcess = null + nitroProcess = null; // Auto-restart Nitro server if it crashes while app is running if (code !== 0 && code !== null && mainWindow && !mainWindow.isDestroyed()) { - log.info('[nitro] Restarting server after crash...') + log.info('[nitro] Restarting server after crash...'); startNitroServer() .then((newPort) => { - serverPort = newPort - writePortFile(newPort) - log.info(`[nitro] Restarted on port ${newPort}`) + serverPort = newPort; + writePortFile(newPort); + log.info(`[nitro] Restarted on port ${newPort}`); if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.loadURL(`http://${NITRO_HOST}:${newPort}/editor`) + mainWindow.loadURL(`http://${NITRO_HOST}:${newPort}/editor`); } }) .catch((err) => { - log.error(`[nitro] Failed to restart: ${err}`) - }) + log.error(`[nitro] Failed to restart: ${err}`); + }); } - }) + }); // Fallback: if no stdout "ready" message comes, wait then resolve anyway. // Use longer timeout on Windows (slower process creation). - const fallbackMs = process.platform === 'win32' ? NITRO_FALLBACK_TIMEOUT_WIN : NITRO_FALLBACK_TIMEOUT_DEFAULT - setTimeout(() => resolve(port), fallbackMs) - }) + const fallbackMs = + process.platform === 'win32' ? NITRO_FALLBACK_TIMEOUT_WIN : NITRO_FALLBACK_TIMEOUT_DEFAULT; + setTimeout(() => resolve(port), fallbackMs); + }); } // --------------------------------------------------------------------------- @@ -297,7 +303,7 @@ async function startNitroServer(): Promise { // --------------------------------------------------------------------------- /** Cached result for Linux controls side detection. */ -let cachedLinuxControlsSide: 'left' | 'right' | null = null +let cachedLinuxControlsSide: 'left' | 'right' | null = null; /** * Detect whether Linux DE places window controls on the left or right. @@ -305,40 +311,40 @@ let cachedLinuxControlsSide: 'left' | 'right' | null = null * for known right-side DEs, then defaults to right. Result is cached. */ function getLinuxControlsSide(): 'left' | 'right' { - if (cachedLinuxControlsSide) return cachedLinuxControlsSide + if (cachedLinuxControlsSide) return cachedLinuxControlsSide; - let result: 'left' | 'right' = 'right' + let result: 'left' | 'right' = 'right'; // Try gsettings (works for GNOME, Cinnamon, MATE, Budgie) try { - const layout = execSync( - 'gsettings get org.gnome.desktop.wm.preferences button-layout', - { encoding: 'utf-8', timeout: 3000 }, - ) + const layout = execSync('gsettings get org.gnome.desktop.wm.preferences button-layout', { + encoding: 'utf-8', + timeout: 3000, + }) .trim() - .replace(/'/g, '') - const colonIndex = layout.indexOf(':') + .replace(/'/g, ''); + const colonIndex = layout.indexOf(':'); if (colonIndex >= 0) { - const beforeColon = layout.slice(0, colonIndex) + const beforeColon = layout.slice(0, colonIndex); if ( beforeColon.includes('close') || beforeColon.includes('minimize') || beforeColon.includes('maximize') ) { - result = 'left' + result = 'left'; } } } catch { // gsettings not available — check desktop environment - const desktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase() + const desktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase(); // KDE, XFCE, LXQt default to right. elementary OS defaults to left. if (desktop.includes('pantheon')) { - result = 'left' + result = 'left'; } } - cachedLinuxControlsSide = result - return result + cachedLinuxControlsSide = result; + return result; } // --------------------------------------------------------------------------- @@ -346,7 +352,7 @@ function getLinuxControlsSide(): 'left' | 'right' { // --------------------------------------------------------------------------- function createWindow(): void { - const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux' + const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux'; const windowOptions: BrowserWindowConstructorOptions = { width: WINDOW_WIDTH, @@ -372,149 +378,180 @@ function createWindow(): void { // Persist localStorage/cookies in a fixed partition so data survives // across random Nitro server port changes (origin-independent storage). partition: 'persist:openpencil', + // Disable DevTools entirely in packaged builds. This blocks the API, + // any role-based menu item, and keyboard shortcuts at the lowest level. + devTools: isDev, }, - } + }; if (process.platform === 'darwin') { - windowOptions.trafficLightPosition = MACOS_TRAFFIC_LIGHT_POSITION + windowOptions.trafficLightPosition = MACOS_TRAFFIC_LIGHT_POSITION; } // Start hidden to avoid visual flash before CSS injection - windowOptions.show = false + windowOptions.show = false; - mainWindow = new BrowserWindow(windowOptions) + mainWindow = new BrowserWindow(windowOptions); // Hide native menu bar on Windows/Linux (shortcuts still work via Alt key) if (isWinOrLinux) { - mainWindow.setAutoHideMenuBar(true) - mainWindow.setMenuBarVisibility(false) + mainWindow.setAutoHideMenuBar(true); + mainWindow.setMenuBarVisibility(false); + } + + // Block reload / DevTools keyboard shortcuts in packaged builds. The View + // menu hides the items in prod (see app-menu.ts), but Chromium still ships + // built-in handlers for Cmd/Ctrl+R, Cmd/Ctrl+Shift+R, F5 and the DevTools + // chord — swallow them at the input layer. + if (!isDev) { + mainWindow.webContents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown') return; + const cmdOrCtrl = process.platform === 'darwin' ? input.meta : input.control; + const key = input.key.toLowerCase(); + const isReload = (cmdOrCtrl && key === 'r') || key === 'f5'; + const isDevTools = + key === 'f12' || + (cmdOrCtrl && input.shift && (key === 'i' || key === 'j' || key === 'c')) || + (process.platform === 'darwin' && input.meta && input.alt && key === 'i'); + if (isReload || isDevTools) { + event.preventDefault(); + } + }); } const url = isDev ? `http://localhost:${VITE_DEV_PORT}/editor` - : `http://${NITRO_HOST}:${serverPort}/editor` + : `http://${NITRO_HOST}:${serverPort}/editor`; // Inject traffic-light padding CSS then show window (no flash) mainWindow.webContents.on('did-finish-load', async () => { - if (!mainWindow) return + if (!mainWindow) return; if (process.platform === 'darwin') { await mainWindow.webContents.insertCSS( `.electron-traffic-light-pad { margin-left: ${MACOS_TRAFFIC_LIGHT_PAD}px; }` + - '.electron-fullscreen .electron-traffic-light-pad { margin-left: 0; }', - ) + '.electron-fullscreen .electron-traffic-light-pad { margin-left: 0; }', + ); } if (process.platform === 'win32') { await mainWindow.webContents.insertCSS( `.electron-win-controls-pad { margin-right: ${WIN_CONTROLS_PAD}px; }`, - ) + ); } if (process.platform === 'linux') { - const side = getLinuxControlsSide() + const side = getLinuxControlsSide(); if (side === 'left') { await mainWindow.webContents.insertCSS( `.electron-traffic-light-pad { margin-left: ${LINUX_CONTROLS_PAD}px; }`, - ) + ); } else { await mainWindow.webContents.insertCSS( `.electron-win-controls-pad { margin-right: ${LINUX_CONTROLS_PAD}px; }`, - ) + ); } } - mainWindow.show() - broadcastUpdaterState() - }) + mainWindow.show(); + broadcastUpdaterState(); + }); // Toggle fullscreen class to remove traffic-light padding in fullscreen if (process.platform === 'darwin') { mainWindow.on('enter-full-screen', () => { mainWindow?.webContents.executeJavaScript( 'document.body.classList.add("electron-fullscreen")', - ) - }) + ); + }); mainWindow.on('leave-full-screen', () => { mainWindow?.webContents.executeJavaScript( 'document.body.classList.remove("electron-fullscreen")', - ) - }) + ); + }); } - mainWindow.loadURL(url) + mainWindow.loadURL(url); if (isDev) { - mainWindow.webContents.openDevTools({ mode: 'detach' }) + mainWindow.webContents.openDevTools({ mode: 'detach' }); } // --------------------------------------------------------------------------- // Unsaved-changes close confirmation // --------------------------------------------------------------------------- - let forceClose = false + let forceClose = false; mainWindow.on('close', (event) => { - if (forceClose || !mainWindow || mainWindow.isDestroyed()) return + if (forceClose || !mainWindow || mainWindow.isDestroyed()) return; // preventDefault must be called synchronously — check dirty state after - event.preventDefault() + event.preventDefault(); - const win = mainWindow + const win = mainWindow; win.webContents .executeJavaScript( '({ dirty: window.__documentIsDirty === true,' + - ' message: typeof window.__i18nT === "function" ? window.__i18nT("topbar.closeConfirmMessage") : "",' + - ' detail: typeof window.__i18nT === "function" ? window.__i18nT("topbar.closeConfirmDetail") : "",' + - ' save: typeof window.__i18nT === "function" ? window.__i18nT("common.save") : "",' + - ' dontSave: typeof window.__i18nT === "function" ? window.__i18nT("topbar.dontSave") : "",' + - ' cancel: typeof window.__i18nT === "function" ? window.__i18nT("common.cancel") : "" })', + ' message: typeof window.__i18nT === "function" ? window.__i18nT("topbar.closeConfirmMessage") : "",' + + ' detail: typeof window.__i18nT === "function" ? window.__i18nT("topbar.closeConfirmDetail") : "",' + + ' yes: typeof window.__i18nT === "function" ? window.__i18nT("common.yes") : "",' + + ' no: typeof window.__i18nT === "function" ? window.__i18nT("common.no") : "",' + + ' cancel: typeof window.__i18nT === "function" ? window.__i18nT("common.cancel") : "" })', ) - .then((result: { dirty: boolean; message: string; detail: string; save: string; dontSave: string; cancel: string }) => { - if (!result.dirty) { - // Not dirty — allow close - forceClose = true - win.close() - return - } + .then( + (result: { + dirty: boolean; + message: string; + detail: string; + yes: string; + no: string; + cancel: string; + }) => { + if (!result.dirty) { + // Not dirty — allow close + forceClose = true; + win.close(); + return; + } - // Show native save dialog with i18n strings - return dialog - .showMessageBox(win, { - type: 'question', - buttons: [ - result.save || 'Save', - result.dontSave || "Don't Save", - result.cancel || 'Cancel', - ], - defaultId: 0, - cancelId: 2, - message: result.message || 'Do you want to save changes before closing?', - detail: result.detail || 'Your changes will be lost if you don\'t save them.', - }) - .then(({ response }) => { - if (response === 0) { - // Save — tell renderer to save then confirm close - win.webContents.send('menu:action', 'save-and-close') - } else if (response === 1) { - // Don't Save — force close - forceClose = true - win.close() - } - // Cancel (response === 2) — do nothing, window stays open - }) - }) + // Show native save dialog with i18n strings + return dialog + .showMessageBox( + win, + buildUnsavedChangesDialogOptions({ + yesLabel: result.yes || 'Yes', + noLabel: result.no || 'No', + cancelLabel: result.cancel || 'Cancel', + message: result.message || 'Do you want to save changes before closing?', + detail: result.detail || "Your changes will be lost if you don't save them.", + }), + ) + .then(({ response }) => { + const decision = mapUnsavedChangesResponse(response); + if (decision === 'save') { + // Save — tell renderer to save then confirm close + win.webContents.send('menu:action', 'save-and-close'); + } else if (decision === 'discard') { + // No — force close without saving + forceClose = true; + win.close(); + } + // Cancel — do nothing, window stays open + }); + }, + ) .catch(() => { // Page not loaded or crashed — allow close - forceClose = true - win.close() - }) - }) + forceClose = true; + win.close(); + }); + }); // Renderer confirms save completed → force close ipcMain.on('window:confirmClose', () => { - forceClose = true - mainWindow?.close() - }) + forceClose = true; + mainWindow?.close(); + }); mainWindow.on('closed', () => { - mainWindow = null - }) + mainWindow = null; + }); } // --------------------------------------------------------------------------- @@ -525,11 +562,14 @@ function initIPC(): void { setupIPC({ getMainWindow: () => mainWindow, getPendingFilePath: () => pendingFilePath, - clearPendingFilePath: () => { pendingFilePath = null }, + clearPendingFilePath: () => { + pendingFilePath = null; + }, prefsCache, schedulePrefsWrite, writeAppSettings, - }) + }); + setupGitIPC(); } // --------------------------------------------------------------------------- @@ -540,51 +580,51 @@ function initIPC(): void { function getFilePathFromArgs(args: string[]): string | null { for (const arg of args) { // Skip flags and the Electron binary/script path - if (arg.startsWith('-') || arg.startsWith('--')) continue - const ext = extname(arg).toLowerCase() + if (arg.startsWith('-') || arg.startsWith('--')) continue; + const ext = extname(arg).toLowerCase(); if (ext === '.op' || ext === '.pen') { - return arg + return arg; } } - return null + return null; } /** Send a file path to the renderer for loading. */ function sendOpenFile(filePath: string): void { if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('file:open', filePath) + mainWindow.webContents.send('file:open', filePath); } else { - pendingFilePath = filePath + pendingFilePath = filePath; } } // macOS: open-file fires when user double-clicks a .op file app.on('open-file', (event, filePath) => { - event.preventDefault() + event.preventDefault(); if (app.isReady()) { - sendOpenFile(filePath) + sendOpenFile(filePath); } else { - pendingFilePath = filePath + pendingFilePath = filePath; } -}) +}); // Single instance lock (Windows/Linux: second instance passes file path as arg) -const gotTheLock = app.requestSingleInstanceLock() +const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { - app.quit() + app.quit(); } else { app.on('second-instance', (_event, argv) => { - const filePath = getFilePathFromArgs(argv) + const filePath = getFilePathFromArgs(argv); if (filePath) { - sendOpenFile(filePath) + sendOpenFile(filePath); } // Focus existing window if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore() - mainWindow.focus() + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); } - }) + }); } // --------------------------------------------------------------------------- @@ -592,97 +632,103 @@ if (!gotTheLock) { // --------------------------------------------------------------------------- app.on('ready', async () => { - await initLogger(app.getPath('userData')) - fixPath() - await loadPrefs() - initIPC() - buildAppMenu() + await initLogger(app.getPath('userData')); + fixPath(); + await loadPrefs(); + initIPC(); + buildAppMenu(); if (!isDev) { try { - serverPort = await startNitroServer() - log.info(`Nitro server started on port ${serverPort}`) - await writePortFile(serverPort) + serverPort = await startNitroServer(); + log.info(`Nitro server started on port ${serverPort}`); + await writePortFile(serverPort); } catch (err) { - log.error(`Failed to start Nitro server: ${err}`) + log.error(`Failed to start Nitro server: ${err}`); dialog.showErrorBox( 'OpenPencil', `Failed to start the application server.\n\n${err instanceof Error ? err.message : String(err)}\n\nThe application will now quit.`, - ) - app.quit() - return + ); + app.quit(); + return; } } else { // Dev mode: Vite dev server runs on port 3000 - await writePortFile(VITE_DEV_PORT) + await writePortFile(VITE_DEV_PORT); } - createWindow() + createWindow(); // Check for file to open: pending open-file event or CLI args (Windows/Linux). // The file path is stored in pendingFilePath and pulled by the renderer // via file:getPending IPC when the React app mounts (useElectronMenu hook). if (!pendingFilePath) { - pendingFilePath = getFilePathFromArgs(process.argv) + pendingFilePath = getFilePathFromArgs(process.argv); } if (!isDev) { - const settings = await readAppSettings() - const autoUpdate = settings.autoUpdate !== false - setAutoUpdateEnabled(autoUpdate) + const settings = await readAppSettings(); + const autoUpdate = settings.autoUpdate !== false; + setAutoUpdateEnabled(autoUpdate); if (autoUpdate) { - setupAutoUpdater() + setupAutoUpdater(); } else { - setUpdaterState({ status: 'disabled' }) + setUpdaterState({ status: 'disabled' }); } } -}) +}); app.on('window-all-closed', () => { - app.quit() -}) + app.quit(); +}); app.on('activate', () => { if (mainWindow === null) { - createWindow() + createWindow(); } -}) +}); app.on('before-quit', () => { - clearUpdateTimer() - killNitroProcess() - cleanupPortFile().catch(() => {}) -}) + clearUpdateTimer(); + killNitroProcess(); + cleanupPortFile().catch(() => {}); +}); /** Platform-aware Nitro process termination. */ function killNitroProcess(): void { - if (!nitroProcess) return - const pid = nitroProcess.pid + if (!nitroProcess) return; + const pid = nitroProcess.pid; if (process.platform === 'win32') { // SIGTERM is unreliable on Windows; use taskkill for proper tree-kill try { if (pid) { - execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' }) + execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' }); } - } catch { /* process may have already exited */ } + } catch { + /* process may have already exited */ + } } else { // SIGTERM may not be processed before the main process exits, // leaving orphan Nitro processes. Kill the entire process group // with SIGKILL for reliable cleanup. try { - if (pid) process.kill(-pid, 'SIGKILL') - } catch { /* process may have already exited */ } + if (pid) process.kill(-pid, 'SIGKILL'); + } catch { + /* process may have already exited */ + } try { - nitroProcess.kill('SIGKILL') - } catch { /* ignore */ } + nitroProcess.kill('SIGKILL'); + } catch { + /* ignore */ + } } - nitroProcess = null + nitroProcess = null; } // Ensure child process cleanup on unexpected termination (Linux/macOS signals) for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { process.on(signal, () => { - killNitroProcess() - cleanupPortFile().finally(() => process.exit(0)) - }) + killNitroProcess(); + cleanupPortFile().finally(() => process.exit(0)); + }); } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 532c07ab..a4c97eb4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/desktop", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module" } diff --git a/apps/desktop/preload.ts b/apps/desktop/preload.ts index e95a4c00..73cf944c 100644 --- a/apps/desktop/preload.ts +++ b/apps/desktop/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' +import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; export type UpdaterStatus = | 'disabled' @@ -8,43 +8,274 @@ export type UpdaterStatus = | 'downloading' | 'downloaded' | 'not-available' - | 'error' + | 'error'; export interface UpdaterState { - status: UpdaterStatus - currentVersion: string - latestVersion?: string - downloadProgress?: number - releaseDate?: string - error?: string + status: UpdaterStatus; + currentVersion: string; + latestVersion?: string; + downloadProgress?: number; + releaseDate?: string; + error?: string; +} + +// ---- Git API types (Phase 2a) ------------------------------------------------ +// These mirror the engine's RepoOpenInfo / StatusInfo / CommitMeta / BranchInfo +// shapes so the renderer can type its IPC calls. The marker-based GitError +// rehydration is implemented on the renderer side in Phase 3. + +export interface GitCandidateFileInfo { + path: string; + relativePath: string; + milestoneCount: number; + autosaveCount: number; + lastCommitAt: number | null; + lastCommitMessage: string | null; +} + +export interface GitRepoOpenInfo { + repoId: string; + mode: 'single-file' | 'folder'; + rootPath: string; + gitdir: string; + engineKind: 'iso' | 'sys'; + trackedFilePath: string | null; + candidates: GitCandidateFileInfo[]; +} + +export interface GitConflictBag { + nodeConflicts: Array<{ + id: string; + pageId: string | null; + nodeId: string; + reason: + | 'both-modified-same-field' + | 'modify-vs-delete' + | 'add-vs-add-different' + | 'reparent-conflict'; + base: unknown; + ours: unknown; + theirs: unknown; + }>; + docFieldConflicts: Array<{ + id: string; + field: string; + path: string; + base: unknown; + ours: unknown; + theirs: unknown; + }>; +} + +export type GitConflictResolution = + | { kind: 'ours' } + | { kind: 'theirs' } + | { kind: 'manual-node'; node: unknown } + | { kind: 'manual-field'; value: unknown }; + +export interface GitStatusInfo { + branch: string; + trackedFilePath: string | null; + workingDirty: boolean; + otherFilesDirty: number; + otherFilesPaths: string[]; + ahead: number; + behind: number; + mergeInProgress: boolean; + unresolvedFiles: string[]; + conflicts: GitConflictBag | null; +} + +export interface GitCommitMeta { + hash: string; + parentHashes: string[]; + message: string; + author: { name: string; email: string; timestamp: number }; + kind: 'milestone' | 'autosave'; +} + +export interface GitBranchInfo { + name: string; + isCurrent: boolean; + ahead: number; + behind: number; + lastCommit: { hash: string; message: string; timestamp: number } | null; +} + +/** + * Phase 6a: renderer-visible remote metadata for the single 'origin' remote. + * Mirrors apps/web/src/services/git-types.ts so the renderer can type its + * IPC calls without importing from the desktop side. + */ +export interface GitRemoteInfo { + name: 'origin'; + url: string | null; + host: string | null; +} + +export interface GitAPI { + detect: (filePath: string) => Promise<{ mode: 'none' } | GitRepoOpenInfo>; + init: (filePath: string) => Promise; + open: (repoPath: string, currentFilePath?: string) => Promise; + bindTrackedFile: (repoId: string, filePath: string) => Promise<{ trackedFilePath: string }>; + listCandidates: (repoId: string) => Promise; + close: (repoId: string) => Promise; + status: (repoId: string) => Promise; + log: ( + repoId: string, + opts: { ref: 'main' | 'autosaves' | string; limit: number }, + ) => Promise; + commit: ( + repoId: string, + opts: { + kind: 'milestone' | 'autosave'; + message: string; + author: { name: string; email: string }; + }, + ) => Promise<{ hash: string }>; + restore: (repoId: string, commitHash: string) => Promise; + promote: ( + repoId: string, + autosaveHash: string, + message: string, + author: { name: string; email: string }, + ) => Promise<{ hash: string }>; + branchList: (repoId: string) => Promise; + branchCreate: (repoId: string, opts: { name: string; fromCommit?: string }) => Promise; + branchSwitch: (repoId: string, name: string) => Promise; + branchDelete: (repoId: string, name: string, opts?: { force?: boolean }) => Promise; + + // ---- Phase 2b: remote ops ---- + clone: (opts: { + url: string; + dest: string; + auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string }; + }) => Promise; + fetch: ( + repoId: string, + auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string }, + ) => Promise<{ ahead: number; behind: number }>; + pull: ( + repoId: string, + auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string }, + ) => Promise<{ + result: 'fast-forward' | 'merge' | 'conflict' | 'conflict-non-op'; + conflicts?: GitConflictBag; + }>; + push: ( + repoId: string, + auth?: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string }, + ) => Promise<{ result: 'ok' }>; + + // ---- Phase 2b: auth ---- + authStore: ( + host: string, + creds: { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string }, + ) => Promise; + authGet: ( + host: string, + ) => Promise< + { kind: 'token'; username: string; token: string } | { kind: 'ssh'; keyId: string } | null + >; + authClear: (host: string) => Promise; + + // ---- Phase 2b: ssh keys (privateKeyPath stripped) ---- + sshListKeys: () => Promise< + Array<{ + id: string; + host: string; + publicKey: string; + fingerprint: string; + comment: string; + }> + >; + sshGenerateKey: (opts: { host: string; comment: string }) => Promise<{ + id: string; + host: string; + publicKey: string; + fingerprint: string; + comment: string; + }>; + sshImportKey: (opts: { privateKeyPath: string; host: string }) => Promise<{ + id: string; + host: string; + publicKey: string; + fingerprint: string; + comment: string; + }>; + sshDeleteKey: (keyId: string) => Promise; + + // ---- Phase 2c: merge orchestration ---- + diff: ( + repoId: string, + fromCommit: string, + toCommit: string, + ) => Promise<{ + summary: { + framesChanged: number; + nodesAdded: number; + nodesRemoved: number; + nodesModified: number; + }; + patches: unknown[]; + }>; + branchMerge: ( + repoId: string, + fromBranch: string, + ) => Promise<{ + result: 'fast-forward' | 'merge' | 'conflict' | 'conflict-non-op'; + conflicts?: GitConflictBag; + }>; + resolveConflict: ( + repoId: string, + conflictId: string, + choice: GitConflictResolution, + ) => Promise; + applyMerge: (repoId: string) => Promise<{ hash: string; noop: boolean }>; + abortMerge: (repoId: string) => Promise; + + // Phase 4a: author identity probe (system git config) + getSystemAuthor: () => Promise<{ name: string; email: string } | null>; + + // Phase 6a: remote metadata + config (no network) + remoteGet: (repoId: string) => Promise; + remoteSet: (repoId: string, url: string | null) => Promise; } export interface ElectronAPI { - isElectron: true - openFile: () => Promise<{ filePath: string; content: string } | null> - saveFile: ( - content: string, - defaultPath?: string, - ) => Promise - saveToPath: (filePath: string, content: string) => Promise - onMenuAction: (callback: (action: string) => void) => () => void - onOpenFile: (callback: (filePath: string) => void) => () => void - readFile: (filePath: string) => Promise<{ filePath: string; content: string } | null> - getPendingFile: () => Promise - getLogDir: () => Promise - setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => void - getPreferences: () => Promise> - setPreference: (key: string, value: string) => Promise - removePreference: (key: string) => Promise - confirmClose: () => void + isElectron: true; + openFile: () => Promise<{ filePath: string; content: string } | null>; + openImageFile: () => Promise<{ filePath: string; name: string; content: string | null } | null>; + openDirectory: () => Promise; + saveFile: (content: string, defaultPath?: string) => Promise; + saveToPath: (filePath: string, content: string) => Promise; + onMenuAction: (callback: (action: string) => void) => () => void; + onOpenFile: (callback: (filePath: string) => void) => () => void; + readFile: (filePath: string) => Promise<{ filePath: string; content: string } | null>; + getPendingFile: () => Promise; + getLogDir: () => Promise; + setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => void; + getPreferences: () => Promise>; + setPreference: (key: string, value: string) => Promise; + removePreference: (key: string) => Promise; + confirmClose: () => void; + confirmUnsavedChanges: (payload: { + message: string; + detail?: string; + yesLabel: string; + noLabel: string; + cancelLabel: string; + }) => Promise<'save' | 'discard' | 'cancel'>; + syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) => void; updater: { - getState: () => Promise - checkForUpdates: () => Promise - quitAndInstall: () => Promise - getAutoCheck: () => Promise - setAutoCheck: (enabled: boolean) => Promise - onStateChange: (callback: (state: UpdaterState) => void) => () => void - } + getState: () => Promise; + checkForUpdates: () => Promise; + quitAndInstall: () => Promise; + getAutoCheck: () => Promise; + setAutoCheck: (enabled: boolean) => Promise; + onStateChange: (callback: (state: UpdaterState) => void) => () => void; + }; + git: GitAPI; } const api: ElectronAPI = { @@ -52,6 +283,10 @@ const api: ElectronAPI = { openFile: () => ipcRenderer.invoke('dialog:openFile'), + openImageFile: () => ipcRenderer.invoke('dialog:openImageFile'), + + openDirectory: () => ipcRenderer.invoke('dialog:openDirectory'), + saveFile: (content: string, defaultPath?: string) => ipcRenderer.invoke('dialog:saveFile', { content, defaultPath }), @@ -69,30 +304,35 @@ const api: ElectronAPI = { onMenuAction: (callback: (action: string) => void) => { const listener = (_event: IpcRendererEvent, action: string) => { - callback(action) - } - ipcRenderer.on('menu:action', listener) + callback(action); + }; + ipcRenderer.on('menu:action', listener); return () => { - ipcRenderer.removeListener('menu:action', listener) - } + ipcRenderer.removeListener('menu:action', listener); + }; }, onOpenFile: (callback: (filePath: string) => void) => { const listener = (_event: IpcRendererEvent, filePath: string) => { - callback(filePath) - } - ipcRenderer.on('file:open', listener) + callback(filePath); + }; + ipcRenderer.on('file:open', listener); return () => { - ipcRenderer.removeListener('file:open', listener) - } + ipcRenderer.removeListener('file:open', listener); + }; }, readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath), getPendingFile: () => ipcRenderer.invoke('file:getPending'), + syncRecentFiles: (files: Array<{ fileName: string; filePath: string }>) => + ipcRenderer.send('recent-files:sync', files), + confirmClose: () => ipcRenderer.send('window:confirmClose'), + confirmUnsavedChanges: (payload) => ipcRenderer.invoke('dialog:confirmUnsavedChanges', payload), + getLogDir: () => ipcRenderer.invoke('log:getDir'), updater: { @@ -103,14 +343,72 @@ const api: ElectronAPI = { setAutoCheck: (enabled: boolean) => ipcRenderer.invoke('updater:setAutoCheck', enabled), onStateChange: (callback: (state: UpdaterState) => void) => { const listener = (_event: IpcRendererEvent, state: UpdaterState) => { - callback(state) - } - ipcRenderer.on('updater:state', listener) + callback(state); + }; + ipcRenderer.on('updater:state', listener); return () => { - ipcRenderer.removeListener('updater:state', listener) - } + ipcRenderer.removeListener('updater:state', listener); + }; }, }, -} -contextBridge.exposeInMainWorld('electronAPI', api) + git: { + detect: (filePath: string) => ipcRenderer.invoke('git:detect', filePath), + init: (filePath: string) => ipcRenderer.invoke('git:init', filePath), + open: (repoPath: string, currentFilePath?: string) => + ipcRenderer.invoke('git:open', repoPath, currentFilePath), + bindTrackedFile: (repoId: string, filePath: string) => + ipcRenderer.invoke('git:bindTrackedFile', repoId, filePath), + listCandidates: (repoId: string) => ipcRenderer.invoke('git:listCandidates', repoId), + close: (repoId: string) => ipcRenderer.invoke('git:close', repoId), + status: (repoId: string) => ipcRenderer.invoke('git:status', repoId), + log: (repoId, opts) => ipcRenderer.invoke('git:log', repoId, opts), + commit: (repoId, opts) => ipcRenderer.invoke('git:commit', repoId, opts), + restore: (repoId: string, commitHash: string) => + ipcRenderer.invoke('git:restore', repoId, commitHash), + promote: (repoId, autosaveHash, message, author) => + ipcRenderer.invoke('git:promote', repoId, autosaveHash, message, author), + branchList: (repoId: string) => ipcRenderer.invoke('git:branchList', repoId), + branchCreate: (repoId, opts) => ipcRenderer.invoke('git:branchCreate', repoId, opts), + branchSwitch: (repoId: string, name: string) => + ipcRenderer.invoke('git:branchSwitch', repoId, name), + branchDelete: (repoId: string, name: string, opts?: { force?: boolean }) => + ipcRenderer.invoke('git:branchDelete', repoId, name, opts), + + // Phase 2b: remote ops + clone: (opts) => ipcRenderer.invoke('git:clone', opts), + fetch: (repoId, auth) => ipcRenderer.invoke('git:fetch', repoId, auth), + pull: (repoId, auth) => ipcRenderer.invoke('git:pull', repoId, auth), + push: (repoId, auth) => ipcRenderer.invoke('git:push', repoId, auth), + + // Phase 2b: auth + authStore: (host, creds) => ipcRenderer.invoke('git:authStore', host, creds), + authGet: (host) => ipcRenderer.invoke('git:authGet', host), + authClear: (host) => ipcRenderer.invoke('git:authClear', host), + + // Phase 2b: ssh keys + sshListKeys: () => ipcRenderer.invoke('git:sshListKeys'), + sshGenerateKey: (opts) => ipcRenderer.invoke('git:sshGenerateKey', opts), + sshImportKey: (opts) => ipcRenderer.invoke('git:sshImportKey', opts), + sshDeleteKey: (keyId) => ipcRenderer.invoke('git:sshDeleteKey', keyId), + + // Phase 2c: merge orchestration + diff: (repoId, fromCommit, toCommit) => + ipcRenderer.invoke('git:diff', repoId, fromCommit, toCommit), + branchMerge: (repoId, fromBranch) => ipcRenderer.invoke('git:branchMerge', repoId, fromBranch), + resolveConflict: (repoId, conflictId, choice) => + ipcRenderer.invoke('git:resolveConflict', repoId, conflictId, choice), + applyMerge: (repoId) => ipcRenderer.invoke('git:applyMerge', repoId), + abortMerge: (repoId) => ipcRenderer.invoke('git:abortMerge', repoId), + + // Phase 4a: author identity probe + getSystemAuthor: () => ipcRenderer.invoke('git:getSystemAuthor'), + + // Phase 6a: remote metadata + config + remoteGet: (repoId: string) => ipcRenderer.invoke('git:remoteGet', repoId), + remoteSet: (repoId: string, url: string | null) => + ipcRenderer.invoke('git:remoteSet', repoId, url), + }, +}; + +contextBridge.exposeInMainWorld('electronAPI', api); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 0fdbff59..1819b375 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,13 +3,21 @@ "target": "ES2022", "module": "CommonJS", "moduleResolution": "node", - "outDir": "../../out/desktop", - "rootDir": ".", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@zseven-w/pen-core": ["../../packages/pen-core/src/index.ts"], + "@zseven-w/pen-types": ["../../packages/pen-types/src/index.ts"] + } }, - "include": ["./**/*.ts"] + "include": ["./**/*.ts"], + // dev.ts is a Bun-only orchestrator script (uses ESM import.meta and + // imports from packages/) — it's executed directly via `bun run`, never + // emitted to out/desktop, so it must not share the bundle constraints + // of the electron main/preload bundle. + "exclude": ["dev.ts", "git/__tests__/**"] } diff --git a/apps/desktop/unsaved-changes-dialog.ts b/apps/desktop/unsaved-changes-dialog.ts new file mode 100644 index 00000000..8bd13ea8 --- /dev/null +++ b/apps/desktop/unsaved-changes-dialog.ts @@ -0,0 +1,26 @@ +export type UnsavedChangesDecision = 'save' | 'discard' | 'cancel'; + +export interface UnsavedChangesDialogLabels { + message: string; + detail?: string; + yesLabel: string; + noLabel: string; + cancelLabel: string; +} + +export function buildUnsavedChangesDialogOptions(labels: UnsavedChangesDialogLabels) { + return { + type: 'question' as const, + buttons: [labels.yesLabel, labels.noLabel, labels.cancelLabel], + defaultId: 0, + cancelId: 2, + message: labels.message, + detail: labels.detail ?? '', + }; +} + +export function mapUnsavedChangesResponse(response: number): UnsavedChangesDecision { + if (response === 0) return 'save'; + if (response === 1) return 'discard'; + return 'cancel'; +} diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index fb08631f..ad9532ec 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -71,6 +71,7 @@ TanStack Start full-stack React app (Vite + Nitro). Routes in `src/routes/`, aut ## AI Services (`src/services/ai/`) 35 files + `role-definitions/` + `design-principles/` subdirs: + - `ai-service.ts` — Main AI chat API wrapper, model negotiation, provider selection - `ai-prompts.ts` — System prompts for design generation - `ai-types.ts` — ChatMessage, ChatAttachment, AIDesignRequest, OrchestratorPlan @@ -93,12 +94,9 @@ TanStack Start full-stack React app (Vite + Nitro). Routes in `src/routes/`, aut - `use-mcp-sync.ts` — MCP live canvas sync - `use-system-fonts.ts` — System font detection -## MCP Server (`src/mcp/`) +## MCP Server -- `server.ts` — MCP server entry point, tool registration (stdio + HTTP modes) -- `document-manager.ts` — Document read/write/cache; live canvas sync via Nitro API -- `tools/` — Core (open-document, batch-get, get-selection, batch-design, node-crud), Layout (snapshot-layout, find-empty-space, import-svg), Variables, Pages, Layered design (design-prompt, design-skeleton, design-content, design-refine) -- `utils/` — `id.ts`, `node-operations.ts` (re-exports `cloneNodeWithNewIds` from pen-core), `sanitize.ts`, `svg-node-parser.ts` +MCP server code lives in `packages/pen-mcp/`. The web app communicates with it via HTTP API routes (`server/api/mcp/*.ts`) using `server/utils/mcp-server-manager.ts` to spawn/manage the server process. ## UIKit (`src/uikit/`) diff --git a/apps/web/components.json b/apps/web/components.json index 5e032666..5bb01ed0 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -1 +1,20 @@ -{"$schema":"https://ui.shadcn.com/schema.json","style":"new-york","rsc":false,"tsx":true,"tailwind":{"config":"","css":"src/styles.css","baseColor":"neutral","cssVariables":true},"aliases":{"components":"@/components","utils":"@/lib/utils","ui":"@/components/ui","lib":"@/lib","hooks":"@/hooks"},"iconLibrary":"lucide"} +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/web/package.json b/apps/web/package.json index 8fecf45e..b9401db9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,16 +1,18 @@ { "name": "@zseven-w/web", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "dependencies": { - "@zseven-w/pen-types": "workspace:*", - "@zseven-w/pen-core": "workspace:*", - "@zseven-w/pen-codegen": "workspace:*", - "@zseven-w/pen-figma": "workspace:*", - "@zseven-w/pen-renderer": "workspace:*", + "@zseven-w/agent-native": "workspace:*", "@zseven-w/pen-ai-skills": "workspace:*", - "@zseven-w/agent": "workspace:*", + "@zseven-w/pen-core": "workspace:*", + "@zseven-w/pen-engine": "workspace:*", + "@zseven-w/pen-figma": "workspace:*", + "@zseven-w/pen-mcp": "workspace:*", + "@zseven-w/pen-react": "workspace:*", + "@zseven-w/pen-renderer": "workspace:*", + "@zseven-w/pen-types": "workspace:*", "zod": "^3.24" } } diff --git a/apps/web/server/__tests__/agent-tool-guard.test.ts b/apps/web/server/__tests__/agent-tool-guard.test.ts new file mode 100644 index 00000000..15945607 --- /dev/null +++ b/apps/web/server/__tests__/agent-tool-guard.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { + shouldShortCircuitPlanLayout, + updateLayoutSessionState, + type LayoutSessionState, +} from '../utils/agent-tool-guard'; + +function createSessionState(): LayoutSessionState { + return { + layoutPhase: 'idle', + layoutRootId: null, + }; +} + +describe('agent tool guard', () => { + it('does not short-circuit the first plan_layout call', () => { + const session = createSessionState(); + + const result = shouldShortCircuitPlanLayout(session, 'plan_layout', { + prompt: 'mobile food app homepage', + }); + + expect(result).toBeNull(); + }); + + it('short-circuits repeated plan_layout calls once layout exists', () => { + const session: LayoutSessionState = { + layoutPhase: 'layout_done', + layoutRootId: 'root-1', + }; + + const result = shouldShortCircuitPlanLayout(session, 'plan_layout', { + prompt: 'mobile food app homepage', + }); + + expect(result).toMatchObject({ + success: false, + data: { rootFrameId: 'root-1' }, + }); + expect(result?.error).toContain('Use batch_insert or insert_node'); + expect(result?.error).toContain('"newRoot": true'); + }); + + it('allows repeated plan_layout when newRoot is explicitly requested', () => { + const session: LayoutSessionState = { + layoutPhase: 'layout_done', + layoutRootId: 'root-1', + }; + + const result = shouldShortCircuitPlanLayout(session, 'plan_layout', { + prompt: 'another screen', + newRoot: true, + }); + + expect(result).toBeNull(); + }); + + it('tracks plan_layout success as layout_done with rootFrameId', () => { + const session = createSessionState(); + + updateLayoutSessionState(session, 'plan_layout', { + success: true, + data: { rootFrameId: 'root-1' }, + }); + + expect(session).toEqual({ + layoutPhase: 'layout_done', + layoutRootId: 'root-1', + }); + }); + + it('tracks content insertion success as content_started', () => { + const session: LayoutSessionState = { + layoutPhase: 'layout_done', + layoutRootId: 'root-1', + }; + + updateLayoutSessionState(session, 'batch_insert', { success: true }); + expect(session.layoutPhase).toBe('content_started'); + + session.layoutPhase = 'layout_done'; + updateLayoutSessionState(session, 'insert_node', { success: true }); + expect(session.layoutPhase).toBe('content_started'); + }); +}); diff --git a/apps/web/server/__tests__/mcp-screenshot-rpc.test.ts b/apps/web/server/__tests__/mcp-screenshot-rpc.test.ts new file mode 100644 index 00000000..580639ae --- /dev/null +++ b/apps/web/server/__tests__/mcp-screenshot-rpc.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { + allocateRequestId, + registerPending, + resolvePending, + rejectPending, +} from '../utils/mcp-screenshot-rpc'; + +describe('mcp-screenshot-rpc', () => { + it('allocates unique request ids', () => { + const a = allocateRequestId(); + const b = allocateRequestId(); + expect(a).not.toBe(b); + expect(a).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('resolvePending completes a registered request', async () => { + const id = allocateRequestId(); + const promise = registerPending(id, 5000); + const response = { + requestId: id, + success: true, + pngBase64: 'aGVsbG8=', + }; + const ok = resolvePending(response); + expect(ok).toBe(true); + const out = await promise; + expect(out.pngBase64).toBe('aGVsbG8='); + }); + + it('resolvePending returns false for unknown request id', () => { + expect(resolvePending({ requestId: 'nope', success: true })).toBe(false); + }); + + it('rejectPending surfaces the error to the pending promise', async () => { + const id = allocateRequestId(); + const promise = registerPending(id, 5000); + rejectPending(id, new Error('client disconnected')); + await expect(promise).rejects.toThrow('client disconnected'); + }); + + it('timeouts after the specified duration', async () => { + const id = allocateRequestId(); + const promise = registerPending(id, 50); + await expect(promise).rejects.toThrow(/timed out/i); + }); +}); diff --git a/apps/web/server/__tests__/mcp-sync-state-active.test.ts b/apps/web/server/__tests__/mcp-sync-state-active.test.ts new file mode 100644 index 00000000..6c62e2ca --- /dev/null +++ b/apps/web/server/__tests__/mcp-sync-state-active.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + registerSSEClient, + unregisterSSEClient, + setSyncDocument, + setSyncSelection, + markClientActive, + getLastActiveClientId, + isClientConnected, + sendToClient, + clearSyncState, +} from '../utils/mcp-sync-state'; +import type { PenDocument } from '../../src/types/pen'; + +describe('mcp-sync-state: active client tracking', () => { + beforeEach(() => { + clearSyncState(); + }); + + it('getLastActiveClientId returns null initially', () => { + expect(getLastActiveClientId()).toBeNull(); + }); + + it('setSyncDocument updates lastActiveClientId when sourceClientId provided', () => { + registerSSEClient('client-a', { push: () => {} }); + setSyncDocument({ version: '1.0.0', children: [] } as PenDocument, 'client-a'); + expect(getLastActiveClientId()).toBe('client-a'); + }); + + it('setSyncSelection updates lastActiveClientId', () => { + registerSSEClient('client-b', { push: () => {} }); + setSyncSelection(['node-1'], 'page-1', 'client-b'); + expect(getLastActiveClientId()).toBe('client-b'); + }); + + it('markClientActive updates lastActiveClientId only for connected clients', () => { + registerSSEClient('client-c', { push: () => {} }); + markClientActive('client-c'); + expect(getLastActiveClientId()).toBe('client-c'); + markClientActive('client-nonexistent'); + expect(getLastActiveClientId()).toBe('client-c'); + }); + + it('isClientConnected reflects registration state', () => { + registerSSEClient('client-d', { push: () => {} }); + expect(isClientConnected('client-d')).toBe(true); + unregisterSSEClient('client-d'); + expect(isClientConnected('client-d')).toBe(false); + }); + + it('sendToClient dispatches payload to a specific client', () => { + const received: string[] = []; + registerSSEClient('client-e', { push: (data: string) => received.push(data) }); + const ok = sendToClient('client-e', { type: 'screenshot:request' }); + expect(ok).toBe(true); + expect(received).toHaveLength(1); + expect(JSON.parse(received[0]).type).toBe('screenshot:request'); + }); + + it('sendToClient returns false for unknown client', () => { + expect(sendToClient('nope', { type: 'x' })).toBe(false); + }); +}); diff --git a/apps/web/server/__tests__/provider-url-presets.test.ts b/apps/web/server/__tests__/provider-url-presets.test.ts new file mode 100644 index 00000000..bdd24b41 --- /dev/null +++ b/apps/web/server/__tests__/provider-url-presets.test.ts @@ -0,0 +1,72 @@ +/** + * Verify that every openai-compat preset's baseURL produces + * the correct chat completions endpoint. + * + * The Zig agent-native module constructs: `${baseURL}/chat/completions` + * So each preset's baseURL must be the API root WITHOUT a trailing /v1 + * for providers that don't use one (e.g. Ark, Zhipu), or WITH /v1 for + * providers that require it (e.g. OpenAI, DeepSeek). + */ +import { describe, expect, it } from 'vitest'; +import { BUILTIN_PROVIDER_PRESETS } from '../../src/lib/builtin-provider-presets'; +import { requireOpenAICompatBaseURL } from '../api/ai/provider-url'; + +/** Known correct chat completions endpoints per provider */ +const EXPECTED_ENDPOINTS: Record = { + openai: 'https://api.openai.com/v1/chat/completions', + openrouter: 'https://openrouter.ai/api/v1/chat/completions', + deepseek: 'https://api.deepseek.com/v1/chat/completions', + gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', + zhipu: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', + kimi: 'https://api.moonshot.cn/v1/chat/completions', + bailian: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', + doubao: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions', + 'ark-coding': 'https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions', + xiaomi: 'https://api.xiaomimimo.com/v1/chat/completions', + modelscope: 'https://api-inference.modelscope.cn/v1/chat/completions', + stepfun: 'https://api.stepfun.com/v1/chat/completions', + nvidia: 'https://integrate.api.nvidia.com/v1/chat/completions', +}; + +describe('preset baseURL → chat/completions endpoint', () => { + for (const [presetId, expectedURL] of Object.entries(EXPECTED_ENDPOINTS)) { + it(`${presetId}: ${expectedURL}`, () => { + const preset = BUILTIN_PROVIDER_PRESETS[presetId as keyof typeof BUILTIN_PROVIDER_PRESETS]; + expect(preset).toBeDefined(); + expect(preset.type).toBe('openai-compat'); + + // Simulate server-side normalization + const normalized = requireOpenAICompatBaseURL(preset.baseURL); + // Simulate Zig-side URL construction + const finalURL = `${normalized}/chat/completions`; + + expect(finalURL).toBe(expectedURL); + }); + } + + it('all openai-compat presets have a baseURL or regions', () => { + for (const [id, preset] of Object.entries(BUILTIN_PROVIDER_PRESETS)) { + if (preset.type === 'openai-compat' && id !== 'custom') { + expect( + preset.baseURL || preset.regions, + `preset "${id}" missing both baseURL and regions`, + ).toBeTruthy(); + } + } + }); + + it('glm-coding regions produce correct endpoints', () => { + const preset = BUILTIN_PROVIDER_PRESETS['glm-coding']; + expect(preset.regions).toBeDefined(); + + const cn = requireOpenAICompatBaseURL(preset.regions!.cn.baseURL); + expect(`${cn}/chat/completions`).toBe( + 'https://open.bigmodel.cn/api/coding/paas/v4/chat/completions', + ); + + const global = requireOpenAICompatBaseURL(preset.regions!.global.baseURL); + expect(`${global}/chat/completions`).toBe( + 'https://api.z.ai/api/coding/paas/v4/chat/completions', + ); + }); +}); diff --git a/apps/web/server/__tests__/provider-url.test.ts b/apps/web/server/__tests__/provider-url.test.ts new file mode 100644 index 00000000..7db95f3e --- /dev/null +++ b/apps/web/server/__tests__/provider-url.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + buildProviderModelsURL, + normalizeBaseURL, + normalizeMemberBaseURL, + normalizeOptionalBaseURL, + requireOpenAICompatBaseURL, +} from '../api/ai/provider-url'; + +describe('provider-url helpers', () => { + it('normalizes whitespace and trailing slashes', () => { + expect(normalizeBaseURL(' https://api.openai.com/v1/ ')).toBe('https://api.openai.com/v1'); + expect(normalizeBaseURL('https://openrouter.ai/api/v1///')).toBe( + 'https://openrouter.ai/api/v1', + ); + }); + + it('normalizes optional baseURL to undefined when empty', () => { + expect(normalizeOptionalBaseURL(undefined)).toBeUndefined(); + expect(normalizeOptionalBaseURL(' ')).toBeUndefined(); + }); + + it('builds /models URL from canonical API root baseURL', () => { + expect(buildProviderModelsURL('https://api.openai.com/v1/')).toBe( + 'https://api.openai.com/v1/models', + ); + expect(buildProviderModelsURL('https://generativelanguage.googleapis.com/v1beta/openai')).toBe( + 'https://generativelanguage.googleapis.com/v1beta/openai/models', + ); + expect(buildProviderModelsURL('https://ark.cn-beijing.volces.com/api/v3')).toBe( + 'https://ark.cn-beijing.volces.com/api/v3/models', + ); + }); + + it('requires baseURL for openai-compatible providers', () => { + expect(() => requireOpenAICompatBaseURL(undefined)).toThrow( + 'OpenAI-compatible provider requires baseURL', + ); + expect(() => requireOpenAICompatBaseURL(' ')).toThrow( + 'OpenAI-compatible provider requires baseURL', + ); + expect(requireOpenAICompatBaseURL('https://api.openai.com/v1/')).toBe( + 'https://api.openai.com/v1', + ); + }); + + it('validates team-member baseURL for openai-compat', () => { + expect(() => normalizeMemberBaseURL('designer', 'openai-compat', undefined)).toThrow( + 'Member "designer" (openai-compat) requires baseURL', + ); + expect(() => normalizeMemberBaseURL('designer', 'openai-compat', ' ')).toThrow( + 'Member "designer" (openai-compat) requires baseURL', + ); + expect(normalizeMemberBaseURL('designer', 'openai-compat', 'https://api.openai.com/v1/')).toBe( + 'https://api.openai.com/v1', + ); + }); + + it('allows missing baseURL for anthropic team members', () => { + expect(normalizeMemberBaseURL('lead', 'anthropic', undefined)).toBeUndefined(); + expect(normalizeMemberBaseURL('lead', 'anthropic', 'https://custom.api.com/')).toBe( + 'https://custom.api.com', + ); + }); +}); diff --git a/apps/web/server/__tests__/security.test.ts b/apps/web/server/__tests__/security.test.ts index f3250337..01871f76 100644 --- a/apps/web/server/__tests__/security.test.ts +++ b/apps/web/server/__tests__/security.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from 'vitest' -import { filterCodexEnv } from '../utils/codex-client' -import { SENSITIVE_LOG_PATTERN, ALLOWED_MEDIA_TYPES, resolveMediaExtension } from '../api/ai/chat' +import { describe, it, expect } from 'vitest'; +import { filterCodexEnv } from '../utils/codex-client'; +import { SENSITIVE_LOG_PATTERN, ALLOWED_MEDIA_TYPES, resolveMediaExtension } from '../api/ai/chat'; // --------------------------------------------------------------------------- // 1. Codex client env allowlist @@ -14,13 +14,13 @@ describe('codex client env allowlist', () => { DATABASE_URL: 'postgres://...', ANTHROPIC_API_KEY: 'sk-ant-xxx', GITHUB_TOKEN: 'ghp_xxx', - } - const filtered = filterCodexEnv(env) - expect(filtered).not.toHaveProperty('AWS_SECRET_KEY') - expect(filtered).not.toHaveProperty('DATABASE_URL') - expect(filtered).not.toHaveProperty('ANTHROPIC_API_KEY') - expect(filtered).not.toHaveProperty('GITHUB_TOKEN') - }) + }; + const filtered = filterCodexEnv(env); + expect(filtered).not.toHaveProperty('AWS_SECRET_KEY'); + expect(filtered).not.toHaveProperty('DATABASE_URL'); + expect(filtered).not.toHaveProperty('ANTHROPIC_API_KEY'); + expect(filtered).not.toHaveProperty('GITHUB_TOKEN'); + }); it('should keep PATH, HOME, and shell vars', () => { const env = { @@ -30,15 +30,15 @@ describe('codex client env allowlist', () => { LANG: 'en_US.UTF-8', SHELL: '/bin/zsh', TMPDIR: '/tmp', - } - const filtered = filterCodexEnv(env) - expect(filtered.PATH).toBe('/usr/bin') - expect(filtered.HOME).toBe('/home/user') - expect(filtered.TERM).toBe('xterm-256color') - expect(filtered.LANG).toBe('en_US.UTF-8') - expect(filtered.SHELL).toBe('/bin/zsh') - expect(filtered.TMPDIR).toBe('/tmp') - }) + }; + const filtered = filterCodexEnv(env); + expect(filtered.PATH).toBe('/usr/bin'); + expect(filtered.HOME).toBe('/home/user'); + expect(filtered.TERM).toBe('xterm-256color'); + expect(filtered.LANG).toBe('en_US.UTF-8'); + expect(filtered.SHELL).toBe('/bin/zsh'); + expect(filtered.TMPDIR).toBe('/tmp'); + }); it('should keep OPENAI_* and CODEX_* vars', () => { const env = { @@ -48,13 +48,13 @@ describe('codex client env allowlist', () => { OPENAI_ORG_ID: 'org-xxx', CODEX_TOKEN: 'codex-xxx', CODEX_SANDBOX: 'read-only', - } - const filtered = filterCodexEnv(env) - expect(filtered.OPENAI_API_KEY).toBe('sk-openai-xxx') - expect(filtered.OPENAI_ORG_ID).toBe('org-xxx') - expect(filtered.CODEX_TOKEN).toBe('codex-xxx') - expect(filtered.CODEX_SANDBOX).toBe('read-only') - }) + }; + const filtered = filterCodexEnv(env); + expect(filtered.OPENAI_API_KEY).toBe('sk-openai-xxx'); + expect(filtered.OPENAI_ORG_ID).toBe('org-xxx'); + expect(filtered.CODEX_TOKEN).toBe('codex-xxx'); + expect(filtered.CODEX_SANDBOX).toBe('read-only'); + }); it('should not leak vars with similar prefixes', () => { const env = { @@ -65,71 +65,71 @@ describe('codex client env allowlist', () => { OPEN_SECRET: 'bad', CODEX_MODE: 'ok', CODE_SECRET: 'bad', - } - const filtered = filterCodexEnv(env) - expect(filtered).not.toHaveProperty('OPEN_SECRET') - expect(filtered).not.toHaveProperty('CODE_SECRET') - expect(filtered.OPENAI_API_KEY).toBe('ok') - expect(filtered.CODEX_MODE).toBe('ok') - }) -}) + }; + const filtered = filterCodexEnv(env); + expect(filtered).not.toHaveProperty('OPEN_SECRET'); + expect(filtered).not.toHaveProperty('CODE_SECRET'); + expect(filtered.OPENAI_API_KEY).toBe('ok'); + expect(filtered.CODEX_MODE).toBe('ok'); + }); +}); // --------------------------------------------------------------------------- // 2. Debug tail sanitization // --------------------------------------------------------------------------- describe('debug tail sanitization', () => { it('should match ANTHROPIC_API_KEY leak', () => { - expect(SENSITIVE_LOG_PATTERN.test('ANTHROPIC_API_KEY=sk-ant-abc123')).toBe(true) - }) + expect(SENSITIVE_LOG_PATTERN.test('ANTHROPIC_API_KEY=sk-ant-abc123')).toBe(true); + }); it('should match Authorization Bearer header', () => { - expect(SENSITIVE_LOG_PATTERN.test('Authorization: Bearer token123')).toBe(true) - expect(SENSITIVE_LOG_PATTERN.test('authorization: Bearer xyz')).toBe(true) - }) + expect(SENSITIVE_LOG_PATTERN.test('Authorization: Bearer token123')).toBe(true); + expect(SENSITIVE_LOG_PATTERN.test('authorization: Bearer xyz')).toBe(true); + }); it('should match api_key and api-key patterns', () => { - expect(SENSITIVE_LOG_PATTERN.test('api_key=secret')).toBe(true) - expect(SENSITIVE_LOG_PATTERN.test('api-key: secret')).toBe(true) - expect(SENSITIVE_LOG_PATTERN.test('apikey=secret')).toBe(true) - }) + expect(SENSITIVE_LOG_PATTERN.test('api_key=secret')).toBe(true); + expect(SENSITIVE_LOG_PATTERN.test('api-key: secret')).toBe(true); + expect(SENSITIVE_LOG_PATTERN.test('apikey=secret')).toBe(true); + }); it('should NOT match normal log lines', () => { - expect(SENSITIVE_LOG_PATTERN.test('Using API endpoint https://api.anthropic.com')).toBe(false) - expect(SENSITIVE_LOG_PATTERN.test('Model: claude-sonnet-4-5-20250929')).toBe(false) - expect(SENSITIVE_LOG_PATTERN.test('Request completed in 1200ms')).toBe(false) - expect(SENSITIVE_LOG_PATTERN.test('Connecting to upstream server...')).toBe(false) - }) -}) + expect(SENSITIVE_LOG_PATTERN.test('Using API endpoint https://api.anthropic.com')).toBe(false); + expect(SENSITIVE_LOG_PATTERN.test('Model: claude-sonnet-4-5-20250929')).toBe(false); + expect(SENSITIVE_LOG_PATTERN.test('Request completed in 1200ms')).toBe(false); + expect(SENSITIVE_LOG_PATTERN.test('Connecting to upstream server...')).toBe(false); + }); +}); // --------------------------------------------------------------------------- // 3. Media type allowlist // --------------------------------------------------------------------------- describe('media type allowlist', () => { it('should allow standard image types', () => { - expect(ALLOWED_MEDIA_TYPES.has('image/png')).toBe(true) - expect(ALLOWED_MEDIA_TYPES.has('image/jpeg')).toBe(true) - expect(ALLOWED_MEDIA_TYPES.has('image/gif')).toBe(true) - expect(ALLOWED_MEDIA_TYPES.has('image/webp')).toBe(true) - }) + expect(ALLOWED_MEDIA_TYPES.has('image/png')).toBe(true); + expect(ALLOWED_MEDIA_TYPES.has('image/jpeg')).toBe(true); + expect(ALLOWED_MEDIA_TYPES.has('image/gif')).toBe(true); + expect(ALLOWED_MEDIA_TYPES.has('image/webp')).toBe(true); + }); it('should reject non-image types', () => { - expect(ALLOWED_MEDIA_TYPES.has('image/svg+xml')).toBe(false) - expect(ALLOWED_MEDIA_TYPES.has('application/pdf')).toBe(false) - expect(ALLOWED_MEDIA_TYPES.has('text/html')).toBe(false) - }) + expect(ALLOWED_MEDIA_TYPES.has('image/svg+xml')).toBe(false); + expect(ALLOWED_MEDIA_TYPES.has('application/pdf')).toBe(false); + expect(ALLOWED_MEDIA_TYPES.has('text/html')).toBe(false); + }); it('should resolve extensions correctly', () => { - expect(resolveMediaExtension('image/png')).toBe('png') - expect(resolveMediaExtension('image/jpeg')).toBe('jpeg') - expect(resolveMediaExtension('image/gif')).toBe('gif') - expect(resolveMediaExtension('image/webp')).toBe('webp') - }) + expect(resolveMediaExtension('image/png')).toBe('png'); + expect(resolveMediaExtension('image/jpeg')).toBe('jpeg'); + expect(resolveMediaExtension('image/gif')).toBe('gif'); + expect(resolveMediaExtension('image/webp')).toBe('webp'); + }); it('should fall back to png for disallowed types', () => { - expect(resolveMediaExtension('image/x-sh')).toBe('png') - expect(resolveMediaExtension('image/svg+xml')).toBe('png') - expect(resolveMediaExtension('application/pdf')).toBe('png') - expect(resolveMediaExtension('text/html')).toBe('png') - expect(resolveMediaExtension('')).toBe('png') - }) -}) + expect(resolveMediaExtension('image/x-sh')).toBe('png'); + expect(resolveMediaExtension('image/svg+xml')).toBe('png'); + expect(resolveMediaExtension('application/pdf')).toBe('png'); + expect(resolveMediaExtension('text/html')).toBe('png'); + expect(resolveMediaExtension('')).toBe('png'); + }); +}); diff --git a/apps/web/server/__tests__/sse-keepalive.test.ts b/apps/web/server/__tests__/sse-keepalive.test.ts new file mode 100644 index 00000000..d677ec39 --- /dev/null +++ b/apps/web/server/__tests__/sse-keepalive.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { startSSEKeepAlive } from '../utils/sse-keepalive'; +import { touchSession } from '../utils/agent-sessions'; + +describe('startSSEKeepAlive', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('emits immediately and keeps emitting until cleared', () => { + vi.useFakeTimers(); + + const send = vi.fn(); + const timer = startSSEKeepAlive(send, 3_000); + + expect(send).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(9_000); + expect(send).toHaveBeenCalledTimes(4); + + clearInterval(timer); + vi.advanceTimersByTime(9_000); + expect(send).toHaveBeenCalledTimes(4); + }); + + it('can keep an agent session active while the stream is only sending pings', () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + + const session = { lastActivity: 0 }; + const timer = startSSEKeepAlive(() => touchSession(session), 5_000); + + expect(session.lastActivity).toBe(1_000); + + vi.advanceTimersByTime(5_000); + + expect(session.lastActivity).toBe(6_000); + + clearInterval(timer); + }); +}); diff --git a/apps/web/server/api/ai/agent.ts b/apps/web/server/api/ai/agent.ts index 5ca529a0..2d4adcb7 100644 --- a/apps/web/server/api/ai/agent.ts +++ b/apps/web/server/api/ai/agent.ts @@ -1,54 +1,380 @@ -import { defineEventHandler, readBody, setResponseHeaders, getQuery, createError } from 'h3' +import { defineEventHandler, readBody, setResponseHeaders, getQuery, createError } from 'h3'; import { - createAgent, - createTeam, createAnthropicProvider, createOpenAICompatProvider, createToolRegistry, - encodeAgentEvent, -} from '@zseven-w/agent' -import type { AuthLevel } from '@zseven-w/agent' -import { jsonSchema } from '@zseven-w/agent' -import { agentSessions } from '../../utils/agent-sessions' + registerToolSchema, + createQueryEngine, + seedMessages, + submitMessage, + nextEvent, + resolveToolResult, + createTeam, + runTeam, + addTeamMember, + resolveTeamToolResult, + teamRegisterDelegate, + runTeamMember, + destroyIterator, + resolveMemberToolResult, + seedTeamMessages, +} from '@zseven-w/agent-native'; +import { resolveSkills } from '@zseven-w/pen-ai-skills'; +import type { Phase } from '@zseven-w/pen-ai-skills'; +import type { AuthLevel } from '../../../src/types/agent'; +import { + agentSessions, + cleanup, + abortSession, + createSession, + touchSession, + type AgentSession, +} from '../../utils/agent-sessions'; +import { + shouldShortCircuitPlanLayout, + updateLayoutSessionState, +} from '../../utils/agent-tool-guard'; +import { getAllToolDefs } from '../../../src/services/ai/agent-tools'; +import { + normalizeOptionalBaseURL, + normalizeMemberBaseURL, + requireOpenAICompatBaseURL, +} from './provider-url'; +import { startSSEKeepAlive } from '../../utils/sse-keepalive'; + +const TOOL_LEVEL_MAP: Record = { + batch_get: 'read', + snapshot_layout: 'read', + find_empty_space: 'read', + generate_design: 'create', + insert_node: 'create', + update_node: 'modify', + delete_node: 'delete', +}; + +const ROLE_TOOL_PRESETS: Record = { + designer: [ + 'batch_get', + 'snapshot_layout', + 'find_empty_space', + 'generate_design', + 'insert_node', + 'plan_layout', + 'batch_insert', + ], + reviewer: ['batch_get', 'snapshot_layout', 'get_selection'], + editor: [ + 'batch_get', + 'snapshot_layout', + 'find_empty_space', + 'update_node', + 'delete_node', + 'insert_node', + ], + researcher: ['batch_get', 'snapshot_layout', 'find_empty_space', 'get_selection'], +}; + +const ROLE_SKILL_PHASE: Record = { + designer: 'generation', + reviewer: 'validation', + editor: 'maintenance', + researcher: 'planning', +}; + +const ROLE_TOOL_INSTRUCTIONS: Record = { + designer: `You are a design team member. When asked to create designs, you MUST call the generate_design tool with a descriptive prompt. You can also use insert_node for manual node creation, batch_get and snapshot_layout to inspect the canvas, and find_empty_space to find placement locations. Always end with a short natural-language summary of what you created or changed. Never stop at tool calls only.`, + reviewer: `You are a design reviewer. Use batch_get and snapshot_layout to inspect the current canvas state. Use get_selection to see what the user has selected. Provide detailed feedback on layout, spacing, typography, and visual hierarchy. Always end with a short natural-language summary for the lead agent.`, + editor: `You are a design editor. Use batch_get and snapshot_layout to understand the current canvas. Use update_node to modify node properties, delete_node to remove elements, and insert_node to add new elements. Use find_empty_space to find placement locations. Always end with a short natural-language summary of what changed. Never stop at tool calls only.`, + researcher: `You are a design researcher. Use batch_get and snapshot_layout to analyze the current canvas state. Use find_empty_space to identify available space. Use get_selection to see what the user has selected. Provide analysis and recommendations. Always end with a short natural-language summary for the lead agent.`, +}; + +function buildTeamCapabilitiesPrompt(concurrency: number): string { + return `\n\n## Team Mode — MANDATORY parallel design + +You MUST use your team of ${concurrency} designers. Do NOT call generate_design yourself. + +**Workflow:** +1. Analyze the user's request and break it into ${concurrency} distinct sections/screens +2. Spawn ${concurrency} designer members: spawn_member({id: "designer-1", role: "designer"}), spawn_member({id: "designer-2", role: "designer"}), etc. +3. Delegate one section to each: delegate({member_id: "designer-1", task: "Design the [section] with [details]..."}) +4. After all delegations complete, summarize what was created. + +**Available roles:** designer, reviewer, editor, researcher + +**Example for a food app with ${concurrency} designers:** +${Array.from({ length: concurrency }, (_, i) => `- designer-${i + 1}: a different screen or section`).join('\n')} + +IMPORTANT: Always spawn exactly ${concurrency} designers and delegate to all of them. Each delegation should include a detailed description of that section. Never call generate_design directly — always delegate to spawned designers. +After all delegations, end with a short summary for the user.`; +} + +function buildMemberSystemPrompt( + role: string, + designMdContent?: string, + hasVariables?: boolean, +): string { + const phase = ROLE_SKILL_PHASE[role] ?? 'generation'; + const toolInstructions = ROLE_TOOL_INSTRUCTIONS[role] ?? ''; + + const skillCtx = resolveSkills(phase, '', { + flags: { + hasDesignMd: !!designMdContent, + hasVariables: !!hasVariables, + }, + dynamicContent: designMdContent ? { designMdContent } : undefined, + }); + const knowledge = skillCtx.skills.map((s) => s.content).join('\n\n'); + + return `${toolInstructions}\n\n${knowledge}`; +} + +const SPAWN_MEMBER_SCHEMA = JSON.stringify({ + type: 'object', + properties: { + id: { type: 'string', description: 'Unique member ID, e.g. "designer-1"' }, + role: { + type: 'string', + enum: ['designer', 'reviewer', 'editor', 'researcher'], + description: 'Member role — determines available tools and knowledge', + }, + model: { + type: 'string', + description: 'Optional model override for this member. Defaults to lead model.', + }, + }, + required: ['id', 'role'], +}); interface ToolDef { - name: string - description: string - level: AuthLevel - /** JSON Schema from client — single source of truth, no server-side duplication */ - parameters?: Record + name: string; + description: string; + level: AuthLevel; + parameters?: Record; } interface MemberDef { - id: string - providerType: 'anthropic' | 'openai-compat' - apiKey: string - model: string - baseURL?: string - systemPrompt?: string + id: string; + providerType: 'anthropic' | 'openai-compat'; + apiKey: string; + model: string; + baseURL?: string; + systemPrompt?: string; } interface AgentBody { - sessionId: string - messages: Array<{ role: string; content: unknown }> - systemPrompt: string - providerType: 'anthropic' | 'openai-compat' - apiKey: string - model: string - baseURL?: string - toolDefs: ToolDef[] - maxTurns?: number - maxOutputTokens?: number - members?: MemberDef[] + sessionId: string; + messages: Array<{ role: string; content: string }>; + systemPrompt: string; + providerType: 'anthropic' | 'openai-compat'; + apiKey: string; + model: string; + baseURL?: string; + toolDefs: ToolDef[]; + maxTurns?: number; + maxOutputTokens?: number; + maxContextTokens?: number; + members?: MemberDef[]; + teamMode?: boolean; + concurrency?: number; + designMdContent?: string; + hasVariables?: boolean; } -function toModelMessages(raw: Array<{ role: string; content: unknown }>) { - return raw - .filter((m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string') - .map((m) => ({ - role: m.role as 'user' | 'assistant', - content: m.content as string, - })) +/** Map Zig event JSON to client SSE format. + * Zig events are tagged unions: {"result":{...}} or {"stream_event":{...}}. + * Extract the tag and inner data, then map to the flat client format. + */ +function zigEventToSSE(raw: string): string { + const evt = JSON.parse(raw); + + // Zig tagged union: the single key is the event type, value is the data. + // For stream_event, the inner object has its own "type" field (text_delta, etc.) + let tag: string; + let data: Record; + if (evt.tool_use) { + // Complete tool call from Zig engine (after input_json_delta accumulation). + // This is the authoritative tool_call event — content_block_start only has metadata. + tag = 'tool_use'; + data = evt.tool_use; + } else if (evt.stream_event) { + tag = evt.stream_event.type ?? 'unknown'; + data = evt.stream_event; + } else if (evt.result) { + tag = 'result'; + data = evt.result; + } else if (evt.tool_progress) { + tag = 'tool_progress'; + data = evt.tool_progress; + } else { + tag = evt.type ?? 'unknown'; + data = evt; + } + + let mapped: Record; + switch (tag) { + case 'text_delta': + mapped = { type: 'text', content: data.text }; + break; + case 'thinking_delta': + mapped = { type: 'thinking', content: data.text }; + break; + case 'tool_use': + // Complete tool call with full args — emitted by Zig engine after input_json_delta accumulation + mapped = { + type: 'tool_call', + id: data.id, + name: data.name, + args: + typeof data.input === 'string' ? JSON.parse(data.input as string) : (data.input ?? {}), + level: TOOL_LEVEL_MAP[data.name as string] ?? 'read', + }; + break; + case 'content_block_start': + // Skip tool_use content_block_start — args aren't available yet. + // The complete tool_call is emitted later as a tool_use event. + if (data.tool_name) { + return ''; // suppress — will come as tool_use event with full args + } + mapped = { type: tag, ...data }; + break; + case 'result': + if (data.is_error) { + mapped = { + type: 'error', + message: `Agent error: ${data.subtype ?? 'unknown'}${data.result ? ' — ' + data.result : ''}`, + fatal: true, + }; + } else { + mapped = { type: 'done', totalTurns: data.num_turns ?? 0 }; + } + break; + case 'member_start': + mapped = { + type: 'member_start', + memberId: data.member_id, + task: data.task ?? '', + }; + break; + case 'member_end': + mapped = { + type: 'member_end', + memberId: data.member_id, + result: data.result ?? '', + }; + break; + default: + mapped = { type: tag, ...data }; + } + return `event: ${mapped.type}\ndata: ${JSON.stringify(mapped)}\n\n`; +} + +/** Run a delegated member asynchronously — does NOT block the caller. */ +async function runDelegateMember( + session: AgentSession, + body: AgentBody, + controller: ReadableStreamDefaultController, + encoder: TextEncoder, + toolUseId: string, + memberId: string, + task: string, +) { + // Resolve task-specific skills based on member role + const memberRole = session.memberRoles.get(memberId); + let enrichedTask = task; + if (memberRole) { + const phase = ROLE_SKILL_PHASE[memberRole] ?? 'generation'; + const taskSkills = resolveSkills(phase, task, { + flags: { + hasDesignMd: !!body.designMdContent, + hasVariables: !!body.hasVariables, + }, + }); + const skillPrefix = taskSkills.skills.map((s) => s.content).join('\n\n'); + if (skillPrefix) enrichedTask = skillPrefix + '\n\n' + task; + } + + controller.enqueue( + encoder.encode( + `event: member_start\ndata: ${JSON.stringify({ type: 'member_start', memberId, task })}\n\n`, + ), + ); + + let memberResult = ''; + const memberIter = await runTeamMember(session.team!, memberId, enrichedTask); + try { + let memberRaw: string | null; + while ((memberRaw = await nextEvent(memberIter)) !== null) { + session.lastActivity = Date.now(); + try { + const mEvt = JSON.parse(memberRaw); + + // Member tool_use → record owner, forward with source + if (mEvt.tool_use) { + const mToolId = mEvt.tool_use.id; + session.toolOwners.set(mToolId, memberId); + const level = TOOL_LEVEL_MAP[mEvt.tool_use.name as string] ?? 'read'; + const toolCallEvt = { + type: 'tool_call', + id: mToolId, + name: mEvt.tool_use.name, + args: + typeof mEvt.tool_use.input === 'string' + ? JSON.parse(mEvt.tool_use.input as string) + : (mEvt.tool_use.input ?? {}), + level, + source: memberId, + }; + controller.enqueue( + encoder.encode(`event: tool_call\ndata: ${JSON.stringify(toolCallEvt)}\n\n`), + ); + continue; + } + + // Collect text + if (mEvt.stream_event?.text && mEvt.stream_event.type === 'text_delta') { + memberResult += mEvt.stream_event.text; + } + } catch { + /* ignore parse errors */ + } + const memberSse = zigEventToSSE(memberRaw); + if (memberSse) controller.enqueue(encoder.encode(memberSse)); + } + } finally { + destroyIterator(memberIter); + for (const [tid, mid] of session.toolOwners) { + if (mid === memberId) session.toolOwners.delete(tid); + } + } + + controller.enqueue( + encoder.encode( + `event: member_end\ndata: ${JSON.stringify({ type: 'member_end', memberId, result: '' })}\n\n`, + ), + ); + + resolveTeamToolResult( + session.team!, + toolUseId, + JSON.stringify({ result: memberResult || 'Member completed task.' }), + ); +} + +function createProviderHandle( + providerType: 'anthropic' | 'openai-compat', + apiKey: string, + model: string, + baseURL?: string, + maxContextTokens?: number, +) { + return providerType === 'anthropic' + ? createAnthropicProvider(apiKey, model, baseURL, maxContextTokens) + : createOpenAICompatProvider( + apiKey, + requireOpenAICompatBaseURL(baseURL), + model, + maxContextTokens, + ); } /** @@ -58,178 +384,556 @@ function toModelMessages(raw: Array<{ role: string; content: unknown }>) { * POST /api/ai/agent?action=abort — Abort an agent session */ export default defineEventHandler(async (event) => { - const { action } = getQuery(event) as { action?: string } + const { action } = getQuery(event) as { action?: string }; // ── Tool result callback ──────────────────────────────────── if (action === 'result') { - const body = await readBody<{ sessionId: string; toolCallId: string; result: any }>(event) + const body = await readBody<{ sessionId: string; toolCallId: string; result: any }>(event); if (!body?.sessionId || !body.toolCallId || !body.result) { - throw createError({ statusCode: 400, message: 'Missing: sessionId, toolCallId, result' }) + throw createError({ statusCode: 400, message: 'Missing: sessionId, toolCallId, result' }); } - const session = agentSessions.get(body.sessionId) + const session = agentSessions.get(body.sessionId); if (!session) { - throw createError({ statusCode: 404, message: 'Session not found' }) + throw createError({ statusCode: 404, message: 'Session not found' }); } - session.agent.resolveToolResult(body.toolCallId, body.result) - session.lastActivity = Date.now() - return { ok: true } + try { + const toolName = session.toolNames.get(body.toolCallId); + updateLayoutSessionState(session, toolName, body.result); + + const resultJson = JSON.stringify(body.result); + // Per-toolCallId routing: check if this tool belongs to a member + const memberId = session.toolOwners?.get(body.toolCallId); + if (memberId && session.team) { + resolveMemberToolResult(session.team, memberId, body.toolCallId, resultJson); + session.toolOwners.delete(body.toolCallId); + } else if (session.team) { + resolveTeamToolResult(session.team, body.toolCallId, resultJson); + } else if (session.engine) { + resolveToolResult(session.engine, body.toolCallId, resultJson); + } + session.toolNames.delete(body.toolCallId); + } catch { + return { ok: true, ignored: true }; + } + session.lastActivity = Date.now(); + return { ok: true }; } // ── Abort ─────────────────────────────────────────────────── if (action === 'abort') { - const body = await readBody<{ sessionId?: string }>(event) - const sid = body?.sessionId + const body = await readBody<{ sessionId?: string }>(event); + const sid = body?.sessionId; if (sid) { - const session = agentSessions.get(sid) + const session = agentSessions.get(sid); if (session) { - session.abortController.abort() - agentSessions.delete(sid) + abortSession(session); + cleanup(session); + agentSessions.delete(sid); } } - return { ok: true } + return { ok: true }; } // ── Start agent loop (SSE stream) ────────────────────────── - const body = await readBody(event) - if (!body?.sessionId || !body.messages || !body.systemPrompt || !body.providerType || !body.apiKey || !body.model) { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - return { error: 'Missing required fields: sessionId, messages, systemPrompt, providerType, apiKey, model' } + const body = await readBody(event); + if ( + !body?.sessionId || + !body.messages || + !body.systemPrompt || + !body.providerType || + !body.apiKey || + !body.model + ) { + throw createError({ + statusCode: 400, + message: + 'Missing required fields: sessionId, messages, systemPrompt, providerType, apiKey, model', + }); } - const provider = body.providerType === 'anthropic' - ? createAnthropicProvider({ apiKey: body.apiKey, model: body.model, baseURL: body.baseURL }) - : createOpenAICompatProvider({ apiKey: body.apiKey, model: body.model, baseURL: body.baseURL }) - - const tools = createToolRegistry() - for (const def of body.toolDefs ?? []) { - // Use client-provided JSON Schema (single source of truth) - // Strip $schema field that strict APIs (MiniMax, StepFun) reject - const params = def.parameters ? { ...def.parameters } : { type: 'object' } - delete (params as any).$schema - tools.register({ - name: def.name, - description: def.description, - level: def.level, - schema: jsonSchema(params as any), - }) + const normalizedBaseURL = normalizeOptionalBaseURL(body.baseURL); + if (body.providerType === 'openai-compat' && !normalizedBaseURL) { + throw createError({ + statusCode: 400, + message: 'OpenAI-compatible provider requires baseURL', + }); } - const abortController = new AbortController() + // Diagnostic logging for the cross-provider empty-response bug. + // When a provider returns a 200 OK + message_start + immediate + // stream close (0 content blocks), the failure is silent at the + // provider edge. This log captures the ACTUAL upstream shape — + // NOT the raw body fields — because the server applies several + // transformations between reading the body and issuing the + // upstream request: + // + // 1. teamMode && concurrency >= 2 appends + // `buildTeamCapabilitiesPrompt(concurrency)` to systemPrompt + // 2. teamMode auto-registers the `spawn_member` tool on top of + // whatever the client sent in `toolDefs` + // 3. Prior messages are filtered to `role in {user, assistant} + // && typeof content === 'string'` before being seeded; the + // LAST message becomes the new-turn prompt + // 4. `registerToolSchema` only sends `parameters` (with $schema + // stripped), not the full `ToolDef`, so tool-schema size is + // computed from `parameters` alone + // + // This block mirrors all four transformations so the logged + // numbers match what the native agent runtime actually sends to + // the provider edge. + // + // Gated by a hard-coded constant so flipping it off is one line. + const OUTER_AGENT_LOG_ENABLED = true; + if (OUTER_AGENT_LOG_ENABLED) { + const concurrency = body.concurrency ?? 1; - // Create agent or team based on whether members are provided - let agentOrTeam: { run: (msgs: any) => AsyncGenerator; resolveToolResult: (id: string, result: any) => void } + // (1) Effective system prompt — mirrors teamSystemPrompt logic below. + const effectiveSystemPrompt = + body.teamMode && concurrency >= 2 + ? (body.systemPrompt ?? '') + buildTeamCapabilitiesPrompt(concurrency) + : (body.systemPrompt ?? ''); - if (body.members?.length) { - // Team mode — create member agents with scoped tools - // Designer only gets generate_design + snapshot_layout (read-only check). - // Giving all tools causes wasteful batch_get calls after generation. - const DESIGNER_TOOLS = new Set(['generate_design', 'snapshot_layout']) + // (3) Seeded prior messages — same filter as seedMessages / + // seedTeamMessages below. + const allMessages = body.messages ?? []; + const newPromptRaw = allMessages[allMessages.length - 1]?.content; + const newPromptChars = typeof newPromptRaw === 'string' ? newPromptRaw.length : 0; + const priorMessages = allMessages + .slice(0, -1) + .filter( + (m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string', + ); + const priorMessageChars = priorMessages.reduce( + (sum, m) => sum + (m.content as string).length, + 0, + ); - const members = body.members.map(m => { - const memberProvider = m.providerType === 'anthropic' - ? createAnthropicProvider({ apiKey: m.apiKey, model: m.model, baseURL: m.baseURL }) - : createOpenAICompatProvider({ apiKey: m.apiKey, model: m.model, baseURL: m.baseURL }) + // (2, 4) Effective tool count + on-wire schema bytes. + // + // On-wire tool list in team mode includes up to THREE classes + // of additions on top of the client-supplied body.toolDefs: + // + // a) `spawn_member` — registered ONLY when body.teamMode===true + // via `registerToolSchema(tools, 'spawn_member', SPAWN_MEMBER_SCHEMA)`. + // + // b) `delegate` — registered by `teamRegisterDelegate(team)`, + // which runs whenever `body.teamMode || normalizedMembers.length` + // (i.e. any team-mode branch). This is a NATIVE runtime-side + // registration inside `team.registerDelegateTool()` in + // packages/agent-native/src/team.zig. The schema it registers + // has a fixed shape: `{type:"object", properties:{member_id, + // task}, required:[member_id,task]}` — 159 bytes as the + // `input_schema` parameters blob. I was missing this + // entirely in the previous log. + // + // c) Member-specific tools registered via addTeamMember() when + // normalizedMembers.length > 0. Each member has its OWN + // tool registry and those tools are NOT on the leader's + // on-wire payload, so they don't count toward the leader + // request shape we log here. + // + // Tool schemas are serialized as JSON.stringify(parameters) with + // `$schema` stripped — see registerToolSchema call below. We + // mirror that transform here so the log matches the bytes the + // native runtime actually pushes over the wire. + const toolDefsChars = (body.toolDefs ?? []).reduce((sum, t) => { + const params = t.parameters ? { ...(t.parameters as Record) } : {}; + delete (params as Record).$schema; + return sum + JSON.stringify(params).length; + }, 0); - const memberTools = createToolRegistry() - const allowedTools = m.id === 'designer' ? DESIGNER_TOOLS : null - for (const def of body.toolDefs ?? []) { - if (allowedTools && !allowedTools.has(def.name)) continue - const params = def.parameters ? { ...def.parameters } : { type: 'object' } - delete (params as any).$schema - memberTools.register({ - name: def.name, - description: def.description, - level: def.level, - schema: jsonSchema(params as any), - }) - } + // Delegate schema text, verbatim from team.zig::registerDelegateTool. + // Hard-coded here rather than imported because it lives inside a + // Zig function body and isn't exported. Keep in sync if the Zig + // side is ever edited (the unit test coverage in team.zig catches + // drift on that side; this side is a diagnostic log only). + const DELEGATE_INPUT_SCHEMA = + '{"type":"object","properties":{"member_id":{"type":"string","description":"ID of the team member to delegate to"},"task":{"type":"string","description":"Task description for the member"}},"required":["member_id","task"]}'; + // The team-mode branch below is gated on `body.teamMode || + // normalizedMembers.length`. `normalizedMembers` is derived from + // `body.members` 1:1 (same length, just adds a normalized + // baseURL field), so the raw count matches. normalizedMembers + // itself is computed AFTER this log block, so we use body.members + // directly to predict whether the team branch will be taken. + const teamModeBranch = !!(body.teamMode || (body.members ?? []).length); - return { - id: m.id, - provider: memberProvider, - tools: memberTools, - systemPrompt: m.systemPrompt || `You are a ${m.id} specialist.`, - turnTimeout: 5 * 60_000, // 5 minutes — design generation is slow - } - }) - - // Remove generate_design from lead tools — force delegation to designer - const leadTools = createToolRegistry() - for (const def of body.toolDefs ?? []) { - if (def.name === 'generate_design') continue // designer-only - const params = def.parameters ? { ...def.parameters } : { type: 'object' } - delete (params as any).$schema - leadTools.register({ - name: def.name, - description: def.description, - level: def.level, - schema: jsonSchema(params as any), - }) + let effectiveToolCount = (body.toolDefs ?? []).length; + let effectiveToolChars = toolDefsChars; + if (body.teamMode) { + effectiveToolCount += 1; + effectiveToolChars += SPAWN_MEMBER_SCHEMA.length; + } + if (teamModeBranch) { + effectiveToolCount += 1; + effectiveToolChars += DELEGATE_INPUT_SCHEMA.length; } - const team = createTeam({ - lead: { provider, tools: leadTools, systemPrompt: body.systemPrompt, maxTurns: body.maxTurns ?? 20 }, - members, - }) - agentOrTeam = { run: (msgs) => team.run(msgs), resolveToolResult: (id, result) => team.resolveToolResult(id, result) } + console.log( + `[agent-request] provider=${body.providerType} model=${body.model} teamMode=${!!body.teamMode} concurrency=${concurrency} ` + + `effectiveSystemPrompt=${effectiveSystemPrompt.length} ` + + `newPromptChars=${newPromptChars} ` + + `seededPriorMessages=${priorMessages.length}(totalChars=${priorMessageChars}) ` + + `effectiveTools=${effectiveToolCount}(onWireSchemaChars=${effectiveToolChars}) ` + + `members=${(body.members ?? []).length} maxOutputTokens=${body.maxOutputTokens ?? 'default'}`, + ); + } + + // Validate all member baseURLs upfront before allocating any native handles + const normalizedMembers = (body.members ?? []).map((m) => { + try { + return { ...m, normalizedBaseURL: normalizeMemberBaseURL(m.id, m.providerType, m.baseURL) }; + } catch (err: any) { + throw createError({ statusCode: 400, message: err.message }); + } + }); + + const provider = createProviderHandle( + body.providerType, + body.apiKey, + body.model, + normalizedBaseURL, + body.maxContextTokens, + ); + const tools = createToolRegistry(); + for (const def of body.toolDefs ?? []) { + const params = def.parameters ? { ...def.parameters } : { type: 'object' }; + delete (params as any).$schema; + registerToolSchema(tools, def.name, JSON.stringify(params)); + } + + const prompt = body.messages[body.messages.length - 1]?.content ?? ''; + + let session: AgentSession; + + if (body.teamMode || normalizedMembers.length) { + const concurrency = body.concurrency ?? 1; + console.info(`[agent] creating team (teamMode=${!!body.teamMode}, concurrency=${concurrency})`); + + // Append team capabilities to system prompt when teamMode + const teamSystemPrompt = + body.teamMode && concurrency >= 2 + ? body.systemPrompt + buildTeamCapabilitiesPrompt(concurrency) + : body.systemPrompt; + + const team = createTeam( + provider, + tools, + teamSystemPrompt, + body.maxTurns ?? 20, + body.maxOutputTokens, + ); + + const memberHandles: Array<{ + provider: ReturnType; + tools: ReturnType; + }> = []; + + // Legacy path: pre-configured members from client + if (normalizedMembers.length) { + for (const m of normalizedMembers) { + const memberProvider = createProviderHandle( + m.providerType, + m.apiKey, + m.model, + m.normalizedBaseURL, + ); + const memberTools = createToolRegistry(); + addTeamMember(team, m.id, memberProvider, memberTools, m.systemPrompt ?? '', 20); + memberHandles.push({ provider: memberProvider, tools: memberTools }); + } + } + + // Register spawn_member + delegate tools when teamMode + if (body.teamMode) { + registerToolSchema(tools, 'spawn_member', SPAWN_MEMBER_SCHEMA); + } + teamRegisterDelegate(team); + + // Seed prior conversation history onto the lead engine + const priorMessages = body.messages + .slice(0, -1) + .filter( + (m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string', + ); + if (priorMessages.length > 0) { + seedTeamMessages(team, JSON.stringify(priorMessages)); + } + + session = createSession({ + team, + provider, + tools, + memberHandles, + createdAt: Date.now(), + lastActivity: Date.now(), + }); } else { - const agent = createAgent({ + // Single engine mode + const engine = createQueryEngine({ provider, tools, systemPrompt: body.systemPrompt, maxTurns: body.maxTurns ?? 20, maxOutputTokens: body.maxOutputTokens, - turnTimeout: 5 * 60_000, - abortSignal: abortController.signal, - }) - agentOrTeam = agent + cwd: process.cwd(), + }); + + // Seed conversation history + const priorMessages = body.messages + .slice(0, -1) + .filter( + (m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string', + ); + if (priorMessages.length > 0) { + seedMessages(engine, JSON.stringify(priorMessages)); + } + + session = createSession({ + engine, + provider, + tools, + createdAt: Date.now(), + lastActivity: Date.now(), + }); } - agentSessions.set(body.sessionId, { agent: agentOrTeam as any, abortController, createdAt: Date.now(), lastActivity: Date.now() }) + // Register session for tool result callbacks and abort + agentSessions.set(body.sessionId, session); setResponseHeaders(event, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', - }) + }); - const encoder = new TextEncoder() - let stream: ReadableStream - try { - stream = new ReadableStream({ - async start(controller) { - const pingTimer = setInterval(() => { - try { - controller.enqueue(encoder.encode(': ping\n\n')) - } catch { /* stream already closed */ } - }, 5_000) + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + // Keep pings active for the full orchestration. Delegated tool calls can + // legitimately stall visible output for >10s while the model waits. + const pingTimer = startSSEKeepAlive(() => { + controller.enqueue(encoder.encode(': ping\n\n')); + touchSession(session); + }, 5_000); - try { - for await (const agentEvent of agentOrTeam.run(toModelMessages(body.messages))) { - const session = agentSessions.get(body.sessionId) - if (session) session.lastActivity = Date.now() - controller.enqueue(encoder.encode(encodeAgentEvent(agentEvent))) + let iter; + try { + iter = session.team + ? await runTeam(session.team, prompt) + : await submitMessage(session.engine!, prompt); + session.iter = iter; + + let raw: string | null; + let eventCount = 0; + while ((raw = await nextEvent(iter)) !== null) { + eventCount++; + session.lastActivity = Date.now(); + // Log first 5 events and any tool_use/result events for diagnostics + if (eventCount <= 5 || raw.includes('tool_use') || raw.includes('"result"')) { + const preview = raw.length > 200 ? raw.substring(0, 200) + '...' : raw; + console.info(`[agent] event #${eventCount}: ${preview}`); } - } catch (err: any) { - try { - controller.enqueue(encoder.encode(encodeAgentEvent({ - type: 'error', - message: err?.message ?? String(err), - fatal: true, - }))) - } catch { /* ignore */ } - } finally { - clearInterval(pingTimer) - agentSessions.delete(body.sessionId) - try { controller.close() } catch { /* ignore */ } - } - }, - }) - } catch (err) { - // Stream construction failed — clean up session - agentSessions.delete(body.sessionId) - throw err - } - return new Response(stream) -}) + if (session.team) { + try { + const evt = JSON.parse(raw); + + // ── spawn_member intercept ── + if (evt.tool_use && evt.tool_use.name === 'spawn_member') { + const toolUseId = evt.tool_use.id; + const inputData = + typeof evt.tool_use.input === 'string' + ? JSON.parse(evt.tool_use.input) + : evt.tool_use.input; + const memberId: string = inputData?.id; + const role: string = inputData?.role; + const memberModel: string | undefined = inputData?.model; + + if (!memberId || !role || !ROLE_TOOL_PRESETS[role]) { + resolveTeamToolResult( + session.team, + toolUseId, + JSON.stringify({ + success: false, + error: `Invalid spawn_member args: id=${memberId}, role=${role}`, + }), + ); + continue; + } + + // Check duplicate + if (session.memberRoles.has(memberId)) { + resolveTeamToolResult( + session.team, + toolUseId, + JSON.stringify({ + success: false, + error: `Member "${memberId}" already exists`, + }), + ); + continue; + } + + // Create provider (use member model or lead's) + const mProvider = createProviderHandle( + body.providerType, + body.apiKey, + memberModel ?? body.model, + normalizedBaseURL, + body.maxContextTokens, + ); + + // Create tool registry with role preset + const mTools = createToolRegistry(); + const allDefs = getAllToolDefs(); + const presetNames = ROLE_TOOL_PRESETS[role]; + for (const name of presetNames) { + const def = allDefs.find((d) => d.name === name); + if (def) { + const params = def.parameters ? { ...def.parameters } : { type: 'object' }; + delete (params as any).$schema; + registerToolSchema(mTools, name, JSON.stringify(params)); + } + } + + // Build member system prompt with role skills + const memberPrompt = buildMemberSystemPrompt( + role, + body.designMdContent, + body.hasVariables, + ); + + addTeamMember(session.team, memberId, mProvider, mTools, memberPrompt, 20); + if (!session.memberHandles) session.memberHandles = []; + session.memberHandles.push({ provider: mProvider, tools: mTools }); + session.memberRoles.set(memberId, role); + + resolveTeamToolResult( + session.team, + toolUseId, + JSON.stringify({ + success: true, + member_id: memberId, + role, + tools: presetNames, + }), + ); + continue; + } + + // ── delegate intercept (enhanced with member tool routing) ── + if (evt.tool_use && evt.tool_use.name === 'delegate') { + const toolUseId = evt.tool_use.id; + let memberIdRaw: string | undefined; + let taskRaw: string | undefined; + + const inputData = evt.tool_use.input; + if (typeof inputData === 'string') { + try { + const parsed = JSON.parse(inputData); + memberIdRaw = parsed.member_id; + taskRaw = parsed.task; + } catch { + /* fallback below */ + } + } else if (inputData && typeof inputData === 'object') { + memberIdRaw = inputData.member_id; + taskRaw = inputData.task; + } + + if (memberIdRaw && taskRaw) { + // Fire-and-forget: run member in parallel. The Zig engine blocks in + // waiting_for_external_tools until ALL delegate results are resolved. + // By not awaiting, multiple delegates run concurrently. + runDelegateMember( + session, + body, + controller, + encoder, + toolUseId, + memberIdRaw, + taskRaw, + ).catch((err) => { + console.error(`[agent] delegate ${memberIdRaw} failed:`, err); + try { + resolveTeamToolResult( + session.team!, + toolUseId, + JSON.stringify({ result: `Error: ${err?.message ?? String(err)}` }), + ); + } catch { + /* ignore */ + } + }); + continue; + } + } + } catch { + /* not JSON or not intercepted — fall through to normal forwarding */ + } + } + + if (!session.team) { + try { + const evt = JSON.parse(raw); + if (evt.tool_use?.id && evt.tool_use?.name) { + const toolUseId = evt.tool_use.id as string; + const toolName = evt.tool_use.name as string; + session.toolNames.set(toolUseId, toolName); + + const syntheticResult = shouldShortCircuitPlanLayout( + session, + toolName, + evt.tool_use.input, + ); + if (syntheticResult && session.engine) { + resolveToolResult(session.engine, toolUseId, JSON.stringify(syntheticResult)); + session.toolNames.delete(toolUseId); + controller.enqueue( + encoder.encode( + `event: tool_result\ndata: ${JSON.stringify({ + type: 'tool_result', + id: toolUseId, + name: toolName, + result: syntheticResult, + })}\n\n`, + ), + ); + continue; + } + } + } catch { + /* ignore parse errors and forward raw event */ + } + } + + const sse = zigEventToSSE(raw); + if (sse) controller.enqueue(encoder.encode(sse)); + } + console.info(`[agent] stream ended after ${eventCount} events`); + } catch (err: any) { + console.error(`[agent] stream error:`, err?.message ?? String(err)); + try { + controller.enqueue( + encoder.encode( + `event: error\ndata: ${JSON.stringify({ type: 'error', message: err?.message ?? String(err), fatal: true })}\n\n`, + ), + ); + } catch { + /* ignore */ + } + } finally { + clearInterval(pingTimer); + agentSessions.delete(body.sessionId); + cleanup(session); + try { + controller.close(); + } catch { + /* ignore */ + } + } + }, + }); + + return new Response(stream); +}); diff --git a/apps/web/server/api/ai/chat.ts b/apps/web/server/api/ai/chat.ts index 25e36f1e..160f7441 100644 --- a/apps/web/server/api/ai/chat.ts +++ b/apps/web/server/api/ai/chat.ts @@ -1,124 +1,132 @@ -import { defineEventHandler, readBody, setResponseHeaders } from 'h3' -import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { resolveClaudeCli } from '../../utils/resolve-claude-cli' -import { runCodexExec } from '../../utils/codex-client' +import { defineEventHandler, readBody, setResponseHeaders } from 'h3'; +import { writeFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { resolveClaudeCli } from '../../utils/resolve-claude-cli'; +import { runCodexExec } from '../../utils/codex-client'; +import { startSSEKeepAlive } from '../../utils/sse-keepalive'; import { buildClaudeAgentEnv, buildSpawnClaudeCodeProcess, getClaudeAgentDebugFilePath, resolveAgentModel, -} from '../../utils/resolve-claude-agent-env' -/** Pattern for detecting sensitive data in debug log output */ -export const SENSITIVE_LOG_PATTERN = /ANTHROPIC_API_KEY=|Authorization:\s*Bearer|api[_-]?key\s*[:=]/i +} from '../../utils/resolve-claude-agent-env'; +import { normalizeOptionalBaseURL, requireOpenAICompatBaseURL } from './provider-url'; +// SENSITIVE_LOG_PATTERN + readDebugTail are now canonical in @zseven-w/pen-mcp. +// Re-export here to keep existing consumers (tests, other modules) working. +import { SENSITIVE_LOG_PATTERN, readDebugTail } from '@zseven-w/pen-mcp'; +export { SENSITIVE_LOG_PATTERN }; /** Allowed media types for image attachments */ -export const ALLOWED_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) +export const ALLOWED_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); /** Resolve file extension from media type, falling back to 'png' for disallowed types */ export function resolveMediaExtension(mediaType: string): string { - return ALLOWED_MEDIA_TYPES.has(mediaType) ? mediaType.split('/')[1] : 'png' + return ALLOWED_MEDIA_TYPES.has(mediaType) ? mediaType.split('/')[1] : 'png'; } interface ChatAttachmentWire { - name: string - mediaType: string - data: string // base64 + name: string; + mediaType: string; + data: string; // base64 } interface ChatBody { - system: string - messages: Array<{ role: 'user' | 'assistant'; content: string; attachments?: ChatAttachmentWire[] }> - model?: string - provider?: 'anthropic' | 'openai' | 'opencode' | 'copilot' | 'gemini' | 'builtin' - thinkingMode?: 'adaptive' | 'disabled' | 'enabled' - thinkingBudgetTokens?: number - effort?: 'low' | 'medium' | 'high' | 'max' + system: string; + messages: Array<{ + role: 'user' | 'assistant'; + content: string; + attachments?: ChatAttachmentWire[]; + }>; + model?: string; + provider?: 'anthropic' | 'openai' | 'opencode' | 'copilot' | 'gemini' | 'builtin'; + thinkingMode?: 'adaptive' | 'disabled' | 'enabled'; + thinkingBudgetTokens?: number; + effort?: 'low' | 'medium' | 'high' | 'max'; /** For builtin provider: direct API key (not CLI-based) */ - builtinApiKey?: string - /** For builtin provider: base URL for OpenAI-compatible endpoints */ - builtinBaseURL?: string + builtinApiKey?: string; + /** For builtin provider: API root base URL (e.g. https://api.openai.com/v1) */ + builtinBaseURL?: string; /** For builtin provider: 'anthropic' or 'openai-compat' */ - builtinType?: 'anthropic' | 'openai-compat' -} - -async function readDebugTail(path?: string, maxLines = 40): Promise { - if (!path) return undefined - try { - const raw = await readFile(path, 'utf-8') - const lines = raw.split('\n').filter((l) => l.trim().length > 0) - const sanitized = lines.filter(l => !SENSITIVE_LOG_PATTERN.test(l)) - return sanitized.slice(-maxLines) - } catch { - return undefined - } + builtinType?: 'anthropic' | 'openai-compat'; } function buildClaudeExitHint(rawError: string, debugTail?: string[]): string | undefined { - if (!/process exited with code 1/i.test(rawError)) return undefined + if (!/process exited with code 1/i.test(rawError)) return undefined; - const hints: string[] = [] + const hints: string[] = []; if (debugTail && debugTail.length > 0) { - const text = debugTail.join('\n') - if (/Failed to save config with lock: Error: EPERM|operation not permitted, .*\.claude\.json/i.test(text)) { + const text = debugTail.join('\n'); + if ( + /Failed to save config with lock: Error: EPERM|operation not permitted, .*\.claude\.json/i.test( + text, + ) + ) { hints.push( 'Claude Code cannot write ~/.claude.json (permission denied). ' + - 'On Windows, try running as Administrator or manually create the file: echo {} > %USERPROFILE%\\.claude.json', - ) + 'On Windows, try running as Administrator or manually create the file: echo {} > %USERPROFILE%\\.claude.json', + ); } - if (/Connection error|Could not resolve host|Failed to connect|ECONNREFUSED|ETIMEDOUT/i.test(text)) { - hints.push('Upstream API connection failed. Check proxy/DNS/network reachability to your ANTHROPIC_BASE_URL.') + if ( + /Connection error|Could not resolve host|Failed to connect|ECONNREFUSED|ETIMEDOUT/i.test(text) + ) { + hints.push( + 'Upstream API connection failed. Check proxy/DNS/network reachability to your ANTHROPIC_BASE_URL.', + ); } if (/ANTHROPIC_CUSTOM_HEADERS present: false, has Authorization header: false/i.test(text)) { hints.push( 'No API auth header detected. Run "claude login" to authenticate, ' + - 'or set ANTHROPIC_API_KEY in ~/.claude/settings.json ' + - '(env: { "ANTHROPIC_API_KEY": "sk-..." }).', - ) + 'or set ANTHROPIC_API_KEY in ~/.claude/settings.json ' + + '(env: { "ANTHROPIC_API_KEY": "sk-..." }).', + ); } if (/invalid.*api.?key|unauthorized|401|authentication/i.test(text)) { - hints.push('API key authentication failed. Verify your ANTHROPIC_API_KEY is correct and has not expired.') + hints.push( + 'API key authentication failed. Verify your ANTHROPIC_API_KEY is correct and has not expired.', + ); } if (/ENOTFOUND|getaddrinfo/i.test(text)) { - hints.push('DNS resolution failed for the API endpoint. Check your ANTHROPIC_BASE_URL is correct.') + hints.push( + 'DNS resolution failed for the API endpoint. Check your ANTHROPIC_BASE_URL is correct.', + ); } if (/certificate|CERT_|ssl|tls/i.test(text)) { hints.push( 'TLS/SSL certificate error. If using a corporate proxy, set NODE_TLS_REJECT_UNAUTHORIZED=0 in ~/.claude/settings.json env (not recommended for production).', - ) + ); } } // Detect proxy/custom endpoint — most common cause of exit-code-1 on Windows - const env = process.env - const hasProxy = !!(env.http_proxy || env.https_proxy || env.HTTP_PROXY || env.HTTPS_PROXY) - const hasCustomBaseUrl = !!env.ANTHROPIC_BASE_URL + const env = process.env; + const hasProxy = !!(env.http_proxy || env.https_proxy || env.HTTP_PROXY || env.HTTPS_PROXY); + const hasCustomBaseUrl = !!env.ANTHROPIC_BASE_URL; if ((hasProxy || hasCustomBaseUrl) && !env.NODE_TLS_REJECT_UNAUTHORIZED) { hints.push( 'Proxy or custom ANTHROPIC_BASE_URL detected but NODE_TLS_REJECT_UNAUTHORIZED is not set. ' + - 'If your proxy uses a self-signed or corporate certificate, add ' + - '"NODE_TLS_REJECT_UNAUTHORIZED": "0" to the env section of ~/.claude/settings.json.', - ) + 'If your proxy uses a self-signed or corporate certificate, add ' + + '"NODE_TLS_REJECT_UNAUTHORIZED": "0" to the env section of ~/.claude/settings.json.', + ); } // If no debug info available, provide generic Windows guidance if (hints.length === 0) { - const isWin = process.platform === 'win32' + const isWin = process.platform === 'win32'; if (isWin) { hints.push( 'Claude Code process crashed on Windows. Common fixes: ' + - '(1) Ensure ~/.claude.json exists: echo {} > %USERPROFILE%\\.claude.json ' + - '(2) Check ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL in ~/.claude/settings.json ' + - '(3) If using a proxy, set NODE_TLS_REJECT_UNAUTHORIZED=0 in env.', - ) + '(1) Ensure ~/.claude.json exists: echo {} > %USERPROFILE%\\.claude.json ' + + '(2) Check ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL in ~/.claude/settings.json ' + + '(3) If using a proxy, set NODE_TLS_REJECT_UNAUTHORIZED=0 in env.', + ); } else { - return undefined + return undefined; } } - return `${rawError}\n${hints.join('\n')}` + return `${rawError}\n${hints.join('\n')}`; } /** @@ -127,51 +135,58 @@ function buildClaudeExitHint(rawError: string, debugTail?: string[]): string | u * Requires explicit provider and model; no fallback routing. */ export default defineEventHandler(async (event) => { - const body = await readBody(event) + const body = await readBody(event); if (!body?.messages || body?.system == null) { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - return { error: 'Missing required fields: system, messages' } + setResponseHeaders(event, { 'Content-Type': 'application/json' }); + return { error: 'Missing required fields: system, messages' }; } if (!body.provider) { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - return { error: 'Missing provider. Provider fallback is disabled.' } + setResponseHeaders(event, { 'Content-Type': 'application/json' }); + return { error: 'Missing provider. Provider fallback is disabled.' }; } if (!body.model?.trim()) { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - return { error: 'Missing model. Model fallback is disabled.' } + setResponseHeaders(event, { 'Content-Type': 'application/json' }); + return { error: 'Missing model. Model fallback is disabled.' }; } - if (body.provider !== 'anthropic' && body.provider !== 'openai' && body.provider !== 'opencode' && body.provider !== 'copilot' && body.provider !== 'gemini' && body.provider !== 'builtin') { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - return { error: 'Missing or unsupported provider. Provider fallback is disabled.' } + if ( + body.provider !== 'anthropic' && + body.provider !== 'openai' && + body.provider !== 'opencode' && + body.provider !== 'copilot' && + body.provider !== 'gemini' && + body.provider !== 'builtin' + ) { + setResponseHeaders(event, { 'Content-Type': 'application/json' }); + return { error: 'Missing or unsupported provider. Provider fallback is disabled.' }; } setResponseHeaders(event, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', - }) + }); - if (body.provider === 'builtin') return streamViaBuiltin(body) - if (body.provider === 'anthropic') return streamViaAgentSDK(body, body.model) - if (body.provider === 'opencode') return streamViaOpenCode(body, body.model) - if (body.provider === 'copilot') return streamViaCopilot(body, body.model) - if (body.provider === 'gemini') return streamViaGemini(body, body.model) - return streamViaCodex(body, body.model) -}) + if (body.provider === 'builtin') return streamViaBuiltin(body); + if (body.provider === 'anthropic') return streamViaAgentSDK(body, body.model); + if (body.provider === 'opencode') return streamViaOpenCode(body, body.model); + if (body.provider === 'copilot') return streamViaCopilot(body, body.model); + if (body.provider === 'gemini') return streamViaGemini(body, body.model); + return streamViaCodex(body, body.model); +}); -// Keep-alive ping interval (ms) — must be shorter than Bun's 10s idle timeout -// to prevent "socket connection was closed unexpectedly" errors -const KEEPALIVE_INTERVAL_MS = 5_000 -function getAgentThinkingConfig(body: ChatBody): - | { type: 'adaptive' | 'disabled' } - | { type: 'enabled'; budgetTokens?: number } - | undefined { - if (!body.thinkingMode) return undefined +// Keep-alive ping interval (ms) — must stay below Bun's 10s idle timeout, +// but shouldn't be so aggressive that long-lived nested SSE streams create +// unnecessary write pressure on Bun dev. +const KEEPALIVE_INTERVAL_MS = 5_000; +function getAgentThinkingConfig( + body: ChatBody, +): { type: 'adaptive' | 'disabled' } | { type: 'enabled'; budgetTokens?: number } | undefined { + if (!body.thinkingMode) return undefined; if (body.thinkingMode === 'enabled') { - return { type: 'enabled', budgetTokens: body.thinkingBudgetTokens } + return { type: 'enabled', budgetTokens: body.thinkingBudgetTokens }; } - return { type: body.thinkingMode } + return { type: body.thinkingMode }; } /** @@ -185,30 +200,30 @@ async function saveAttachmentsToTempFiles( attachments: ChatAttachmentWire[], insideProject = false, ): Promise<{ tempDir: string; files: string[] }> { - let tempDir: string + let tempDir: string; if (insideProject) { - const { mkdirSync, chmodSync } = await import('node:fs') - const baseDir = join(process.cwd(), '.openpencil-tmp') - mkdirSync(baseDir, { recursive: true, mode: 0o700 }) - chmodSync(baseDir, 0o700) - tempDir = await mkdtemp(join(baseDir, 'attach-')) + const { mkdirSync, chmodSync } = await import('node:fs'); + const baseDir = join(process.cwd(), '.openpencil-tmp'); + mkdirSync(baseDir, { recursive: true, mode: 0o700 }); + chmodSync(baseDir, 0o700); + tempDir = await mkdtemp(join(baseDir, 'attach-')); } else { - tempDir = await mkdtemp(join(tmpdir(), 'openpencil-attach-')) + tempDir = await mkdtemp(join(tmpdir(), 'openpencil-attach-')); } - const files: string[] = [] + const files: string[] = []; for (const att of attachments) { - const ext = resolveMediaExtension(att.mediaType) - const filePath = join(tempDir, `${files.length}.${ext}`) - await writeFile(filePath, Buffer.from(att.data, 'base64')) - files.push(filePath) + const ext = resolveMediaExtension(att.mediaType); + const filePath = join(tempDir, `${files.length}.${ext}`); + await writeFile(filePath, Buffer.from(att.data, 'base64')); + files.push(filePath); } - return { tempDir, files } + return { tempDir, files }; } /** Collect all attachments from the last user message */ function getLastUserAttachments(body: ChatBody): ChatAttachmentWire[] { - const lastUser = [...body.messages].reverse().find((m) => m.role === 'user') - return lastUser?.attachments ?? [] + const lastUser = [...body.messages].reverse().find((m) => m.role === 'user'); + return lastUser?.attachments ?? []; } /** @@ -216,63 +231,84 @@ function getLastUserAttachments(body: ChatBody): ChatAttachmentWire[] { * when we need Claude Code Agent SDK to use its Read tool for image analysis. */ function stripNoToolsRestriction(systemPrompt: string): string { - return systemPrompt - .replace(/^.*NEVER use tools.*$/gim, '') - .replace(/\n{3,}/g, '\n\n') + return systemPrompt.replace(/^.*NEVER use tools.*$/gim, '').replace(/\n{3,}/g, '\n\n'); } /** Stream via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */ function streamViaAgentSDK(body: ChatBody, requestedModel?: string) { + let activeQuery: { close(): void } | undefined; + let cancelled = false; const stream = new ReadableStream({ async start(controller) { - const encoder = new TextEncoder() - // Send keep-alive pings until the first real chunk arrives - const pingTimer = setInterval(() => { + const encoder = new TextEncoder(); + const safeEnqueue = (payload: Record) => { + if (cancelled) return false; try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`)) - } catch { /* stream already closed */ } - }, KEEPALIVE_INTERVAL_MS) - let debugFile: string | undefined - let attachTempDir: string | undefined + controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)); + return true; + } catch { + cancelled = true; + return false; + } + }; + const safeClose = () => { + if (cancelled) return; + cancelled = true; + try { + controller.close(); + } catch { + /* already closed */ + } + }; + // Keep emitting pings for the full stream lifetime. Some providers pause + // for >10s between text deltas, and Bun will otherwise kill the SSE socket. + const pingTimer = startSSEKeepAlive(() => { + safeEnqueue({ type: 'ping', content: '' }); + }, KEEPALIVE_INTERVAL_MS); + let debugFile: string | undefined; + let attachTempDir: string | undefined; try { - const { query } = await import('@anthropic-ai/claude-agent-sdk') + const { query } = await import('@anthropic-ai/claude-agent-sdk'); // Build prompt from the last user message - const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user') - let prompt = lastUserMsg?.content ?? '' + const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user'); + let prompt = lastUserMsg?.content ?? ''; // If the last user message has image attachments, save to temp files // inside the project directory so Claude Code has read permission. - const attachments = getLastUserAttachments(body) - const hasImageAttachments = attachments.length > 0 + const attachments = getLastUserAttachments(body); + const hasImageAttachments = attachments.length > 0; if (hasImageAttachments) { - const saved = await saveAttachmentsToTempFiles(attachments, true) - attachTempDir = saved.tempDir - const imageRefs = saved.files.map((f) => - `First, use the Read tool to read the image file at "${f}". Then analyze it and respond to the user.`, - ).join('\n') - prompt = imageRefs + '\n\n' + (prompt || 'Describe what you see in the image.') + const saved = await saveAttachmentsToTempFiles(attachments, true); + attachTempDir = saved.tempDir; + const imageRefs = saved.files + .map( + (f) => + `First, use the Read tool to read the image file at "${f}". Then analyze it and respond to the user.`, + ) + .join('\n'); + prompt = imageRefs + '\n\n' + (prompt || 'Describe what you see in the image.'); } // Remove CLAUDECODE env to allow running from within a CC terminal - const env = buildClaudeAgentEnv() - debugFile = getClaudeAgentDebugFilePath() + const env = buildClaudeAgentEnv(); + debugFile = getClaudeAgentDebugFilePath(); // When using a custom proxy (ANTHROPIC_BASE_URL), skip explicit model // so Claude Code uses ANTHROPIC_MODEL from env — the proxy may not // recognize standard Claude model IDs. - const model = resolveAgentModel(requestedModel, env) + const model = resolveAgentModel(requestedModel, env); - const claudePath = resolveClaudeCli() - const spawnProcess = buildSpawnClaudeCodeProcess() - const thinking = getAgentThinkingConfig(body) + const claudePath = resolveClaudeCli(); + const spawnProcess = buildSpawnClaudeCodeProcess(); + const thinking = getAgentThinkingConfig(body); // When images are attached, strip the "NEVER use tools" restriction from // the system prompt so Claude Code will use its Read tool to view images. const effectiveSystemPrompt = hasImageAttachments ? stripNoToolsRestriction(body.system) - : body.system + : body.system; // When images are attached, use result-based flow (like validate.ts): // let Claude Code read the image via its Read tool internally, then @@ -296,34 +332,36 @@ function streamViaAgentSDK(body: ChatBody, requestedModel?: string) { ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}), ...(spawnProcess ? { spawnClaudeCodeProcess: spawnProcess } : {}), }, - }) + }); + activeQuery = q; try { for await (const message of q) { + if (cancelled) return ''; if (message.type === 'result') { - const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error) + const isErrorResult = + 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error); if (message.subtype === 'success' && !isErrorResult) { - return message.result ?? '' + return message.result ?? ''; } - const errors = 'errors' in message ? (message.errors as string[]) : [] - const resultText = 'result' in message ? String(message.result ?? '') : '' - const errContent = errors.join('; ') || resultText || `Query ended with: ${message.subtype}` - throw new Error(errContent) + const errors = 'errors' in message ? (message.errors as string[]) : []; + const resultText = 'result' in message ? String(message.result ?? '') : ''; + const errContent = + errors.join('; ') || resultText || `Query ended with: ${message.subtype}`; + throw new Error(errContent); } } - return '' + return ''; } finally { - q.close() + activeQuery = undefined; + q.close(); } - } + }; - const resultText = await runImageQuery() + const resultText = await runImageQuery(); - clearInterval(pingTimer) if (resultText) { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: 'text', content: resultText })}\n\n`), - ) + safeEnqueue({ type: 'text', content: resultText }); } } else { // Normal text-only chat: stream partial messages as before @@ -346,72 +384,85 @@ function streamViaAgentSDK(body: ChatBody, requestedModel?: string) { ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}), ...(spawnProcess ? { spawnClaudeCodeProcess: spawnProcess } : {}), }, - }) + }); + activeQuery = q; try { for await (const message of q) { + if (cancelled) return; if (message.type === 'stream_event') { - const ev = message.event + const ev = message.event; if (ev.type === 'content_block_delta') { if (ev.delta.type === 'text_delta') { - clearInterval(pingTimer) - const data = JSON.stringify({ type: 'text', content: ev.delta.text }) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + safeEnqueue({ type: 'text', content: ev.delta.text }); } else if (ev.delta.type === 'thinking_delta') { - // Keep pings alive during thinking — only stop on text output - const data = JSON.stringify({ type: 'thinking', content: (ev.delta as any).thinking }) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + safeEnqueue({ + type: 'thinking', + content: (ev.delta as any).thinking, + }); } } } else if (message.type === 'result') { - const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error) - if (message.subtype !== 'success' || isErrorResult) { - const errors = 'errors' in message ? (message.errors as string[]) : [] - const resultText = 'result' in message ? String(message.result ?? '') : '' - const content = errors.join('; ') || resultText || `Query ended with: ${message.subtype}` - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`), - ) + const isErrorResult = + 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error); + if (message.subtype !== 'success' || isErrorResult) { + const errors = 'errors' in message ? (message.errors as string[]) : []; + const resultText = 'result' in message ? String(message.result ?? '') : ''; + const content = + errors.join('; ') || resultText || `Query ended with: ${message.subtype}`; + safeEnqueue({ type: 'error', content }); } } } } finally { - q.close() + activeQuery = undefined; + q.close(); } - } + }; - await runQuery() + await runQuery(); } - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`), - ) + safeEnqueue({ type: 'done', content: '' }); } catch (error) { - const rawContent = error instanceof Error ? error.message : 'Unknown error' + const rawContent = error instanceof Error ? error.message : 'Unknown error'; - const tail = await readDebugTail(debugFile) + const tail = await readDebugTail(debugFile); - const hintedContent = buildClaudeExitHint(rawContent, tail) + const hintedContent = buildClaudeExitHint(rawContent, tail); // Append debug log tail so the user can see what Claude Code actually reported - let content = hintedContent ?? rawContent + let content = hintedContent ?? rawContent; if (tail && tail.length > 0 && /process exited with code/i.test(rawContent)) { - const debugSnippet = tail.slice(-10).join('\n') - content += `\n\n[Debug log]:\n${debugSnippet}` + const debugSnippet = tail.slice(-10).join('\n'); + content += `\n\n[Debug log]:\n${debugSnippet}`; } - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`), - ) + safeEnqueue({ type: 'error', content }); } finally { - clearInterval(pingTimer) - if (attachTempDir) { - rm(attachTempDir, { recursive: true, force: true }).catch(() => {}) + clearInterval(pingTimer); + try { + activeQuery?.close(); + } catch { + /* ignore */ } - controller.close() + activeQuery = undefined; + if (attachTempDir) { + rm(attachTempDir, { recursive: true, force: true }).catch(() => {}); + } + safeClose(); } }, - }) + cancel() { + cancelled = true; + try { + activeQuery?.close(); + } catch { + /* ignore */ + } + activeQuery = undefined; + }, + }); - return new Response(stream) + return new Response(stream); } /** Error name → user-friendly label mapping */ @@ -423,7 +474,7 @@ const OPENCODE_ERROR_LABELS: Record = { MessageAbortedError: 'Request aborted', StructuredOutputError: 'Output format error', ContextOverflowError: 'Context too long', -} +}; /** * Extract a human-readable message from an OpenCode error object. @@ -431,46 +482,48 @@ const OPENCODE_ERROR_LABELS: Record = { * and nested JSON in message strings. */ export function formatOpenCodeError(error: unknown): string { - if (!error) return 'Unknown error' - if (typeof error === 'string') return error + if (!error) return 'Unknown error'; + if (typeof error === 'string') return error; - const err = error as Record + const err = error as Record; // Structured OpenCode error: { name, data: { message, ... } } if (err.name && err.data?.message) { - const label = OPENCODE_ERROR_LABELS[err.name] ?? err.name - let msg: string = err.data.message + const label = OPENCODE_ERROR_LABELS[err.name] ?? err.name; + let msg: string = err.data.message; // Try to extract nested error message from JSON in the message string // e.g. 'Unauthorized: {"error":{"code":"invalid_api_key","message":"invalid access token"}}' - const jsonStart = msg.indexOf('{') + const jsonStart = msg.indexOf('{'); if (jsonStart > 0) { try { - const nested = JSON.parse(msg.slice(jsonStart)) - const nestedMsg = nested?.error?.message ?? nested?.message + const nested = JSON.parse(msg.slice(jsonStart)); + const nestedMsg = nested?.error?.message ?? nested?.message; if (nestedMsg) { - const prefix = msg.slice(0, jsonStart).replace(/:\s*$/, '').trim() - msg = prefix ? `${prefix}: ${nestedMsg}` : nestedMsg + const prefix = msg.slice(0, jsonStart).replace(/:\s*$/, '').trim(); + msg = prefix ? `${prefix}: ${nestedMsg}` : nestedMsg; } - } catch { /* not JSON, use as-is */ } + } catch { + /* not JSON, use as-is */ + } } - return `${label} — ${msg}` + return `${label} — ${msg}`; } // Plain { message } object - if (err.message) return err.message + if (err.message) return err.message; // Fallback: truncated JSON - const json = JSON.stringify(error) - return json.length > 200 ? json.slice(0, 200) + '…' : json + const json = JSON.stringify(error); + return json.length > 200 ? json.slice(0, 200) + '…' : json; } /** Parse an OpenCode model string ("providerID/modelID") into its parts */ function parseOpenCodeModel(model?: string): { providerID: string; modelID: string } | undefined { - if (!model || !model.includes('/')) return undefined - const idx = model.indexOf('/') - return { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) } + if (!model || !model.includes('/')) return undefined; + const idx = model.indexOf('/'); + return { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) }; } // Note: OpenCode SDK does not support `reasoning` in promptAsync/prompt params. @@ -482,37 +535,34 @@ async function* streamWithTimeout( timeoutPromise: Promise<{ done: true; value: undefined }>, ): AsyncGenerator { while (true) { - const result = await Promise.race([ - stream.next(), - timeoutPromise, - ]) as IteratorResult - if (result.done) break - yield result.value + const result = (await Promise.race([stream.next(), timeoutPromise])) as IteratorResult; + if (result.done) break; + yield result.value; } } function streamViaCodex(body: ChatBody, model?: string) { const stream = new ReadableStream({ async start(controller) { - const encoder = new TextEncoder() - const pingTimer = setInterval(() => { - try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`)) - } catch { /* stream already closed */ } - }, KEEPALIVE_INTERVAL_MS) + const encoder = new TextEncoder(); + const pingTimer = startSSEKeepAlive(() => { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`), + ); + }, KEEPALIVE_INTERVAL_MS); - let attachTempDir: string | undefined + let attachTempDir: string | undefined; try { - const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user') - const prompt = lastUserMsg?.content ?? '' + const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user'); + const prompt = lastUserMsg?.content ?? ''; // Save image attachments to temp files for Codex CLI - const attachments = getLastUserAttachments(body) - let imageFiles: string[] | undefined + const attachments = getLastUserAttachments(body); + let imageFiles: string[] | undefined; if (attachments.length > 0) { - const saved = await saveAttachmentsToTempFiles(attachments) - attachTempDir = saved.tempDir - imageFiles = saved.files + const saved = await saveAttachmentsToTempFiles(attachments); + attachTempDir = saved.tempDir; + imageFiles = saved.files; } const result = await runCodexExec(prompt, { @@ -522,225 +572,240 @@ function streamViaCodex(body: ChatBody, model?: string) { thinkingBudgetTokens: body.thinkingBudgetTokens, effort: body.effort, imageFiles, - }) + }); - clearInterval(pingTimer) if (result.error) { controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'error', content: result.error })}\n\n`), - ) - return + ); + return; } if (result.text) { controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'text', content: result.text })}\n\n`), - ) + ); } controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`), - ) + ); } catch (error) { - const content = error instanceof Error ? error.message : 'Unknown error' + const content = error instanceof Error ? error.message : 'Unknown error'; controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`), - ) + ); } finally { - clearInterval(pingTimer) + clearInterval(pingTimer); if (attachTempDir) { - rm(attachTempDir, { recursive: true, force: true }).catch(() => {}) + rm(attachTempDir, { recursive: true, force: true }).catch(() => {}); } - controller.close() + controller.close(); } }, - }) + }); - return new Response(stream) + return new Response(stream); } /** Stream via OpenCode SDK using event subscription for real-time streaming */ function streamViaOpenCode(body: ChatBody, model?: string) { const stream = new ReadableStream({ async start(controller) { - const encoder = new TextEncoder() - const pingTimer = setInterval(() => { - try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`)) - } catch { /* stream already closed */ } - }, KEEPALIVE_INTERVAL_MS) + const encoder = new TextEncoder(); + const pingTimer = startSSEKeepAlive(() => { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`), + ); + }, KEEPALIVE_INTERVAL_MS); - let ocServer: { close(): void } | undefined + let ocServer: { close(): void } | undefined; try { - const { getOpencodeClient } = await import('../../utils/opencode-client') - const oc = await getOpencodeClient() - const ocClient = oc.client - ocServer = oc.server + const { getOpencodeClient } = await import('../../utils/opencode-client'); + const oc = await getOpencodeClient(); + const ocClient = oc.client; + ocServer = oc.server; // Create a session for this conversation const { data: session, error: sessionError } = await ocClient.session.create({ title: 'OpenPencil Chat', - }) + }); if (sessionError || !session) { - throw new Error(`Failed to create OpenCode session: ${formatOpenCodeError(sessionError)}`) + throw new Error( + `Failed to create OpenCode session: ${formatOpenCodeError(sessionError)}`, + ); } // Inject system prompt as context (no AI reply) - const { error: sysPromptError } = await ocClient.session.prompt({ + const { error: sysPromptError } = (await ocClient.session.prompt({ sessionID: session.id, noReply: true, parts: [{ type: 'text', text: body.system }], - }) as any + })) as any; if (sysPromptError) { - console.error('[AI] OpenCode system prompt injection failed:', formatOpenCodeError(sysPromptError)) + console.error( + '[AI] OpenCode system prompt injection failed:', + formatOpenCodeError(sysPromptError), + ); } // Build prompt from the last user message - const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user') - const prompt = lastUserMsg?.content ?? '' + const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user'); + const prompt = lastUserMsg?.content ?? ''; - const parsed = parseOpenCodeModel(model) + const parsed = parseOpenCodeModel(model); if (model && !parsed) { - console.warn(`[AI] OpenCode: could not parse model string "${model}", sending without model override`) + console.warn( + `[AI] OpenCode: could not parse model string "${model}", sending without model override`, + ); } // Build parts array, adding image attachments if present - const attachments = getLastUserAttachments(body) + const attachments = getLastUserAttachments(body); const parts: Array> = [ ...attachments.map((a) => ({ type: 'image', url: `data:${a.mediaType};base64,${a.data}`, })), { type: 'text', text: prompt || 'Analyze these images.' }, - ] - + ]; // Build prompt payload with optional model and reasoning const promptPayload: Record = { sessionID: session.id, ...(parsed ? { model: parsed } : {}), parts, - } + }; // Subscribe to event stream for real-time deltas. // IMPORTANT: The SSE connection is lazy — it only connects when // iteration starts. We must start consuming BEFORE sending the // prompt to avoid a race where events are emitted before the // SSE connection is established. - const eventResult = await ocClient.event.subscribe() - const eventStream = eventResult.stream + const eventResult = await ocClient.event.subscribe(); + const eventStream = eventResult.stream; - const sessionId = session.id - const STREAM_TIMEOUT_MS = 180_000 + const sessionId = session.id; + const STREAM_TIMEOUT_MS = 180_000; // Start eagerly consuming the event stream into a buffer. // This triggers the SSE HTTP connection immediately. - const eventBuffer: unknown[] = [] - let streamDone = false - let notifyFn: (() => void) | null = null + const eventBuffer: unknown[] = []; + let streamDone = false; + let notifyFn: (() => void) | null = null; - const notify = () => { if (notifyFn) { const fn = notifyFn; notifyFn = null; fn() } } + const notify = () => { + if (notifyFn) { + const fn = notifyFn; + notifyFn = null; + fn(); + } + }; // eslint-disable-next-line @typescript-eslint/no-floating-promises void (async () => { const timeoutPromise = new Promise<{ done: true; value: undefined }>((resolve) => setTimeout(() => resolve({ done: true, value: undefined }), STREAM_TIMEOUT_MS), - ) + ); try { for await (const event of streamWithTimeout(eventStream, timeoutPromise)) { - eventBuffer.push(event) - notify() + eventBuffer.push(event); + notify(); } } finally { - streamDone = true - notify() + streamDone = true; + notify(); } - })() + })(); // Give the SSE connection a moment to establish before sending prompt - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)); // Now send the prompt — SSE connection should already be active - const { error: asyncError } = await ocClient.session.promptAsync(promptPayload as any) + const { error: asyncError } = await ocClient.session.promptAsync(promptPayload as any); if (asyncError) { - const detail = formatOpenCodeError(asyncError) - console.error('[AI] OpenCode promptAsync error:', detail) - throw new Error(detail) + const detail = formatOpenCodeError(asyncError); + console.error('[AI] OpenCode promptAsync error:', detail); + throw new Error(detail); } // Consume buffered events + wait for new ones - let emittedText = false - let eventCount = 0 - let shouldBreak = false + let emittedText = false; + let eventCount = 0; + let shouldBreak = false; while (!shouldBreak) { // Wait for events if buffer is empty if (eventBuffer.length === 0) { - if (streamDone) break - await new Promise(resolve => { notifyFn = resolve }) - continue + if (streamDone) break; + await new Promise((resolve) => { + notifyFn = resolve; + }); + continue; } - const event = eventBuffer.shift() - if (!event || !('type' in (event as any))) continue + const event = eventBuffer.shift(); + if (!event || !('type' in (event as any))) continue; - const eventType = (event as any).type as string - eventCount++ + const eventType = (event as any).type as string; + eventCount++; // Stream text deltas for our session if (eventType === 'message.part.delta') { - const props = (event as any).properties + const props = (event as any).properties; if (props?.sessionID === sessionId && props.field === 'text') { - const data = JSON.stringify({ type: 'text', content: props.delta }) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) - emittedText = true + const data = JSON.stringify({ type: 'text', content: props.delta }); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + emittedText = true; } // Forward reasoning deltas as thinking chunks if (props?.sessionID === sessionId && props.field === 'reasoning') { - const data = JSON.stringify({ type: 'thinking', content: props.delta }) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + const data = JSON.stringify({ type: 'thinking', content: props.delta }); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); } - continue + continue; } // Session went idle — response complete if (eventType === 'session.idle') { - const props = (event as any).properties + const props = (event as any).properties; if (props?.sessionID === sessionId) { - shouldBreak = true + shouldBreak = true; } - continue + continue; } // Session error if (eventType === 'session.error') { - const props = (event as any).properties + const props = (event as any).properties; if (props?.sessionID === sessionId || !props?.sessionID) { - const errMsg = formatOpenCodeError(props?.error) - console.error('[AI] OpenCode session error:', errMsg) - const data = JSON.stringify({ type: 'error', content: errMsg }) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) - shouldBreak = true + const errMsg = formatOpenCodeError(props?.error); + console.error('[AI] OpenCode session error:', errMsg); + const data = JSON.stringify({ type: 'error', content: errMsg }); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + shouldBreak = true; } - continue + continue; } } - clearInterval(pingTimer) - // Fallback: if no text was streamed, try reading session messages directly if (!emittedText) { try { - const { data: messages } = await ocClient.session.messages({ sessionID: sessionId }) as any + const { data: messages } = (await ocClient.session.messages({ + sessionID: sessionId, + })) as any; if (messages && Array.isArray(messages)) { // Find the last assistant message (each item has { info, parts }) - const assistantMsg = [...messages].reverse().find((m: any) => m.info?.role === 'assistant') + const assistantMsg = [...messages] + .reverse() + .find((m: any) => m.info?.role === 'assistant'); if (assistantMsg?.parts) { for (const part of assistantMsg.parts) { if (part.type === 'text' && part.text) { - const data = JSON.stringify({ type: 'text', content: part.text }) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) - emittedText = true + const data = JSON.stringify({ type: 'text', content: part.text }); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + emittedText = true; } } } @@ -751,119 +816,125 @@ function streamViaOpenCode(body: ChatBody, model?: string) { } if (!emittedText) { - const data = JSON.stringify({ type: 'error', content: 'OpenCode returned an empty response. The model may not have generated any output.' }) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + const data = JSON.stringify({ + type: 'error', + content: + 'OpenCode returned an empty response. The model may not have generated any output.', + }); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); } controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`), - ) + ); } catch (error) { - const content = error instanceof Error ? error.message : 'Unknown error' + const content = error instanceof Error ? error.message : 'Unknown error'; controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`), - ) + ); } finally { - const { releaseOpencodeServer } = await import('../../utils/opencode-client') - releaseOpencodeServer(ocServer) - clearInterval(pingTimer) - controller.close() + const { releaseOpencodeServer } = await import('../../utils/opencode-client'); + releaseOpencodeServer(ocServer); + clearInterval(pingTimer); + controller.close(); } }, - }) + }); - return new Response(stream) + return new Response(stream); } /** Map ChatBody effort to Copilot SDK ReasoningEffort */ function mapCopilotReasoningEffort( effort?: 'low' | 'medium' | 'high' | 'max', ): 'low' | 'medium' | 'high' | 'xhigh' | undefined { - if (!effort) return undefined - if (effort === 'max') return 'xhigh' - return effort + if (!effort) return undefined; + if (effort === 'max') return 'xhigh'; + return effort; } /** Stream via Gemini CLI (`gemini -p -o stream-json`) — CLI handles its own auth */ function streamViaGemini(body: ChatBody, model?: string) { const stream = new ReadableStream({ async start(controller) { - const encoder = new TextEncoder() - const pingTimer = setInterval(() => { - try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`)) - } catch { /* stream already closed */ } - }, KEEPALIVE_INTERVAL_MS) + const encoder = new TextEncoder(); + const pingTimer = startSSEKeepAlive(() => { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`), + ); + }, KEEPALIVE_INTERVAL_MS); try { - const { streamGeminiExec } = await import('../../utils/gemini-client') + const { streamGeminiExec } = await import('../../utils/gemini-client'); // Build prompt from messages - const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user') - const prompt = lastUserMsg?.content ?? '' + const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user'); + const prompt = lastUserMsg?.content ?? ''; const { stream: geminiStream } = streamGeminiExec(prompt, { model, systemPrompt: body.system, - }) + }); for await (const event of geminiStream) { - clearInterval(pingTimer) if (event.type === 'text') { - const data = JSON.stringify({ type: 'text', content: event.content }) + const data = JSON.stringify({ type: 'text', content: event.content }); try { - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) - } catch { /* stream closed */ } + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + } catch { + /* stream closed */ + } } else if (event.type === 'error') { - const data = JSON.stringify({ type: 'error', content: event.content }) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + const data = JSON.stringify({ type: 'error', content: event.content }); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); } // 'done' is handled after loop } controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`), - ) + ); } catch (error) { - const content = error instanceof Error ? error.message : 'Unknown error' + const content = error instanceof Error ? error.message : 'Unknown error'; controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`), - ) + ); } finally { - clearInterval(pingTimer) - controller.close() + clearInterval(pingTimer); + controller.close(); } }, - }) + }); - return new Response(stream) + return new Response(stream); } /** Stream via GitHub Copilot SDK (@github/copilot-sdk) */ function streamViaCopilot(body: ChatBody, model?: string) { const stream = new ReadableStream({ async start(controller) { - const encoder = new TextEncoder() - const pingTimer = setInterval(() => { - try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`)) - } catch { /* stream already closed */ } - }, KEEPALIVE_INTERVAL_MS) + const encoder = new TextEncoder(); + const pingTimer = startSSEKeepAlive(() => { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`), + ); + }, KEEPALIVE_INTERVAL_MS); - let copilotClient: { stop(): Promise } | undefined + let copilotClient: { stop(): Promise } | undefined; try { - const { CopilotClient, approveAll } = await import('@github/copilot-sdk') + const { CopilotClient, approveAll } = await import('@github/copilot-sdk'); // Use standalone copilot binary to avoid Bun's node:sqlite issue - const { resolveCopilotCli, resolveCliPathForSdk } = await import('../../utils/copilot-client') - const rawCliPath = resolveCopilotCli() + const { resolveCopilotCli, resolveCliPathForSdk } = + await import('../../utils/copilot-client'); + const rawCliPath = resolveCopilotCli(); // On Windows, .cmd wrappers cause "spawn EINVAL" — resolve to .js entry point - const cliPath = rawCliPath ? resolveCliPathForSdk(rawCliPath) : undefined + const cliPath = rawCliPath ? resolveCliPathForSdk(rawCliPath) : undefined; const client = new CopilotClient({ autoStart: true, ...(cliPath ? { cliPath } : {}), - }) - copilotClient = client - await client.start() + }); + copilotClient = client; + await client.start(); const session = await client.createSession({ ...(model ? { model } : {}), @@ -871,108 +942,212 @@ function streamViaCopilot(body: ChatBody, model?: string) { onPermissionRequest: approveAll, systemMessage: { mode: 'replace', content: body.system }, ...(body.effort ? { reasoningEffort: mapCopilotReasoningEffort(body.effort) } : {}), - }) + }); - const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user') - const prompt = lastUserMsg?.content ?? '' + const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user'); + const prompt = lastUserMsg?.content ?? ''; // Subscribe to streaming deltas session.on('assistant.message_delta', (event) => { - clearInterval(pingTimer) - const deltaContent = (event as any).data?.deltaContent ?? '' + const deltaContent = (event as any).data?.deltaContent ?? ''; if (deltaContent) { - const data = JSON.stringify({ type: 'text', content: deltaContent }) + const data = JSON.stringify({ type: 'text', content: deltaContent }); try { - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) - } catch { /* stream closed */ } + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + } catch { + /* stream closed */ + } } - }) + }); // Wait for completion - await session.sendAndWait({ prompt }, 120_000) - await session.destroy() + await session.sendAndWait({ prompt }, 120_000); + await session.destroy(); controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`), - ) + ); } catch (error) { - const content = error instanceof Error ? error.message : 'Unknown error' + const content = error instanceof Error ? error.message : 'Unknown error'; controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`), - ) + ); } finally { - clearInterval(pingTimer) + clearInterval(pingTimer); if (copilotClient) { - copilotClient.stop().catch(() => {}) + copilotClient.stop().catch(() => {}); } - controller.close() + controller.close(); } }, - }) + }); - return new Response(stream) + return new Response(stream); } /** * Stream via builtin provider — direct API key, no CLI tool needed. - * Uses Vercel AI SDK's streamText with Anthropic or OpenAI-compatible providers. + * Uses Zig NAPI addon (agent-native) with Anthropic or OpenAI-compatible providers. */ function streamViaBuiltin(body: ChatBody) { const stream = new ReadableStream({ async start(controller) { - const encoder = new TextEncoder() - const pingTimer = setInterval(() => { - try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`)) - } catch { /* stream already closed */ } - }, KEEPALIVE_INTERVAL_MS) + const encoder = new TextEncoder(); + const BUILTIN_EVENT_IDLE_TIMEOUT_MS = 45_000; + const pingTimer = startSSEKeepAlive(() => { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`), + ); + }, KEEPALIVE_INTERVAL_MS); try { - const { streamText } = await import('@zseven-w/agent') - const apiKey = body.builtinApiKey - const model = body.model?.trim() - if (!apiKey || !model) throw new Error('Builtin provider requires apiKey and model') + const { + createAnthropicProvider, + createOpenAICompatProvider, + createQueryEngine, + seedMessages, + submitMessage, + nextEvent, + abortEngine, + destroyIterator, + destroyQueryEngine, + destroyProvider, + } = await import('@zseven-w/agent-native'); - const { createAnthropicProvider, createOpenAICompatProvider } = await import('@zseven-w/agent') - const provider = body.builtinType === 'anthropic' - ? createAnthropicProvider({ apiKey, model, baseURL: body.builtinBaseURL }) - : createOpenAICompatProvider({ apiKey, model, baseURL: body.builtinBaseURL }) - const llmModel = provider.model + const apiKey = body.builtinApiKey; + const rawModel = body.model?.trim() ?? ''; + // Model string may be "builtin::" — extract the actual model name + const model = rawModel.startsWith('builtin:') + ? rawModel.split(':').slice(2).join(':') + : rawModel; + if (!apiKey || !model) throw new Error('Builtin provider requires apiKey and model'); - const messages = body.messages.map((m) => ({ - role: m.role as 'user' | 'assistant', - content: m.content, - })) + const normalizedBuiltinBaseURL = normalizeOptionalBaseURL(body.builtinBaseURL); + const builtinProvider = + body.builtinType === 'anthropic' + ? createAnthropicProvider(apiKey, model, normalizedBuiltinBaseURL) + : createOpenAICompatProvider( + apiKey, + requireOpenAICompatBaseURL(normalizedBuiltinBaseURL), + model, + ); - const response = streamText({ - model: llmModel, - system: body.system, - messages, - }) + // Pure streaming — no tools, maxTurns=1 prevents agentic looping + const builtinEngine = createQueryEngine({ + provider: builtinProvider, + systemPrompt: body.system, + maxTurns: 1, + maxOutputTokens: 16384, + cwd: process.cwd(), + }); - for await (const part of response.fullStream) { - if (part.type === 'text-delta' && part.text) { - clearInterval(pingTimer) - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: 'text', content: part.text })}\n\n`), - ) - } else if (part.type === 'reasoning-delta' && (part as any).text) { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: 'thinking', content: (part as any).text })}\n\n`), - ) + // Seed prior conversation history for multi-turn context + const priorMsgs = body.messages + .slice(0, -1) + .filter( + (m: any) => + (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string', + ); + if (priorMsgs.length > 0) { + seedMessages(builtinEngine, JSON.stringify(priorMsgs)); + } + + const lastMsg = body.messages[body.messages.length - 1]?.content ?? ''; + const builtinIter = await submitMessage(builtinEngine, lastMsg); + + // Abort engine if no events arrive within 60s (provider sent 200 but no SSE data) + let gotFirstEvent = false; + const firstEventTimer = setTimeout(() => { + if (!gotFirstEvent) { + console.warn('[builtin] No SSE events received within 60s — aborting engine'); + abortEngine(builtinEngine); } + }, 60_000); + + try { + let raw: string | null; + while ( + (raw = await waitForBuiltinEvent( + nextEvent, + builtinIter, + () => abortEngine(builtinEngine), + BUILTIN_EVENT_IDLE_TIMEOUT_MS, + )) !== null + ) { + if (!gotFirstEvent) { + gotFirstEvent = true; + clearTimeout(firstEventTimer); + } + const evt = JSON.parse(raw); + // Zig events are tagged unions: {"stream_event":{...}} or {"result":{...}} + const se = evt.stream_event; + if (se?.type === 'text_delta' && se.text) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'text', content: se.text })}\n\n`), + ); + } else if (se?.type === 'thinking_delta' && se.text) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: 'thinking', content: se.text })}\n\n`, + ), + ); + } else if (evt.result?.is_error) { + const errMsg = `Provider error: ${evt.result.subtype ?? 'unknown'}`; + console.error('[builtin]', errMsg); + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'error', content: errMsg })}\n\n`), + ); + } + } + + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`), + ); + } finally { + clearTimeout(firstEventTimer); + destroyIterator(builtinIter); + destroyQueryEngine(builtinEngine); + destroyProvider(builtinProvider); } } catch (error) { - const content = error instanceof Error ? error.message : 'Unknown error' + const content = error instanceof Error ? error.message : 'Unknown error'; controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`), - ) + ); } finally { - clearInterval(pingTimer) - controller.close() + clearInterval(pingTimer); + controller.close(); } }, - }) + }); - return new Response(stream) + return new Response(stream); +} + +async function waitForBuiltinEvent( + nextEventFn: (iter: TIterator) => Promise, + iter: TIterator, + onTimeout: () => void, + timeoutMs: number, +): Promise { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + try { + onTimeout(); + } catch { + /* ignore */ + } + reject(new Error('Builtin provider stalled without output. Please retry.')); + }, timeoutMs); + + nextEventFn(iter) + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); } diff --git a/apps/web/server/api/ai/connect-agent.ts b/apps/web/server/api/ai/connect-agent.ts index 7d4f5450..f17110bc 100644 --- a/apps/web/server/api/ai/connect-agent.ts +++ b/apps/web/server/api/ai/connect-agent.ts @@ -1,18 +1,18 @@ -import { defineEventHandler, readBody, setResponseHeaders } from 'h3' -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import type { GroupedModel } from '../../../src/types/agent-settings' -import { resolveClaudeCli } from '../../utils/resolve-claude-cli' -import { serverLog } from '../../utils/server-logger' +import { defineEventHandler, readBody, setResponseHeaders } from 'h3'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { GroupedModel } from '../../../src/types/agent-settings'; +import { resolveClaudeCli } from '../../utils/resolve-claude-cli'; +import { serverLog } from '../../utils/server-logger'; import { buildClaudeAgentEnv, buildSpawnClaudeCodeProcess, getClaudeAgentDebugFilePath, -} from '../../utils/resolve-claude-agent-env' +} from '../../utils/resolve-claude-agent-env'; /** Windows npm global installs may create .cmd or .ps1 wrappers — try both */ function winNpmCandidates(dir: string, name: string): string[] { - return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)] + return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)]; } /** @@ -20,38 +20,38 @@ function winNpmCandidates(dir: string, name: string): string[] { * This file exists but can't be executed. Prefer `.cmd` or `.ps1` wrapper at the same location. */ function resolveWinExtension(binPath: string): string { - if (process.platform !== 'win32') return binPath + if (process.platform !== 'win32') return binPath; // Already has a usable extension - if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath + if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath; // Try .cmd then .ps1 for (const ext of ['.cmd', '.ps1']) { - if (existsSync(binPath + ext)) return binPath + ext + if (existsSync(binPath + ext)) return binPath + ext; } - return binPath + return binPath; } /** Build a shell command to invoke a resolved binary (handles .ps1 on Windows) */ function buildExecCmd(binPath: string, args: string): string { if (binPath.endsWith('.ps1')) { - return `powershell -ExecutionPolicy Bypass -File "${binPath}" ${args}` + return `powershell -ExecutionPolicy Bypass -File "${binPath}" ${args}`; } - return `"${binPath}" ${args}` + return `"${binPath}" ${args}`; } interface ConnectBody { - agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli' + agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli'; } interface ConnectResult { - connected: boolean - models: GroupedModel[] - error?: string - warning?: string - notInstalled?: boolean + connected: boolean; + models: GroupedModel[]; + error?: string; + warning?: string; + notInstalled?: boolean; /** Human-readable connection status, e.g. "Connected via API key" */ - connectionInfo?: string + connectionInfo?: string; /** Config file path for the hint (client renders localized text) */ - hintPath?: string + hintPath?: string; } /** @@ -59,35 +59,39 @@ interface ConnectResult { * Actively connects to a local CLI tool and fetches its supported models. */ export default defineEventHandler(async (event) => { - const body = await readBody(event) - setResponseHeaders(event, { 'Content-Type': 'application/json' }) + const body = await readBody(event); + setResponseHeaders(event, { 'Content-Type': 'application/json' }); if (!body?.agent) { - return { connected: false, models: [], error: 'Missing agent field' } satisfies ConnectResult + return { connected: false, models: [], error: 'Missing agent field' } satisfies ConnectResult; } if (body.agent === 'claude-code') { - return connectClaudeCode() + return connectClaudeCode(); } if (body.agent === 'codex-cli') { - return connectCodexCli() + return connectCodexCli(); } if (body.agent === 'opencode') { - return connectOpenCode() + return connectOpenCode(); } if (body.agent === 'copilot') { - return connectCopilot() + return connectCopilot(); } if (body.agent === 'gemini-cli') { - return connectGeminiCli() + return connectGeminiCli(); } - return { connected: false, models: [], error: `Unknown agent: ${body.agent}` } satisfies ConnectResult -}) + return { + connected: false, + models: [], + error: `Unknown agent: ${body.agent}`, + } satisfies ConnectResult; +}); /** * Fallback models when supportedModels() fails. @@ -95,33 +99,68 @@ export default defineEventHandler(async (event) => { * the model-listing endpoint. Covers common model IDs routers typically expose. */ const FALLBACK_CLAUDE_MODELS: GroupedModel[] = [ - { value: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6', description: '', provider: 'anthropic' }, - { value: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', description: '', provider: 'anthropic' }, - { value: 'claude-sonnet-4-5-20250514', displayName: 'Claude Sonnet 4.5', description: '', provider: 'anthropic' }, - { value: 'claude-haiku-4-5-20251001', displayName: 'Claude Haiku 4.5', description: '', provider: 'anthropic' }, - { value: 'claude-3-7-sonnet-20250219', displayName: 'Claude 3.7 Sonnet', description: '', provider: 'anthropic' }, - { value: 'claude-3-5-sonnet-20241022', displayName: 'Claude 3.5 Sonnet', description: '', provider: 'anthropic' }, - { value: 'claude-3-5-haiku-20241022', displayName: 'Claude 3.5 Haiku', description: '', provider: 'anthropic' }, -] + { + value: 'claude-sonnet-4-6', + displayName: 'Claude Sonnet 4.6', + description: '', + provider: 'anthropic', + }, + { + value: 'claude-opus-4-6', + displayName: 'Claude Opus 4.6', + description: '', + provider: 'anthropic', + }, + { + value: 'claude-sonnet-4-5-20250514', + displayName: 'Claude Sonnet 4.5', + description: '', + provider: 'anthropic', + }, + { + value: 'claude-haiku-4-5-20251001', + displayName: 'Claude Haiku 4.5', + description: '', + provider: 'anthropic', + }, + { + value: 'claude-3-7-sonnet-20250219', + displayName: 'Claude 3.7 Sonnet', + description: '', + provider: 'anthropic', + }, + { + value: 'claude-3-5-sonnet-20241022', + displayName: 'Claude 3.5 Sonnet', + description: '', + provider: 'anthropic', + }, + { + value: 'claude-3-5-haiku-20241022', + displayName: 'Claude 3.5 Haiku', + description: '', + provider: 'anthropic', + }, +]; /** Connect to Claude Code via Agent SDK and fetch real supported models */ async function connectClaudeCode(): Promise { - serverLog.info('[connect-agent] connecting to Claude Code...') - const claudePath = resolveClaudeCli() - serverLog.info(`[connect-agent] resolved claude path: ${claudePath ?? 'NOT FOUND'}`) + serverLog.info('[connect-agent] connecting to Claude Code...'); + const claudePath = resolveClaudeCli(); + serverLog.info(`[connect-agent] resolved claude path: ${claudePath ?? 'NOT FOUND'}`); if (!claudePath) { - return { connected: false, models: [], notInstalled: true, error: 'Claude Code CLI not found' } + return { connected: false, models: [], notInstalled: true, error: 'Claude Code CLI not found' }; } try { - const { query } = await import('@anthropic-ai/claude-agent-sdk') + const { query } = await import('@anthropic-ai/claude-agent-sdk'); - const env = buildClaudeAgentEnv() - const debugFile = getClaudeAgentDebugFilePath() - serverLog.info(`[connect-agent] claude env keys: ${Object.keys(env).join(', ')}`) - serverLog.info(`[connect-agent] claude debugFile: ${debugFile ?? 'none'}`) + const env = buildClaudeAgentEnv(); + const debugFile = getClaudeAgentDebugFilePath(); + serverLog.info(`[connect-agent] claude env keys: ${Object.keys(env).join(', ')}`); + serverLog.info(`[connect-agent] claude debugFile: ${debugFile ?? 'none'}`); - const spawnProcess = buildSpawnClaudeCodeProcess() + const spawnProcess = buildSpawnClaudeCodeProcess(); const q = query({ prompt: '', @@ -135,172 +174,197 @@ async function connectClaudeCode(): Promise { ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}), ...(spawnProcess ? { spawnClaudeCodeProcess: spawnProcess } : {}), }, - }) + }); - serverLog.info('[connect-agent] querying supportedModels...') - const raw = await q.supportedModels() + serverLog.info('[connect-agent] querying supportedModels...'); + const raw = await q.supportedModels(); // Fetch account info (email, org, subscription type) - let account: { email?: string; organization?: string; subscriptionType?: string; apiKeySource?: string } | null = null + let account: { + email?: string; + organization?: string; + subscriptionType?: string; + apiKeySource?: string; + } | null = null; try { - account = await q.accountInfo() - serverLog.info(`[connect-agent] claude account: email=${account?.email ?? 'n/a'}, type=${account?.subscriptionType ?? 'n/a'}, source=${account?.apiKeySource ?? 'n/a'}`) + account = await q.accountInfo(); + serverLog.info( + `[connect-agent] claude account: email=${account?.email ?? 'n/a'}, type=${account?.subscriptionType ?? 'n/a'}, source=${account?.apiKeySource ?? 'n/a'}`, + ); } catch { - serverLog.info('[connect-agent] accountInfo() not available') + serverLog.info('[connect-agent] accountInfo() not available'); } - q.close() + q.close(); const models: GroupedModel[] = raw.map((m) => ({ value: m.value, displayName: m.displayName, description: m.description, provider: 'anthropic' as const, - })) + })); - serverLog.info(`[connect-agent] claude connected, ${models.length} models found`) - const claudeInfo = buildClaudeConnectionInfo(env, account) - return { connected: true, models, ...claudeInfo } + serverLog.info(`[connect-agent] claude connected, ${models.length} models found`); + const claudeInfo = buildClaudeConnectionInfo(env, account); + return { connected: true, models, ...claudeInfo }; } catch (error) { - const msg = error instanceof Error ? error.message : 'Failed to connect' - serverLog.error(`[connect-agent] claude connection error: ${msg}`) + const msg = error instanceof Error ? error.message : 'Failed to connect'; + serverLog.error(`[connect-agent] claude connection error: ${msg}`); // Third-party API proxies often don't support the supportedModels() call, // causing "query closed before response". Fall back to a default model list // so users can still connect and choose a model. if (/closed before|closed early|query closed/i.test(msg)) { - serverLog.info('[connect-agent] using fallback model list (proxy detected)') - const fallbackEnv = buildClaudeAgentEnv() - const claudeInfo = buildClaudeConnectionInfo(fallbackEnv, null) + serverLog.info('[connect-agent] using fallback model list (proxy detected)'); + const fallbackEnv = buildClaudeAgentEnv(); + const claudeInfo = buildClaudeConnectionInfo(fallbackEnv, null); // Read debug log for diagnostic warning — the process may have written // useful info (e.g. TLS errors, auth failures) before exiting - let warning: string | undefined - const debugPath = getClaudeAgentDebugFilePath() + let warning: string | undefined; + const debugPath = getClaudeAgentDebugFilePath(); if (debugPath) { try { - const raw = readFileSync(debugPath, 'utf-8') - const lines = raw.split('\n').filter((l) => l.trim().length > 0) - const tail = lines.slice(-10).join('\n') + const raw = readFileSync(debugPath, 'utf-8'); + const lines = raw.split('\n').filter((l) => l.trim().length > 0); + const tail = lines.slice(-10).join('\n'); if (tail) { // Surface specific issues as warnings if (/certificate|CERT_|ssl|tls/i.test(tail)) { - warning = 'TLS/SSL error detected. If using a proxy, add "NODE_TLS_REJECT_UNAUTHORIZED": "0" to ~/.claude/settings.json env.' + warning = + 'TLS/SSL error detected. If using a proxy, add "NODE_TLS_REJECT_UNAUTHORIZED": "0" to ~/.claude/settings.json env.'; } else if (/EPERM|operation not permitted/i.test(tail)) { - warning = 'Permission error writing config. Try: echo {} > %USERPROFILE%\\.claude.json' + warning = + 'Permission error writing config. Try: echo {} > %USERPROFILE%\\.claude.json'; } else if (/stderr exit=/i.test(tail)) { // Show captured stderr - const stderrMatch = tail.match(/\[stderr exit=\d+\]\s*(.+)/s) + const stderrMatch = tail.match(/\[stderr exit=\d+\]\s*(.+)/s); if (stderrMatch) { - warning = `Claude Code stderr: ${stderrMatch[1].slice(0, 300)}` + warning = `Claude Code stderr: ${stderrMatch[1].slice(0, 300)}`; } } } - } catch { /* debug file not available */ } + } catch { + /* debug file not available */ + } } - return { connected: true, models: FALLBACK_CLAUDE_MODELS, ...claudeInfo, ...(warning ? { warning } : {}) } + return { + connected: true, + models: FALLBACK_CLAUDE_MODELS, + ...claudeInfo, + ...(warning ? { warning } : {}), + }; } - return { connected: false, models: [], error: friendlyClaudeError(msg) } + return { connected: false, models: [], error: friendlyClaudeError(msg) }; } } /** Resolve config file path (cross-platform) */ function configPath(unixPath: string, winPath: string): string { - return process.platform === 'win32' ? winPath : unixPath + return process.platform === 'win32' ? winPath : unixPath; } /** Build Claude connection info from env + SDK account info */ function buildClaudeConnectionInfo( env: Record, - account: { email?: string; organization?: string; subscriptionType?: string; apiKeySource?: string } | null, + account: { + email?: string; + organization?: string; + subscriptionType?: string; + apiKeySource?: string; + } | null, ): { connectionInfo: string; hintPath?: string } { - const hp = configPath('~/.claude/settings.json', '%USERPROFILE%\\.claude\\settings.json') - const apiKey = env.ANTHROPIC_API_KEY - const baseUrl = env.ANTHROPIC_BASE_URL + const hp = configPath('~/.claude/settings.json', '%USERPROFILE%\\.claude\\settings.json'); + const apiKey = env.ANTHROPIC_API_KEY; + const baseUrl = env.ANTHROPIC_BASE_URL; if (account?.email) { - const sub = account.subscriptionType ?? 'subscription' - return { connectionInfo: `Connected via ${sub} (${account.email})`, hintPath: hp } + const sub = account.subscriptionType ?? 'subscription'; + return { connectionInfo: `Connected via ${sub} (${account.email})`, hintPath: hp }; } if (apiKey && baseUrl) { - return { connectionInfo: 'Connected via API key (custom endpoint)', hintPath: hp } + return { connectionInfo: 'Connected via API key (custom endpoint)', hintPath: hp }; } if (apiKey) { - const masked = apiKey.length > 12 ? `${apiKey.slice(0, 8)}...` : '***' - return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp } + const masked = apiKey.length > 12 ? `${apiKey.slice(0, 8)}...` : '***'; + return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp }; } - return { connectionInfo: 'Connected via subscription', hintPath: hp } + return { connectionInfo: 'Connected via subscription', hintPath: hp }; } /** Decode a JWT payload (no verification — just base64url decode the middle part) */ function decodeJwtPayload(token: string): Record | null { try { - const parts = token.split('.') - if (parts.length !== 3) return null + const parts = token.split('.'); + if (parts.length !== 3) return null; // base64url → base64 → Buffer → JSON - const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/') - const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4) - return JSON.parse(Buffer.from(padded, 'base64').toString('utf-8')) + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + return JSON.parse(Buffer.from(padded, 'base64').toString('utf-8')); } catch { - return null + return null; } } /** Build Codex CLI connection info by reading ~/.codex/auth.json + JWT tokens */ async function buildCodexConnectionInfo(): Promise<{ connectionInfo: string; hintPath?: string }> { - const { readFile } = await import('node:fs/promises') - const { homedir } = await import('node:os') - const { join } = await import('node:path') - const hp = configPath('~/.codex/config.toml', '%USERPROFILE%\\.codex\\config.toml') + const { readFile } = await import('node:fs/promises'); + const { homedir } = await import('node:os'); + const { join } = await import('node:path'); + const hp = configPath('~/.codex/config.toml', '%USERPROFILE%\\.codex\\config.toml'); if (process.env.OPENAI_API_KEY) { - const key = process.env.OPENAI_API_KEY - const masked = key.length > 12 ? `${key.slice(0, 8)}...` : '***' - return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp } + const key = process.env.OPENAI_API_KEY; + const masked = key.length > 12 ? `${key.slice(0, 8)}...` : '***'; + return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp }; } try { - const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex') - const authPath = join(codexHome, 'auth.json') - const raw = await readFile(authPath, 'utf-8') - const auth = JSON.parse(raw) as { auth_mode?: string; tokens?: { id_token?: string } } + const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'); + const authPath = join(codexHome, 'auth.json'); + const raw = await readFile(authPath, 'utf-8'); + const auth = JSON.parse(raw) as { auth_mode?: string; tokens?: { id_token?: string } }; - const idToken = auth.tokens?.id_token + const idToken = auth.tokens?.id_token; if (idToken) { - const payload = decodeJwtPayload(idToken) + const payload = decodeJwtPayload(idToken); if (payload) { - const email = payload.email as string | undefined - const authClaims = payload['https://api.openai.com/auth'] as Record | undefined - const plan = authClaims?.chatgpt_plan_type as string | undefined - serverLog.info(`[connect-agent] codex JWT: email=${email ?? 'n/a'}, plan=${plan ?? 'n/a'}`) + const email = payload.email as string | undefined; + const authClaims = payload['https://api.openai.com/auth'] as + | Record + | undefined; + const plan = authClaims?.chatgpt_plan_type as string | undefined; + serverLog.info(`[connect-agent] codex JWT: email=${email ?? 'n/a'}, plan=${plan ?? 'n/a'}`); if (email) { - const label = plan ?? auth.auth_mode ?? 'subscription' - return { connectionInfo: `Connected via ${label} (${email})`, hintPath: hp } + const label = plan ?? auth.auth_mode ?? 'subscription'; + return { connectionInfo: `Connected via ${label} (${email})`, hintPath: hp }; } } } if (auth.auth_mode) { - return { connectionInfo: `Connected via ${auth.auth_mode}`, hintPath: hp } + return { connectionInfo: `Connected via ${auth.auth_mode}`, hintPath: hp }; } - } catch { /* auth.json not found */ } + } catch { + /* auth.json not found */ + } - return { connectionInfo: 'Connected via Codex CLI', hintPath: hp } + return { connectionInfo: 'Connected via Codex CLI', hintPath: hp }; } /** Map raw Agent SDK errors to user-friendly messages */ function friendlyClaudeError(raw: string): string { if (/process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)) { - return 'Claude Code exited with code 1. Run "claude login" to authenticate, or set ANTHROPIC_API_KEY in ~/.claude/settings.json.' + return 'Claude Code exited with code 1. Run "claude login" to authenticate, or set ANTHROPIC_API_KEY in ~/.claude/settings.json.'; } if (/exited with code/i.test(raw)) { - return 'Unable to connect. Claude Code process exited unexpectedly.' + return 'Unable to connect. Claude Code process exited unexpectedly.'; } if (/not found|ENOENT/i.test(raw)) { - return 'Claude Code CLI not found. Please install it first.' + return 'Claude Code CLI not found. Please install it first.'; } if (/timed?\s*out/i.test(raw)) { - return 'Connection timed out. Please try again.' + return 'Connection timed out. Please try again.'; } - return raw + return raw; } /** @@ -309,79 +373,101 @@ function friendlyClaudeError(raw: string): string { * Only includes text/reasoning models (skips image, audio, video, embedding, moderation). */ async function parseCodexLatestModelMd(codexHome: string): Promise { - const { readFile } = await import('node:fs/promises') - const { join } = await import('node:path') - const mdPath = join(codexHome, 'skills', '.system', 'openai-docs', 'references', 'latest-model.md') + const { readFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const mdPath = join( + codexHome, + 'skills', + '.system', + 'openai-docs', + 'references', + 'latest-model.md', + ); try { - const content = await readFile(mdPath, 'utf-8') - const models: GroupedModel[] = [] + const content = await readFile(mdPath, 'utf-8'); + const models: GroupedModel[] = []; // Match markdown table rows: | `model-id` | description | - const rowRe = /^\|\s*`([^`]+)`\s*\|\s*(.+?)\s*\|/gm - const skipRe = /image|audio|tts|transcribe|realtime|sora|video|embedding|moderation/i - let match: RegExpExecArray | null - const seen = new Set() + const rowRe = /^\|\s*`([^`]+)`\s*\|\s*(.+?)\s*\|/gm; + const skipRe = /image|audio|tts|transcribe|realtime|sora|video|embedding|moderation/i; + let match: RegExpExecArray | null; + const seen = new Set(); while ((match = rowRe.exec(content)) !== null) { - const slug = match[1] - const desc = match[2].trim() - if (skipRe.test(slug) || skipRe.test(desc) || seen.has(slug)) continue - seen.add(slug) + const slug = match[1]; + const desc = match[2].trim(); + if (skipRe.test(slug) || skipRe.test(desc) || seen.has(slug)) continue; + seen.add(slug); models.push({ value: slug, displayName: slug, description: desc, provider: 'openai' as const, - }) + }); } - return models + return models; } catch { - return [] + return []; } } /** Connect to Codex CLI and fetch its supported models from the local cache */ async function connectCodexCli(): Promise { - serverLog.info('[connect-agent] connecting to Codex CLI...') + serverLog.info('[connect-agent] connecting to Codex CLI...'); try { - const { execSync } = await import('node:child_process') - const { readFile } = await import('node:fs/promises') - const { homedir } = await import('node:os') - const { join } = await import('node:path') - const isWin = process.platform === 'win32' + const { execSync } = await import('node:child_process'); + const { readFile } = await import('node:fs/promises'); + const { homedir } = await import('node:os'); + const { join } = await import('node:path'); + const isWin = process.platform === 'win32'; // Check if codex binary exists — PATH, npm prefix, then common locations - let which = '' + let which = ''; // 1. PATH lookup try { - const whichCmd = isWin ? 'where codex 2>nul' : 'which codex 2>/dev/null || echo ""' - serverLog.info(`[connect-agent] codex PATH lookup: ${whichCmd}`) - const result = execSync(whichCmd, { - encoding: 'utf-8', - timeout: 5000, - }).trim().split(/\r?\n/)[0]?.trim() ?? '' - if (result && existsSync(result)) which = resolveWinExtension(result) - serverLog.info(`[connect-agent] codex PATH result: "${result}" resolved="${which}" (exists=${result ? existsSync(result) : false})`) + const whichCmd = isWin ? 'where codex 2>nul' : 'which codex 2>/dev/null || echo ""'; + serverLog.info(`[connect-agent] codex PATH lookup: ${whichCmd}`); + const result = + execSync(whichCmd, { + encoding: 'utf-8', + timeout: 5000, + }) + .trim() + .split(/\r?\n/)[0] + ?.trim() ?? ''; + if (result && existsSync(result)) which = resolveWinExtension(result); + serverLog.info( + `[connect-agent] codex PATH result: "${result}" resolved="${which}" (exists=${result ? existsSync(result) : false})`, + ); } catch (err) { - serverLog.info(`[connect-agent] codex PATH lookup failed: ${err instanceof Error ? err.message : err}`) + serverLog.info( + `[connect-agent] codex PATH lookup failed: ${err instanceof Error ? err.message : err}`, + ); } // 2. npm prefix -g (Windows: npm global creates .cmd or .ps1 wrappers) if (!which && isWin) { try { - serverLog.info('[connect-agent] codex: trying npm.cmd prefix -g') + serverLog.info('[connect-agent] codex: trying npm.cmd prefix -g'); const prefix = execSync('npm.cmd prefix -g', { encoding: 'utf-8', timeout: 5000, - }).trim() - serverLog.info(`[connect-agent] codex npm global prefix: "${prefix}"`) + }).trim(); + serverLog.info(`[connect-agent] codex npm global prefix: "${prefix}"`); if (prefix) { for (const bin of winNpmCandidates(prefix, 'codex')) { - serverLog.info(`[connect-agent] codex npm global bin: "${bin}" (exists=${existsSync(bin)})`) - if (existsSync(bin)) { which = bin; break } + serverLog.info( + `[connect-agent] codex npm global bin: "${bin}" (exists=${existsSync(bin)})`, + ); + if (existsSync(bin)) { + which = bin; + break; + } } } } catch (err) { - serverLog.info(`[connect-agent] codex npm prefix -g failed: ${err instanceof Error ? err.message : err}`) + serverLog.info( + `[connect-agent] codex npm prefix -g failed: ${err instanceof Error ? err.message : err}`, + ); } } @@ -391,46 +477,50 @@ async function connectCodexCli(): Promise { ...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'codex'), ...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'codex'), ...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'codex'), - ] + ]; for (const c of candidates) { - const exists = c ? existsSync(c) : false - serverLog.info(`[connect-agent] codex candidate: "${c}" (exists=${exists})`) - if (c && exists) { which = c; break } + const exists = c ? existsSync(c) : false; + serverLog.info(`[connect-agent] codex candidate: "${c}" (exists=${exists})`); + if (c && exists) { + which = c; + break; + } } } if (!which) { - serverLog.warn('[connect-agent] codex not found') - return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' } + serverLog.warn('[connect-agent] codex not found'); + return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' }; } - serverLog.info(`[connect-agent] codex resolved: "${which}"`) - + serverLog.info(`[connect-agent] codex resolved: "${which}"`); // Verify codex is responsive — always use the resolved path - const versionCmd = buildExecCmd(which, '--version') + ' 2>&1' + const versionCmd = buildExecCmd(which, '--version') + ' 2>&1'; try { - const ver = execSync(versionCmd, { encoding: 'utf-8', timeout: 5000 }).trim() - serverLog.info(`[connect-agent] codex version: ${ver}`) + const ver = execSync(versionCmd, { encoding: 'utf-8', timeout: 5000 }).trim(); + serverLog.info(`[connect-agent] codex version: ${ver}`); } catch (err) { - serverLog.error(`[connect-agent] codex --version failed: ${err instanceof Error ? err.message : err}`) - return { connected: false, models: [], error: 'Codex CLI not responding' } + serverLog.error( + `[connect-agent] codex --version failed: ${err instanceof Error ? err.message : err}`, + ); + return { connected: false, models: [], error: 'Codex CLI not responding' }; } // Read models from Codex CLI's local models cache (best-effort) - let models: GroupedModel[] = [] - const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex') - const cachePath = join(codexHome, 'models_cache.json') + let models: GroupedModel[] = []; + const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'); + const cachePath = join(codexHome, 'models_cache.json'); try { - const raw = await readFile(cachePath, 'utf-8') + const raw = await readFile(cachePath, 'utf-8'); const cache = JSON.parse(raw) as { models?: Array<{ - slug: string - display_name: string - description: string - visibility: string - priority: number - }> - } + slug: string; + display_name: string; + description: string; + visibility: string; + priority: number; + }>; + }; if (cache.models && Array.isArray(cache.models)) { models = cache.models .filter((m) => m.visibility === 'list') @@ -440,80 +530,94 @@ async function connectCodexCli(): Promise { displayName: m.display_name, description: m.description ?? '', provider: 'openai' as const, - })) + })); } } catch { - serverLog.info(`[connect-agent] codex models cache not available`) + serverLog.info(`[connect-agent] codex models cache not available`); } // Fallback: parse models from Codex's bundled latest-model.md reference if (models.length === 0) { - models = await parseCodexLatestModelMd(codexHome) + models = await parseCodexLatestModelMd(codexHome); if (models.length > 0) { - serverLog.info(`[connect-agent] codex models loaded from latest-model.md: ${models.length}`) + serverLog.info( + `[connect-agent] codex models loaded from latest-model.md: ${models.length}`, + ); } } - serverLog.info(`[connect-agent] codex connected, ${models.length} models found`) - const codexInfo = await buildCodexConnectionInfo() - const warning = models.length === 0 ? 'No models found. Try running codex once to populate the model cache.' : undefined - return { connected: true, models, warning, ...codexInfo } + serverLog.info(`[connect-agent] codex connected, ${models.length} models found`); + const codexInfo = await buildCodexConnectionInfo(); + const warning = + models.length === 0 + ? 'No models found. Try running codex once to populate the model cache.' + : undefined; + return { connected: true, models, warning, ...codexInfo }; } catch (error) { - const msg = error instanceof Error ? error.message : 'Failed to connect' - serverLog.error(`[connect-agent] codex connection error: ${msg}`) - return { connected: false, models: [], error: msg } + const msg = error instanceof Error ? error.message : 'Failed to connect'; + serverLog.error(`[connect-agent] codex connection error: ${msg}`); + return { connected: false, models: [], error: msg }; } } /** Resolve the opencode binary path, checking PATH then common install locations. */ async function resolveOpencodeBinary(): Promise { - const { execSync } = await import('node:child_process') - const { existsSync } = await import('node:fs') - const { homedir } = await import('node:os') - const { join } = await import('node:path') - const isWin = process.platform === 'win32' + const { execSync } = await import('node:child_process'); + const { existsSync } = await import('node:fs'); + const { homedir } = await import('node:os'); + const { join } = await import('node:path'); + const isWin = process.platform === 'win32'; - serverLog.info(`[resolve-opencode] platform=${process.platform}, isWindows=${isWin}`) + serverLog.info(`[resolve-opencode] platform=${process.platform}, isWindows=${isWin}`); // 1. Try PATH lookup try { - const cmd = isWin ? 'where opencode 2>nul' : 'which opencode 2>/dev/null' - serverLog.info(`[resolve-opencode] PATH lookup: ${cmd}`) - const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim().split(/\r?\n/)[0]?.trim() - serverLog.info(`[resolve-opencode] PATH result: "${result}" (exists=${result ? existsSync(result) : false})`) - if (result && existsSync(result)) return resolveWinExtension(result) + const cmd = isWin ? 'where opencode 2>nul' : 'which opencode 2>/dev/null'; + serverLog.info(`[resolve-opencode] PATH lookup: ${cmd}`); + const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }) + .trim() + .split(/\r?\n/)[0] + ?.trim(); + serverLog.info( + `[resolve-opencode] PATH result: "${result}" (exists=${result ? existsSync(result) : false})`, + ); + if (result && existsSync(result)) return resolveWinExtension(result); } catch (err) { - serverLog.info(`[resolve-opencode] PATH lookup failed: ${err instanceof Error ? err.message : err}`) + serverLog.info( + `[resolve-opencode] PATH lookup failed: ${err instanceof Error ? err.message : err}`, + ); } // 2. Try `npm prefix -g` to find actual npm global bin directory // On Windows, must use `npm.cmd` since Electron spawns cmd.exe try { - const npmCmd = isWin ? 'npm.cmd prefix -g' : 'npm prefix -g' - serverLog.info(`[resolve-opencode] npm prefix lookup: ${npmCmd}`) - const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim() - serverLog.info(`[resolve-opencode] npm global prefix: "${prefix}"`) + const npmCmd = isWin ? 'npm.cmd prefix -g' : 'npm prefix -g'; + serverLog.info(`[resolve-opencode] npm prefix lookup: ${npmCmd}`); + const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim(); + serverLog.info(`[resolve-opencode] npm global prefix: "${prefix}"`); if (prefix) { if (isWin) { for (const bin of winNpmCandidates(prefix, 'opencode')) { - serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`) - if (existsSync(bin)) return bin + serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`); + if (existsSync(bin)) return bin; } } else { - const bin = join(prefix, 'bin', 'opencode') - serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`) - if (existsSync(bin)) return bin + const bin = join(prefix, 'bin', 'opencode'); + serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`); + if (existsSync(bin)) return bin; } } } catch (err) { - serverLog.info(`[resolve-opencode] npm prefix -g failed: ${err instanceof Error ? err.message : err}`) + serverLog.info( + `[resolve-opencode] npm prefix -g failed: ${err instanceof Error ? err.message : err}`, + ); } // 3. Common install locations // npm -g → %APPDATA%\npm (Windows), /usr/local (macOS/Linux) // curl installer → ~/.opencode/bin (macOS/Linux) // Homebrew → /usr/local/bin or /opt/homebrew/bin (macOS) - const home = homedir() + const home = homedir(); const candidates = isWin ? [ // npm global (.cmd + .ps1) @@ -535,100 +639,123 @@ async function resolveOpencodeBinary(): Promise { // Homebrew '/opt/homebrew/bin/opencode', join(home, '.local', 'bin', 'opencode'), - ] + ]; for (const c of candidates) { - const exists = c ? existsSync(c) : false - serverLog.info(`[resolve-opencode] candidate: "${c}" (exists=${exists})`) - if (c && exists) return c + const exists = c ? existsSync(c) : false; + serverLog.info(`[resolve-opencode] candidate: "${c}" (exists=${exists})`); + if (c && exists) return c; } - serverLog.info('[resolve-opencode] no opencode binary found') - return undefined + serverLog.info('[resolve-opencode] no opencode binary found'); + return undefined; } /** Connect to OpenCode and fetch its configured providers/models. */ async function connectOpenCode(): Promise { - serverLog.info('[connect-agent] connecting to OpenCode...') + serverLog.info('[connect-agent] connecting to OpenCode...'); try { - const binaryPath = await resolveOpencodeBinary() - serverLog.info(`[connect-agent] resolved opencode path: ${binaryPath ?? 'NOT FOUND'}`) + const binaryPath = await resolveOpencodeBinary(); + serverLog.info(`[connect-agent] resolved opencode path: ${binaryPath ?? 'NOT FOUND'}`); if (!binaryPath) { - return { connected: false, models: [], notInstalled: true, error: 'OpenCode CLI not found' } + return { connected: false, models: [], notInstalled: true, error: 'OpenCode CLI not found' }; } - const { getOpencodeClient, releaseOpencodeServer } = await import('../../utils/opencode-client') - serverLog.info('[connect-agent] creating opencode client...') - const { client, server } = await getOpencodeClient(binaryPath) + const { getOpencodeClient, releaseOpencodeServer } = + await import('../../utils/opencode-client'); + serverLog.info('[connect-agent] creating opencode client...'); + const { client, server } = await getOpencodeClient(binaryPath); - serverLog.info('[connect-agent] fetching opencode providers...') - const { data, error } = await client.config.providers() - releaseOpencodeServer(server) + serverLog.info('[connect-agent] fetching opencode providers...'); + const { data, error } = await client.config.providers(); + releaseOpencodeServer(server); if (error) { - serverLog.error(`[connect-agent] opencode providers error: ${JSON.stringify(error)}`) - return { connected: false, models: [], error: 'Failed to fetch providers from OpenCode server.' } + serverLog.error(`[connect-agent] opencode providers error: ${JSON.stringify(error)}`); + return { + connected: false, + models: [], + error: 'Failed to fetch providers from OpenCode server.', + }; } - const models: GroupedModel[] = [] + const models: GroupedModel[] = []; for (const provider of data?.providers ?? []) { - if (!provider.models) continue + if (!provider.models) continue; for (const [, model] of Object.entries(provider.models)) { models.push({ value: `${provider.id}/${model.id}`, displayName: model.name || model.id, description: `via ${provider.name || provider.id}`, provider: 'opencode' as const, - }) + }); } } if (models.length === 0) { - serverLog.info('[connect-agent] opencode: no models found') - return { connected: false, models: [], error: 'No models configured in OpenCode. Run "opencode" to set up providers.' } + serverLog.info('[connect-agent] opencode: no models found'); + return { + connected: false, + models: [], + error: 'No models configured in OpenCode. Run "opencode" to set up providers.', + }; } - const providerNames = (data?.providers ?? []).map((p) => p.name || p.id).filter(Boolean) - const providerSummary = providerNames.length > 0 - ? `Connected (${providerNames.slice(0, 3).join(', ')}${providerNames.length > 3 ? ` +${providerNames.length - 3}` : ''})` - : 'Connected via OpenCode server' - serverLog.info(`[connect-agent] opencode connected, ${models.length} models found`) + const providerNames = (data?.providers ?? []).map((p) => p.name || p.id).filter(Boolean); + const providerSummary = + providerNames.length > 0 + ? `Connected (${providerNames.slice(0, 3).join(', ')}${providerNames.length > 3 ? ` +${providerNames.length - 3}` : ''})` + : 'Connected via OpenCode server'; + serverLog.info(`[connect-agent] opencode connected, ${models.length} models found`); return { - connected: true, models, + connected: true, + models, connectionInfo: providerSummary, hintPath: configPath('~/.opencode/config.json', '%USERPROFILE%\\.opencode\\config.json'), - } + }; } catch (error) { - const raw = error instanceof Error ? error.message : 'Failed to connect' - serverLog.error(`[connect-agent] opencode connection error: ${raw}`) - return { connected: false, models: [], error: friendlyOpenCodeError(raw) } + const raw = error instanceof Error ? error.message : 'Failed to connect'; + serverLog.error(`[connect-agent] opencode connection error: ${raw}`); + return { connected: false, models: [], error: friendlyOpenCodeError(raw) }; } } /** Connect to GitHub Copilot CLI via @github/copilot-sdk and fetch available models. */ async function connectCopilot(): Promise { - serverLog.info('[connect-agent] connecting to Copilot...') + serverLog.info('[connect-agent] connecting to Copilot...'); // Use standalone copilot binary to avoid Bun's node:sqlite issue - const { resolveCopilotCli, resolveCliPathForSdk } = await import('../../utils/copilot-client') - const rawCliPath = resolveCopilotCli() - serverLog.info(`[connect-agent] resolved copilot path: ${rawCliPath ?? 'NOT FOUND'}`) + const { resolveCopilotCli, resolveCliPathForSdk } = await import('../../utils/copilot-client'); + const rawCliPath = resolveCopilotCli(); + serverLog.info(`[connect-agent] resolved copilot path: ${rawCliPath ?? 'NOT FOUND'}`); if (!rawCliPath) { - return { connected: false, models: [], notInstalled: true, error: 'GitHub Copilot CLI not found' } + return { + connected: false, + models: [], + notInstalled: true, + error: 'GitHub Copilot CLI not found', + }; } // On Windows, .cmd wrappers cause "spawn EINVAL" — resolve to .js entry point - const cliPath = resolveCliPathForSdk(rawCliPath) + const cliPath = resolveCliPathForSdk(rawCliPath); try { - const { CopilotClient } = await import('@github/copilot-sdk') - const client = new CopilotClient({ autoStart: true, cliPath }) + const { CopilotClient } = await import('@github/copilot-sdk'); + // In Electron, the SDK spawns `process.execPath` (= OpenPencil.exe) to run + // the .js entry. ELECTRON_RUN_AS_NODE makes that binary behave as plain Node + // so it doesn't reject `index.js` as a stray positional argument. + const client = new CopilotClient({ + autoStart: true, + cliPath, + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + }); - serverLog.info('[connect-agent] starting copilot client...') - await client.start() + serverLog.info('[connect-agent] starting copilot client...'); + await client.start(); - let models: GroupedModel[] = [] + let models: GroupedModel[] = []; try { - serverLog.info('[connect-agent] listing copilot models...') - const modelList = await client.listModels() + serverLog.info('[connect-agent] listing copilot models...'); + const modelList = await client.listModels(); models = modelList .filter((m) => !m.policy || m.policy.state === 'enabled') .map((m) => ({ @@ -636,241 +763,295 @@ async function connectCopilot(): Promise { displayName: m.name, description: m.capabilities?.supports?.vision ? 'vision' : '', provider: 'copilot' as const, - })) + })); } catch (listErr) { - const msg = listErr instanceof Error ? listErr.message : 'Failed to list models' - serverLog.error(`[connect-agent] copilot listModels error: ${msg}`) - await client.stop().catch(() => {}) - return { connected: false, models: [], error: friendlyCopilotError(msg) } + const msg = listErr instanceof Error ? listErr.message : 'Failed to list models'; + serverLog.error(`[connect-agent] copilot listModels error: ${msg}`); + await client.stop().catch(() => {}); + return { connected: false, models: [], error: friendlyCopilotError(msg) }; } // Try to get auth status for user info - const copilotHintPath = configPath('~/.config/github-copilot/config.json', '%USERPROFILE%\\.config\\github-copilot\\config.json') - let copilotInfo: { connectionInfo: string; hintPath?: string } = { connectionInfo: 'Connected via GitHub', hintPath: copilotHintPath } + const copilotHintPath = configPath( + '~/.config/github-copilot/config.json', + '%USERPROFILE%\\.config\\github-copilot\\config.json', + ); + let copilotInfo: { connectionInfo: string; hintPath?: string } = { + connectionInfo: 'Connected via GitHub', + hintPath: copilotHintPath, + }; try { - const authStatus = await client.getAuthStatus() - serverLog.info(`[connect-agent] copilot auth: ${JSON.stringify(authStatus)}`) + const authStatus = await client.getAuthStatus(); + serverLog.info(`[connect-agent] copilot auth: ${JSON.stringify(authStatus)}`); if (authStatus?.login) { - const method = authStatus.authType ? ` (${authStatus.authType})` : '' - copilotInfo = { connectionInfo: `Connected as @${authStatus.login}${method}`, hintPath: copilotHintPath } + const method = authStatus.authType ? ` (${authStatus.authType})` : ''; + copilotInfo = { + connectionInfo: `Connected as @${authStatus.login}${method}`, + hintPath: copilotHintPath, + }; } else if (authStatus?.statusMessage) { - copilotInfo = { connectionInfo: authStatus.statusMessage, hintPath: copilotHintPath } + copilotInfo = { connectionInfo: authStatus.statusMessage, hintPath: copilotHintPath }; } } catch (authErr) { - serverLog.warn(`[connect-agent] copilot getAuthStatus failed: ${authErr instanceof Error ? authErr.message : authErr}`) + serverLog.warn( + `[connect-agent] copilot getAuthStatus failed: ${authErr instanceof Error ? authErr.message : authErr}`, + ); } - await client.stop() + await client.stop(); if (models.length === 0) { - serverLog.info('[connect-agent] copilot: no models found') - return { connected: false, models: [], error: 'No models found. Run "copilot login" to authenticate first.' } + serverLog.info('[connect-agent] copilot: no models found'); + return { + connected: false, + models: [], + error: 'No models found. Run "copilot login" to authenticate first.', + }; } - serverLog.info(`[connect-agent] copilot connected, ${models.length} models found`) - return { connected: true, models, ...copilotInfo } + serverLog.info(`[connect-agent] copilot connected, ${models.length} models found`); + return { connected: true, models, ...copilotInfo }; } catch (error) { - const raw = error instanceof Error ? error.message : 'Failed to connect' - serverLog.error(`[connect-agent] copilot connection error: ${raw}`) - return { connected: false, models: [], error: friendlyCopilotError(raw) } + const raw = error instanceof Error ? error.message : 'Failed to connect'; + serverLog.error(`[connect-agent] copilot connection error: ${raw}`); + return { connected: false, models: [], error: friendlyCopilotError(raw) }; } } /** Map Copilot SDK errors to user-friendly messages */ function friendlyCopilotError(raw: string): string { if (/not found|ENOENT/i.test(raw)) { - return 'GitHub Copilot CLI not found. Install it from https://docs.github.com/copilot/how-tos/copilot-cli' + return 'GitHub Copilot CLI not found. Install it from https://docs.github.com/copilot/how-tos/copilot-cli'; } if (/not authenticated|authenticate first|auth|unauthenticated|login/i.test(raw)) { - return 'Not authenticated. Run "copilot login" in your terminal first.' + return 'Not authenticated. Run "copilot login" in your terminal first.'; } if (/timed?\s*out/i.test(raw)) { - return 'Connection timed out. Please try again.' + return 'Connection timed out. Please try again.'; } - return raw + return raw; } /** Map OpenCode connection errors to user-friendly messages */ function friendlyOpenCodeError(raw: string): string { if (/ECONNREFUSED/i.test(raw)) { - return 'OpenCode server not running. Start it with "opencode" in your terminal first.' + return 'OpenCode server not running. Start it with "opencode" in your terminal first.'; } if (/not found|ENOENT/i.test(raw)) { - return 'OpenCode CLI not found. Please install it first.' + return 'OpenCode CLI not found. Please install it first.'; } if (/timed?\s*out/i.test(raw)) { - return 'Connection timed out. Please try again.' + return 'Connection timed out. Please try again.'; } - return raw + return raw; } /** Fallback model list when dynamic fetch fails */ const FALLBACK_GEMINI_MODELS: GroupedModel[] = [ - { value: 'gemini-3-pro-preview', displayName: 'Gemini 3 Pro', description: 'Most capable', provider: 'gemini' }, - { value: 'gemini-3-flash-preview', displayName: 'Gemini 3 Flash', description: 'Fast + capable', provider: 'gemini' }, - { value: 'gemini-2.5-pro', displayName: 'Gemini 2.5 Pro', description: 'Thinking model', provider: 'gemini' }, - { value: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', description: 'Fast + thinking', provider: 'gemini' }, - { value: 'gemini-2.0-flash', displayName: 'Gemini 2.0 Flash', description: 'Fast model', provider: 'gemini' }, -] + { + value: 'gemini-3-pro-preview', + displayName: 'Gemini 3 Pro', + description: 'Most capable', + provider: 'gemini', + }, + { + value: 'gemini-3-flash-preview', + displayName: 'Gemini 3 Flash', + description: 'Fast + capable', + provider: 'gemini', + }, + { + value: 'gemini-2.5-pro', + displayName: 'Gemini 2.5 Pro', + description: 'Thinking model', + provider: 'gemini', + }, + { + value: 'gemini-2.5-flash', + displayName: 'Gemini 2.5 Flash', + description: 'Fast + thinking', + provider: 'gemini', + }, + { + value: 'gemini-2.0-flash', + displayName: 'Gemini 2.0 Flash', + description: 'Fast model', + provider: 'gemini', + }, +]; /** Fetch available models from Gemini API using local auth credentials */ async function fetchGeminiModels(): Promise { - const { readFile } = await import('node:fs/promises') - const { homedir } = await import('node:os') - const { join } = await import('node:path') + const { readFile } = await import('node:fs/promises'); + const { homedir } = await import('node:os'); + const { join } = await import('node:path'); // Build auth header — try API key first, then OAuth token - let authUrl: (base: string) => string - let headers: Record = {} + let authUrl: (base: string) => string; + let headers: Record = {}; - const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY + const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; if (envKey) { - authUrl = (base) => `${base}?key=${envKey}` + authUrl = (base) => `${base}?key=${envKey}`; } else { // Read OAuth token - const oauthPath = join(homedir(), '.gemini', 'oauth_creds.json') - const raw = await readFile(oauthPath, 'utf-8') - const creds = JSON.parse(raw) as { access_token?: string; expiry_date?: number } - if (!creds.access_token) throw new Error('No access token') - if (creds.expiry_date && Date.now() > creds.expiry_date - 60_000) throw new Error('Token expired') - authUrl = (base) => base - headers = { Authorization: `Bearer ${creds.access_token}` } + const oauthPath = join(homedir(), '.gemini', 'oauth_creds.json'); + const raw = await readFile(oauthPath, 'utf-8'); + const creds = JSON.parse(raw) as { access_token?: string; expiry_date?: number }; + if (!creds.access_token) throw new Error('No access token'); + if (creds.expiry_date && Date.now() > creds.expiry_date - 60_000) + throw new Error('Token expired'); + authUrl = (base) => base; + headers = { Authorization: `Bearer ${creds.access_token}` }; } - const res = await fetch(authUrl('https://generativelanguage.googleapis.com/v1beta/models'), { headers }) - if (!res.ok) throw new Error(`API ${res.status}`) + const res = await fetch(authUrl('https://generativelanguage.googleapis.com/v1beta/models'), { + headers, + }); + if (!res.ok) throw new Error(`API ${res.status}`); - const data = await res.json() as { + const data = (await res.json()) as { models?: Array<{ - name?: string - displayName?: string - description?: string - supportedGenerationMethods?: string[] - }> - } + name?: string; + displayName?: string; + description?: string; + supportedGenerationMethods?: string[]; + }>; + }; - const models: GroupedModel[] = [] - const seen = new Set() + const models: GroupedModel[] = []; + const seen = new Set(); for (const m of data.models ?? []) { // Only include models that support generateContent (text generation) - if (!m.supportedGenerationMethods?.includes('generateContent')) continue - const id = m.name?.replace('models/', '') ?? '' - if (!id || seen.has(id)) continue + if (!m.supportedGenerationMethods?.includes('generateContent')) continue; + const id = m.name?.replace('models/', '') ?? ''; + if (!id || seen.has(id)) continue; // Skip embedding, AQA, and legacy models - if (/embed|aqa|^chat-bison|^text-bison|^gemini-1\.0/i.test(id)) continue - seen.add(id) + if (/embed|aqa|^chat-bison|^text-bison|^gemini-1\.0/i.test(id)) continue; + seen.add(id); models.push({ value: id, displayName: m.displayName ?? id, description: m.description?.slice(0, 60) ?? '', provider: 'gemini' as const, - }) + }); } // Sort: gemini-3 first, then 2.5, then others models.sort((a, b) => { const order = (v: string) => { - if (v.includes('gemini-3')) return 0 - if (v.includes('gemini-2.5-pro')) return 1 - if (v.includes('gemini-2.5-flash')) return 2 - if (v.includes('gemini-2.0')) return 3 - return 4 - } - return order(a.value) - order(b.value) - }) + if (v.includes('gemini-3')) return 0; + if (v.includes('gemini-2.5-pro')) return 1; + if (v.includes('gemini-2.5-flash')) return 2; + if (v.includes('gemini-2.0')) return 3; + return 4; + }; + return order(a.value) - order(b.value); + }); - return models + return models; } /** Connect to Gemini CLI and return available models. */ async function connectGeminiCli(): Promise { - serverLog.info('[connect-agent] connecting to Gemini CLI...') + serverLog.info('[connect-agent] connecting to Gemini CLI...'); try { - const { resolveGeminiCli } = await import('../../utils/resolve-gemini-cli') - const binPath = resolveGeminiCli() - serverLog.info(`[connect-agent] resolved gemini path: ${binPath ?? 'NOT FOUND'}`) + const { resolveGeminiCli } = await import('../../utils/resolve-gemini-cli'); + const binPath = resolveGeminiCli(); + serverLog.info(`[connect-agent] resolved gemini path: ${binPath ?? 'NOT FOUND'}`); if (!binPath) { - return { connected: false, models: [], notInstalled: true, error: 'Gemini CLI not found' } + return { connected: false, models: [], notInstalled: true, error: 'Gemini CLI not found' }; } // Verify binary responds - const { execSync } = await import('node:child_process') - const versionCmd = buildExecCmd(binPath, '--version') + const { execSync } = await import('node:child_process'); + const versionCmd = buildExecCmd(binPath, '--version'); try { - const ver = execSync(`${versionCmd} 2>&1`, { encoding: 'utf-8', timeout: 10000 }).trim() - serverLog.info(`[connect-agent] gemini version: ${ver}`) + const ver = execSync(`${versionCmd} 2>&1`, { encoding: 'utf-8', timeout: 10000 }).trim(); + serverLog.info(`[connect-agent] gemini version: ${ver}`); } catch (err) { - serverLog.error(`[connect-agent] gemini --version failed: ${err instanceof Error ? err.message : err}`) - return { connected: false, models: [], error: 'Gemini CLI not responding' } + serverLog.error( + `[connect-agent] gemini --version failed: ${err instanceof Error ? err.message : err}`, + ); + return { connected: false, models: [], error: 'Gemini CLI not responding' }; } // Dynamically fetch models, fallback to hardcoded list - let models: GroupedModel[] + let models: GroupedModel[]; try { - models = await fetchGeminiModels() - serverLog.info(`[connect-agent] gemini: fetched ${models.length} models from API`) + models = await fetchGeminiModels(); + serverLog.info(`[connect-agent] gemini: fetched ${models.length} models from API`); } catch (err) { - serverLog.info(`[connect-agent] gemini: model fetch failed (${err instanceof Error ? err.message : err}), using fallback`) - models = FALLBACK_GEMINI_MODELS + serverLog.info( + `[connect-agent] gemini: model fetch failed (${err instanceof Error ? err.message : err}), using fallback`, + ); + models = FALLBACK_GEMINI_MODELS; } - const geminiInfo = await buildGeminiConnectionInfo() - const warning = models.length === 0 ? 'No models found. Try running "gemini" once to authenticate.' : undefined - if (models.length === 0) models = FALLBACK_GEMINI_MODELS - serverLog.info(`[connect-agent] gemini connected, ${models.length} models`) - return { connected: true, models, warning, ...geminiInfo } + const geminiInfo = await buildGeminiConnectionInfo(); + const warning = + models.length === 0 + ? 'No models found. Try running "gemini" once to authenticate.' + : undefined; + if (models.length === 0) models = FALLBACK_GEMINI_MODELS; + serverLog.info(`[connect-agent] gemini connected, ${models.length} models`); + return { connected: true, models, warning, ...geminiInfo }; } catch (error) { - const raw = error instanceof Error ? error.message : 'Failed to connect' - serverLog.error(`[connect-agent] gemini connection error: ${raw}`) - return { connected: false, models: [], error: friendlyGeminiError(raw) } + const raw = error instanceof Error ? error.message : 'Failed to connect'; + serverLog.error(`[connect-agent] gemini connection error: ${raw}`); + return { connected: false, models: [], error: friendlyGeminiError(raw) }; } } /** Build Gemini CLI connection info from local config files */ async function buildGeminiConnectionInfo(): Promise<{ connectionInfo: string; hintPath?: string }> { - const { readFile } = await import('node:fs/promises') - const { homedir } = await import('node:os') - const { join } = await import('node:path') - const hp = configPath('~/.gemini/settings.json', '%USERPROFILE%\\.gemini\\settings.json') + const { readFile } = await import('node:fs/promises'); + const { homedir } = await import('node:os'); + const { join } = await import('node:path'); + const hp = configPath('~/.gemini/settings.json', '%USERPROFILE%\\.gemini\\settings.json'); // Check env for API key - const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY + const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; if (envKey) { - const masked = envKey.length > 12 ? `${envKey.slice(0, 8)}...` : '***' - return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp } + const masked = envKey.length > 12 ? `${envKey.slice(0, 8)}...` : '***'; + return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp }; } // Check OAuth creds (Gemini CLI login) try { - const oauthPath = join(homedir(), '.gemini', 'oauth_creds.json') - await readFile(oauthPath, 'utf-8') // Check existence + const oauthPath = join(homedir(), '.gemini', 'oauth_creds.json'); + await readFile(oauthPath, 'utf-8'); // Check existence // Try to get account email try { - const accountsPath = join(homedir(), '.gemini', 'google_accounts.json') - const accountsRaw = await readFile(accountsPath, 'utf-8') - const accounts = JSON.parse(accountsRaw) as { active?: string } + const accountsPath = join(homedir(), '.gemini', 'google_accounts.json'); + const accountsRaw = await readFile(accountsPath, 'utf-8'); + const accounts = JSON.parse(accountsRaw) as { active?: string }; if (accounts.active) { - return { connectionInfo: `Connected via Google (${accounts.active})`, hintPath: hp } + return { connectionInfo: `Connected via Google (${accounts.active})`, hintPath: hp }; } - } catch { /* no accounts file */ } + } catch { + /* no accounts file */ + } - return { connectionInfo: 'Connected via Google OAuth', hintPath: hp } - } catch { /* no OAuth creds */ } + return { connectionInfo: 'Connected via Google OAuth', hintPath: hp }; + } catch { + /* no OAuth creds */ + } - return { connectionInfo: 'Connected via Gemini CLI', hintPath: hp } + return { connectionInfo: 'Connected via Gemini CLI', hintPath: hp }; } /** Map Gemini CLI errors to user-friendly messages */ function friendlyGeminiError(raw: string): string { if (/not found|ENOENT/i.test(raw)) { - return 'Gemini CLI not found. Install it with: npm install -g @anthropic-ai/gemini-cli' + return 'Gemini CLI not found. Install it with: npm install -g @anthropic-ai/gemini-cli'; } if (/not authenticated|authenticate|auth|login/i.test(raw)) { - return 'Not authenticated. Run "gemini" in your terminal first to set up authentication.' + return 'Not authenticated. Run "gemini" in your terminal first to set up authentication.'; } if (/timed?\s*out/i.test(raw)) { - return 'Connection timed out. Please try again.' + return 'Connection timed out. Please try again.'; } - return raw + return raw; } diff --git a/apps/web/server/api/ai/generate.ts b/apps/web/server/api/ai/generate.ts index 72d2e9fd..e63414b6 100644 --- a/apps/web/server/api/ai/generate.ts +++ b/apps/web/server/api/ai/generate.ts @@ -1,22 +1,23 @@ -import { defineEventHandler, readBody, setResponseHeaders } from 'h3' -import { resolveClaudeCli } from '../../utils/resolve-claude-cli' -import { runCodexExec } from '../../utils/codex-client' +import { defineEventHandler, getHeader, readBody, setResponseHeaders } from 'h3'; +import { resolveClaudeCli } from '../../utils/resolve-claude-cli'; +import { runCodexExec, streamCodexExec } from '../../utils/codex-client'; import { buildClaudeAgentEnv, buildSpawnClaudeCodeProcess, getClaudeAgentDebugFilePath, resolveAgentModel, -} from '../../utils/resolve-claude-agent-env' -import { formatOpenCodeError } from './chat' +} from '../../utils/resolve-claude-agent-env'; +import { formatOpenCodeError } from './chat'; +import { createSSEResponse } from '../../utils/sse-stream'; interface GenerateBody { - system: string - message: string - model?: string - provider?: 'anthropic' | 'openai' | 'opencode' | 'gemini' - thinkingMode?: 'adaptive' | 'disabled' | 'enabled' - thinkingBudgetTokens?: number - effort?: 'low' | 'medium' | 'high' | 'max' + system: string; + message: string; + model?: string; + provider?: 'anthropic' | 'openai' | 'opencode' | 'gemini'; + thinkingMode?: 'adaptive' | 'disabled' | 'enabled'; + thinkingBudgetTokens?: number; + effort?: 'low' | 'medium' | 'high' | 'max'; } /** @@ -25,47 +26,52 @@ interface GenerateBody { * Requires explicit provider and model; no fallback routing. */ export default defineEventHandler(async (event) => { - const body = await readBody(event) + const body = await readBody(event); if (!body?.message || body?.system == null) { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - return { error: 'Missing required fields: system, message' } + setResponseHeaders(event, { 'Content-Type': 'application/json' }); + return { error: 'Missing required fields: system, message' }; } if (!body.provider) { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - return { error: 'Missing provider. Provider fallback is disabled.' } + setResponseHeaders(event, { 'Content-Type': 'application/json' }); + return { error: 'Missing provider. Provider fallback is disabled.' }; } if (!body.model?.trim()) { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - return { error: 'Missing model. Model fallback is disabled.' } + setResponseHeaders(event, { 'Content-Type': 'application/json' }); + return { error: 'Missing model. Model fallback is disabled.' }; } + const acceptSSE = getHeader(event, 'accept')?.includes('text/event-stream'); + if (body.provider === 'anthropic') { - return generateViaAgentSDK(body, body.model) + return acceptSSE ? streamViaAgentSDK(body, body.model) : generateViaAgentSDK(body, body.model); } if (body.provider === 'opencode') { - return generateViaOpenCode(body, body.model) + return acceptSSE ? streamViaOpenCode(body, body.model) : generateViaOpenCode(body, body.model); } if (body.provider === 'openai') { - return generateViaCodex(body, body.model) + return acceptSSE ? streamViaCodex(body, body.model) : generateViaCodex(body, body.model); } if (body.provider === 'gemini') { - return generateViaGemini(body, body.model) + return acceptSSE ? streamViaGemini(body, body.model) : generateViaGemini(body, body.model); } - return { error: 'Missing or unsupported provider. Provider fallback is disabled.' } -}) + return { error: 'Missing or unsupported provider. Provider fallback is disabled.' }; +}); /** Generate via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */ -async function generateViaAgentSDK(body: GenerateBody, requestedModel?: string): Promise<{ text?: string; error?: string }> { +async function generateViaAgentSDK( + body: GenerateBody, + requestedModel?: string, +): Promise<{ text?: string; error?: string }> { const runQuery = async (): Promise<{ text?: string; error?: string }> => { - const { query } = await import('@anthropic-ai/claude-agent-sdk') + const { query } = await import('@anthropic-ai/claude-agent-sdk'); // Remove CLAUDECODE env to allow running from within a CC terminal - const env = buildClaudeAgentEnv() - const debugFile = getClaudeAgentDebugFilePath() - const model = resolveAgentModel(requestedModel, env) + const env = buildClaudeAgentEnv(); + const debugFile = getClaudeAgentDebugFilePath(); + const model = resolveAgentModel(requestedModel, env); - const claudePath = resolveClaudeCli() + const claudePath = resolveClaudeCli(); const q = query({ prompt: body.message, @@ -80,77 +86,83 @@ async function generateViaAgentSDK(body: GenerateBody, requestedModel?: string): env, ...(debugFile ? { debugFile } : {}), ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}), - ...(buildSpawnClaudeCodeProcess() ? { spawnClaudeCodeProcess: buildSpawnClaudeCodeProcess() } : {}), + ...(buildSpawnClaudeCodeProcess() + ? { spawnClaudeCodeProcess: buildSpawnClaudeCodeProcess() } + : {}), }, - }) + }); try { for await (const message of q) { if (message.type === 'result') { - const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error) + const isErrorResult = + 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error); if (message.subtype === 'success' && !isErrorResult) { - return { text: message.result } + return { text: message.result }; } - const errors = 'errors' in message ? (message.errors as string[]) : [] - const resultText = 'result' in message ? String(message.result ?? '') : '' - return { error: errors.join('; ') || resultText || `Query ended with: ${message.subtype}` } + const errors = 'errors' in message ? (message.errors as string[]) : []; + const resultText = 'result' in message ? String(message.result ?? '') : ''; + return { + error: errors.join('; ') || resultText || `Query ended with: ${message.subtype}`, + }; } } } finally { - q.close() + q.close(); } - return { error: 'No result received from Claude Agent SDK' } - } + return { error: 'No result received from Claude Agent SDK' }; + }; try { - return await runQuery() + return await runQuery(); } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { error: message } + const message = error instanceof Error ? error.message : String(error); + return { error: message }; } } -async function generateViaCodex(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> { +async function generateViaCodex( + body: GenerateBody, + model?: string, +): Promise<{ text?: string; error?: string }> { const result = await runCodexExec(body.message, { model, systemPrompt: body.system, thinkingMode: body.thinkingMode, thinkingBudgetTokens: body.thinkingBudgetTokens, effort: body.effort, - }) - return result.error ? { error: result.error } : { text: result.text ?? '' } + }); + return result.error ? { error: result.error } : { text: result.text ?? '' }; } function mapOpenCodeEffort( effort?: 'low' | 'medium' | 'high' | 'max', ): 'low' | 'medium' | 'high' | undefined { - if (!effort) return undefined - if (effort === 'max') return 'high' - return effort + if (!effort) return undefined; + if (effort === 'max') return 'high'; + return effort; } -function buildOpenCodeReasoning( - body: GenerateBody, -): Record | undefined { - const reasoning: Record = {} - const effort = mapOpenCodeEffort(body.effort) +function buildOpenCodeReasoning(body: GenerateBody): Record | undefined { + const reasoning: Record = {}; + const effort = mapOpenCodeEffort(body.effort); if (effort) { - reasoning.effort = effort + reasoning.effort = effort; } if (body.thinkingMode === 'enabled') { - reasoning.enabled = true + reasoning.enabled = true; } else if (body.thinkingMode === 'disabled') { - reasoning.enabled = false + reasoning.enabled = false; } if (typeof body.thinkingBudgetTokens === 'number' && body.thinkingBudgetTokens > 0) { - reasoning.budgetTokens = body.thinkingBudgetTokens + reasoning.budgetTokens = body.thinkingBudgetTokens; } - return Object.keys(reasoning).length > 0 ? reasoning : undefined + return Object.keys(reasoning).length > 0 ? reasoning : undefined; } /** Timeout for OpenCode prompt calls (3 minutes) */ -const OPENCODE_PROMPT_TIMEOUT_MS = 180_000 +const OPENCODE_PROMPT_TIMEOUT_MS = 180_000; async function promptWithTimeout( ocClient: any, @@ -161,12 +173,13 @@ async function promptWithTimeout( ocClient.session.prompt(payload), new Promise<{ data: null; error: string }>((resolve) => setTimeout( - () => resolve({ data: null, error: `OpenCode prompt timed out after ${timeoutMs / 1000}s` }), + () => + resolve({ data: null, error: `OpenCode prompt timed out after ${timeoutMs / 1000}s` }), timeoutMs, ), ), - ]) - return result + ]); + return result; } async function promptOpenCodeWithThinking( @@ -174,36 +187,39 @@ async function promptOpenCodeWithThinking( basePayload: Record, body: GenerateBody, ): Promise<{ data: any; error: any }> { - const reasoning = buildOpenCodeReasoning(body) + const reasoning = buildOpenCodeReasoning(body); if (!reasoning) { - return await promptWithTimeout(ocClient, basePayload) + return await promptWithTimeout(ocClient, basePayload); } - const enhanced = { ...basePayload, reasoning } - const firstTry = await promptWithTimeout(ocClient, enhanced) + const enhanced = { ...basePayload, reasoning }; + const firstTry = await promptWithTimeout(ocClient, enhanced); if (!firstTry.error) { - return firstTry + return firstTry; } - console.warn('[AI] OpenCode reasoning options rejected, retrying without reasoning.') - return await promptWithTimeout(ocClient, basePayload) + console.warn('[AI] OpenCode reasoning options rejected, retrying without reasoning.'); + return await promptWithTimeout(ocClient, basePayload); } /** Generate via OpenCode SDK (connects to a running OpenCode server) */ -async function generateViaOpenCode(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> { - let ocServer: { close(): void } | undefined +async function generateViaOpenCode( + body: GenerateBody, + model?: string, +): Promise<{ text?: string; error?: string }> { + let ocServer: { close(): void } | undefined; try { - const { getOpencodeClient } = await import('../../utils/opencode-client') - const oc = await getOpencodeClient() - const ocClient = oc.client - ocServer = oc.server + const { getOpencodeClient } = await import('../../utils/opencode-client'); + const oc = await getOpencodeClient(); + const ocClient = oc.client; + ocServer = oc.server; const { data: session, error: sessionError } = await ocClient.session.create({ title: 'OpenPencil Generate', - }) + }); if (sessionError || !session) { - const detail = formatOpenCodeError(sessionError) - return { error: `Failed to create OpenCode session: ${detail}` } + const detail = formatOpenCodeError(sessionError); + return { error: `Failed to create OpenCode session: ${detail}` }; } // Inject system prompt as context (no AI reply) @@ -211,15 +227,17 @@ async function generateViaOpenCode(body: GenerateBody, model?: string): Promise< sessionID: session.id, noReply: true, parts: [{ type: 'text', text: body.system }], - }) + }); // Parse model string ("providerID/modelID") - let modelOption: { providerID: string; modelID: string } | undefined + let modelOption: { providerID: string; modelID: string } | undefined; if (model && model.includes('/')) { - const idx = model.indexOf('/') - modelOption = { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) } + const idx = model.indexOf('/'); + modelOption = { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) }; } else if (model) { - console.warn(`[AI] OpenCode generate: could not parse model string "${model}", sending without model override`) + console.warn( + `[AI] OpenCode generate: could not parse model string "${model}", sending without model override`, + ); } // Send main prompt and await full response @@ -227,54 +245,219 @@ async function generateViaOpenCode(body: GenerateBody, model?: string): Promise< sessionID: session.id, ...(modelOption ? { model: modelOption } : {}), parts: [{ type: 'text', text: body.message }], - } + }; - console.log(`[AI] OpenCode generate: model=${model}, parsed=${JSON.stringify(modelOption)}`) + console.log(`[AI] OpenCode generate: model=${model}, parsed=${JSON.stringify(modelOption)}`); const { data: result, error: promptError } = await promptOpenCodeWithThinking( ocClient, promptPayload, body, - ) + ); if (promptError) { - const errorDetail = formatOpenCodeError(promptError) - console.error('[AI] OpenCode generate error:', errorDetail) - return { error: errorDetail } + const errorDetail = formatOpenCodeError(promptError); + console.error('[AI] OpenCode generate error:', errorDetail); + return { error: errorDetail }; } // Extract text from response parts - const texts: string[] = [] + const texts: string[] = []; if (result?.parts) { for (const part of result.parts) { if (part.type === 'text' && part.text) { - texts.push(part.text) + texts.push(part.text); } } } if (texts.length === 0) { - return { error: 'OpenCode returned an empty response. The model may not have generated any output.' } + return { + error: 'OpenCode returned an empty response. The model may not have generated any output.', + }; } - return { text: texts.join('') } + return { text: texts.join('') }; } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - return { error: message } + const message = error instanceof Error ? error.message : 'Unknown error'; + return { error: message }; } finally { - const { releaseOpencodeServer } = await import('../../utils/opencode-client') - releaseOpencodeServer(ocServer) + const { releaseOpencodeServer } = await import('../../utils/opencode-client'); + releaseOpencodeServer(ocServer); } } /** Generate via Gemini CLI (`gemini -p -o json`) — CLI handles its own auth */ -async function generateViaGemini(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> { - const { runGeminiExec } = await import('../../utils/gemini-client') +async function generateViaGemini( + body: GenerateBody, + model?: string, +): Promise<{ text?: string; error?: string }> { + const { runGeminiExec } = await import('../../utils/gemini-client'); return runGeminiExec(body.message, { model, systemPrompt: body.system, thinkingMode: body.thinkingMode, thinkingBudgetTokens: body.thinkingBudgetTokens, effort: body.effort, - }) + }); +} + +// ─── SSE streaming variants ─────────────────────────────────────────────────── + +/** Stream via Claude Agent SDK — emits text/thinking deltas as SSE */ +function streamViaAgentSDK(body: GenerateBody, requestedModel?: string) { + return createSSEResponse(async (emit) => { + const { query } = await import('@anthropic-ai/claude-agent-sdk'); + const env = buildClaudeAgentEnv(); + const debugFile = getClaudeAgentDebugFilePath(); + const model = resolveAgentModel(requestedModel, env); + const claudePath = resolveClaudeCli(); + + const q = query({ + prompt: body.message, + options: { + systemPrompt: body.system, + ...(model ? { model } : {}), + maxTurns: 1, + includePartialMessages: true, + tools: [], + plugins: [], + permissionMode: 'plan', + persistSession: false, + env, + ...(debugFile ? { debugFile } : {}), + ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}), + ...(buildSpawnClaudeCodeProcess() + ? { spawnClaudeCodeProcess: buildSpawnClaudeCodeProcess() } + : {}), + }, + }); + + try { + for await (const message of q) { + if (message.type === 'stream_event') { + const ev = (message as any).event; + if (ev.type === 'content_block_delta') { + if (ev.delta.type === 'text_delta') emit({ type: 'text', content: ev.delta.text }); + else if (ev.delta.type === 'thinking_delta') + emit({ type: 'thinking', content: (ev.delta as any).thinking }); + } + } else if (message.type === 'result') { + const isError = 'is_error' in message && Boolean((message as any).is_error); + if (message.subtype !== 'success' || isError) { + const errors = 'errors' in message ? (message.errors as string[]) : []; + const resultText = 'result' in message ? String((message as any).result ?? '') : ''; + throw new Error(errors.join('; ') || resultText || `Query ended: ${message.subtype}`); + } + } + } + } finally { + q.close(); + } + }); +} + +/** Stream via Codex CLI — emits text/error deltas as SSE */ +function streamViaCodex(body: GenerateBody, model?: string) { + return createSSEResponse(async (emit) => { + let hasOutput = false; + for await (const event of streamCodexExec(body.message, { + model, + systemPrompt: body.system, + thinkingMode: body.thinkingMode, + thinkingBudgetTokens: body.thinkingBudgetTokens, + effort: body.effort, + })) { + emit(event); + if (event.type === 'text') hasOutput = true; + } + if (!hasOutput) throw new Error('Codex returned no output.'); + }); +} + +/** Parse an OpenCode model string ("providerID/modelID") into its parts */ +function parseOpenCodeModel(model?: string): { providerID: string; modelID: string } | undefined { + if (!model || !model.includes('/')) return undefined; + const idx = model.indexOf('/'); + return { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) }; +} + +/** Stream via OpenCode SDK — emits text/thinking deltas as SSE */ +function streamViaOpenCode(body: GenerateBody, model?: string) { + return createSSEResponse(async (emit) => { + const { getOpencodeClient, releaseOpencodeServer } = + await import('../../utils/opencode-client'); + const oc = await getOpencodeClient(); + try { + const ocClient = oc.client; + const { data: session, error: sessionError } = await ocClient.session.create({ + title: 'OpenPencil Generate', + }); + if (sessionError || !session) + throw new Error(`Session create failed: ${formatOpenCodeError(sessionError)}`); + + await ocClient.session.prompt({ + sessionID: session.id, + noReply: true, + parts: [{ type: 'text', text: body.system }], + }); + + const parsed = parseOpenCodeModel(model); + const basePayload: Record = { + sessionID: session.id, + ...(parsed ? { model: parsed } : {}), + parts: [{ type: 'text', text: body.message }], + }; + + const reasoning = buildOpenCodeReasoning(body); + const payloadWithReasoning = reasoning ? { ...basePayload, reasoning } : basePayload; + + const eventResult = await ocClient.event.subscribe(); + await new Promise((r) => setTimeout(r, 100)); + + let { error: asyncError } = await ocClient.session.promptAsync(payloadWithReasoning as any); + if (asyncError && reasoning) { + console.warn('[AI] OpenCode reasoning rejected, retrying without'); + ({ error: asyncError } = await ocClient.session.promptAsync(basePayload as any)); + } + if (asyncError) throw new Error(formatOpenCodeError(asyncError)); + + const sessionId = session.id; + for await (const event of eventResult.stream) { + const eventType = (event as any).type as string; + const props = (event as any).properties; + if (eventType === 'message.part.delta' && props?.sessionID === sessionId) { + if (props.field === 'text') emit({ type: 'text', content: props.delta }); + if (props.field === 'reasoning') emit({ type: 'thinking', content: props.delta }); + } + if (eventType === 'session.idle' && props?.sessionID === sessionId) break; + if ( + eventType === 'session.error' && + (props?.sessionID === sessionId || !props?.sessionID) + ) { + throw new Error(formatOpenCodeError(props?.error)); + } + } + } finally { + releaseOpencodeServer(oc.server); + } + }); +} + +/** Stream via Gemini CLI — emits text deltas as SSE */ +function streamViaGemini(body: GenerateBody, model?: string) { + return createSSEResponse(async (emit) => { + const { streamGeminiExec } = await import('../../utils/gemini-client'); + const { stream } = streamGeminiExec(body.message, { + model, + systemPrompt: body.system, + thinkingMode: body.thinkingMode, + thinkingBudgetTokens: body.thinkingBudgetTokens, + effort: body.effort, + }); + for await (const event of stream) { + if (event.type === 'text') emit({ type: 'text', content: event.content }); + if (event.type === 'error') throw new Error(event.content); + } + }); } diff --git a/apps/web/server/api/ai/icon.ts b/apps/web/server/api/ai/icon.ts index d69a3d47..1cd4b327 100644 --- a/apps/web/server/api/ai/icon.ts +++ b/apps/web/server/api/ai/icon.ts @@ -1,26 +1,26 @@ -import { defineEventHandler, getQuery, setResponseHeaders } from 'h3' -import simpleIconsData from '@iconify-json/simple-icons/icons.json' -import lucideData from '@iconify-json/lucide/icons.json' +import { defineEventHandler, getQuery, setResponseHeaders } from 'h3'; +import simpleIconsData from '@iconify-json/simple-icons/icons.json'; +import lucideData from '@iconify-json/lucide/icons.json'; interface IconResult { - d: string - style: 'stroke' | 'fill' - width: number - height: number - iconId: string + d: string; + style: 'stroke' | 'fill'; + width: number; + height: number; + iconId: string; } type IconifySet = { - width?: number - height?: number - icons: Record -} + width?: number; + height?: number; + icons: Record; +}; -const simpleIcons = simpleIconsData as unknown as IconifySet -const lucideIcons = lucideData as unknown as IconifySet +const simpleIcons = simpleIconsData as unknown as IconifySet; +const lucideIcons = lucideData as unknown as IconifySet; // In-memory cache: normalized name → result (null = confirmed miss) -const iconCache = new Map() +const iconCache = new Map(); /** * GET /api/ai/icon?name=google @@ -30,26 +30,26 @@ const iconCache = new Map() * No external network requests — instant, offline-capable. */ export default defineEventHandler(async (event) => { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) + setResponseHeaders(event, { 'Content-Type': 'application/json' }); - const { name } = getQuery(event) as { name?: string } + const { name } = getQuery(event) as { name?: string }; if (!name || typeof name !== 'string') { - return { icon: null, error: 'Missing required query parameter: name' } + return { icon: null, error: 'Missing required query parameter: name' }; } - const normalizedName = name.trim().toLowerCase() + const normalizedName = name.trim().toLowerCase(); if (!normalizedName) { - return { icon: null, error: 'Empty icon name' } + return { icon: null, error: 'Empty icon name' }; } if (iconCache.has(normalizedName)) { - return { icon: iconCache.get(normalizedName) ?? null } + return { icon: iconCache.get(normalizedName) ?? null }; } - const result = resolveIcon(normalizedName) - iconCache.set(normalizedName, result) - return { icon: result } -}) + const result = resolveIcon(normalizedName); + iconCache.set(normalizedName, result); + return { icon: result }; +}); // Common name aliases for icons AI models frequently request. // Keep in sync with commonAliases in src/services/ai/icon-resolver.ts @@ -117,41 +117,37 @@ const NAME_ALIASES: Record = { scan: 'scan', qrcode: 'qr-code', barcode: 'barcode', -} +}; function resolveIcon(name: string): IconResult | null { - const kebab = toKebabCase(name) - const aliased = NAME_ALIASES[name] ?? NAME_ALIASES[kebab] - const candidates = new Set([name, kebab]) - if (aliased) candidates.add(aliased) + const kebab = toKebabCase(name); + const aliased = NAME_ALIASES[name] ?? NAME_ALIASES[kebab]; + const candidates = new Set([name, kebab]); + if (aliased) candidates.add(aliased); // 1. Try simple-icons first (brand/product icons). // simple-icons only contains brand logos, so a hit here is unambiguously // a brand — no risk of shadowing UI icon names like "search" or "home". for (const n of candidates) { - const result = lookupLocal(simpleIcons, 'simple-icons', n) - if (result) return result + const result = lookupLocal(simpleIcons, 'simple-icons', n); + if (result) return result; } // 2. Try Lucide (UI icons) for (const n of candidates) { - const result = lookupLocal(lucideIcons, 'lucide', n) - if (result) return result + const result = lookupLocal(lucideIcons, 'lucide', n); + if (result) return result; } - return null + return null; } -function lookupLocal( - set: IconifySet, - collection: string, - iconName: string, -): IconResult | null { - const icon = set.icons[iconName] - if (!icon) return null - const w = icon.width ?? set.width ?? 24 - const h = icon.height ?? set.height ?? 24 - return parseIconBody(icon.body, w, h, `${collection}:${iconName}`) +function lookupLocal(set: IconifySet, collection: string, iconName: string): IconResult | null { + const icon = set.icons[iconName]; + if (!icon) return null; + const w = icon.width ?? set.width ?? 24; + const h = icon.height ?? set.height ?? 24; + return parseIconBody(icon.body, w, h, `${collection}:${iconName}`); } /** @@ -164,34 +160,34 @@ function parseIconBody( height: number, iconId: string, ): IconResult | null { - const pathRegex = /]*?\bd="([^"]+)"[^>]*?\/?>/gi - const paths: string[] = [] - let hasStroke = false - let hasFill = false - let match: RegExpExecArray | null + const pathRegex = /]*?\bd="([^"]+)"[^>]*?\/?>/gi; + const paths: string[] = []; + let hasStroke = false; + let hasFill = false; + let match: RegExpExecArray | null; while ((match = pathRegex.exec(body)) !== null) { - paths.push(match[1]) - const tag = match[0] + paths.push(match[1]); + const tag = match[0]; if (/\bstroke=/.test(tag) || /\bstroke-width=/.test(tag) || /\bstroke-linecap=/.test(tag)) { - hasStroke = true + hasStroke = true; } if (/\bfill="(?!none)[^"]*"/.test(tag)) { - hasFill = true + hasFill = true; } if (/\bfill="none"/.test(tag)) { - hasStroke = true + hasStroke = true; } } - if (paths.length === 0) return null + if (paths.length === 0) return null; // Check body-level stroke/fill attributes if (/\bstroke="currentColor"/.test(body) || /\bstroke-linecap=/.test(body)) { - hasStroke = true + hasStroke = true; } if (/\bfill="currentColor"/.test(body) && !/\bfill="none"/.test(body)) { - hasFill = true + hasFill = true; } // When joining multiple d-values, ensure each sub-path starts with @@ -199,13 +195,13 @@ function parseIconBody( // but after concatenation it becomes relative to the previous endpoint. for (let i = 1; i < paths.length; i++) { if (paths[i].startsWith('m')) { - paths[i] = 'M' + paths[i].slice(1) + paths[i] = 'M' + paths[i].slice(1); } } - const d = paths.join(' ') - const style: 'stroke' | 'fill' = hasStroke && !hasFill ? 'stroke' : 'fill' + const d = paths.join(' '); + const style: 'stroke' | 'fill' = hasStroke && !hasFill ? 'stroke' : 'fill'; - return { d, style, width, height, iconId } + return { d, style, width, height, iconId }; } /** @@ -214,13 +210,20 @@ function parseIconBody( */ function toKebabCase(name: string): string { const prefixes = [ - 'arrow', 'chevron', 'circle', 'alert', 'help', - 'external', 'bar', 'message', 'log', - ] + 'arrow', + 'chevron', + 'circle', + 'alert', + 'help', + 'external', + 'bar', + 'message', + 'log', + ]; for (const prefix of prefixes) { if (name.startsWith(prefix) && name.length > prefix.length) { - return `${prefix}-${name.slice(prefix.length)}` + return `${prefix}-${name.slice(prefix.length)}`; } } - return name + return name; } diff --git a/apps/web/server/api/ai/image-generate.ts b/apps/web/server/api/ai/image-generate.ts index c986d712..32ab4bc6 100644 --- a/apps/web/server/api/ai/image-generate.ts +++ b/apps/web/server/api/ai/image-generate.ts @@ -1,13 +1,13 @@ -import { defineEventHandler, readBody, setResponseHeaders, createError } from 'h3' +import { defineEventHandler, readBody, setResponseHeaders, createError } from 'h3'; interface ImageGenerateBody { - prompt: string - provider: 'openai' | 'custom' | 'gemini' | 'replicate' - model: string - apiKey: string - baseUrl?: string - width?: number - height?: number + prompt: string; + provider: 'openai' | 'custom' | 'gemini' | 'replicate'; + model: string; + apiKey: string; + baseUrl?: string; + width?: number; + height?: number; } /** @@ -18,47 +18,47 @@ interface ImageGenerateBody { * Returns { url: string } — either a remote URL or a base64 data URL. */ export default defineEventHandler(async (event) => { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) + setResponseHeaders(event, { 'Content-Type': 'application/json' }); - const body = await readBody(event) + const body = await readBody(event); if (!body?.prompt?.trim()) { - throw createError({ statusCode: 400, message: 'Missing required field: prompt' }) + throw createError({ statusCode: 400, message: 'Missing required field: prompt' }); } if (!body?.provider) { - throw createError({ statusCode: 400, message: 'Missing required field: provider' }) + throw createError({ statusCode: 400, message: 'Missing required field: provider' }); } if (!body?.apiKey?.trim()) { - throw createError({ statusCode: 400, message: 'Missing required field: apiKey' }) + throw createError({ statusCode: 400, message: 'Missing required field: apiKey' }); } - const { prompt, provider, model, apiKey, baseUrl, width, height } = body + const { prompt, provider, model, apiKey, baseUrl, width, height } = body; if (provider === 'openai' || provider === 'custom') { - return await generateOpenAI({ prompt, model, apiKey, baseUrl, width, height }) + return await generateOpenAI({ prompt, model, apiKey, baseUrl, width, height }); } if (provider === 'gemini') { - return await generateGemini({ prompt, model, apiKey, baseUrl, width, height }) + return await generateGemini({ prompt, model, apiKey, baseUrl, width, height }); } if (provider === 'replicate') { - return await generateReplicate({ prompt, model, apiKey, baseUrl, width, height }) + return await generateReplicate({ prompt, model, apiKey, baseUrl, width, height }); } - throw createError({ statusCode: 400, message: `Unsupported provider: ${provider}` }) -}) + throw createError({ statusCode: 400, message: `Unsupported provider: ${provider}` }); +}); // --------------------------------------------------------------------------- // Size mapping // --------------------------------------------------------------------------- function mapToOpenAISize(w?: number, h?: number): string { - if (!w || !h) return '1024x1024' - const ratio = w / h - if (ratio > 1.3) return '1792x1024' - if (ratio < 0.77) return '1024x1792' - return '1024x1024' + if (!w || !h) return '1024x1024'; + const ratio = w / h; + if (ratio > 1.3) return '1792x1024'; + if (ratio < 0.77) return '1024x1792'; + return '1024x1024'; } // --------------------------------------------------------------------------- @@ -66,18 +66,18 @@ function mapToOpenAISize(w?: number, h?: number): string { // --------------------------------------------------------------------------- async function generateOpenAI(opts: { - prompt: string - model: string - apiKey: string - baseUrl?: string - width?: number - height?: number + prompt: string; + model: string; + apiKey: string; + baseUrl?: string; + width?: number; + height?: number; }): Promise<{ url: string }> { - const { prompt, model, apiKey, baseUrl, width, height } = opts - const size = mapToOpenAISize(width, height) - const endpoint = `${baseUrl ?? 'https://api.openai.com'}/v1/images/generations` + const { prompt, model, apiKey, baseUrl, width, height } = opts; + const size = mapToOpenAISize(width, height); + const endpoint = `${baseUrl ?? 'https://api.openai.com'}/v1/images/generations`; - let res: Response + let res: Response; try { res = await fetch(endpoint, { method: 'POST', @@ -86,30 +86,30 @@ async function generateOpenAI(opts: { Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model, prompt, n: 1, size, response_format: 'url' }), - }) + }); } catch (err) { - throw createError({ statusCode: 502, message: `OpenAI request failed: ${String(err)}` }) + throw createError({ statusCode: 502, message: `OpenAI request failed: ${String(err)}` }); } if (!res.ok) { - const text = await res.text().catch(() => '') - let msg = `OpenAI returned ${res.status}` + const text = await res.text().catch(() => ''); + let msg = `OpenAI returned ${res.status}`; try { - const errJson = JSON.parse(text) as { error?: { message?: string } } - if (errJson.error?.message) msg = errJson.error.message + const errJson = JSON.parse(text) as { error?: { message?: string } }; + if (errJson.error?.message) msg = errJson.error.message; } catch { - if (text) msg += `: ${text.slice(0, 150)}` + if (text) msg += `: ${text.slice(0, 150)}`; } - throw createError({ statusCode: 502, message: msg }) + throw createError({ statusCode: 502, message: msg }); } - const data = (await res.json()) as { data?: { url?: string }[] } - const url = data?.data?.[0]?.url + const data = (await res.json()) as { data?: { url?: string }[] }; + const url = data?.data?.[0]?.url; if (!url) { - throw createError({ statusCode: 502, message: 'OpenAI response missing image URL' }) + throw createError({ statusCode: 502, message: 'OpenAI response missing image URL' }); } - return { url } + return { url }; } // --------------------------------------------------------------------------- @@ -117,34 +117,34 @@ async function generateOpenAI(opts: { // --------------------------------------------------------------------------- function mapToGeminiAspectRatio(w?: number, h?: number): string | undefined { - if (!w || !h) return undefined - const ratio = w / h - if (ratio > 1.6) return '16:9' - if (ratio > 1.3) return '4:3' - if (ratio < 0.625) return '9:16' - if (ratio < 0.77) return '3:4' - return '1:1' + if (!w || !h) return undefined; + const ratio = w / h; + if (ratio > 1.6) return '16:9'; + if (ratio > 1.3) return '4:3'; + if (ratio < 0.625) return '9:16'; + if (ratio < 0.77) return '3:4'; + return '1:1'; } async function generateGemini(opts: { - prompt: string - model: string - apiKey: string - baseUrl?: string - width?: number - height?: number + prompt: string; + model: string; + apiKey: string; + baseUrl?: string; + width?: number; + height?: number; }): Promise<{ url: string }> { - const { prompt, model, apiKey, baseUrl, width, height } = opts - const base = baseUrl ?? 'https://generativelanguage.googleapis.com' - const endpoint = `${base}/v1beta/models/${model}:generateContent?key=${apiKey}` + const { prompt, model, apiKey, baseUrl, width, height } = opts; + const base = baseUrl ?? 'https://generativelanguage.googleapis.com'; + const endpoint = `${base}/v1beta/models/${model}:generateContent?key=${apiKey}`; - const generationConfig: Record = { responseModalities: ['TEXT', 'IMAGE'] } - const aspectRatio = mapToGeminiAspectRatio(width, height) + const generationConfig: Record = { responseModalities: ['TEXT', 'IMAGE'] }; + const aspectRatio = mapToGeminiAspectRatio(width, height); if (aspectRatio) { - generationConfig.imageConfig = { aspectRatio } + generationConfig.imageConfig = { aspectRatio }; } - let res: Response + let res: Response; try { res = await fetch(endpoint, { method: 'POST', @@ -153,43 +153,43 @@ async function generateGemini(opts: { contents: [{ parts: [{ text: prompt }] }], generationConfig, }), - }) + }); } catch (err) { - throw createError({ statusCode: 502, message: `Gemini request failed: ${String(err)}` }) + throw createError({ statusCode: 502, message: `Gemini request failed: ${String(err)}` }); } if (!res.ok) { - const text = await res.text().catch(() => '') - let msg = `Gemini returned ${res.status}` + const text = await res.text().catch(() => ''); + let msg = `Gemini returned ${res.status}`; try { - const errJson = JSON.parse(text) as { error?: { message?: string } } - if (errJson.error?.message) msg = errJson.error.message + const errJson = JSON.parse(text) as { error?: { message?: string } }; + if (errJson.error?.message) msg = errJson.error.message; } catch { - if (text) msg += `: ${text.slice(0, 150)}` + if (text) msg += `: ${text.slice(0, 150)}`; } - throw createError({ statusCode: 502, message: msg }) + throw createError({ statusCode: 502, message: msg }); } const data = (await res.json()) as { candidates?: { content?: { parts?: { - inlineData?: { mimeType?: string; data?: string } - text?: string - }[] - } - }[] - } + inlineData?: { mimeType?: string; data?: string }; + text?: string; + }[]; + }; + }[]; + }; - const parts = data?.candidates?.[0]?.content?.parts ?? [] - const imagePart = parts.find((p) => p.inlineData?.mimeType?.startsWith('image/')) + const parts = data?.candidates?.[0]?.content?.parts ?? []; + const imagePart = parts.find((p) => p.inlineData?.mimeType?.startsWith('image/')); if (!imagePart?.inlineData?.data || !imagePart.inlineData.mimeType) { - throw createError({ statusCode: 502, message: 'Gemini response missing inline image data' }) + throw createError({ statusCode: 502, message: 'Gemini response missing inline image data' }); } - const { mimeType, data: base64data } = imagePart.inlineData - return { url: `data:${mimeType};base64,${base64data}` } + const { mimeType, data: base64data } = imagePart.inlineData; + return { url: `data:${mimeType};base64,${base64data}` }; } // --------------------------------------------------------------------------- @@ -197,22 +197,22 @@ async function generateGemini(opts: { // --------------------------------------------------------------------------- async function generateReplicate(opts: { - prompt: string - model: string - apiKey: string - baseUrl?: string - width?: number - height?: number + prompt: string; + model: string; + apiKey: string; + baseUrl?: string; + width?: number; + height?: number; }): Promise<{ url: string }> { - const { prompt, model, apiKey, baseUrl, width, height } = opts - const base = baseUrl ?? 'https://api.replicate.com' + const { prompt, model, apiKey, baseUrl, width, height } = opts; + const base = baseUrl ?? 'https://api.replicate.com'; // Start prediction - let createRes: Response + let createRes: Response; try { - const input: Record = { prompt } - if (width) input.width = width - if (height) input.height = height + const input: Record = { prompt }; + if (width) input.width = width; + if (height) input.height = height; createRes = await fetch(`${base}/v1/predictions`, { method: 'POST', @@ -221,84 +221,84 @@ async function generateReplicate(opts: { Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model, input }), - }) + }); } catch (err) { - throw createError({ statusCode: 502, message: `Replicate request failed: ${String(err)}` }) + throw createError({ statusCode: 502, message: `Replicate request failed: ${String(err)}` }); } if (!createRes.ok) { - const text = await createRes.text().catch(() => '') - let msg = `Replicate returned ${createRes.status}` + const text = await createRes.text().catch(() => ''); + let msg = `Replicate returned ${createRes.status}`; try { - const errJson = JSON.parse(text) as { detail?: string } - if (errJson.detail) msg = errJson.detail + const errJson = JSON.parse(text) as { detail?: string }; + if (errJson.detail) msg = errJson.detail; } catch { - if (text) msg += `: ${text.slice(0, 150)}` + if (text) msg += `: ${text.slice(0, 150)}`; } - throw createError({ statusCode: 502, message: msg }) + throw createError({ statusCode: 502, message: msg }); } - const prediction = (await createRes.json()) as { id?: string; status?: string } - const predictionId = prediction?.id + const prediction = (await createRes.json()) as { id?: string; status?: string }; + const predictionId = prediction?.id; if (!predictionId) { - throw createError({ statusCode: 502, message: 'Replicate response missing prediction ID' }) + throw createError({ statusCode: 502, message: 'Replicate response missing prediction ID' }); } // Poll until succeeded or failed (max 120s, polling every 2s) - const maxAttempts = 60 + const maxAttempts = 60; for (let attempt = 0; attempt < maxAttempts; attempt++) { - await new Promise((resolve) => setTimeout(resolve, 2000)) + await new Promise((resolve) => setTimeout(resolve, 2000)); - let pollRes: Response + let pollRes: Response; try { pollRes = await fetch(`${base}/v1/predictions/${predictionId}`, { headers: { Authorization: `Bearer ${apiKey}` }, - }) + }); } catch (err) { throw createError({ statusCode: 502, message: `Replicate poll request failed: ${String(err)}`, - }) + }); } if (!pollRes.ok) { - const text = await pollRes.text().catch(() => '') + const text = await pollRes.text().catch(() => ''); throw createError({ statusCode: 502, message: `Replicate poll returned ${pollRes.status}: ${text.slice(0, 200)}`, - }) + }); } const status = (await pollRes.json()) as { - id?: string - status?: string - output?: string | string[] - error?: string - } + id?: string; + status?: string; + output?: string | string[]; + error?: string; + }; if (status.status === 'succeeded') { - const output = status.output + const output = status.output; if (Array.isArray(output)) { - const first = output[0] + const first = output[0]; if (!first) { throw createError({ statusCode: 502, message: 'Replicate succeeded but output array is empty', - }) + }); } - return { url: first } + return { url: first }; } if (typeof output === 'string') { - return { url: output } + return { url: output }; } - throw createError({ statusCode: 502, message: 'Replicate succeeded but output is missing' }) + throw createError({ statusCode: 502, message: 'Replicate succeeded but output is missing' }); } if (status.status === 'failed' || status.status === 'canceled') { throw createError({ statusCode: 502, message: `Replicate prediction ${status.status}: ${status.error ?? 'unknown error'}`, - }) + }); } // Still starting/processing — keep polling @@ -307,5 +307,5 @@ async function generateReplicate(opts: { throw createError({ statusCode: 502, message: 'Replicate prediction timed out after 120 seconds', - }) + }); } diff --git a/apps/web/server/api/ai/image-search.ts b/apps/web/server/api/ai/image-search.ts index 6fe5c64e..b841d923 100644 --- a/apps/web/server/api/ai/image-search.ts +++ b/apps/web/server/api/ai/image-search.ts @@ -1,59 +1,59 @@ -import { defineEventHandler, readBody, setResponseHeaders } from 'h3' -import type { ImageSearchResult, ImageSearchResponse } from '../../../src/types/image-service' +import { defineEventHandler, readBody, setResponseHeaders } from 'h3'; +import type { ImageSearchResult, ImageSearchResponse } from '../../../src/types/image-service'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface OpenverseImageResult { - id: string - url: string - thumbnail: string - width: number - height: number - license: string - license_version: string - attribution: string + id: string; + url: string; + thumbnail: string; + width: number; + height: number; + license: string; + license_version: string; + attribution: string; } interface OpenverseSearchResponse { - results: OpenverseImageResult[] + results: OpenverseImageResult[]; } interface WikimediaImageInfo { - url: string - thumburl: string - width: number - height: number - mime: string + url: string; + thumburl: string; + width: number; + height: number; + mime: string; extmetadata?: { - LicenseShortName?: { value: string } - } + LicenseShortName?: { value: string }; + }; } interface WikimediaPage { - pageid: number - title: string - imageinfo?: WikimediaImageInfo[] + pageid: number; + title: string; + imageinfo?: WikimediaImageInfo[]; } interface WikimediaQueryResponse { query?: { - pages?: Record - } + pages?: Record; + }; } // --------------------------------------------------------------------------- // OAuth token cache // --------------------------------------------------------------------------- -let cachedToken: string | null = null -let tokenExpiresAt = 0 +let cachedToken: string | null = null; +let tokenExpiresAt = 0; async function getOpenverseToken(clientId: string, clientSecret: string): Promise { - const now = Date.now() + const now = Date.now(); if (cachedToken && now < tokenExpiresAt) { - return cachedToken + return cachedToken; } try { @@ -61,20 +61,20 @@ async function getOpenverseToken(clientId: string, clientSecret: string): Promis grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, - }) + }); const res = await fetch('https://api.openverse.org/v1/auth_tokens/token/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), - }) - if (!res.ok) return null - const data = (await res.json()) as { access_token: string; expires_in: number } - cachedToken = data.access_token + }); + if (!res.ok) return null; + const data = (await res.json()) as { access_token: string; expires_in: number }; + cachedToken = data.access_token; // Refresh 60 seconds before expiry - tokenExpiresAt = now + (data.expires_in - 60) * 1000 - return cachedToken + tokenExpiresAt = now + (data.expires_in - 60) * 1000; + return cachedToken; } catch { - return null + return null; } } @@ -83,18 +83,98 @@ async function getOpenverseToken(clientId: string, clientSecret: string): Promis // --------------------------------------------------------------------------- const STOP_WORDS = new Set([ - 'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', - 'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been', - 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', - 'could', 'should', 'may', 'might', 'shall', 'can', 'that', 'this', - 'these', 'those', 'it', 'its', 'very', 'really', 'just', 'also', - 'about', 'above', 'after', 'before', 'between', 'into', 'through', - 'during', 'each', 'some', 'such', 'no', 'not', 'only', 'same', 'so', - 'than', 'too', 'up', 'out', 'if', 'then', 'once', 'here', 'there', - 'when', 'where', 'how', 'all', 'both', 'few', 'more', 'most', 'other', - 'any', 'as', 'while', 'using', 'showing', 'featuring', 'looking', - 'style', 'styled', 'inspired', 'based', -]) + 'a', + 'an', + 'the', + 'and', + 'or', + 'but', + 'in', + 'on', + 'at', + 'to', + 'for', + 'of', + 'with', + 'by', + 'from', + 'is', + 'are', + 'was', + 'were', + 'be', + 'been', + 'being', + 'have', + 'has', + 'had', + 'do', + 'does', + 'did', + 'will', + 'would', + 'could', + 'should', + 'may', + 'might', + 'shall', + 'can', + 'that', + 'this', + 'these', + 'those', + 'it', + 'its', + 'very', + 'really', + 'just', + 'also', + 'about', + 'above', + 'after', + 'before', + 'between', + 'into', + 'through', + 'during', + 'each', + 'some', + 'such', + 'no', + 'not', + 'only', + 'same', + 'so', + 'than', + 'too', + 'up', + 'out', + 'if', + 'then', + 'once', + 'here', + 'there', + 'when', + 'where', + 'how', + 'all', + 'both', + 'few', + 'more', + 'most', + 'other', + 'any', + 'as', + 'while', + 'using', + 'showing', + 'featuring', + 'looking', + 'style', + 'styled', + 'inspired', + 'based', +]); /** * Simplify a verbose image generation prompt into 2-4 search keywords. @@ -106,11 +186,11 @@ export function simplifySearchQuery(prompt: string): string { .toLowerCase() .replace(/[^a-z0-9\s-]/g, ' ') .split(/\s+/) - .filter((w) => w.length > 2 && !STOP_WORDS.has(w)) + .filter((w) => w.length > 2 && !STOP_WORDS.has(w)); // Take up to 4 keywords - const keywords = words.slice(0, 4) - return keywords.join(' ') || prompt.slice(0, 30) + const keywords = words.slice(0, 4); + return keywords.join(' ') || prompt.slice(0, 30); } // --------------------------------------------------------------------------- @@ -127,16 +207,14 @@ export function mapOpenverseResult(r: OpenverseImageResult): ImageSearchResult { source: 'openverse', license: `${r.license} ${r.license_version}`.trim(), attribution: r.attribution, - } + }; } -export function mapWikimediaPages( - pages: Record, -): ImageSearchResult[] { - const results: ImageSearchResult[] = [] +export function mapWikimediaPages(pages: Record): ImageSearchResult[] { + const results: ImageSearchResult[] = []; for (const page of Object.values(pages)) { - const info = page.imageinfo?.[0] - if (!info) continue + const info = page.imageinfo?.[0]; + if (!info) continue; results.push({ id: String(page.pageid), url: info.url, @@ -145,9 +223,9 @@ export function mapWikimediaPages( height: info.height, source: 'wikimedia', license: info.extmetadata?.LicenseShortName?.value ?? '', - }) + }); } - return results + return results; } // --------------------------------------------------------------------------- @@ -161,58 +239,55 @@ async function fetchFromOpenverse( clientId: string | undefined, clientSecret: string | undefined, ): Promise { - const url = new URL('https://api.openverse.org/v1/images/') - url.searchParams.set('q', query) - url.searchParams.set('page_size', String(count)) + const url = new URL('https://api.openverse.org/v1/images/'); + url.searchParams.set('q', query); + url.searchParams.set('page_size', String(count)); if (aspectRatio) { - url.searchParams.set('aspect_ratio', aspectRatio) + url.searchParams.set('aspect_ratio', aspectRatio); } - const headers: Record = {} + const headers: Record = {}; if (clientId && clientSecret) { - const token = await getOpenverseToken(clientId, clientSecret) + const token = await getOpenverseToken(clientId, clientSecret); if (token) { - headers['Authorization'] = `Bearer ${token}` + headers['Authorization'] = `Bearer ${token}`; } } - const res = await fetch(url.toString(), { headers }) + const res = await fetch(url.toString(), { headers }); if (res.status === 429) { // Rate limited — signal fallback - return null + return null; } if (!res.ok) { - return null + return null; } - const data = (await res.json()) as OpenverseSearchResponse - return (data.results ?? []).map(mapOpenverseResult) + const data = (await res.json()) as OpenverseSearchResponse; + return (data.results ?? []).map(mapOpenverseResult); } -async function fetchFromWikimedia( - query: string, - count: number, -): Promise { - const url = new URL('https://commons.wikimedia.org/w/api.php') - url.searchParams.set('action', 'query') - url.searchParams.set('generator', 'search') - url.searchParams.set('gsrsearch', query) - url.searchParams.set('gsrnamespace', '6') - url.searchParams.set('gsrlimit', String(count)) - url.searchParams.set('prop', 'imageinfo') - url.searchParams.set('iiprop', 'url|size|mime|extmetadata') - url.searchParams.set('iiurlwidth', '800') - url.searchParams.set('format', 'json') - url.searchParams.set('origin', '*') +async function fetchFromWikimedia(query: string, count: number): Promise { + const url = new URL('https://commons.wikimedia.org/w/api.php'); + url.searchParams.set('action', 'query'); + url.searchParams.set('generator', 'search'); + url.searchParams.set('gsrsearch', query); + url.searchParams.set('gsrnamespace', '6'); + url.searchParams.set('gsrlimit', String(count)); + url.searchParams.set('prop', 'imageinfo'); + url.searchParams.set('iiprop', 'url|size|mime|extmetadata'); + url.searchParams.set('iiurlwidth', '800'); + url.searchParams.set('format', 'json'); + url.searchParams.set('origin', '*'); - const res = await fetch(url.toString()) - if (!res.ok) return [] + const res = await fetch(url.toString()); + if (!res.ok) return []; - const data = (await res.json()) as WikimediaQueryResponse - const pages = data.query?.pages - if (!pages) return [] + const data = (await res.json()) as WikimediaQueryResponse; + const pages = data.query?.pages; + if (!pages) return []; - return mapWikimediaPages(pages) + return mapWikimediaPages(pages); } // --------------------------------------------------------------------------- @@ -228,28 +303,28 @@ async function fetchFromWikimedia( * Body: { query, count?, aspectRatio?, openverseClientId?, openverseClientSecret? } */ export default defineEventHandler(async (event) => { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) + setResponseHeaders(event, { 'Content-Type': 'application/json' }); - const body = await readBody(event) as { - query?: string - count?: number - aspectRatio?: string - openverseClientId?: string - openverseClientSecret?: string - } + const body = (await readBody(event)) as { + query?: string; + count?: number; + aspectRatio?: string; + openverseClientId?: string; + openverseClientSecret?: string; + }; - const rawQuery = body?.query?.trim() ?? '' + const rawQuery = body?.query?.trim() ?? ''; if (!rawQuery) { - return { error: 'Missing required field: query' } + return { error: 'Missing required field: query' }; } // Simplify verbose AI prompts into search-friendly keywords - const query = simplifySearchQuery(rawQuery) + const query = simplifySearchQuery(rawQuery); - const count = Math.min(Math.max(Number(body?.count ?? 10), 1), 50) - const aspectRatio = body?.aspectRatio - const clientId = body?.openverseClientId - const clientSecret = body?.openverseClientSecret + const count = Math.min(Math.max(Number(body?.count ?? 10), 1), 50); + const aspectRatio = body?.aspectRatio; + const clientId = body?.openverseClientId; + const clientSecret = body?.openverseClientSecret; // Try Openverse first const openverseResults = await fetchFromOpenverse( @@ -258,19 +333,19 @@ export default defineEventHandler(async (event) => { aspectRatio, clientId, clientSecret, - ) + ); if (openverseResults !== null) { return { results: openverseResults, source: 'openverse', - } satisfies ImageSearchResponse + } satisfies ImageSearchResponse; } // Openverse returned 429 or failed — fall back to Wikimedia - const wikimediaResults = await fetchFromWikimedia(query, count) + const wikimediaResults = await fetchFromWikimedia(query, count); return { results: wikimediaResults, source: 'wikimedia', - } satisfies ImageSearchResponse -}) + } satisfies ImageSearchResponse; +}); diff --git a/apps/web/server/api/ai/image-service-test.ts b/apps/web/server/api/ai/image-service-test.ts index 4e82118e..2053231b 100644 --- a/apps/web/server/api/ai/image-service-test.ts +++ b/apps/web/server/api/ai/image-service-test.ts @@ -1,17 +1,17 @@ -import { defineEventHandler, readBody } from 'h3' +import { defineEventHandler, readBody } from 'h3'; interface ImageServiceTestRequest { - service: string - apiKey?: string - model?: string - baseUrl?: string - clientId?: string - clientSecret?: string + service: string; + apiKey?: string; + model?: string; + baseUrl?: string; + clientId?: string; + clientSecret?: string; } interface ImageServiceTestResponse { - valid: boolean - error?: string + valid: boolean; + error?: string; } /** @@ -21,84 +21,87 @@ interface ImageServiceTestResponse { * Returns { valid: boolean, error?: string } */ export default defineEventHandler(async (event): Promise => { - const body = await readBody(event) - const { service, apiKey, baseUrl, clientId, clientSecret } = body ?? {} + const body = await readBody(event); + const { service, apiKey, baseUrl, clientId, clientSecret } = body ?? {}; if (!service) { - return { valid: false, error: 'Missing required field: service' } + return { valid: false, error: 'Missing required field: service' }; } try { switch (service) { case 'openverse': { if (!clientId || !clientSecret) { - return { valid: false, error: 'Openverse requires clientId and clientSecret' } + return { valid: false, error: 'Openverse requires clientId and clientSecret' }; } - const formData = new URLSearchParams() - formData.set('grant_type', 'client_credentials') - formData.set('client_id', clientId) - formData.set('client_secret', clientSecret) + const formData = new URLSearchParams(); + formData.set('grant_type', 'client_credentials'); + formData.set('client_id', clientId); + formData.set('client_secret', clientSecret); const res = await fetch('https://api.openverse.org/v1/auth_tokens/token/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formData.toString(), - }) + }); if (!res.ok) { - const text = await res.text().catch(() => '') - return { valid: false, error: `Openverse auth failed (${res.status}): ${text}` } + const text = await res.text().catch(() => ''); + return { valid: false, error: `Openverse auth failed (${res.status}): ${text}` }; } - return { valid: true } + return { valid: true }; } case 'openai': case 'custom': { if (!apiKey) { - return { valid: false, error: 'Missing required field: apiKey' } + return { valid: false, error: 'Missing required field: apiKey' }; } - const origin = baseUrl ?? 'https://api.openai.com' + const origin = baseUrl ?? 'https://api.openai.com'; const res = await fetch(`${origin}/v1/models`, { headers: { Authorization: `Bearer ${apiKey}` }, - }) + }); if (!res.ok) { - const text = await res.text().catch(() => '') - return { valid: false, error: `Models request failed (${res.status}): ${text}` } + const text = await res.text().catch(() => ''); + return { valid: false, error: `Models request failed (${res.status}): ${text}` }; } - return { valid: true } + return { valid: true }; } case 'gemini': { if (!apiKey) { - return { valid: false, error: 'Missing required field: apiKey' } + return { valid: false, error: 'Missing required field: apiKey' }; } - const origin = baseUrl ?? 'https://generativelanguage.googleapis.com' - const res = await fetch(`${origin}/v1beta/models?key=${encodeURIComponent(apiKey)}`) + const origin = baseUrl ?? 'https://generativelanguage.googleapis.com'; + const res = await fetch(`${origin}/v1beta/models?key=${encodeURIComponent(apiKey)}`); if (!res.ok) { - const text = await res.text().catch(() => '') - return { valid: false, error: `Gemini models request failed (${res.status}): ${text}` } + const text = await res.text().catch(() => ''); + return { valid: false, error: `Gemini models request failed (${res.status}): ${text}` }; } - return { valid: true } + return { valid: true }; } case 'replicate': { if (!apiKey) { - return { valid: false, error: 'Missing required field: apiKey' } + return { valid: false, error: 'Missing required field: apiKey' }; } - const origin = baseUrl ?? 'https://api.replicate.com' + const origin = baseUrl ?? 'https://api.replicate.com'; const res = await fetch(`${origin}/v1/models`, { headers: { Authorization: `Bearer ${apiKey}` }, - }) + }); if (!res.ok) { - const text = await res.text().catch(() => '') - return { valid: false, error: `Replicate models request failed (${res.status}): ${text}` } + const text = await res.text().catch(() => ''); + return { + valid: false, + error: `Replicate models request failed (${res.status}): ${text}`, + }; } - return { valid: true } + return { valid: true }; } default: - return { valid: false, error: `Unknown service: ${service}` } + return { valid: false, error: `Unknown service: ${service}` }; } } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { valid: false, error: message } + const message = err instanceof Error ? err.message : String(err); + return { valid: false, error: message }; } -}) +}); diff --git a/apps/web/server/api/ai/install-agent.ts b/apps/web/server/api/ai/install-agent.ts index da7426b8..8fe6820d 100644 --- a/apps/web/server/api/ai/install-agent.ts +++ b/apps/web/server/api/ai/install-agent.ts @@ -1,62 +1,61 @@ -import { defineEventHandler, readBody, setResponseHeaders } from 'h3' -import { execSync } from 'node:child_process' +import { defineEventHandler, readBody, setResponseHeaders } from 'h3'; +import { execSync } from 'node:child_process'; interface InstallBody { - agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli' + agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli'; } interface InstallResult { - success: boolean - error?: string - command?: string - docsUrl?: string + success: boolean; + error?: string; + command?: string; + docsUrl?: string; } const BINARY_MAP: Record = { 'claude-code': 'claude', 'codex-cli': 'codex', - 'opencode': 'opencode', - 'copilot': 'copilot', + opencode: 'opencode', + copilot: 'copilot', 'gemini-cli': 'gemini', -} +}; function checkBinary(binary: string): boolean { try { - const cmd = process.platform === 'win32' - ? `where ${binary} 2>nul` - : `which ${binary} 2>/dev/null` - return !!execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim() + const cmd = + process.platform === 'win32' ? `where ${binary} 2>nul` : `which ${binary} 2>/dev/null`; + return !!execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim(); } catch { - return false + return false; } } function hasCommand(cmd: string): boolean { - return checkBinary(cmd) + return checkBinary(cmd); } function getInstallInfo(agent: string): { command: string; docsUrl: string } { - const isWin = process.platform === 'win32' - const isMac = process.platform === 'darwin' + const isWin = process.platform === 'win32'; + const isMac = process.platform === 'darwin'; switch (agent) { case 'claude-code': return { command: 'npm install -g @anthropic-ai/claude-code', docsUrl: 'https://docs.anthropic.com/en/docs/claude-code', - } + }; case 'codex-cli': return { command: 'npm install -g @openai/codex', docsUrl: 'https://github.com/openai/codex', - } + }; case 'opencode': return { command: isWin ? 'npm install -g opencode-ai' : 'curl -fsSL https://opencode.ai/install | bash', docsUrl: 'https://opencode.ai', - } + }; case 'copilot': return { command: isMac @@ -65,14 +64,14 @@ function getInstallInfo(agent: string): { command: string; docsUrl: string } { ? 'winget install GitHub.CopilotCLI' : 'See documentation', docsUrl: 'https://docs.github.com/copilot/how-tos/copilot-cli', - } + }; case 'gemini-cli': return { command: 'npm install -g @anthropic-ai/gemini-cli', docsUrl: 'https://github.com/anthropics/gemini-cli', - } + }; default: - return { command: '', docsUrl: '' } + return { command: '', docsUrl: '' }; } } @@ -81,28 +80,28 @@ function getInstallInfo(agent: string): { command: string; docsUrl: string } { * Attempts to auto-install a CLI agent. Returns manual instructions on failure. */ export default defineEventHandler(async (event) => { - const body = await readBody(event) - setResponseHeaders(event, { 'Content-Type': 'application/json' }) + const body = await readBody(event); + setResponseHeaders(event, { 'Content-Type': 'application/json' }); if (!body?.agent) { - return { success: false, error: 'Missing agent field' } satisfies InstallResult + return { success: false, error: 'Missing agent field' } satisfies InstallResult; } - const binary = BINARY_MAP[body.agent] + const binary = BINARY_MAP[body.agent]; if (!binary) { - return { success: false, error: `Unknown agent: ${body.agent}` } satisfies InstallResult + return { success: false, error: `Unknown agent: ${body.agent}` } satisfies InstallResult; } // Already installed if (checkBinary(binary)) { - return { success: true } satisfies InstallResult + return { success: true } satisfies InstallResult; } - const info = getInstallInfo(body.agent) + const info = getInstallInfo(body.agent); // Try auto-install - const result = await tryAutoInstall(body.agent, binary) - if (result.success) return result + const result = await tryAutoInstall(body.agent, binary); + if (result.success) return result; // Return failure with manual instructions return { @@ -110,71 +109,73 @@ export default defineEventHandler(async (event) => { error: result.error || 'Auto-install failed', command: info.command, docsUrl: info.docsUrl, - } satisfies InstallResult -}) + } satisfies InstallResult; +}); async function tryAutoInstall(agent: string, binary: string): Promise { switch (agent) { case 'claude-code': - return tryNpmInstall('@anthropic-ai/claude-code', binary) + return tryNpmInstall('@anthropic-ai/claude-code', binary); case 'codex-cli': - return tryNpmInstall('@openai/codex', binary) + return tryNpmInstall('@openai/codex', binary); case 'opencode': - return tryOpenCodeInstall(binary) + return tryOpenCodeInstall(binary); case 'copilot': - return tryCopilotInstall(binary) + return tryCopilotInstall(binary); case 'gemini-cli': - return tryNpmInstall('@anthropic-ai/gemini-cli', binary) + return tryNpmInstall('@anthropic-ai/gemini-cli', binary); default: - return { success: false, error: 'Unknown agent' } + return { success: false, error: 'Unknown agent' }; } } async function tryNpmInstall(pkg: string, binary: string): Promise { if (!hasCommand('npm')) { - return { success: false, error: 'npm not found. Install Node.js first.' } + return { success: false, error: 'npm not found. Install Node.js first.' }; } try { - const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm' + const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm'; execSync(`${npmBin} install -g ${pkg}`, { encoding: 'utf-8', timeout: 180_000, stdio: 'pipe', - }) + }); } catch (err) { - const stderr = (err as { stderr?: string }).stderr || '' + const stderr = (err as { stderr?: string }).stderr || ''; const msg = stderr.includes('EACCES') ? 'Permission denied. Try running with sudo or fix npm permissions.' - : err instanceof Error ? err.message : 'npm install failed' - return { success: false, error: msg } + : err instanceof Error + ? err.message + : 'npm install failed'; + return { success: false, error: msg }; } return checkBinary(binary) ? { success: true } - : { success: false, error: 'Install completed but binary not found in PATH' } + : { success: false, error: 'Install completed but binary not found in PATH' }; } async function tryOpenCodeInstall(binary: string): Promise { - const isWin = process.platform === 'win32' + const isWin = process.platform === 'win32'; const cmd = isWin ? 'npm.cmd install -g opencode-ai' - : 'curl -fsSL https://opencode.ai/install | bash' + : 'curl -fsSL https://opencode.ai/install | bash'; try { execSync(cmd, { encoding: 'utf-8', timeout: 120_000, stdio: 'pipe', - }) + }); } catch (err) { - const msg = err instanceof Error ? err.message : 'Install failed' - return { success: false, error: msg } + const msg = err instanceof Error ? err.message : 'Install failed'; + return { success: false, error: msg }; } return checkBinary(binary) ? { success: true } - : { success: false, error: 'Install completed but binary not found in PATH' } + : { success: false, error: 'Install completed but binary not found in PATH' }; } async function tryCopilotInstall(binary: string): Promise { @@ -185,12 +186,12 @@ async function tryCopilotInstall(binary: string): Promise { encoding: 'utf-8', timeout: 180_000, stdio: 'pipe', - }) - if (checkBinary(binary)) return { success: true } + }); + if (checkBinary(binary)) return { success: true }; } catch { // Fall through to failure } } - return { success: false, error: 'Auto-install not available for this platform' } + return { success: false, error: 'Auto-install not available for this platform' }; } diff --git a/apps/web/server/api/ai/mcp-install.ts b/apps/web/server/api/ai/mcp-install.ts index d345bdb2..c004bfbb 100644 --- a/apps/web/server/api/ai/mcp-install.ts +++ b/apps/web/server/api/ai/mcp-install.ts @@ -1,34 +1,34 @@ -import { defineEventHandler, readBody, setResponseHeaders } from 'h3' -import { homedir } from 'node:os' -import { join, resolve, dirname } from 'node:path' -import { readFile, writeFile, mkdir } from 'node:fs/promises' -import { existsSync, readdirSync, statSync } from 'node:fs' -import { execSync, execFileSync } from 'node:child_process' -import { fileURLToPath } from 'node:url' +import { defineEventHandler, readBody, setResponseHeaders } from 'h3'; +import { homedir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { execSync, execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; // ESM-compatible __dirname polyfill -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); -const MCP_DEFAULT_PORT = 3100 +const MCP_DEFAULT_PORT = 3100; interface InstallBody { - tool: string - action: 'install' | 'uninstall' - transportMode?: 'stdio' | 'http' | 'both' - httpPort?: number + tool: string; + action: 'install' | 'uninstall'; + transportMode?: 'stdio' | 'http' | 'both'; + httpPort?: number; } interface InstallResult { - success: boolean - error?: string - configPath?: string + success: boolean; + error?: string; + configPath?: string; /** True when node was not found and HTTP URL fallback was used */ - fallbackHttp?: boolean + fallbackHttp?: boolean; } -const MCP_SERVER_NAME = 'openpencil' -const CODEX_CONFIG_PATH = join(homedir(), '.codex', 'config.toml') +const MCP_SERVER_NAME = 'openpencil'; +const CODEX_CONFIG_PATH = join(homedir(), '.codex', 'config.toml'); /** * Resolve the absolute path to the compiled MCP server. @@ -37,26 +37,26 @@ const CODEX_CONFIG_PATH = join(homedir(), '.codex', 'config.toml') */ function resolveMcpServerPath(): string { // Electron production: extraResources places it in resourcesPath - const electronResources = process.env.ELECTRON_RESOURCES_PATH + const electronResources = process.env.ELECTRON_RESOURCES_PATH; if (electronResources) { - const electronPath = join(electronResources, 'mcp-server.cjs') - if (existsSync(electronPath)) return electronPath + const electronPath = join(electronResources, 'mcp-server.cjs'); + if (existsSync(electronPath)) return electronPath; } // Monorepo root: cwd may be apps/web (dev) or project root (Electron) // Walk up from cwd to find monorepo root (has package.json with workspaces) - let root = process.cwd() + let root = process.cwd(); for (let i = 0; i < 5; i++) { - const candidate = join(root, 'out', 'mcp-server.cjs') - if (existsSync(candidate)) return candidate - const parent = dirname(root) - if (parent === root) break - root = parent + const candidate = join(root, 'out', 'mcp-server.cjs'); + if (existsSync(candidate)) return candidate; + const parent = dirname(root); + if (parent === root) break; + root = parent; } // Fallback: try relative to this file (Nitro bundles server code) - const fromFile = resolve(__dirname, '..', '..', '..', 'out', 'mcp-server.cjs') - if (existsSync(fromFile)) return fromFile + const fromFile = resolve(__dirname, '..', '..', '..', 'out', 'mcp-server.cjs'); + if (existsSync(fromFile)) return fromFile; // Return expected monorepo root path - return join(root, 'out', 'mcp-server.cjs') + return join(root, 'out', 'mcp-server.cjs'); } /** @@ -66,8 +66,8 @@ function resolveMcpServerPath(): string { * node installation). * Caches the result for the lifetime of the process. */ -let _nodeAvailable: boolean | null = null -let _nodeCommand: string | null = null +let _nodeAvailable: boolean | null = null; +let _nodeCommand: string | null = null; function nodeCandidates(): string[] { if (process.platform === 'win32') { @@ -76,58 +76,55 @@ function nodeCandidates(): string[] { join(process.env.ProgramFiles ?? 'C:\\Program Files', 'nodejs', 'node.exe'), join(process.env.LOCALAPPDATA ?? '', 'fnm_multishells', '**', 'node.exe'), join(homedir(), '.nvm', 'current', 'bin', 'node.exe'), - ] + ]; } - const candidates = [ - 'node', - '/usr/local/bin/node', - '/usr/bin/node', - '/opt/homebrew/bin/node', - ] + const candidates = ['node', '/usr/local/bin/node', '/usr/bin/node', '/opt/homebrew/bin/node']; // NVM: resolve the active node version via the symlink or by reading // .nvm/alias/default, then constructing the versioned bin path. // The old path (`.nvm/versions/node`) is a directory, not a binary — // existsSync would return true but executing it gives "Permission denied". - const nvmDir = join(homedir(), '.nvm') - const nvmCurrent = join(nvmDir, 'current', 'bin', 'node') - candidates.push(nvmCurrent) + const nvmDir = join(homedir(), '.nvm'); + const nvmCurrent = join(nvmDir, 'current', 'bin', 'node'); + candidates.push(nvmCurrent); // Fallback: if NVM_DIR/current doesn't exist, find the highest installed version if (!existsSync(nvmCurrent)) { - const versionsDir = join(nvmDir, 'versions', 'node') + const versionsDir = join(nvmDir, 'versions', 'node'); if (existsSync(versionsDir)) { try { const versions = readdirSync(versionsDir) .filter((d) => d.startsWith('v')) - .sort() + .sort(); if (versions.length > 0) { - candidates.push(join(versionsDir, versions[versions.length - 1], 'bin', 'node')) + candidates.push(join(versionsDir, versions[versions.length - 1], 'bin', 'node')); } - } catch { /* ignore */ } + } catch { + /* ignore */ + } } } - return candidates + return candidates; } function isNodeAvailable(): boolean { - if (_nodeAvailable !== null) return _nodeAvailable + if (_nodeAvailable !== null) return _nodeAvailable; // Try PATH first try { - const whichCmd = process.platform === 'win32' - ? 'where node 2>nul' - : 'which node 2>/dev/null' + const whichCmd = process.platform === 'win32' ? 'where node 2>nul' : 'which node 2>/dev/null'; const resolved = execSync(whichCmd, { encoding: 'utf-8', timeout: 5000 }) .trim() .split(/\r?\n/)[0] - ?.trim() - _nodeCommand = resolved || 'node' - _nodeAvailable = true - return true - } catch { /* not on PATH */ } + ?.trim(); + _nodeCommand = resolved || 'node'; + _nodeAvailable = true; + return true; + } catch { + /* not on PATH */ + } // Check common absolute paths (macOS/Linux + Windows). // Must verify the path is a file, not a directory — existsSync returns @@ -136,20 +133,22 @@ function isNodeAvailable(): boolean { for (const p of nodeCandidates().slice(1)) { try { if (existsSync(p) && statSync(p).isFile()) { - _nodeCommand = p - _nodeAvailable = true - return true + _nodeCommand = p; + _nodeAvailable = true; + return true; } - } catch { /* ignore stat errors */ } + } catch { + /* ignore stat errors */ + } } - _nodeAvailable = false - return false + _nodeAvailable = false; + return false; } function resolveNodeCommand(): string { - if (isNodeAvailable()) return _nodeCommand ?? 'node' - throw new Error('Node.js not found') + if (isNodeAvailable()) return _nodeCommand ?? 'node'; + throw new Error('Node.js not found'); } function buildMcpServerEntry( @@ -159,24 +158,27 @@ function buildMcpServerEntry( ): { command: string; args: string[] } { switch (transportMode) { case 'http': - return { command: 'node', args: [serverPath, '--http', '--port', String(httpPort)] } + return { command: 'node', args: [serverPath, '--http', '--port', String(httpPort)] }; case 'both': - return { command: 'node', args: [serverPath, '--http', '--port', String(httpPort), '--stdio'] } + return { + command: 'node', + args: [serverPath, '--http', '--port', String(httpPort), '--stdio'], + }; default: - return { command: 'node', args: [serverPath] } + return { command: 'node', args: [serverPath] }; } } /** Build an HTTP URL-based MCP server entry (no local node required). */ function buildMcpHttpUrlEntry(httpPort = MCP_DEFAULT_PORT): { type: 'http'; url: string } { - return { type: 'http', url: `http://127.0.0.1:${httpPort}/mcp` } + return { type: 'http', url: `http://127.0.0.1:${httpPort}/mcp` }; } /** Config file locations and formats for each CLI tool. */ interface CliConfigDef { - configPath: () => string - read: (filePath: string) => Promise> - write: (filePath: string, config: Record) => Promise + configPath: () => string; + read: (filePath: string) => Promise>; + write: (filePath: string, config: Record) => Promise; } function installMcpServer( @@ -191,7 +193,7 @@ function installMcpServer( ...(config.mcpServers ?? {}), [MCP_SERVER_NAME]: buildMcpServerEntry(serverPath, transportMode, httpPort), }, - } + }; } /** Install MCP server using HTTP URL (for environments without node). */ @@ -205,13 +207,13 @@ function installMcpServerHttpUrl( ...(config.mcpServers ?? {}), [MCP_SERVER_NAME]: buildMcpHttpUrlEntry(httpPort), }, - } + }; } function uninstallMcpServer(config: Record): Record { - const servers = { ...(config.mcpServers ?? {}) } - delete servers[MCP_SERVER_NAME] - return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined } + const servers = { ...(config.mcpServers ?? {}) }; + delete servers[MCP_SERVER_NAME]; + return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }; } const CLI_CONFIGS: Record = { @@ -240,50 +242,47 @@ const CLI_CONFIGS: Record = { read: readJsonConfig, write: writeJsonConfig, }, -} +}; async function readJsonConfig(filePath: string): Promise> { try { - const text = await readFile(filePath, 'utf-8') - return JSON.parse(text) + const text = await readFile(filePath, 'utf-8'); + return JSON.parse(text); } catch { - return {} + return {}; } } -async function writeJsonConfig( - filePath: string, - config: Record, -): Promise { - const dir = join(filePath, '..') +async function writeJsonConfig(filePath: string, config: Record): Promise { + const dir = join(filePath, '..'); if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }) + await mkdir(dir, { recursive: true }); } - await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', 'utf-8') + await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); } function codexBinary(): string { - return process.platform === 'win32' ? 'codex.cmd' : 'codex' + return process.platform === 'win32' ? 'codex.cmd' : 'codex'; } async function installCodexMcp( transportMode?: 'stdio' | 'http' | 'both', httpPort?: number, ): Promise<{ configPath: string; fallbackHttp: boolean }> { - const serverPath = resolveMcpServerPath() - const port = httpPort ?? MCP_DEFAULT_PORT - const useHttp = transportMode === 'http' || !isNodeAvailable() + const serverPath = resolveMcpServerPath(); + const port = httpPort ?? MCP_DEFAULT_PORT; + const useHttp = transportMode === 'http' || !isNodeAvailable(); try { - uninstallCodexMcp() + uninstallCodexMcp(); } catch { // Ignore missing-entry cleanup failures; install below is the real operation. } if (useHttp) { try { - const { startMcpHttpServer } = await import('../../utils/mcp-server-manager') - startMcpHttpServer(port) + const { startMcpHttpServer } = await import('../../utils/mcp-server-manager'); + startMcpHttpServer(port); } catch { // Non-fatal: server may already be running or will be started manually } @@ -292,25 +291,25 @@ async function installCodexMcp( codexBinary(), ['mcp', 'add', MCP_SERVER_NAME, '--url', `http://127.0.0.1:${port}/mcp`], { encoding: 'utf-8', timeout: 15_000, stdio: 'pipe' }, - ) - return { configPath: CODEX_CONFIG_PATH, fallbackHttp: true } + ); + return { configPath: CODEX_CONFIG_PATH, fallbackHttp: true }; } execFileSync( codexBinary(), ['mcp', 'add', MCP_SERVER_NAME, '--', resolveNodeCommand(), serverPath], { encoding: 'utf-8', timeout: 15_000, stdio: 'pipe' }, - ) - return { configPath: CODEX_CONFIG_PATH, fallbackHttp: false } + ); + return { configPath: CODEX_CONFIG_PATH, fallbackHttp: false }; } function uninstallCodexMcp(): { configPath: string } { - execFileSync( - codexBinary(), - ['mcp', 'remove', MCP_SERVER_NAME], - { encoding: 'utf-8', timeout: 15_000, stdio: 'pipe' }, - ) - return { configPath: CODEX_CONFIG_PATH } + execFileSync(codexBinary(), ['mcp', 'remove', MCP_SERVER_NAME], { + encoding: 'utf-8', + timeout: 15_000, + stdio: 'pipe', + }); + return { configPath: CODEX_CONFIG_PATH }; } /** @@ -318,72 +317,77 @@ function uninstallCodexMcp(): { configPath: string } { * Install or uninstall the openpencil MCP server into a CLI tool's config. */ export default defineEventHandler(async (event) => { - const body = await readBody(event) - setResponseHeaders(event, { 'Content-Type': 'application/json' }) + const body = await readBody(event); + setResponseHeaders(event, { 'Content-Type': 'application/json' }); if (!body?.tool || !body?.action) { - return { success: false, error: 'Missing tool or action field' } satisfies InstallResult + return { success: false, error: 'Missing tool or action field' } satisfies InstallResult; } // Codex CLI uses its own `codex mcp add/remove` commands (writes ~/.codex/config.toml) if (body.tool === 'codex-cli') { try { - const result = body.action === 'uninstall' - ? uninstallCodexMcp() - : await installCodexMcp(body.transportMode, body.httpPort) + const result = + body.action === 'uninstall' + ? uninstallCodexMcp() + : await installCodexMcp(body.transportMode, body.httpPort); return { success: true, configPath: result.configPath, ...('fallbackHttp' in result && result.fallbackHttp ? { fallbackHttp: true } : {}), - } satisfies InstallResult + } satisfies InstallResult; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), - } satisfies InstallResult + } satisfies InstallResult; } } - const cliConfig = CLI_CONFIGS[body.tool] + const cliConfig = CLI_CONFIGS[body.tool]; if (!cliConfig) { - return { success: false, error: `Unknown CLI tool: ${body.tool}` } satisfies InstallResult + return { success: false, error: `Unknown CLI tool: ${body.tool}` } satisfies InstallResult; } try { - const configPath = cliConfig.configPath() - const config = await cliConfig.read(configPath) + const configPath = cliConfig.configPath(); + const config = await cliConfig.read(configPath); - let updated: Record - let fallbackHttp = false + let updated: Record; + let fallbackHttp = false; if (body.action === 'uninstall') { - updated = uninstallMcpServer(config) + updated = uninstallMcpServer(config); } else if (!isNodeAvailable()) { // No node on this machine — fall back to HTTP URL config // and ensure the MCP HTTP server is running - const httpPort = body.httpPort ?? MCP_DEFAULT_PORT - updated = installMcpServerHttpUrl(config, httpPort) - fallbackHttp = true + const httpPort = body.httpPort ?? MCP_DEFAULT_PORT; + updated = installMcpServerHttpUrl(config, httpPort); + fallbackHttp = true; // Auto-start the MCP HTTP server so the URL is reachable try { - const { startMcpHttpServer } = await import('../../utils/mcp-server-manager') - startMcpHttpServer(httpPort) + const { startMcpHttpServer } = await import('../../utils/mcp-server-manager'); + startMcpHttpServer(httpPort); } catch { // Non-fatal: server may already be running or will be started manually } } else { - const serverPath = resolveMcpServerPath() - updated = installMcpServer(config, serverPath, body.transportMode, body.httpPort) + const serverPath = resolveMcpServerPath(); + updated = installMcpServer(config, serverPath, body.transportMode, body.httpPort); } - await cliConfig.write(configPath, updated) + await cliConfig.write(configPath, updated); - return { success: true, configPath, ...(fallbackHttp ? { fallbackHttp } : {}) } satisfies InstallResult + return { + success: true, + configPath, + ...(fallbackHttp ? { fallbackHttp } : {}), + } satisfies InstallResult; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), - } satisfies InstallResult + } satisfies InstallResult; } -}) +}); diff --git a/apps/web/server/api/ai/models.ts b/apps/web/server/api/ai/models.ts index 5834faad..cfc0f225 100644 --- a/apps/web/server/api/ai/models.ts +++ b/apps/web/server/api/ai/models.ts @@ -1,16 +1,16 @@ -import { defineEventHandler } from 'h3' +import { defineEventHandler } from 'h3'; import { buildClaudeAgentEnv, getClaudeAgentDebugFilePath, -} from '../../utils/resolve-claude-agent-env' +} from '../../utils/resolve-claude-agent-env'; interface ModelInfo { - value: string - displayName: string - description: string + value: string; + displayName: string; + description: string; } -let cachedModels: ModelInfo[] | null = null +let cachedModels: ModelInfo[] | null = null; /** * Returns the list of available AI models via Claude Agent SDK. @@ -18,14 +18,14 @@ let cachedModels: ModelInfo[] | null = null */ export default defineEventHandler(async () => { if (cachedModels) { - return { models: cachedModels } + return { models: cachedModels }; } try { - const { query } = await import('@anthropic-ai/claude-agent-sdk') + const { query } = await import('@anthropic-ai/claude-agent-sdk'); - const env = buildClaudeAgentEnv() - const debugFile = getClaudeAgentDebugFilePath() + const env = buildClaudeAgentEnv(); + const debugFile = getClaudeAgentDebugFilePath(); const q = query({ prompt: '', @@ -37,15 +37,15 @@ export default defineEventHandler(async () => { env, ...(debugFile ? { debugFile } : {}), }, - }) + }); - const models = await q.supportedModels() - cachedModels = models - q.close() + const models = await q.supportedModels(); + cachedModels = models; + q.close(); - return { models } + return { models }; } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - return { models: [], error: message } + const message = error instanceof Error ? error.message : 'Unknown error'; + return { models: [], error: message }; } -}) +}); diff --git a/apps/web/server/api/ai/provider-models.ts b/apps/web/server/api/ai/provider-models.ts index 08c21148..f5168324 100644 --- a/apps/web/server/api/ai/provider-models.ts +++ b/apps/web/server/api/ai/provider-models.ts @@ -1,13 +1,14 @@ -import { defineEventHandler, readBody } from 'h3' +import { defineEventHandler, readBody } from 'h3'; +import { buildProviderModelsURL, normalizeOptionalBaseURL } from './provider-url'; interface ProviderModelsBody { - baseURL: string - apiKey?: string + baseURL: string; + apiKey?: string; } interface ModelEntry { - id: string - name: string + id: string; + name: string; } /** @@ -17,34 +18,39 @@ interface ModelEntry { * Returns: { models: Array<{ id: string, name: string }> } */ export default defineEventHandler(async (event) => { - const body = await readBody(event) - if (!body?.baseURL) { - return { models: [], error: 'baseURL is required' } + const body = await readBody(event); + const normalizedBaseURL = normalizeOptionalBaseURL(body?.baseURL); + const apiKey = body?.apiKey; + if (!normalizedBaseURL) { + return { models: [], error: 'baseURL is required' }; } - const url = body.baseURL.replace(/\/+$/, '') + '/models' + const url = buildProviderModelsURL(normalizedBaseURL); const headers: Record = { Accept: 'application/json', - } - if (body.apiKey) { - headers.Authorization = `Bearer ${body.apiKey}` + }; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; } try { - const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) }) + const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) }); if (!res.ok) { - const text = await res.text().catch(() => '') - return { models: [], error: `Provider returned ${res.status}: ${text.slice(0, 200)}` } + const text = await res.text().catch(() => ''); + return { models: [], error: `Provider returned ${res.status}: ${text.slice(0, 200)}` }; } - const json = await res.json() as Record + const json = (await res.json()) as Record; // Handle different response formats: { data: [...] } (OpenAI), { models: [...] }, or [...] - const rawModels = Array.isArray(json.data) ? json.data - : Array.isArray(json.models) ? json.models - : Array.isArray(json) ? json - : null + const rawModels = Array.isArray(json.data) + ? json.data + : Array.isArray(json.models) + ? json.models + : Array.isArray(json) + ? json + : null; if (!rawModels) { - return { models: [], error: 'Unexpected response format (no model array found)' } + return { models: [], error: 'Unexpected response format (no model array found)' }; } const models: ModelEntry[] = (rawModels as Array>) @@ -53,11 +59,11 @@ export default defineEventHandler(async (event) => { id: String(m.id), name: (typeof m.name === 'string' ? m.name : '') || String(m.id), })) - .sort((a, b) => a.name.localeCompare(b.name)) + .sort((a, b) => a.name.localeCompare(b.name)); - return { models } + return { models }; } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error' - return { models: [], error: message } + const message = err instanceof Error ? err.message : 'Unknown error'; + return { models: [], error: message }; } -}) +}); diff --git a/apps/web/server/api/ai/provider-url.ts b/apps/web/server/api/ai/provider-url.ts new file mode 100644 index 00000000..e3c13f95 --- /dev/null +++ b/apps/web/server/api/ai/provider-url.ts @@ -0,0 +1,65 @@ +export function normalizeBaseURL(baseURL: string): string { + return baseURL.trim().replace(/\/+$/, ''); +} + +export function normalizeOptionalBaseURL(baseURL?: string): string | undefined { + if (!baseURL) return undefined; + const normalized = normalizeBaseURL(baseURL); + return normalized.length > 0 ? normalized : undefined; +} + +export function normalizeOpenAICompatBaseURL(baseURL?: string): string | undefined { + const normalized = normalizeOptionalBaseURL(baseURL); + if (!normalized) return undefined; + + switch (normalized) { + case 'https://ark.cn-beijing.volces.com/api/v3/v1': + return 'https://ark.cn-beijing.volces.com/api/v3'; + case 'https://ark.cn-beijing.volces.com/api/coding/v3/v1': + return 'https://ark.cn-beijing.volces.com/api/coding/v3'; + case 'https://open.bigmodel.cn/api/paas/v4/v1': + return 'https://open.bigmodel.cn/api/paas/v4'; + case 'https://api.z.ai/api/paas/v4/v1': + case 'https://open.z.ai/api/paas/v4/v1': + return 'https://api.z.ai/api/paas/v4'; + case 'https://open.bigmodel.cn/api/coding/paas/v4/v1': + return 'https://open.bigmodel.cn/api/coding/paas/v4'; + case 'https://api.z.ai/api/coding/paas/v4/v1': + case 'https://open.z.ai/api/coding/paas/v4/v1': + return 'https://api.z.ai/api/coding/paas/v4'; + case 'https://generativelanguage.googleapis.com/v1beta/openai/v1': + return 'https://generativelanguage.googleapis.com/v1beta/openai'; + default: + return normalized; + } +} + +export function requireOpenAICompatBaseURL(baseURL?: string): string { + const normalized = normalizeOpenAICompatBaseURL(baseURL); + if (!normalized) { + throw new Error('OpenAI-compatible provider requires baseURL'); + } + return normalized; +} + +/** + * Normalize a team-member's baseURL. Throws if an openai-compat member has no baseURL. + */ +export function normalizeMemberBaseURL( + memberId: string, + providerType: string, + baseURL?: string, +): string | undefined { + const normalized = + providerType === 'openai-compat' + ? normalizeOpenAICompatBaseURL(baseURL) + : normalizeOptionalBaseURL(baseURL); + if (providerType === 'openai-compat' && !normalized) { + throw new Error(`Member "${memberId}" (openai-compat) requires baseURL`); + } + return normalized; +} + +export function buildProviderModelsURL(baseURL: string): string { + return `${normalizeOpenAICompatBaseURL(baseURL) ?? normalizeBaseURL(baseURL)}/models`; +} diff --git a/apps/web/server/api/ai/validate.ts b/apps/web/server/api/ai/validate.ts index ba31a3d3..34af1b61 100644 --- a/apps/web/server/api/ai/validate.ts +++ b/apps/web/server/api/ai/validate.ts @@ -1,22 +1,22 @@ -import { defineEventHandler, readBody, setResponseHeaders } from 'h3' -import { resolveClaudeCli } from '../../utils/resolve-claude-cli' +import { defineEventHandler, readBody, setResponseHeaders } from 'h3'; +import { resolveClaudeCli } from '../../utils/resolve-claude-cli'; import { buildClaudeAgentEnv, buildSpawnClaudeCodeProcess, getClaudeAgentDebugFilePath, resolveAgentModel, -} from '../../utils/resolve-claude-agent-env' -import { writeFile, mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { runCodexExec } from '../../utils/codex-client' +} from '../../utils/resolve-claude-agent-env'; +import { writeFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runCodexExec } from '../../utils/codex-client'; interface ValidateBody { - system: string - message: string - imageBase64: string - model?: string - provider?: 'anthropic' | 'openai' | 'opencode' | 'gemini' + system: string; + message: string; + imageBase64: string; + model?: string; + provider?: 'anthropic' | 'openai' | 'opencode' | 'gemini'; } /** @@ -28,41 +28,41 @@ interface ValidateBody { * built-in Read tool. */ export default defineEventHandler(async (event) => { - const body = await readBody(event) + const body = await readBody(event); if (!body?.system || !body?.message || !body?.imageBase64) { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - return { error: 'Missing required fields: system, message, imageBase64' } + setResponseHeaders(event, { 'Content-Type': 'application/json' }); + return { error: 'Missing required fields: system, message, imageBase64' }; } if (!body.model?.trim()) { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - return { error: 'Missing model. Model fallback is disabled.' } + setResponseHeaders(event, { 'Content-Type': 'application/json' }); + return { error: 'Missing model. Model fallback is disabled.' }; } try { if (body.provider === 'anthropic') { - return await validateViaAgentSDK(body, body.model) + return await validateViaAgentSDK(body, body.model); } if (body.provider === 'openai') { - return await validateViaCodex(body, body.model) + return await validateViaCodex(body, body.model); } if (body.provider === 'opencode') { - return await validateViaOpenCode(body, body.model) + return await validateViaOpenCode(body, body.model); } if (body.provider === 'gemini') { - return await validateViaGemini(body, body.model) + return await validateViaGemini(body, body.model); } - return { error: 'Missing or unsupported provider. Provider fallback is disabled.' } + return { error: 'Missing or unsupported provider. Provider fallback is disabled.' }; } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - return { error: message } + const message = error instanceof Error ? error.message : 'Unknown error'; + return { error: message }; } -}) +}); function toImageBase64(data: string): string { - const dataUrlPrefix = 'data:image/png;base64,' - return data.startsWith(dataUrlPrefix) ? data.slice(dataUrlPrefix.length) : data + const dataUrlPrefix = 'data:image/png;base64,'; + return data.startsWith(dataUrlPrefix) ? data.slice(dataUrlPrefix.length) : data; } async function withTempImageFile( @@ -70,24 +70,24 @@ async function withTempImageFile( run: (tempPath: string) => Promise, insideProject = false, ): Promise { - let tempDir: string + let tempDir: string; if (insideProject) { // Save inside the project directory so Claude Code Agent SDK (plan mode) // can read the file — it restricts reads to the project directory. - const { mkdirSync, chmodSync } = await import('node:fs') - const baseDir = join(process.cwd(), '.openpencil-tmp') - mkdirSync(baseDir, { recursive: true }) - chmodSync(baseDir, 0o700) - tempDir = await mkdtemp(join(baseDir, 'validate-')) + const { mkdirSync, chmodSync } = await import('node:fs'); + const baseDir = join(process.cwd(), '.openpencil-tmp'); + mkdirSync(baseDir, { recursive: true }); + chmodSync(baseDir, 0o700); + tempDir = await mkdtemp(join(baseDir, 'validate-')); } else { - tempDir = await mkdtemp(join(tmpdir(), 'openpencil-validate-')) + tempDir = await mkdtemp(join(tmpdir(), 'openpencil-validate-')); } - const tempPath = join(tempDir, 'screenshot.png') + const tempPath = join(tempDir, 'screenshot.png'); try { - await writeFile(tempPath, Buffer.from(toImageBase64(imageBase64), 'base64')) - return await run(tempPath) + await writeFile(tempPath, Buffer.from(toImageBase64(imageBase64), 'base64')); + return await run(tempPath); } finally { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + await rm(tempDir, { recursive: true, force: true }).catch(() => {}); } } @@ -101,15 +101,17 @@ async function validateViaAgentSDK( body: ValidateBody, requestedModel?: string, ): Promise<{ text: string; skipped?: boolean; error?: string }> { - return await withTempImageFile(body.imageBase64, async (tempPath) => { - const { query } = await import('@anthropic-ai/claude-agent-sdk') + return await withTempImageFile( + body.imageBase64, + async (tempPath) => { + const { query } = await import('@anthropic-ai/claude-agent-sdk'); - const env = buildClaudeAgentEnv() - const debugFile = getClaudeAgentDebugFilePath() - const claudePath = resolveClaudeCli() - const model = resolveAgentModel(requestedModel, env) + const env = buildClaudeAgentEnv(); + const debugFile = getClaudeAgentDebugFilePath(); + const claudePath = resolveClaudeCli(); + const model = resolveAgentModel(requestedModel, env); - const prompt = `IMPORTANT: First, use the Read tool to read the image file at "${tempPath}". This is a PNG screenshot of a UI design. + const prompt = `IMPORTANT: First, use the Read tool to read the image file at "${tempPath}". This is a PNG screenshot of a UI design. After viewing the image, analyze it according to these instructions: @@ -117,42 +119,50 @@ ${body.system} ${body.message} -CRITICAL: Your ENTIRE response must be a single JSON object. No markdown, no explanation, no tool calls after reading the image. Just the JSON.` +CRITICAL: Your ENTIRE response must be a single JSON object. No markdown, no explanation, no tool calls after reading the image. Just the JSON.`; - const q = query({ - prompt, - options: { - ...(model ? { model } : {}), - maxTurns: 3, - tools: [], - plugins: [], - permissionMode: 'plan', - persistSession: false, - env, - ...(debugFile ? { debugFile } : {}), - ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}), - ...(buildSpawnClaudeCodeProcess() ? { spawnClaudeCodeProcess: buildSpawnClaudeCodeProcess() } : {}), - }, - }) + const q = query({ + prompt, + options: { + ...(model ? { model } : {}), + maxTurns: 3, + tools: [], + plugins: [], + permissionMode: 'plan', + persistSession: false, + env, + ...(debugFile ? { debugFile } : {}), + ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}), + ...(buildSpawnClaudeCodeProcess() + ? { spawnClaudeCodeProcess: buildSpawnClaudeCodeProcess() } + : {}), + }, + }); - try { - for await (const message of q) { - if (message.type === 'result') { - const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error) - if (message.subtype === 'success' && !isErrorResult) { - return { text: message.result } + try { + for await (const message of q) { + if (message.type === 'result') { + const isErrorResult = + 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error); + if (message.subtype === 'success' && !isErrorResult) { + return { text: message.result }; + } + const errors = 'errors' in message ? (message.errors as string[]) : []; + const resultText = 'result' in message ? String(message.result ?? '') : ''; + return { + error: errors.join('; ') || resultText || `Query ended with: ${message.subtype}`, + text: '', + }; } - const errors = 'errors' in message ? (message.errors as string[]) : [] - const resultText = 'result' in message ? String(message.result ?? '') : '' - return { error: errors.join('; ') || resultText || `Query ended with: ${message.subtype}`, text: '' } } + } finally { + q.close(); } - } finally { - q.close() - } - return { text: '', skipped: true } - }, true) + return { text: '', skipped: true }; + }, + true, + ); } async function validateViaCodex( @@ -167,50 +177,50 @@ async function validateViaCodex( systemPrompt: body.system, imageFiles: [tempPath], }, - ) + ); if (result.error) { - return { text: '', error: result.error } + return { text: '', error: result.error }; } - return { text: result.text ?? '' } - }) + return { text: result.text ?? '' }; + }); } function parseOpenCodeModel(model?: string): { providerID: string; modelID: string } | undefined { - if (!model || !model.includes('/')) return undefined - const idx = model.indexOf('/') - return { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) } + if (!model || !model.includes('/')) return undefined; + const idx = model.indexOf('/'); + return { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) }; } async function validateViaOpenCode( body: ValidateBody, model?: string, ): Promise<{ text: string; skipped?: boolean; error?: string }> { - let ocServer: { close(): void } | undefined + let ocServer: { close(): void } | undefined; try { - const { getOpencodeClient } = await import('../../utils/opencode-client') - const oc = await getOpencodeClient() - const ocClient: any = oc.client - ocServer = oc.server + const { getOpencodeClient } = await import('../../utils/opencode-client'); + const oc = await getOpencodeClient(); + const ocClient: any = oc.client; + ocServer = oc.server; const { data: session, error: sessionError } = await ocClient.session.create({ title: 'OpenPencil Validate', - }) + }); if (sessionError || !session) { - return { text: '', error: 'Failed to create OpenCode session' } + return { text: '', error: 'Failed to create OpenCode session' }; } await ocClient.session.prompt({ sessionID: session.id, noReply: true, parts: [{ type: 'text', text: body.system }], - }) + }); - const parsed = parseOpenCodeModel(model) + const parsed = parseOpenCodeModel(model); if (!parsed) { - return { text: '', error: 'Invalid OpenCode model format. Expected "provider/model".' } + return { text: '', error: 'Invalid OpenCode model format. Expected "provider/model".' }; } - const base64 = toImageBase64(body.imageBase64) + const base64 = toImageBase64(body.imageBase64); const promptPayload = { sessionID: session.id, model: parsed, @@ -221,28 +231,28 @@ async function validateViaOpenCode( text: `${body.message}\n\nOutput ONLY the JSON object, no markdown fences, no explanation.`, }, ], - } + }; - const { data: result, error: promptError } = await ocClient.session.prompt(promptPayload) + const { data: result, error: promptError } = await ocClient.session.prompt(promptPayload); if (promptError) { - return { text: '', error: 'OpenCode validation failed' } + return { text: '', error: 'OpenCode validation failed' }; } - const texts: string[] = [] + const texts: string[] = []; if (result?.parts) { for (const part of result.parts) { if (part.type === 'text' && part.text) { - texts.push(part.text) + texts.push(part.text); } } } - return { text: texts.join('') } + return { text: texts.join('') }; } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - return { text: '', error: message } + const message = error instanceof Error ? error.message : 'Unknown error'; + return { text: '', error: message }; } finally { - const { releaseOpencodeServer } = await import('../../utils/opencode-client') - releaseOpencodeServer(ocServer) + const { releaseOpencodeServer } = await import('../../utils/opencode-client'); + releaseOpencodeServer(ocServer); } } @@ -252,15 +262,15 @@ async function validateViaGemini( model?: string, ): Promise<{ text: string; skipped?: boolean; error?: string }> { return await withTempImageFile(body.imageBase64, async (tempPath) => { - const { runGeminiExec } = await import('../../utils/gemini-client') - const prompt = `Read the image file at "${tempPath}". This is a PNG screenshot of a UI design.\n\n${body.message}\n\nOutput ONLY the JSON object, no markdown fences, no explanation.` + const { runGeminiExec } = await import('../../utils/gemini-client'); + const prompt = `Read the image file at "${tempPath}". This is a PNG screenshot of a UI design.\n\n${body.message}\n\nOutput ONLY the JSON object, no markdown fences, no explanation.`; const result = await runGeminiExec(prompt, { model, systemPrompt: body.system, - }) + }); if (result.error) { - return { text: '', error: result.error } + return { text: '', error: result.error }; } - return { text: result.text ?? '' } - }) + return { text: result.text ?? '' }; + }); } diff --git a/apps/web/server/api/local-asset.get.ts b/apps/web/server/api/local-asset.get.ts new file mode 100644 index 00000000..05ba13e7 --- /dev/null +++ b/apps/web/server/api/local-asset.get.ts @@ -0,0 +1,58 @@ +import { + createError, + defineEventHandler, + getQuery, + getRequestHeader, + setResponseHeaders, +} from 'h3'; +import { readFile } from 'node:fs/promises'; + +import { resolveServableLocalImagePath } from '../utils/local-asset'; + +export default defineEventHandler(async (event) => { + const { path } = getQuery(event) as { path?: string }; + const secFetchSite = getRequestHeader(event, 'sec-fetch-site'); + + if (secFetchSite === 'cross-site') { + throw createError({ + statusCode: 403, + message: 'Cross-site local asset requests are blocked', + }); + } + + if (!path?.trim()) { + throw createError({ + statusCode: 400, + message: 'Missing required query parameter: path', + }); + } + + if (path.includes('\0') || !isAbsoluteLocalPath(path)) { + throw createError({ + statusCode: 400, + message: 'Only absolute local image paths are supported', + }); + } + + const resolvedAsset = await resolveServableLocalImagePath(path); + if (!resolvedAsset) { + throw createError({ + statusCode: 404, + message: 'Image file not found or unsupported', + }); + } + + const content = await readFile(resolvedAsset.resolvedPath); + setResponseHeaders(event, { + 'Content-Type': resolvedAsset.mimeType, + 'Cache-Control': 'no-cache', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'X-Content-Type-Options': 'nosniff', + }); + + return content; +}); + +function isAbsoluteLocalPath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('/'); +} diff --git a/apps/web/server/api/mcp/active-ping.post.ts b/apps/web/server/api/mcp/active-ping.post.ts new file mode 100644 index 00000000..b76c2711 --- /dev/null +++ b/apps/web/server/api/mcp/active-ping.post.ts @@ -0,0 +1,10 @@ +import { defineEventHandler, readBody } from 'h3'; +import { markClientActive } from '../../utils/mcp-sync-state'; + +export default defineEventHandler(async (event) => { + const body = (await readBody(event)) as { clientId?: string }; + if (body.clientId) { + markClientActive(body.clientId); + } + return { ok: true }; +}); diff --git a/apps/web/server/api/mcp/codegen/assemble/[id].get.ts b/apps/web/server/api/mcp/codegen/assemble/[id].get.ts new file mode 100644 index 00000000..5cabd9ae --- /dev/null +++ b/apps/web/server/api/mcp/codegen/assemble/[id].get.ts @@ -0,0 +1,22 @@ +import { defineEventHandler, getRouterParam, getQuery, createError } from 'h3'; +import { assemblePlan } from '../../../../utils/codegen-plan-store'; +import type { Framework } from '@zseven-w/pen-types'; + +export default defineEventHandler((event) => { + const planId = getRouterParam(event, 'id'); + const query = getQuery(event); + const framework = (query.framework as Framework) || 'react'; + + if (!planId) { + throw createError({ statusCode: 400, statusMessage: 'Missing plan ID' }); + } + + try { + return assemblePlan(planId, framework); + } catch (err) { + throw createError({ + statusCode: 404, + statusMessage: err instanceof Error ? err.message : String(err), + }); + } +}); diff --git a/apps/web/server/api/mcp/codegen/plan.post.ts b/apps/web/server/api/mcp/codegen/plan.post.ts new file mode 100644 index 00000000..edb904e3 --- /dev/null +++ b/apps/web/server/api/mcp/codegen/plan.post.ts @@ -0,0 +1,42 @@ +import { defineEventHandler, readBody, createError } from 'h3'; +import { getSyncDocument } from '../../../utils/mcp-sync-state'; +import { createPlan } from '../../../utils/codegen-plan-store'; +import { getActivePageChildren } from '@zseven-w/pen-core'; +import type { PenDocument, CodePlanFromAI } from '@zseven-w/pen-types'; +import { openDocument, LIVE_CANVAS_PATH } from '@zseven-w/pen-mcp'; + +interface PlanBody { + plan: CodePlanFromAI; + filePath?: string; + pageId?: string; +} + +async function resolveDocument(filePath?: string): Promise { + if (filePath && filePath !== LIVE_CANVAS_PATH) { + return openDocument(filePath); + } + const sync = getSyncDocument(); + if (!sync.doc) { + throw createError({ statusCode: 404, statusMessage: 'No document loaded in editor' }); + } + return sync.doc; +} + +export default defineEventHandler(async (event) => { + const body = await readBody(event); + if (!body?.plan) { + throw createError({ statusCode: 400, statusMessage: 'Missing plan in request body' }); + } + + const doc = await resolveDocument(body.filePath); + const pageChildren = getActivePageChildren(doc, body.pageId ?? null); + + try { + return createPlan(body.plan, pageChildren); + } catch (err) { + throw createError({ + statusCode: 400, + statusMessage: err instanceof Error ? err.message : String(err), + }); + } +}); diff --git a/apps/web/server/api/mcp/codegen/plan/[id].delete.ts b/apps/web/server/api/mcp/codegen/plan/[id].delete.ts new file mode 100644 index 00000000..7f0fa6fa --- /dev/null +++ b/apps/web/server/api/mcp/codegen/plan/[id].delete.ts @@ -0,0 +1,10 @@ +import { defineEventHandler, getRouterParam, createError } from 'h3'; +import { cleanPlan } from '../../../../utils/codegen-plan-store'; + +export default defineEventHandler((event) => { + const planId = getRouterParam(event, 'id'); + if (!planId) { + throw createError({ statusCode: 400, statusMessage: 'Missing plan ID' }); + } + return cleanPlan(planId); +}); diff --git a/apps/web/server/api/mcp/codegen/submit.post.ts b/apps/web/server/api/mcp/codegen/submit.post.ts new file mode 100644 index 00000000..f50e74e7 --- /dev/null +++ b/apps/web/server/api/mcp/codegen/submit.post.ts @@ -0,0 +1,25 @@ +import { defineEventHandler, readBody, createError } from 'h3'; +import { submitChunkResult } from '../../../utils/codegen-plan-store'; +import type { ChunkResult } from '@zseven-w/pen-types'; + +interface SubmitBody { + planId: string; + result: ChunkResult; + status?: 'failed' | 'skipped'; +} + +export default defineEventHandler(async (event) => { + const body = await readBody(event); + if (!body?.planId || !body?.result) { + throw createError({ statusCode: 400, statusMessage: 'Missing planId or result' }); + } + + try { + return submitChunkResult(body.planId, body.result, body.status); + } catch (err) { + throw createError({ + statusCode: 404, + statusMessage: err instanceof Error ? err.message : String(err), + }); + } +}); diff --git a/apps/web/server/api/mcp/document.get.ts b/apps/web/server/api/mcp/document.get.ts index 53f5559b..2b3c1c58 100644 --- a/apps/web/server/api/mcp/document.get.ts +++ b/apps/web/server/api/mcp/document.get.ts @@ -1,11 +1,11 @@ -import { defineEventHandler, createError } from 'h3' -import { getSyncDocument } from '../../utils/mcp-sync-state' +import { defineEventHandler, createError } from 'h3'; +import { getSyncDocument } from '../../utils/mcp-sync-state'; /** GET /api/mcp/document — Returns the current canvas document for MCP to read. */ export default defineEventHandler(() => { - const { doc, version } = getSyncDocument() + const { doc, version } = getSyncDocument(); if (!doc) { - throw createError({ statusCode: 404, statusMessage: 'No document loaded in editor' }) + throw createError({ statusCode: 404, statusMessage: 'No document loaded in editor' }); } - return { version, document: doc } -}) + return { version, document: doc }; +}); diff --git a/apps/web/server/api/mcp/document.post.ts b/apps/web/server/api/mcp/document.post.ts index 54202380..2795d6ed 100644 --- a/apps/web/server/api/mcp/document.post.ts +++ b/apps/web/server/api/mcp/document.post.ts @@ -1,22 +1,22 @@ -import { defineEventHandler, readBody, createError } from 'h3' -import { setSyncDocument } from '../../utils/mcp-sync-state' -import type { PenDocument } from '../../../src/types/pen' +import { defineEventHandler, readBody, createError } from 'h3'; +import { setSyncDocument } from '../../utils/mcp-sync-state'; +import type { PenDocument } from '../../../src/types/pen'; interface PostBody { - document: PenDocument - sourceClientId?: string + document: PenDocument; + sourceClientId?: string; } /** POST /api/mcp/document — Receives document update from MCP or renderer, triggers SSE broadcast. */ export default defineEventHandler(async (event) => { - const body = await readBody(event) + const body = await readBody(event); if (!body?.document) { - throw createError({ statusCode: 400, statusMessage: 'Missing document in request body' }) + throw createError({ statusCode: 400, statusMessage: 'Missing document in request body' }); } - const doc = body.document + const doc = body.document; if (!doc.version || (!Array.isArray(doc.children) && !Array.isArray(doc.pages))) { - throw createError({ statusCode: 400, statusMessage: 'Invalid document format' }) + throw createError({ statusCode: 400, statusMessage: 'Invalid document format' }); } - const version = setSyncDocument(doc, body.sourceClientId) - return { ok: true, version } -}) + const version = setSyncDocument(doc, body.sourceClientId); + return { ok: true, version }; +}); diff --git a/apps/web/server/api/mcp/events.get.ts b/apps/web/server/api/mcp/events.get.ts index 1442f51a..e93a2fea 100644 --- a/apps/web/server/api/mcp/events.get.ts +++ b/apps/web/server/api/mcp/events.get.ts @@ -1,49 +1,53 @@ -import { defineEventHandler, createEventStream } from 'h3' -import { randomUUID } from 'node:crypto' -import { registerSSEClient, unregisterSSEClient, getSyncDocument } from '../../utils/mcp-sync-state' +import { defineEventHandler, createEventStream } from 'h3'; +import { randomUUID } from 'node:crypto'; +import { + registerSSEClient, + unregisterSSEClient, + getSyncDocument, +} from '../../utils/mcp-sync-state'; // Bun.serve has a default idleTimeout of 10s. Heartbeat must be shorter // to prevent the SSE connection from being killed. -const HEARTBEAT_MS = 8_000 +const HEARTBEAT_MS = 8_000; /** GET /api/mcp/events — SSE stream for renderer to subscribe to live document changes. */ export default defineEventHandler((event) => { - const clientId = randomUUID() - const stream = createEventStream(event) + const clientId = randomUUID(); + const stream = createEventStream(event); - let closed = false + let closed = false; const cleanup = () => { - if (closed) return - closed = true - clearInterval(heartbeat) - unregisterSSEClient(clientId) - stream.close() - } + if (closed) return; + closed = true; + clearInterval(heartbeat); + unregisterSSEClient(clientId); + stream.close(); + }; const write = (data: string) => { - if (closed) return - stream.push(data).catch(cleanup) - } + if (closed) return; + stream.push(data).catch(cleanup); + }; // Send client ID so renderer can use it as sourceClientId when pushing back - write(JSON.stringify({ type: 'client:id', clientId })) + write(JSON.stringify({ type: 'client:id', clientId })); // Send current document as initial state (if any) - const { doc, version } = getSyncDocument() + const { doc, version } = getSyncDocument(); if (doc) { - write(JSON.stringify({ type: 'document:init', version, document: doc })) + write(JSON.stringify({ type: 'document:init', version, document: doc })); } - registerSSEClient(clientId, { push: write }) + registerSSEClient(clientId, { push: write }); // Keep-alive heartbeat — must be shorter than Bun's idle timeout (10s) const heartbeat = setInterval(() => { - if (closed) return - stream.push(':heartbeat').catch(cleanup) - }, HEARTBEAT_MS) + if (closed) return; + stream.push(':heartbeat').catch(cleanup); + }, HEARTBEAT_MS); // Clean up when client disconnects - stream.onClosed(cleanup) + stream.onClosed(cleanup); - return stream.send() -}) + return stream.send(); +}); diff --git a/apps/web/server/api/mcp/read-nodes.post.ts b/apps/web/server/api/mcp/read-nodes.post.ts new file mode 100644 index 00000000..5a82a3a5 --- /dev/null +++ b/apps/web/server/api/mcp/read-nodes.post.ts @@ -0,0 +1,59 @@ +import { defineEventHandler, readBody, createError } from 'h3'; +import { getSyncDocument } from '../../utils/mcp-sync-state'; +import type { PenDocument, PenNode, NodeSnapshot } from '@zseven-w/pen-types'; +import { findNodeInTree, getActivePageChildren } from '@zseven-w/pen-core'; +import { openDocument, LIVE_CANVAS_PATH, readNodeWithDepth } from '@zseven-w/pen-mcp'; + +async function resolveDocument(filePath?: string): Promise { + if (filePath && filePath !== LIVE_CANVAS_PATH) { + return openDocument(filePath); + } + const { doc } = getSyncDocument(); + if (!doc) { + throw createError({ statusCode: 404, statusMessage: 'No document loaded in editor' }); + } + return doc; +} + +interface ReadNodesBody { + nodeIds?: string[]; + depth?: number; + pageId?: string; + filePath?: string; + includeVariables?: boolean; +} + +export default defineEventHandler(async (event) => { + const body = await readBody(event); + const doc = await resolveDocument(body?.filePath); + + const depth = body?.depth ?? -1; + const pageChildren = getActivePageChildren(doc, body?.pageId ?? null); + + let nodes: NodeSnapshot[]; + + if (body?.nodeIds && body.nodeIds.length > 0) { + nodes = body.nodeIds + .map((id) => findNodeInTree(pageChildren, id)) + .filter((n): n is PenNode => n !== undefined) + .map((n) => + depth === -1 + ? (n as unknown as NodeSnapshot) + : (readNodeWithDepth(n, depth) as unknown as NodeSnapshot), + ); + } else { + nodes = pageChildren.map((n) => + depth === -1 + ? (n as unknown as NodeSnapshot) + : (readNodeWithDepth(n, depth) as unknown as NodeSnapshot), + ); + } + + const result: Record = { nodes }; + if (body?.includeVariables) { + result.variables = doc.variables ?? {}; + result.themes = (doc as { themes?: unknown[] }).themes ?? []; + } + + return result; +}); diff --git a/apps/web/server/api/mcp/screenshot-response.post.ts b/apps/web/server/api/mcp/screenshot-response.post.ts new file mode 100644 index 00000000..ec2bbaae --- /dev/null +++ b/apps/web/server/api/mcp/screenshot-response.post.ts @@ -0,0 +1,12 @@ +// apps/web/server/api/mcp/screenshot-response.post.ts +import { defineEventHandler, readBody } from 'h3'; +import { resolvePending, type ScreenshotResponse } from '../../utils/mcp-screenshot-rpc'; + +export default defineEventHandler(async (event) => { + const body = (await readBody(event)) as ScreenshotResponse; + const accepted = resolvePending(body); + if (!accepted) { + return { received: false, reason: 'no pending request (timed out or duplicate)' }; + } + return { received: true }; +}); diff --git a/apps/web/server/api/mcp/screenshot.post.ts b/apps/web/server/api/mcp/screenshot.post.ts new file mode 100644 index 00000000..23851872 --- /dev/null +++ b/apps/web/server/api/mcp/screenshot.post.ts @@ -0,0 +1,46 @@ +// apps/web/server/api/mcp/screenshot.post.ts +import { defineEventHandler, readBody, createError } from 'h3'; +import { sendToClient, getLastActiveClientId, isClientConnected } from '../../utils/mcp-sync-state'; +import { + allocateRequestId, + registerPending, + type ScreenshotRequestBody, +} from '../../utils/mcp-screenshot-rpc'; + +export default defineEventHandler(async (event) => { + const body = (await readBody(event)) as ScreenshotRequestBody; + const timeoutMs = Math.min(body.timeoutMs ?? 15000, 60000); + + // 1. Resolve target renderer — fail fast if none + const targetClientId = getLastActiveClientId(); + if (!targetClientId || !isClientConnected(targetClientId)) { + throw createError({ + statusCode: 503, + statusMessage: + 'No active editor client — make sure an Electron window or /editor tab is open and focused.', + }); + } + + // 2. Allocate request id and try to send + const requestId = allocateRequestId(); + const sent = sendToClient(targetClientId, { + type: 'screenshot:request', + requestId, + bounds: body.bounds, + nodeId: body.nodeId, + opts: body.opts, + timeoutMs, + }); + + // 3. Only register pending + start timeout AFTER successful send (Q3 decision) + if (!sent) { + throw createError({ + statusCode: 503, + statusMessage: + 'Failed to deliver screenshot request — target editor client disconnected between check and send.', + }); + } + + // 4. Await renderer response (or timeout) + return await registerPending(requestId, timeoutMs); +}); diff --git a/apps/web/server/api/mcp/selection.get.ts b/apps/web/server/api/mcp/selection.get.ts index c42a4ca6..55cacdd3 100644 --- a/apps/web/server/api/mcp/selection.get.ts +++ b/apps/web/server/api/mcp/selection.get.ts @@ -1,8 +1,8 @@ -import { defineEventHandler, setResponseHeaders } from 'h3' -import { getSyncSelection } from '../../utils/mcp-sync-state' +import { defineEventHandler, setResponseHeaders } from 'h3'; +import { getSyncSelection } from '../../utils/mcp-sync-state'; /** GET /api/mcp/selection — Returns the current canvas selection for MCP to read. */ export default defineEventHandler((event) => { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - return getSyncSelection() -}) + setResponseHeaders(event, { 'Content-Type': 'application/json' }); + return getSyncSelection(); +}); diff --git a/apps/web/server/api/mcp/selection.post.ts b/apps/web/server/api/mcp/selection.post.ts index 5c84b640..0cd9976b 100644 --- a/apps/web/server/api/mcp/selection.post.ts +++ b/apps/web/server/api/mcp/selection.post.ts @@ -1,17 +1,18 @@ -import { defineEventHandler, readBody, createError } from 'h3' -import { setSyncSelection } from '../../utils/mcp-sync-state' +import { defineEventHandler, readBody, createError } from 'h3'; +import { setSyncSelection } from '../../utils/mcp-sync-state'; interface PostBody { - selectedIds: string[] - activePageId?: string | null + selectedIds: string[]; + activePageId?: string | null; + sourceClientId?: string; } /** POST /api/mcp/selection — Receives selection update from renderer. */ export default defineEventHandler(async (event) => { - const body = await readBody(event) + const body = await readBody(event); if (!body || !Array.isArray(body.selectedIds)) { - throw createError({ statusCode: 400, statusMessage: 'Missing selectedIds array' }) + throw createError({ statusCode: 400, statusMessage: 'Missing selectedIds array' }); } - setSyncSelection(body.selectedIds, body.activePageId) - return { ok: true } -}) + setSyncSelection(body.selectedIds, body.activePageId, body.sourceClientId); + return { ok: true }; +}); diff --git a/apps/web/server/api/mcp/server.get.ts b/apps/web/server/api/mcp/server.get.ts index 9b83f744..8b649070 100644 --- a/apps/web/server/api/mcp/server.get.ts +++ b/apps/web/server/api/mcp/server.get.ts @@ -1,8 +1,8 @@ -import { defineEventHandler, setResponseHeaders } from 'h3' -import { getMcpServerStatus } from '../../utils/mcp-server-manager' +import { defineEventHandler, setResponseHeaders } from 'h3'; +import { getMcpServerStatus } from '../../utils/mcp-server-manager'; /** GET /api/mcp/server — Returns the current MCP server status. */ export default defineEventHandler((event) => { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - return getMcpServerStatus() -}) + setResponseHeaders(event, { 'Content-Type': 'application/json' }); + return getMcpServerStatus(); +}); diff --git a/apps/web/server/api/mcp/server.post.ts b/apps/web/server/api/mcp/server.post.ts index c9db360b..754557ce 100644 --- a/apps/web/server/api/mcp/server.post.ts +++ b/apps/web/server/api/mcp/server.post.ts @@ -1,26 +1,26 @@ -import { defineEventHandler, readBody, setResponseHeaders } from 'h3' -import { startMcpHttpServer, stopMcpHttpServer } from '../../utils/mcp-server-manager' +import { defineEventHandler, readBody, setResponseHeaders } from 'h3'; +import { startMcpHttpServer, stopMcpHttpServer } from '../../utils/mcp-server-manager'; -const MCP_DEFAULT_PORT = 3100 +const MCP_DEFAULT_PORT = 3100; interface PostBody { - action: 'start' | 'stop' - port?: number + action: 'start' | 'stop'; + port?: number; } /** POST /api/mcp/server — Start or stop the standalone MCP HTTP server. */ export default defineEventHandler(async (event) => { - setResponseHeaders(event, { 'Content-Type': 'application/json' }) - const body = await readBody(event) + setResponseHeaders(event, { 'Content-Type': 'application/json' }); + const body = await readBody(event); if (!body?.action || !['start', 'stop'].includes(body.action)) { - return { error: 'Invalid action. Use "start" or "stop".' } + return { error: 'Invalid action. Use "start" or "stop".' }; } if (body.action === 'start') { - const port = body.port ?? MCP_DEFAULT_PORT - return startMcpHttpServer(port) + const port = body.port ?? MCP_DEFAULT_PORT; + return startMcpHttpServer(port); } - return stopMcpHttpServer() -}) + return stopMcpHttpServer(); +}); diff --git a/apps/web/server/api/mcp/sync-reset.post.ts b/apps/web/server/api/mcp/sync-reset.post.ts index b13eeb7b..32df3417 100644 --- a/apps/web/server/api/mcp/sync-reset.post.ts +++ b/apps/web/server/api/mcp/sync-reset.post.ts @@ -1,8 +1,8 @@ -import { defineEventHandler } from 'h3' -import { clearSyncState } from '../../utils/mcp-sync-state' +import { defineEventHandler } from 'h3'; +import { clearSyncState } from '../../utils/mcp-sync-state'; /** POST /api/mcp/sync-reset — Clears stale sync cache on page load / file open. */ export default defineEventHandler(() => { - clearSyncState() - return { ok: true } -}) + clearSyncState(); + return { ok: true }; +}); diff --git a/apps/web/server/opencode/client.ts b/apps/web/server/opencode/client.ts index d517a09b..6c2dc512 100644 --- a/apps/web/server/opencode/client.ts +++ b/apps/web/server/opencode/client.ts @@ -1,39 +1,41 @@ -export * from "./gen/types.gen" +export * from './gen/types.gen'; -import { createClient } from "./gen/client/client.gen" -import { type Config } from "./gen/client/types.gen" -import { OpencodeClient } from "./gen/sdk.gen" -export { type Config as OpencodeClientConfig, OpencodeClient } +import { createClient } from './gen/client/client.gen'; +import { type Config } from './gen/client/types.gen'; +import { OpencodeClient } from './gen/sdk.gen'; +export { type Config as OpencodeClientConfig, OpencodeClient }; -export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) { +export function createOpencodeClient( + config?: Config & { directory?: string; experimental_workspaceID?: string }, +) { if (!config?.fetch) { const customFetch: any = (req: any) => { // @ts-ignore - req.timeout = false - return fetch(req) - } + req.timeout = false; + return fetch(req); + }; config = { ...config, fetch: customFetch, - } + }; } if (config?.directory) { - const isNonASCII = /[^\x00-\x7F]/.test(config.directory) - const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory + const isNonASCII = /[^\x00-\x7F]/.test(config.directory); + const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory; config.headers = { ...config.headers, - "x-opencode-directory": encodedDirectory, - } + 'x-opencode-directory': encodedDirectory, + }; } if (config?.experimental_workspaceID) { config.headers = { ...config.headers, - "x-opencode-workspace": config.experimental_workspaceID, - } + 'x-opencode-workspace': config.experimental_workspaceID, + }; } - const client = createClient(config) - return new OpencodeClient({ client }) + const client = createClient(config); + return new OpencodeClient({ client }); } diff --git a/apps/web/server/opencode/gen/client.gen.ts b/apps/web/server/opencode/gen/client.gen.ts index 17d12c76..42d5eae6 100644 --- a/apps/web/server/opencode/gen/client.gen.ts +++ b/apps/web/server/opencode/gen/client.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts -import { type ClientOptions, type Config, createClient, createConfig } from "./client/index" -import type { ClientOptions as ClientOptions2 } from "./types.gen" +import { type ClientOptions, type Config, createClient, createConfig } from './client/index'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; /** * The `createClientConfig()` function will be called on client initialization @@ -13,6 +13,8 @@ import type { ClientOptions as ClientOptions2 } from "./types.gen" */ export type CreateClientConfig = ( override?: Config, -) => Config & T> +) => Config & T>; -export const client = createClient(createConfig({ baseUrl: "http://localhost:4096" })) +export const client = createClient( + createConfig({ baseUrl: 'http://localhost:4096' }), +); diff --git a/apps/web/server/opencode/gen/client/client.gen.ts b/apps/web/server/opencode/gen/client/client.gen.ts index 30237375..d2e55a14 100644 --- a/apps/web/server/opencode/gen/client/client.gen.ts +++ b/apps/web/server/opencode/gen/client/client.gen.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @hey-api/openapi-ts -import { createSseClient } from "../core/serverSentEvents.gen" -import type { HttpMethod } from "../core/types.gen" -import { getValidRequestBody } from "../core/utils.gen" -import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen" +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; import { buildUrl, createConfig, @@ -12,24 +12,24 @@ import { mergeConfigs, mergeHeaders, setAuthParams, -} from "./utils.gen" +} from './utils.gen'; -type ReqInit = Omit & { - body?: any - headers: ReturnType -} +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; export const createClient = (config: Config = {}): Client => { - let _config = mergeConfigs(createConfig(), config) + let _config = mergeConfigs(createConfig(), config); - const getConfig = (): Config => ({ ..._config }) + const getConfig = (): Config => ({ ..._config }); const setConfig = (config: Config): Config => { - _config = mergeConfigs(_config, config) - return getConfig() - } + _config = mergeConfigs(_config, config); + return getConfig(); + }; - const interceptors = createInterceptors() + const interceptors = createInterceptors(); const beforeRequest = async (options: RequestOptions) => { const opts = { @@ -38,248 +38,251 @@ export const createClient = (config: Config = {}): Client => { fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, headers: mergeHeaders(_config.headers, options.headers), serializedBody: undefined, - } + }; if (opts.security) { await setAuthParams({ ...opts, security: opts.security, - }) + }); } if (opts.requestValidator) { - await opts.requestValidator(opts) + await opts.requestValidator(opts); } if (opts.body !== undefined && opts.bodySerializer) { - opts.serializedBody = opts.bodySerializer(opts.body) + opts.serializedBody = opts.bodySerializer(opts.body); } // remove Content-Type header if body is empty to avoid sending invalid requests - if (opts.body === undefined || opts.serializedBody === "") { - opts.headers.delete("Content-Type") + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); } - const url = buildUrl(opts) + const url = buildUrl(opts); - return { opts, url } - } + return { opts, url }; + }; - const request: Client["request"] = async (options) => { + const request: Client['request'] = async (options) => { // @ts-expect-error - const { opts, url } = await beforeRequest(options) + const { opts, url } = await beforeRequest(options); const requestInit: ReqInit = { - redirect: "follow", + redirect: 'follow', ...opts, body: getValidRequestBody(opts), - } + }; - let request = new Request(url, requestInit) + let request = new Request(url, requestInit); for (const fn of interceptors.request.fns) { if (fn) { - request = await fn(request, opts) + request = await fn(request, opts); } } // fetch must be assigned here, otherwise it would throw the error: // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = opts.fetch! - let response: Response + const _fetch = opts.fetch!; + let response: Response; try { - response = await _fetch(request) + response = await _fetch(request); } catch (error) { // Handle fetch exceptions (AbortError, network errors, etc.) - let finalError = error + let finalError = error; for (const fn of interceptors.error.fns) { if (fn) { - finalError = (await fn(error, undefined as any, request, opts)) as unknown + finalError = (await fn(error, undefined as any, request, opts)) as unknown; } } - finalError = finalError || ({} as unknown) + finalError = finalError || ({} as unknown); if (opts.throwOnError) { - throw finalError + throw finalError; } // Return error response - return opts.responseStyle === "data" + return opts.responseStyle === 'data' ? undefined : { error: finalError, request, response: undefined as any, - } + }; } for (const fn of interceptors.response.fns) { if (fn) { - response = await fn(response, request, opts) + response = await fn(response, request, opts); } } const result = { request, response, - } + }; if (response.ok) { const parseAs = - (opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json" + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; - if (response.status === 204 || response.headers.get("Content-Length") === "0") { - let emptyData: any + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; switch (parseAs) { - case "arrayBuffer": - case "blob": - case "text": - emptyData = await response[parseAs]() - break - case "formData": - emptyData = new FormData() - break - case "stream": - emptyData = response.body - break - case "json": + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': default: - emptyData = {} - break + emptyData = {}; + break; } - return opts.responseStyle === "data" + return opts.responseStyle === 'data' ? emptyData : { data: emptyData, ...result, - } + }; } - let data: any + let data: any; switch (parseAs) { - case "arrayBuffer": - case "blob": - case "formData": - case "text": - data = await response[parseAs]() - break - case "json": { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { // Some servers return 200 with no Content-Length and empty body. // response.json() would throw; read as text and parse if non-empty. - const text = await response.text() - data = text ? JSON.parse(text) : {} - break + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; } - case "stream": - return opts.responseStyle === "data" + case 'stream': + return opts.responseStyle === 'data' ? response.body : { data: response.body, ...result, - } + }; } - if (parseAs === "json") { + if (parseAs === 'json') { if (opts.responseValidator) { - await opts.responseValidator(data) + await opts.responseValidator(data); } if (opts.responseTransformer) { - data = await opts.responseTransformer(data) + data = await opts.responseTransformer(data); } } - return opts.responseStyle === "data" + return opts.responseStyle === 'data' ? data : { data, ...result, - } + }; } - const textError = await response.text() - let jsonError: unknown + const textError = await response.text(); + let jsonError: unknown; try { - jsonError = JSON.parse(textError) + jsonError = JSON.parse(textError); } catch { // noop } - const error = jsonError ?? textError - let finalError = error + const error = jsonError ?? textError; + let finalError = error; for (const fn of interceptors.error.fns) { if (fn) { - finalError = (await fn(error, response, request, opts)) as string + finalError = (await fn(error, response, request, opts)) as string; } } - finalError = finalError || ({} as string) + finalError = finalError || ({} as string); if (opts.throwOnError) { - throw finalError + throw finalError; } // TODO: we probably want to return error and improve types - return opts.responseStyle === "data" + return opts.responseStyle === 'data' ? undefined : { error: finalError, ...result, - } - } + }; + }; - const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => request({ ...options, method }) + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { - const { opts, url } = await beforeRequest(options) + const { opts, url } = await beforeRequest(options); return createSseClient({ ...opts, body: opts.body as BodyInit | null | undefined, headers: opts.headers as unknown as Record, method, onRequest: async (url, init) => { - let request = new Request(url, init) + let request = new Request(url, init); for (const fn of interceptors.request.fns) { if (fn) { - request = await fn(request, opts) + request = await fn(request, opts); } } - return request + return request; }, serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, url, - }) - } + }); + }; return { buildUrl, - connect: makeMethodFn("CONNECT"), - delete: makeMethodFn("DELETE"), - get: makeMethodFn("GET"), + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), getConfig, - head: makeMethodFn("HEAD"), + head: makeMethodFn('HEAD'), interceptors, - options: makeMethodFn("OPTIONS"), - patch: makeMethodFn("PATCH"), - post: makeMethodFn("POST"), - put: makeMethodFn("PUT"), + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), request, setConfig, sse: { - connect: makeSseFn("CONNECT"), - delete: makeSseFn("DELETE"), - get: makeSseFn("GET"), - head: makeSseFn("HEAD"), - options: makeSseFn("OPTIONS"), - patch: makeSseFn("PATCH"), - post: makeSseFn("POST"), - put: makeSseFn("PUT"), - trace: makeSseFn("TRACE"), + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), }, - trace: makeMethodFn("TRACE"), - } as Client -} + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/apps/web/server/opencode/gen/client/index.ts b/apps/web/server/opencode/gen/client/index.ts index 77aabe6f..b295edec 100644 --- a/apps/web/server/opencode/gen/client/index.ts +++ b/apps/web/server/opencode/gen/client/index.ts @@ -1,15 +1,15 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { Auth } from "../core/auth.gen" -export type { QuerySerializerOptions } from "../core/bodySerializer.gen" +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, -} from "../core/bodySerializer.gen" -export { buildClientParams } from "../core/params.gen" -export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen" -export { createClient } from "./client.gen" +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export { createClient } from './client.gen'; export type { Client, ClientOptions, @@ -21,5 +21,5 @@ export type { ResolvedRequestOptions, ResponseStyle, TDataShape, -} from "./types.gen" -export { createConfig, mergeHeaders } from "./utils.gen" +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/apps/web/server/opencode/gen/client/types.gen.ts b/apps/web/server/opencode/gen/client/types.gen.ts index 90a6ad28..61a8dffa 100644 --- a/apps/web/server/opencode/gen/client/types.gen.ts +++ b/apps/web/server/opencode/gen/client/types.gen.ts @@ -1,33 +1,32 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Auth } from "../core/auth.gen" -import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen" -import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen" -import type { Middleware } from "./utils.gen" +import type { Auth } from '../core/auth.gen'; +import type { ServerSentEventsOptions, ServerSentEventsResult } from '../core/serverSentEvents.gen'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; +import type { Middleware } from './utils.gen'; -export type ResponseStyle = "data" | "fields" +export type ResponseStyle = 'data' | 'fields'; export interface Config - extends Omit, - CoreConfig { + extends Omit, CoreConfig { /** * Base URL for all requests made by this client. */ - baseUrl?: T["baseUrl"] + baseUrl?: T['baseUrl']; /** * Fetch API implementation. You can use this option to provide a custom * fetch instance. * * @default globalThis.fetch */ - fetch?: typeof fetch + fetch?: typeof fetch; /** * Please don't use the Fetch client for Next.js applications. The `next` * options won't have any effect. * * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. */ - next?: never + next?: never; /** * Return the response data parsed in a specified format. By default, `auto` * will infer the appropriate method from the `Content-Type` response header. @@ -36,140 +35,146 @@ export interface Config * * @default 'auto' */ - parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text" + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; /** * Should we return only data or multiple fields (data, error, response, etc.)? * * @default 'fields' */ - responseStyle?: ResponseStyle + responseStyle?: ResponseStyle; /** * Throw an error instead of returning it in the response? * * @default false */ - throwOnError?: T["throwOnError"] + throwOnError?: T['throwOnError']; } export interface RequestOptions< TData = unknown, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', ThrowOnError extends boolean = boolean, Url extends string = string, -> extends Config<{ - responseStyle: TResponseStyle - throwOnError: ThrowOnError +> + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; }>, Pick< ServerSentEventsOptions, - "onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay" + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' > { /** * Any body that you want to add to your request. * * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} */ - body?: unknown - path?: Record - query?: Record + body?: unknown; + path?: Record; + query?: Record; /** * Security mechanism(s) to use for the request. */ - security?: ReadonlyArray - url: Url + security?: ReadonlyArray; + url: Url; } export interface ResolvedRequestOptions< - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', ThrowOnError extends boolean = boolean, Url extends string = string, > extends RequestOptions { - serializedBody?: string + serializedBody?: string; } export type RequestResult< TData = unknown, TError = unknown, ThrowOnError extends boolean = boolean, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', > = ThrowOnError extends true ? Promise< - TResponseStyle extends "data" + TResponseStyle extends 'data' ? TData extends Record ? TData[keyof TData] : TData : { - data: TData extends Record ? TData[keyof TData] : TData - request: Request - response: Response + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; } > : Promise< - TResponseStyle extends "data" + TResponseStyle extends 'data' ? (TData extends Record ? TData[keyof TData] : TData) | undefined : ( | { - data: TData extends Record ? TData[keyof TData] : TData - error: undefined + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; } | { - data: undefined - error: TError extends Record ? TError[keyof TError] : TError + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; } ) & { - request: Request - response: Response + request: Request; + response: Response; } - > + >; export interface ClientOptions { - baseUrl?: string - responseStyle?: ResponseStyle - throwOnError?: boolean + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; } type MethodFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, "method">, -) => RequestResult + options: Omit, 'method'>, +) => RequestResult; type SseFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, "method">, -) => Promise> + options: Omit, 'method'>, +) => Promise>; type RequestFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, "method"> & - Pick>, "method">, -) => RequestResult + options: Omit, 'method'> & + Pick>, 'method'>, +) => RequestResult; type BuildUrlFn = < TData extends { - body?: unknown - path?: Record - query?: Record - url: string + body?: unknown; + path?: Record; + query?: Record; + url: string; }, >( options: TData & Options, -) => string +) => string; export type Client = CoreClient & { - interceptors: Middleware -} + interceptors: Middleware; +}; /** * The `createClientConfig()` function will be called on client initialization @@ -181,22 +186,25 @@ export type Client = CoreClient */ export type CreateClientConfig = ( override?: Config, -) => Config & T> +) => Config & T>; export interface TDataShape { - body?: unknown - headers?: unknown - path?: unknown - query?: unknown - url: string + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; } -type OmitKeys = Pick> +type OmitKeys = Pick>; export type Options< TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown, - TResponseStyle extends ResponseStyle = "fields", -> = OmitKeys, "body" | "path" | "query" | "url"> & - ([TData] extends [never] ? unknown : Omit) + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/apps/web/server/opencode/gen/client/utils.gen.ts b/apps/web/server/opencode/gen/client/utils.gen.ts index 65007692..b4bd2435 100644 --- a/apps/web/server/opencode/gen/client/utils.gen.ts +++ b/apps/web/server/opencode/gen/client/utils.gen.ts @@ -1,289 +1,316 @@ // This file is auto-generated by @hey-api/openapi-ts -import { getAuthToken } from "../core/auth.gen" -import type { QuerySerializerOptions } from "../core/bodySerializer.gen" -import { jsonBodySerializer } from "../core/bodySerializer.gen" -import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen" -import { getUrl } from "../core/utils.gen" -import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen" +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; -export const createQuerySerializer = ({ parameters = {}, ...args }: QuerySerializerOptions = {}) => { +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}) => { const querySerializer = (queryParams: T) => { - const search: string[] = [] - if (queryParams && typeof queryParams === "object") { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { for (const name in queryParams) { - const value = queryParams[name] + const value = queryParams[name]; if (value === undefined || value === null) { - continue + continue; } - const options = parameters[name] || args + const options = parameters[name] || args; if (Array.isArray(value)) { const serializedArray = serializeArrayParam({ allowReserved: options.allowReserved, explode: true, name, - style: "form", + style: 'form', value, ...options.array, - }) - if (serializedArray) search.push(serializedArray) - } else if (typeof value === "object") { + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { const serializedObject = serializeObjectParam({ allowReserved: options.allowReserved, explode: true, name, - style: "deepObject", + style: 'deepObject', value: value as Record, ...options.object, - }) - if (serializedObject) search.push(serializedObject) + }); + if (serializedObject) search.push(serializedObject); } else { const serializedPrimitive = serializePrimitiveParam({ allowReserved: options.allowReserved, name, value: value as string, - }) - if (serializedPrimitive) search.push(serializedPrimitive) + }); + if (serializedPrimitive) search.push(serializedPrimitive); } } } - return search.join("&") - } - return querySerializer -} + return search.join('&'); + }; + return querySerializer; +}; /** * Infers parseAs value from provided Content-Type header. */ -export const getParseAs = (contentType: string | null): Exclude => { +export const getParseAs = (contentType: string | null): Exclude => { if (!contentType) { // If no Content-Type header is provided, the best we can do is return the raw response body, // which is effectively the same as the 'stream' option. - return "stream" + return 'stream'; } - const cleanContent = contentType.split(";")[0]?.trim() + const cleanContent = contentType.split(';')[0]?.trim(); if (!cleanContent) { - return + return; } - if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) { - return "json" + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { + return 'json'; } - if (cleanContent === "multipart/form-data") { - return "formData" + if (cleanContent === 'multipart/form-data') { + return 'formData'; } - if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) { - return "blob" + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) + ) { + return 'blob'; } - if (cleanContent.startsWith("text/")) { - return "text" + if (cleanContent.startsWith('text/')) { + return 'text'; } - return -} + return; +}; const checkForExistence = ( - options: Pick & { - headers: Headers + options: Pick & { + headers: Headers; }, name?: string, ): boolean => { if (!name) { - return false + return false; } - if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) { - return true + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; } - return false -} + return false; +}; export const setAuthParams = async ({ security, ...options -}: Pick, "security"> & - Pick & { - headers: Headers +}: Pick, 'security'> & + Pick & { + headers: Headers; }) => { for (const auth of security) { if (checkForExistence(options, auth.name)) { - continue + continue; } - const token = await getAuthToken(auth, options.auth) + const token = await getAuthToken(auth, options.auth); if (!token) { - continue + continue; } - const name = auth.name ?? "Authorization" + const name = auth.name ?? 'Authorization'; switch (auth.in) { - case "query": + case 'query': if (!options.query) { - options.query = {} + options.query = {}; } - options.query[name] = token - break - case "cookie": - options.headers.append("Cookie", `${name}=${token}`) - break - case "header": + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': default: - options.headers.set(name, token) - break + options.headers.set(name, token); + break; } } -} +}; -export const buildUrl: Client["buildUrl"] = (options) => +export const buildUrl: Client['buildUrl'] = (options) => getUrl({ baseUrl: options.baseUrl as string, path: options.path, query: options.query, querySerializer: - typeof options.querySerializer === "function" + typeof options.querySerializer === 'function' ? options.querySerializer : createQuerySerializer(options.querySerializer), url: options.url, - }) + }); export const mergeConfigs = (a: Config, b: Config): Config => { - const config = { ...a, ...b } - if (config.baseUrl?.endsWith("/")) { - config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1) + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); } - config.headers = mergeHeaders(a.headers, b.headers) - return config -} + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; const headersEntries = (headers: Headers): Array<[string, string]> => { - const entries: Array<[string, string]> = [] + const entries: Array<[string, string]> = []; headers.forEach((value, key) => { - entries.push([key, value]) - }) - return entries -} + entries.push([key, value]); + }); + return entries; +}; -export const mergeHeaders = (...headers: Array["headers"] | undefined>): Headers => { - const mergedHeaders = new Headers() +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); for (const header of headers) { if (!header) { - continue + continue; } - const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header) + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); for (const [key, value] of iterator) { if (value === null) { - mergedHeaders.delete(key) + mergedHeaders.delete(key); } else if (Array.isArray(value)) { for (const v of value) { - mergedHeaders.append(key, v as string) + mergedHeaders.append(key, v as string); } } else if (value !== undefined) { // assume object headers are meant to be JSON stringified, i.e. their // content value in OpenAPI specification is 'application/json' - mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string)) + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); } } } - return mergedHeaders -} + return mergedHeaders; +}; type ErrInterceptor = ( error: Err, response: Res, request: Req, options: Options, -) => Err | Promise +) => Err | Promise; -type ReqInterceptor = (request: Req, options: Options) => Req | Promise +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; -type ResInterceptor = (response: Res, request: Req, options: Options) => Res | Promise +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; class Interceptors { - fns: Array = [] + fns: Array = []; clear(): void { - this.fns = [] + this.fns = []; } eject(id: number | Interceptor): void { - const index = this.getInterceptorIndex(id) + const index = this.getInterceptorIndex(id); if (this.fns[index]) { - this.fns[index] = null + this.fns[index] = null; } } exists(id: number | Interceptor): boolean { - const index = this.getInterceptorIndex(id) - return Boolean(this.fns[index]) + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); } getInterceptorIndex(id: number | Interceptor): number { - if (typeof id === "number") { - return this.fns[id] ? id : -1 + if (typeof id === 'number') { + return this.fns[id] ? id : -1; } - return this.fns.indexOf(id) + return this.fns.indexOf(id); } update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { - const index = this.getInterceptorIndex(id) + const index = this.getInterceptorIndex(id); if (this.fns[index]) { - this.fns[index] = fn - return id + this.fns[index] = fn; + return id; } - return false + return false; } use(fn: Interceptor): number { - this.fns.push(fn) - return this.fns.length - 1 + this.fns.push(fn); + return this.fns.length - 1; } } export interface Middleware { - error: Interceptors> - request: Interceptors> - response: Interceptors> + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; } -export const createInterceptors = (): Middleware => ({ +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ error: new Interceptors>(), request: new Interceptors>(), response: new Interceptors>(), -}) +}); const defaultQuerySerializer = createQuerySerializer({ allowReserved: false, array: { explode: true, - style: "form", + style: 'form', }, object: { explode: true, - style: "deepObject", + style: 'deepObject', }, -}) +}); const defaultHeaders = { - "Content-Type": "application/json", -} + 'Content-Type': 'application/json', +}; export const createConfig = ( override: Config & T> = {}, ): Config & T> => ({ ...jsonBodySerializer, headers: defaultHeaders, - parseAs: "auto", + parseAs: 'auto', querySerializer: defaultQuerySerializer, ...override, -}) +}); diff --git a/apps/web/server/opencode/gen/core/auth.gen.ts b/apps/web/server/opencode/gen/core/auth.gen.ts index bc7b230f..3ebf9947 100644 --- a/apps/web/server/opencode/gen/core/auth.gen.ts +++ b/apps/web/server/opencode/gen/core/auth.gen.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type AuthToken = string | undefined +export type AuthToken = string | undefined; export interface Auth { /** @@ -8,34 +8,34 @@ export interface Auth { * * @default 'header' */ - in?: "header" | "query" | "cookie" + in?: 'header' | 'query' | 'cookie'; /** * Header or query parameter name. * * @default 'Authorization' */ - name?: string - scheme?: "basic" | "bearer" - type: "apiKey" | "http" + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; } export const getAuthToken = async ( auth: Auth, callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, ): Promise => { - const token = typeof callback === "function" ? await callback(auth) : callback + const token = typeof callback === 'function' ? await callback(auth) : callback; if (!token) { - return + return; } - if (auth.scheme === "bearer") { - return `Bearer ${token}` + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; } - if (auth.scheme === "basic") { - return `Basic ${btoa(token)}` + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; } - return token -} + return token; +}; diff --git a/apps/web/server/opencode/gen/core/bodySerializer.gen.ts b/apps/web/server/opencode/gen/core/bodySerializer.gen.ts index 24808ed1..8ad92c9f 100644 --- a/apps/web/server/opencode/gen/core/bodySerializer.gen.ts +++ b/apps/web/server/opencode/gen/core/bodySerializer.gen.ts @@ -1,82 +1,84 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen" +import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; -export type QuerySerializer = (query: Record) => string +export type QuerySerializer = (query: Record) => string; -export type BodySerializer = (body: any) => any +export type BodySerializer = (body: any) => any; type QuerySerializerOptionsObject = { - allowReserved?: boolean - array?: Partial> - object?: Partial> -} + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; export type QuerySerializerOptions = QuerySerializerOptionsObject & { /** * Per-parameter serialization overrides. When provided, these settings * override the global array/object settings for specific parameter names. */ - parameters?: Record -} + parameters?: Record; +}; const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { - if (typeof value === "string" || value instanceof Blob) { - data.append(key, value) + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); } else if (value instanceof Date) { - data.append(key, value.toISOString()) + data.append(key, value.toISOString()); } else { - data.append(key, JSON.stringify(value)) + data.append(key, JSON.stringify(value)); } -} +}; const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { - if (typeof value === "string") { - data.append(key, value) + if (typeof value === 'string') { + data.append(key, value); } else { - data.append(key, JSON.stringify(value)) + data.append(key, JSON.stringify(value)); } -} +}; export const formDataBodySerializer = { - bodySerializer: | Array>>(body: T): FormData => { - const data = new FormData() + bodySerializer: | Array>>( + body: T, + ): FormData => { + const data = new FormData(); Object.entries(body).forEach(([key, value]) => { if (value === undefined || value === null) { - return + return; } if (Array.isArray(value)) { - value.forEach((v) => serializeFormDataPair(data, key, v)) + value.forEach((v) => serializeFormDataPair(data, key, v)); } else { - serializeFormDataPair(data, key, value) + serializeFormDataPair(data, key, value); } - }) + }); - return data + return data; }, -} +}; export const jsonBodySerializer = { bodySerializer: (body: T): string => - JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)), -} + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), +}; export const urlSearchParamsBodySerializer = { bodySerializer: | Array>>(body: T): string => { - const data = new URLSearchParams() + const data = new URLSearchParams(); Object.entries(body).forEach(([key, value]) => { if (value === undefined || value === null) { - return + return; } if (Array.isArray(value)) { - value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)) + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); } else { - serializeUrlSearchParamsPair(data, key, value) + serializeUrlSearchParamsPair(data, key, value); } - }) + }); - return data.toString() + return data.toString(); }, -} +}; diff --git a/apps/web/server/opencode/gen/core/params.gen.ts b/apps/web/server/opencode/gen/core/params.gen.ts index 6e9d0b9a..6099cab1 100644 --- a/apps/web/server/opencode/gen/core/params.gen.ts +++ b/apps/web/server/opencode/gen/core/params.gen.ts @@ -1,106 +1,106 @@ // This file is auto-generated by @hey-api/openapi-ts -type Slot = "body" | "headers" | "path" | "query" +type Slot = 'body' | 'headers' | 'path' | 'query'; export type Field = | { - in: Exclude + in: Exclude; /** * Field name. This is the name we want the user to see and use. */ - key: string + key: string; /** * Field mapped name. This is the name we want to use in the request. * If omitted, we use the same value as `key`. */ - map?: string + map?: string; } | { - in: Extract + in: Extract; /** * Key isn't required for bodies. */ - key?: string - map?: string + key?: string; + map?: string; } | { /** * Field name. This is the name we want the user to see and use. */ - key: string + key: string; /** * Field mapped name. This is the name we want to use in the request. * If `in` is omitted, `map` aliases `key` to the transport layer. */ - map: Slot - } + map: Slot; + }; export interface Fields { - allowExtra?: Partial> - args?: ReadonlyArray + allowExtra?: Partial>; + args?: ReadonlyArray; } -export type FieldsConfig = ReadonlyArray +export type FieldsConfig = ReadonlyArray; const extraPrefixesMap: Record = { - $body_: "body", - $headers_: "headers", - $path_: "path", - $query_: "query", -} -const extraPrefixes = Object.entries(extraPrefixesMap) + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); type KeyMap = Map< string, | { - in: Slot - map?: string + in: Slot; + map?: string; } | { - in?: never - map: Slot + in?: never; + map: Slot; } -> +>; const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { if (!map) { - map = new Map() + map = new Map(); } for (const config of fields) { - if ("in" in config) { + if ('in' in config) { if (config.key) { map.set(config.key, { in: config.in, map: config.map, - }) + }); } - } else if ("key" in config) { + } else if ('key' in config) { map.set(config.key, { map: config.map, - }) + }); } else if (config.args) { - buildKeyMap(config.args, map) + buildKeyMap(config.args, map); } } - return map -} + return map; +}; interface Params { - body: unknown - headers: Record - path: Record - query: Record + body: unknown; + headers: Record; + path: Record; + query: Record; } const stripEmptySlots = (params: Params) => { for (const [slot, value] of Object.entries(params)) { - if (value && typeof value === "object" && !Object.keys(value).length) { - delete params[slot as Slot] + if (value && typeof value === 'object' && !Object.keys(value).length) { + delete params[slot as Slot]; } } -} +}; export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { const params: Params = { @@ -108,53 +108,53 @@ export const buildClientParams = (args: ReadonlyArray, fields: FieldsCo headers: {}, path: {}, query: {}, - } + }; - const map = buildKeyMap(fields) + const map = buildKeyMap(fields); - let config: FieldsConfig[number] | undefined + let config: FieldsConfig[number] | undefined; for (const [index, arg] of args.entries()) { if (fields[index]) { - config = fields[index] + config = fields[index]; } if (!config) { - continue + continue; } - if ("in" in config) { + if ('in' in config) { if (config.key) { - const field = map.get(config.key)! - const name = field.map || config.key + const field = map.get(config.key)!; + const name = field.map || config.key; if (field.in) { - ;(params[field.in] as Record)[name] = arg + (params[field.in] as Record)[name] = arg; } } else { - params.body = arg + params.body = arg; } } else { for (const [key, value] of Object.entries(arg ?? {})) { - const field = map.get(key) + const field = map.get(key); if (field) { if (field.in) { - const name = field.map || key - ;(params[field.in] as Record)[name] = value + const name = field.map || key; + (params[field.in] as Record)[name] = value; } else { - params[field.map] = value + params[field.map] = value; } } else { - const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)) + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); if (extra) { - const [prefix, slot] = extra - ;(params[slot] as Record)[key.slice(prefix.length)] = value - } else if ("allowExtra" in config && config.allowExtra) { + const [prefix, slot] = extra; + (params[slot] as Record)[key.slice(prefix.length)] = value; + } else if ('allowExtra' in config && config.allowExtra) { for (const [slot, allowed] of Object.entries(config.allowExtra)) { if (allowed) { - ;(params[slot as Slot] as Record)[key] = value - break + (params[slot as Slot] as Record)[key] = value; + break; } } } @@ -163,7 +163,7 @@ export const buildClientParams = (args: ReadonlyArray, fields: FieldsCo } } - stripEmptySlots(params) + stripEmptySlots(params); - return params -} + return params; +}; diff --git a/apps/web/server/opencode/gen/core/pathSerializer.gen.ts b/apps/web/server/opencode/gen/core/pathSerializer.gen.ts index 96be3bc5..994b2848 100644 --- a/apps/web/server/opencode/gen/core/pathSerializer.gen.ts +++ b/apps/web/server/opencode/gen/core/pathSerializer.gen.ts @@ -3,66 +3,66 @@ interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} interface SerializePrimitiveOptions { - allowReserved?: boolean - name: string + allowReserved?: boolean; + name: string; } export interface SerializerOptions { /** * @default true */ - explode: boolean - style: T + explode: boolean; + style: T; } -export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited" -export type ArraySeparatorStyle = ArrayStyle | MatrixStyle -type MatrixStyle = "label" | "matrix" | "simple" -export type ObjectStyle = "form" | "deepObject" -type ObjectSeparatorStyle = ObjectStyle | MatrixStyle +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; interface SerializePrimitiveParam extends SerializePrimitiveOptions { - value: string + value: string; } export const separatorArrayExplode = (style: ArraySeparatorStyle) => { switch (style) { - case "label": - return "." - case "matrix": - return ";" - case "simple": - return "," + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; default: - return "&" + return '&'; } -} +}; export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { switch (style) { - case "form": - return "," - case "pipeDelimited": - return "|" - case "spaceDelimited": - return "%20" + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; default: - return "," + return ','; } -} +}; export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { switch (style) { - case "label": - return "." - case "matrix": - return ";" - case "simple": - return "," + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; default: - return "&" + return '&'; } -} +}; export const serializeArrayParam = ({ allowReserved, @@ -71,54 +71,58 @@ export const serializeArrayParam = ({ style, value, }: SerializeOptions & { - value: unknown[] + value: unknown[]; }) => { if (!explode) { - const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join( - separatorArrayNoExplode(style), - ) + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); switch (style) { - case "label": - return `.${joinedValues}` - case "matrix": - return `;${name}=${joinedValues}` - case "simple": - return joinedValues + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; default: - return `${name}=${joinedValues}` + return `${name}=${joinedValues}`; } } - const separator = separatorArrayExplode(style) + const separator = separatorArrayExplode(style); const joinedValues = value .map((v) => { - if (style === "label" || style === "simple") { - return allowReserved ? v : encodeURIComponent(v as string) + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); } return serializePrimitiveParam({ allowReserved, name, value: v as string, - }) + }); }) - .join(separator) - return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues -} + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; -export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => { +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { if (value === undefined || value === null) { - return "" + return ''; } - if (typeof value === "object") { + if (typeof value === 'object') { throw new Error( - "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", - ) + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); } - return `${name}=${allowReserved ? value : encodeURIComponent(value)}` -} + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; export const serializeObjectParam = ({ allowReserved, @@ -128,40 +132,40 @@ export const serializeObjectParam = ({ value, valueOnly, }: SerializeOptions & { - value: Record | Date - valueOnly?: boolean + value: Record | Date; + valueOnly?: boolean; }) => { if (value instanceof Date) { - return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}` + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; } - if (style !== "deepObject" && !explode) { - let values: string[] = [] + if (style !== 'deepObject' && !explode) { + let values: string[] = []; Object.entries(value).forEach(([key, v]) => { - values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)] - }) - const joinedValues = values.join(",") + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(','); switch (style) { - case "form": - return `${name}=${joinedValues}` - case "label": - return `.${joinedValues}` - case "matrix": - return `;${name}=${joinedValues}` + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; default: - return joinedValues + return joinedValues; } } - const separator = separatorObjectExplode(style) + const separator = separatorObjectExplode(style); const joinedValues = Object.entries(value) .map(([key, v]) => serializePrimitiveParam({ allowReserved, - name: style === "deepObject" ? `${name}[${key}]` : key, + name: style === 'deepObject' ? `${name}[${key}]` : key, value: v as string, }), ) - .join(separator) - return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues -} + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; diff --git a/apps/web/server/opencode/gen/core/queryKeySerializer.gen.ts b/apps/web/server/opencode/gen/core/queryKeySerializer.gen.ts index 320204ae..5000df60 100644 --- a/apps/web/server/opencode/gen/core/queryKeySerializer.gen.ts +++ b/apps/web/server/opencode/gen/core/queryKeySerializer.gen.ts @@ -3,109 +3,115 @@ /** * JSON-friendly union that mirrors what Pinia Colada can hash. */ -export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue } +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; /** * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. */ export const queryKeyJsonReplacer = (_key: string, value: unknown) => { - if (value === undefined || typeof value === "function" || typeof value === "symbol") { - return undefined + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; } - if (typeof value === "bigint") { - return value.toString() + if (typeof value === 'bigint') { + return value.toString(); } if (value instanceof Date) { - return value.toISOString() + return value.toISOString(); } - return value -} + return value; +}; /** * Safely stringifies a value and parses it back into a JsonValue. */ export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { try { - const json = JSON.stringify(input, queryKeyJsonReplacer) + const json = JSON.stringify(input, queryKeyJsonReplacer); if (json === undefined) { - return undefined + return undefined; } - return JSON.parse(json) as JsonValue + return JSON.parse(json) as JsonValue; } catch { - return undefined + return undefined; } -} +}; /** * Detects plain objects (including objects with a null prototype). */ const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== "object") { - return false + if (value === null || typeof value !== 'object') { + return false; } - const prototype = Object.getPrototypeOf(value as object) - return prototype === Object.prototype || prototype === null -} + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; /** * Turns URLSearchParams into a sorted JSON object for deterministic keys. */ const serializeSearchParams = (params: URLSearchParams): JsonValue => { - const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)) - const result: Record = {} + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; for (const [key, value] of entries) { - const existing = result[key] + const existing = result[key]; if (existing === undefined) { - result[key] = value - continue + result[key] = value; + continue; } if (Array.isArray(existing)) { - ;(existing as string[]).push(value) + (existing as string[]).push(value); } else { - result[key] = [existing, value] + result[key] = [existing, value]; } } - return result -} + return result; +}; /** * Normalizes any accepted value into a JSON-friendly shape for query keys. */ export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { if (value === null) { - return null + return null; } - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - return value + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; } - if (value === undefined || typeof value === "function" || typeof value === "symbol") { - return undefined + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; } - if (typeof value === "bigint") { - return value.toString() + if (typeof value === 'bigint') { + return value.toString(); } if (value instanceof Date) { - return value.toISOString() + return value.toISOString(); } if (Array.isArray(value)) { - return stringifyToJsonValue(value) + return stringifyToJsonValue(value); } - if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) { - return serializeSearchParams(value) + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { + return serializeSearchParams(value); } if (isPlainObject(value)) { - return stringifyToJsonValue(value) + return stringifyToJsonValue(value); } - return undefined -} + return undefined; +}; diff --git a/apps/web/server/opencode/gen/core/serverSentEvents.gen.ts b/apps/web/server/opencode/gen/core/serverSentEvents.gen.ts index 6b389c18..6aa6cf02 100644 --- a/apps/web/server/opencode/gen/core/serverSentEvents.gen.ts +++ b/apps/web/server/opencode/gen/core/serverSentEvents.gen.ts @@ -1,20 +1,20 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Config } from "./types.gen" +import type { Config } from './types.gen'; -export type ServerSentEventsOptions = Omit & - Pick & { +export type ServerSentEventsOptions = Omit & + Pick & { /** * Fetch API implementation. You can use this option to provide a custom * fetch instance. * * @default globalThis.fetch */ - fetch?: typeof fetch + fetch?: typeof fetch; /** * Implementing clients can call request interceptors inside this hook. */ - onRequest?: (url: string, init: RequestInit) => Promise + onRequest?: (url: string, init: RequestInit) => Promise; /** * Callback invoked when a network or parsing error occurs during streaming. * @@ -22,7 +22,7 @@ export type ServerSentEventsOptions = Omit void + onSseError?: (error: unknown) => void; /** * Callback invoked when an event is streamed from the server. * @@ -31,8 +31,8 @@ export type ServerSentEventsOptions = Omit) => void - serializedBody?: RequestInit["body"] + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; /** * Default retry delay in milliseconds. * @@ -40,11 +40,11 @@ export type ServerSentEventsOptions = Omit = Omit Promise - url: string - } + sseSleepFn?: (ms: number) => Promise; + url: string; + }; export interface StreamEvent { - data: TData - event?: string - id?: string - retry?: number + data: TData; + event?: string; + id?: string; + retry?: number; } export type ServerSentEventsResult = { - stream: AsyncGenerator ? TData[keyof TData] : TData, TReturn, TNext> -} + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; export const createSseClient = ({ onRequest, @@ -88,115 +92,115 @@ export const createSseClient = ({ url, ...options }: ServerSentEventsOptions): ServerSentEventsResult => { - let lastEventId: string | undefined + let lastEventId: string | undefined; - const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); const createStream = async function* () { - let retryDelay: number = sseDefaultRetryDelay ?? 3000 - let attempt = 0 - const signal = options.signal ?? new AbortController().signal + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; while (true) { - if (signal.aborted) break + if (signal.aborted) break; - attempt++ + attempt++; const headers = options.headers instanceof Headers ? options.headers - : new Headers(options.headers as Record | undefined) + : new Headers(options.headers as Record | undefined); if (lastEventId !== undefined) { - headers.set("Last-Event-ID", lastEventId) + headers.set('Last-Event-ID', lastEventId); } try { const requestInit: RequestInit = { - redirect: "follow", + redirect: 'follow', ...options, body: options.serializedBody, headers, signal, - } - let request = new Request(url, requestInit) + }; + let request = new Request(url, requestInit); if (onRequest) { - request = await onRequest(url, requestInit) + request = await onRequest(url, requestInit); } // fetch must be assigned here, otherwise it would throw the error: // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = options.fetch ?? globalThis.fetch - const response = await _fetch(request) + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); - if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`) + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); - if (!response.body) throw new Error("No body in SSE response") + if (!response.body) throw new Error('No body in SSE response'); - const reader = response.body.pipeThrough(new TextDecoderStream()).getReader() + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); - let buffer = "" + let buffer = ''; const abortHandler = () => { try { - reader.cancel() + reader.cancel(); } catch { // noop } - } + }; - signal.addEventListener("abort", abortHandler) + signal.addEventListener('abort', abortHandler); try { while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += value + const { done, value } = await reader.read(); + if (done) break; + buffer += value; // Normalize line endings: CRLF -> LF, then CR -> LF - buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const chunks = buffer.split("\n\n") - buffer = chunks.pop() ?? "" + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; for (const chunk of chunks) { - const lines = chunk.split("\n") - const dataLines: Array = [] - let eventName: string | undefined + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; for (const line of lines) { - if (line.startsWith("data:")) { - dataLines.push(line.replace(/^data:\s*/, "")) - } else if (line.startsWith("event:")) { - eventName = line.replace(/^event:\s*/, "") - } else if (line.startsWith("id:")) { - lastEventId = line.replace(/^id:\s*/, "") - } else if (line.startsWith("retry:")) { - const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10) + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); if (!Number.isNaN(parsed)) { - retryDelay = parsed + retryDelay = parsed; } } } - let data: unknown - let parsedJson = false + let data: unknown; + let parsedJson = false; if (dataLines.length) { - const rawData = dataLines.join("\n") + const rawData = dataLines.join('\n'); try { - data = JSON.parse(rawData) - parsedJson = true + data = JSON.parse(rawData); + parsedJson = true; } catch { - data = rawData + data = rawData; } } if (parsedJson) { if (responseValidator) { - await responseValidator(data) + await responseValidator(data); } if (responseTransformer) { - data = await responseTransformer(data) + data = await responseTransformer(data); } } @@ -205,35 +209,35 @@ export const createSseClient = ({ event: eventName, id: lastEventId, retry: retryDelay, - }) + }); if (dataLines.length) { - yield data as any + yield data as any; } } } } finally { - signal.removeEventListener("abort", abortHandler) - reader.releaseLock() + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); } - break // exit loop on normal completion + break; // exit loop on normal completion } catch (error) { // connection failed or aborted; retry after delay - onSseError?.(error) + onSseError?.(error); if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { - break // stop after firing error + break; // stop after firing error } // exponential backoff: double retry each attempt, cap at 30s - const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000) - await sleep(backoff) + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); } } - } + }; - const stream = createStream() + const stream = createStream(); - return { stream } -} + return { stream }; +}; diff --git a/apps/web/server/opencode/gen/core/types.gen.ts b/apps/web/server/opencode/gen/core/types.gen.ts index 624ba56a..97463257 100644 --- a/apps/web/server/opencode/gen/core/types.gen.ts +++ b/apps/web/server/opencode/gen/core/types.gen.ts @@ -1,33 +1,48 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Auth, AuthToken } from "./auth.gen" -import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen" +import type { Auth, AuthToken } from './auth.gen'; +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; -export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace" +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; -export type Client = { +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { /** * Returns the final request URL. */ - buildUrl: BuildUrlFn - getConfig: () => Config - request: RequestFn - setConfig: (config: Config) => Config + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; } & { - [K in HttpMethod]: MethodFn -} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }) + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); export interface Config { /** * Auth token or a function returning auth token. The resolved value will be * added to the request payload as defined by its `security` array. */ - auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; /** * A function for serializing request body parameter. By default, * {@link JSON.stringify()} will be used. */ - bodySerializer?: BodySerializer | null + bodySerializer?: BodySerializer | null; /** * An object containing any HTTP headers that you want to pre-populate your * `Headers` object with. @@ -35,14 +50,17 @@ export interface Config { * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} */ headers?: - | RequestInit["headers"] - | Record + | RequestInit['headers'] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; /** * The request method. * * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} */ - method?: Uppercase + method?: Uppercase; /** * A function for serializing request query parameters. By default, arrays * will be exploded in form style, objects will be exploded in deepObject @@ -53,24 +71,24 @@ export interface Config { * * {@link https://swagger.io/docs/specification/serialization/#query View examples} */ - querySerializer?: QuerySerializer | QuerySerializerOptions + querySerializer?: QuerySerializer | QuerySerializerOptions; /** * A function validating request data. This is useful if you want to ensure * the request conforms to the desired shape, so it can be safely sent to * the server. */ - requestValidator?: (data: unknown) => Promise + requestValidator?: (data: unknown) => Promise; /** * A function transforming response data before it's returned. This is useful * for post-processing data, e.g. converting ISO strings into Date objects. */ - responseTransformer?: (data: unknown) => Promise + responseTransformer?: (data: unknown) => Promise; /** * A function validating response data. This is useful if you want to ensure * the response conforms to the desired shape, so it can be safely passed to * the transformers and returned to the user. */ - responseValidator?: (data: unknown) => Promise + responseValidator?: (data: unknown) => Promise; } type IsExactlyNeverOrNeverUndefined = [T] extends [never] @@ -79,8 +97,8 @@ type IsExactlyNeverOrNeverUndefined = [T] extends [never] ? [undefined] extends [T] ? false : true - : false + : false; export type OmitNever> = { - [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K] -} + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; +}; diff --git a/apps/web/server/opencode/gen/core/utils.gen.ts b/apps/web/server/opencode/gen/core/utils.gen.ts index 2d9ab7b0..e7ddbe35 100644 --- a/apps/web/server/opencode/gen/core/utils.gen.ts +++ b/apps/web/server/opencode/gen/core/utils.gen.ts @@ -1,54 +1,54 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen" +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; import { type ArraySeparatorStyle, serializeArrayParam, serializeObjectParam, serializePrimitiveParam, -} from "./pathSerializer.gen" +} from './pathSerializer.gen'; export interface PathSerializer { - path: Record - url: string + path: Record; + url: string; } -export const PATH_PARAM_RE = /\{[^{}]+\}/g +export const PATH_PARAM_RE = /\{[^{}]+\}/g; export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { - let url = _url - const matches = _url.match(PATH_PARAM_RE) + let url = _url; + const matches = _url.match(PATH_PARAM_RE); if (matches) { for (const match of matches) { - let explode = false - let name = match.substring(1, match.length - 1) - let style: ArraySeparatorStyle = "simple" + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; - if (name.endsWith("*")) { - explode = true - name = name.substring(0, name.length - 1) + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); } - if (name.startsWith(".")) { - name = name.substring(1) - style = "label" - } else if (name.startsWith(";")) { - name = name.substring(1) - style = "matrix" + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; } - const value = path[name] + const value = path[name]; if (value === undefined || value === null) { - continue + continue; } if (Array.isArray(value)) { - url = url.replace(match, serializeArrayParam({ explode, name, style, value })) - continue + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; } - if (typeof value === "object") { + if (typeof value === 'object') { url = url.replace( match, serializeObjectParam({ @@ -58,27 +58,29 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { value: value as Record, valueOnly: true, }), - ) - continue + ); + continue; } - if (style === "matrix") { + if (style === 'matrix') { url = url.replace( match, `;${serializePrimitiveParam({ name, value: value as string, })}`, - ) - continue + ); + continue; } - const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string)) - url = url.replace(match, replaceValue) + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); } } - return url -} + return url; +}; export const getUrl = ({ baseUrl, @@ -87,51 +89,52 @@ export const getUrl = ({ querySerializer, url: _url, }: { - baseUrl?: string - path?: Record - query?: Record - querySerializer: QuerySerializer - url: string + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; }) => { - const pathUrl = _url.startsWith("/") ? _url : `/${_url}` - let url = (baseUrl ?? "") + pathUrl + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; if (path) { - url = defaultPathSerializer({ path, url }) + url = defaultPathSerializer({ path, url }); } - let search = query ? querySerializer(query) : "" - if (search.startsWith("?")) { - search = search.substring(1) + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); } if (search) { - url += `?${search}` + url += `?${search}`; } - return url -} + return url; +}; export function getValidRequestBody(options: { - body?: unknown - bodySerializer?: BodySerializer | null - serializedBody?: unknown + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; }) { - const hasBody = options.body !== undefined - const isSerializedBody = hasBody && options.bodySerializer + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; if (isSerializedBody) { - if ("serializedBody" in options) { - const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== "" + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; - return hasSerializedBody ? options.serializedBody : null + return hasSerializedBody ? options.serializedBody : null; } // not all clients implement a serializedBody property (i.e. client-axios) - return options.body !== "" ? options.body : null + return options.body !== '' ? options.body : null; } // plain/text body if (hasBody) { - return options.body + return options.body; } // no body was provided - return undefined + return undefined; } diff --git a/apps/web/server/opencode/gen/sdk.gen.ts b/apps/web/server/opencode/gen/sdk.gen.ts index b90419b5..642dc00f 100644 --- a/apps/web/server/opencode/gen/sdk.gen.ts +++ b/apps/web/server/opencode/gen/sdk.gen.ts @@ -1,7 +1,12 @@ // This file is auto-generated by @hey-api/openapi-ts -import { client } from "./client.gen" -import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index" +import { client } from './client.gen'; +import { + buildClientParams, + type Client, + type Options as Options2, + type TDataShape, +} from './client/index'; import type { AgentPartInput, AppAgentsResponses, @@ -183,48 +188,50 @@ import type { WorktreeResetErrors, WorktreeResetInput, WorktreeResetResponses, -} from "./types.gen" +} from './types.gen'; -export type Options = Options2< - TData, - ThrowOnError -> & { +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, +> = Options2 & { /** * You can provide a client instance returned by `createClient()` instead of * individual options. This might be also useful if you want to implement a * custom client. */ - client?: Client + client?: Client; /** * You can pass arbitrary values through the `meta` object. This can be * used to access values that aren't defined as part of the SDK function. */ - meta?: Record -} + meta?: Record; +}; class HeyApiClient { - protected client: Client + protected client: Client; constructor(args?: { client?: Client }) { - this.client = args?.client ?? client + this.client = args?.client ?? client; } } class HeyApiRegistry { - private readonly defaultKey = "default" + private readonly defaultKey = 'default'; - private readonly instances: Map = new Map() + private readonly instances: Map = new Map(); get(key?: string): T { - const instance = this.instances.get(key ?? this.defaultKey) + const instance = this.instances.get(key ?? this.defaultKey); if (!instance) { - throw new Error(`No SDK client found. Create one with "new OpencodeClient()" to fix this error.`) + throw new Error( + `No SDK client found. Create one with "new OpencodeClient()" to fix this error.`, + ); } - return instance + return instance; } set(value: T, key?: string): void { - this.instances.set(key ?? this.defaultKey, value) + this.instances.set(key ?? this.defaultKey, value); } } @@ -236,9 +243,9 @@ export class Config extends HeyApiClient { */ public get(options?: Options) { return (options?.client ?? this.client).get({ - url: "/global/config", + url: '/global/config', ...options, - }) + }); } /** @@ -248,21 +255,25 @@ export class Config extends HeyApiClient { */ public update( parameters?: { - config?: Config3 + config?: Config3; }, options?: Options, ) { - const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) - return (options?.client ?? this.client).patch({ - url: "/global/config", + const params = buildClientParams([parameters], [{ args: [{ key: 'config', map: 'body' }] }]); + return (options?.client ?? this.client).patch< + GlobalConfigUpdateResponses, + GlobalConfigUpdateErrors, + ThrowOnError + >({ + url: '/global/config', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -274,9 +285,9 @@ export class Global extends HeyApiClient { */ public health(options?: Options) { return (options?.client ?? this.client).get({ - url: "/global/health", + url: '/global/health', ...options, - }) + }); } /** @@ -286,9 +297,9 @@ export class Global extends HeyApiClient { */ public event(options?: Options) { return (options?.client ?? this.client).sse.get({ - url: "/global/event", + url: '/global/event', ...options, - }) + }); } /** @@ -298,14 +309,14 @@ export class Global extends HeyApiClient { */ public dispose(options?: Options) { return (options?.client ?? this.client).post({ - url: "/global/dispose", + url: '/global/dispose', ...options, - }) + }); } - private _config?: Config + private _config?: Config; get config(): Config { - return (this._config ??= new Config({ client: this.client })) + return (this._config ??= new Config({ client: this.client })); } } @@ -317,16 +328,20 @@ export class Auth extends HeyApiClient { */ public remove( parameters: { - providerID: string + providerID: string; }, options?: Options, ) { - const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }]) - return (options?.client ?? this.client).delete({ - url: "/auth/{providerID}", + const params = buildClientParams([parameters], [{ args: [{ in: 'path', key: 'providerID' }] }]); + return (options?.client ?? this.client).delete< + AuthRemoveResponses, + AuthRemoveErrors, + ThrowOnError + >({ + url: '/auth/{providerID}', ...options, ...params, - }) + }); } /** @@ -336,8 +351,8 @@ export class Auth extends HeyApiClient { */ public set( parameters: { - providerID: string - auth?: Auth3 + providerID: string; + auth?: Auth3; }, options?: Options, ) { @@ -346,22 +361,22 @@ export class Auth extends HeyApiClient { [ { args: [ - { in: "path", key: "providerID" }, - { key: "auth", map: "body" }, + { in: 'path', key: 'providerID' }, + { key: 'auth', map: 'body' }, ], }, ], - ) + ); return (options?.client ?? this.client).put({ - url: "/auth/{providerID}", + url: '/auth/{providerID}', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -373,8 +388,8 @@ export class Project extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -383,17 +398,17 @@ export class Project extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/project", + url: '/project', ...options, ...params, - }) + }); } /** @@ -403,8 +418,8 @@ export class Project extends HeyApiClient { */ public current( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -413,17 +428,17 @@ export class Project extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/project/current", + url: '/project/current', ...options, ...params, - }) + }); } /** @@ -433,8 +448,8 @@ export class Project extends HeyApiClient { */ public initGit( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -443,17 +458,17 @@ export class Project extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/project/git/init", + url: '/project/git/init', ...options, ...params, - }) + }); } /** @@ -463,21 +478,21 @@ export class Project extends HeyApiClient { */ public update( parameters: { - projectID: string - directory?: string - workspace?: string - name?: string + projectID: string; + directory?: string; + workspace?: string; + name?: string; icon?: { - url?: string - override?: string - color?: string - } + url?: string; + override?: string; + color?: string; + }; commands?: { /** * Startup script to run when creating a new workspace (worktree) */ - start?: string - } + start?: string; + }; }, options?: Options, ) { @@ -486,26 +501,30 @@ export class Project extends HeyApiClient { [ { args: [ - { in: "path", key: "projectID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "icon" }, - { in: "body", key: "commands" }, + { in: 'path', key: 'projectID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'name' }, + { in: 'body', key: 'icon' }, + { in: 'body', key: 'commands' }, ], }, ], - ) - return (options?.client ?? this.client).patch({ - url: "/project/{projectID}", + ); + return (options?.client ?? this.client).patch< + ProjectUpdateResponses, + ProjectUpdateErrors, + ThrowOnError + >({ + url: '/project/{projectID}', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -517,8 +536,8 @@ export class Pty extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -527,17 +546,17 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/pty", + url: '/pty', ...options, ...params, - }) + }); } /** @@ -547,15 +566,15 @@ export class Pty extends HeyApiClient { */ public create( parameters?: { - directory?: string - workspace?: string - command?: string - args?: Array - cwd?: string - title?: string + directory?: string; + workspace?: string; + command?: string; + args?: Array; + cwd?: string; + title?: string; env?: { - [key: string]: string - } + [key: string]: string; + }; }, options?: Options, ) { @@ -564,27 +583,29 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "command" }, - { in: "body", key: "args" }, - { in: "body", key: "cwd" }, - { in: "body", key: "title" }, - { in: "body", key: "env" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'command' }, + { in: 'body', key: 'args' }, + { in: 'body', key: 'cwd' }, + { in: 'body', key: 'title' }, + { in: 'body', key: 'env' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/pty", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, + ); + return (options?.client ?? this.client).post( + { + url: '/pty', + ...options, + ...params, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + ...params.headers, + }, }, - }) + ); } /** @@ -594,9 +615,9 @@ export class Pty extends HeyApiClient { */ public remove( parameters: { - ptyID: string - directory?: string - workspace?: string + ptyID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -605,18 +626,22 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'ptyID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).delete({ - url: "/pty/{ptyID}", + ); + return (options?.client ?? this.client).delete< + PtyRemoveResponses, + PtyRemoveErrors, + ThrowOnError + >({ + url: '/pty/{ptyID}', ...options, ...params, - }) + }); } /** @@ -626,9 +651,9 @@ export class Pty extends HeyApiClient { */ public get( parameters: { - ptyID: string - directory?: string - workspace?: string + ptyID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -637,18 +662,18 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'ptyID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/pty/{ptyID}", + url: '/pty/{ptyID}', ...options, ...params, - }) + }); } /** @@ -658,14 +683,14 @@ export class Pty extends HeyApiClient { */ public update( parameters: { - ptyID: string - directory?: string - workspace?: string - title?: string + ptyID: string; + directory?: string; + workspace?: string; + title?: string; size?: { - rows: number - cols: number - } + rows: number; + cols: number; + }; }, options?: Options, ) { @@ -674,25 +699,25 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "title" }, - { in: "body", key: "size" }, + { in: 'path', key: 'ptyID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'title' }, + { in: 'body', key: 'size' }, ], }, ], - ) + ); return (options?.client ?? this.client).put({ - url: "/pty/{ptyID}", + url: '/pty/{ptyID}', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -702,9 +727,9 @@ export class Pty extends HeyApiClient { */ public connect( parameters: { - ptyID: string - directory?: string - workspace?: string + ptyID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -713,18 +738,22 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'ptyID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/pty/{ptyID}/connect", + ); + return (options?.client ?? this.client).get< + PtyConnectResponses, + PtyConnectErrors, + ThrowOnError + >({ + url: '/pty/{ptyID}/connect', ...options, ...params, - }) + }); } } @@ -736,8 +765,8 @@ export class Config2 extends HeyApiClient { */ public get( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -746,17 +775,17 @@ export class Config2 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/config", + url: '/config', ...options, ...params, - }) + }); } /** @@ -766,9 +795,9 @@ export class Config2 extends HeyApiClient { */ public update( parameters?: { - directory?: string - workspace?: string - config?: Config3 + directory?: string; + workspace?: string; + config?: Config3; }, options?: Options, ) { @@ -777,23 +806,27 @@ export class Config2 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "config", map: "body" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'config', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).patch({ - url: "/config", + ); + return (options?.client ?? this.client).patch< + ConfigUpdateResponses, + ConfigUpdateErrors, + ThrowOnError + >({ + url: '/config', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -803,8 +836,8 @@ export class Config2 extends HeyApiClient { */ public providers( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -813,17 +846,17 @@ export class Config2 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/config/providers", + url: '/config/providers', ...options, ...params, - }) + }); } } @@ -835,8 +868,8 @@ export class Tool extends HeyApiClient { */ public ids( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -845,17 +878,17 @@ export class Tool extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/experimental/tool/ids", + url: '/experimental/tool/ids', ...options, ...params, - }) + }); } /** @@ -865,10 +898,10 @@ export class Tool extends HeyApiClient { */ public list( parameters: { - directory?: string - workspace?: string - provider: string - model: string + directory?: string; + workspace?: string; + provider: string; + model: string; }, options?: Options, ) { @@ -877,19 +910,19 @@ export class Tool extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "provider" }, - { in: "query", key: "model" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'provider' }, + { in: 'query', key: 'model' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/experimental/tool", + url: '/experimental/tool', ...options, ...params, - }) + }); } } @@ -901,8 +934,8 @@ export class Workspace extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -911,17 +944,21 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace", + ); + return (options?.client ?? this.client).get< + ExperimentalWorkspaceListResponses, + unknown, + ThrowOnError + >({ + url: '/experimental/workspace', ...options, ...params, - }) + }); } /** @@ -931,12 +968,12 @@ export class Workspace extends HeyApiClient { */ public create( parameters?: { - directory?: string - workspace?: string - id?: string - type?: string - branch?: string | null - extra?: unknown | null + directory?: string; + workspace?: string; + id?: string; + type?: string; + branch?: string | null; + extra?: unknown | null; }, options?: Options, ) { @@ -945,30 +982,30 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "id" }, - { in: "body", key: "type" }, - { in: "body", key: "branch" }, - { in: "body", key: "extra" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'id' }, + { in: 'body', key: 'type' }, + { in: 'body', key: 'branch' }, + { in: 'body', key: 'extra' }, ], }, ], - ) + ); return (options?.client ?? this.client).post< ExperimentalWorkspaceCreateResponses, ExperimentalWorkspaceCreateErrors, ThrowOnError >({ - url: "/experimental/workspace", + url: '/experimental/workspace', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -978,9 +1015,9 @@ export class Workspace extends HeyApiClient { */ public remove( parameters: { - id: string - directory?: string - workspace?: string + id: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -989,22 +1026,22 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "path", key: "id" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'id' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).delete< ExperimentalWorkspaceRemoveResponses, ExperimentalWorkspaceRemoveErrors, ThrowOnError >({ - url: "/experimental/workspace/{id}", + url: '/experimental/workspace/{id}', ...options, ...params, - }) + }); } } @@ -1016,14 +1053,14 @@ export class Session extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string - roots?: boolean - start?: number - cursor?: number - search?: string - limit?: number - archived?: boolean + directory?: string; + workspace?: string; + roots?: boolean; + start?: number; + cursor?: number; + search?: string; + limit?: number; + archived?: boolean; }, options?: Options, ) { @@ -1032,23 +1069,27 @@ export class Session extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "roots" }, - { in: "query", key: "start" }, - { in: "query", key: "cursor" }, - { in: "query", key: "search" }, - { in: "query", key: "limit" }, - { in: "query", key: "archived" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'roots' }, + { in: 'query', key: 'start' }, + { in: 'query', key: 'cursor' }, + { in: 'query', key: 'search' }, + { in: 'query', key: 'limit' }, + { in: 'query', key: 'archived' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/session", + ); + return (options?.client ?? this.client).get< + ExperimentalSessionListResponses, + unknown, + ThrowOnError + >({ + url: '/experimental/session', ...options, ...params, - }) + }); } } @@ -1060,8 +1101,8 @@ export class Resource extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1070,34 +1111,38 @@ export class Resource extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/resource", + ); + return (options?.client ?? this.client).get< + ExperimentalResourceListResponses, + unknown, + ThrowOnError + >({ + url: '/experimental/resource', ...options, ...params, - }) + }); } } export class Experimental extends HeyApiClient { - private _workspace?: Workspace + private _workspace?: Workspace; get workspace(): Workspace { - return (this._workspace ??= new Workspace({ client: this.client })) + return (this._workspace ??= new Workspace({ client: this.client })); } - private _session?: Session + private _session?: Session; get session(): Session { - return (this._session ??= new Session({ client: this.client })) + return (this._session ??= new Session({ client: this.client })); } - private _resource?: Resource + private _resource?: Resource; get resource(): Resource { - return (this._resource ??= new Resource({ client: this.client })) + return (this._resource ??= new Resource({ client: this.client })); } } @@ -1109,9 +1154,9 @@ export class Worktree extends HeyApiClient { */ public remove( parameters?: { - directory?: string - workspace?: string - worktreeRemoveInput?: WorktreeRemoveInput + directory?: string; + workspace?: string; + worktreeRemoveInput?: WorktreeRemoveInput; }, options?: Options, ) { @@ -1120,23 +1165,27 @@ export class Worktree extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "worktreeRemoveInput", map: "body" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'worktreeRemoveInput', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).delete({ - url: "/experimental/worktree", + ); + return (options?.client ?? this.client).delete< + WorktreeRemoveResponses, + WorktreeRemoveErrors, + ThrowOnError + >({ + url: '/experimental/worktree', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1146,8 +1195,8 @@ export class Worktree extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1156,17 +1205,17 @@ export class Worktree extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/experimental/worktree", + url: '/experimental/worktree', ...options, ...params, - }) + }); } /** @@ -1176,9 +1225,9 @@ export class Worktree extends HeyApiClient { */ public create( parameters?: { - directory?: string - workspace?: string - worktreeCreateInput?: WorktreeCreateInput + directory?: string; + workspace?: string; + worktreeCreateInput?: WorktreeCreateInput; }, options?: Options, ) { @@ -1187,23 +1236,27 @@ export class Worktree extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "worktreeCreateInput", map: "body" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'worktreeCreateInput', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree", + ); + return (options?.client ?? this.client).post< + WorktreeCreateResponses, + WorktreeCreateErrors, + ThrowOnError + >({ + url: '/experimental/worktree', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1213,9 +1266,9 @@ export class Worktree extends HeyApiClient { */ public reset( parameters?: { - directory?: string - workspace?: string - worktreeResetInput?: WorktreeResetInput + directory?: string; + workspace?: string; + worktreeResetInput?: WorktreeResetInput; }, options?: Options, ) { @@ -1224,23 +1277,27 @@ export class Worktree extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "worktreeResetInput", map: "body" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'worktreeResetInput', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree/reset", + ); + return (options?.client ?? this.client).post< + WorktreeResetResponses, + WorktreeResetErrors, + ThrowOnError + >({ + url: '/experimental/worktree/reset', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -1252,12 +1309,12 @@ export class Session2 extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string - roots?: boolean - start?: number - search?: string - limit?: number + directory?: string; + workspace?: string; + roots?: boolean; + start?: number; + search?: string; + limit?: number; }, options?: Options, ) { @@ -1266,21 +1323,21 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "roots" }, - { in: "query", key: "start" }, - { in: "query", key: "search" }, - { in: "query", key: "limit" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'roots' }, + { in: 'query', key: 'start' }, + { in: 'query', key: 'search' }, + { in: 'query', key: 'limit' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/session", + url: '/session', ...options, ...params, - }) + }); } /** @@ -1290,12 +1347,12 @@ export class Session2 extends HeyApiClient { */ public create( parameters?: { - directory?: string - workspace?: string - parentID?: string - title?: string - permission?: PermissionRuleset - workspaceID?: string + directory?: string; + workspace?: string; + parentID?: string; + title?: string; + permission?: PermissionRuleset; + workspaceID?: string; }, options?: Options, ) { @@ -1304,26 +1361,30 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "parentID" }, - { in: "body", key: "title" }, - { in: "body", key: "permission" }, - { in: "body", key: "workspaceID" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'parentID' }, + { in: 'body', key: 'title' }, + { in: 'body', key: 'permission' }, + { in: 'body', key: 'workspaceID' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session", + ); + return (options?.client ?? this.client).post< + SessionCreateResponses, + SessionCreateErrors, + ThrowOnError + >({ + url: '/session', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1333,8 +1394,8 @@ export class Session2 extends HeyApiClient { */ public status( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1343,17 +1404,21 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/session/status", + ); + return (options?.client ?? this.client).get< + SessionStatusResponses, + SessionStatusErrors, + ThrowOnError + >({ + url: '/session/status', ...options, ...params, - }) + }); } /** @@ -1363,9 +1428,9 @@ export class Session2 extends HeyApiClient { */ public delete( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1374,18 +1439,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}", + ); + return (options?.client ?? this.client).delete< + SessionDeleteResponses, + SessionDeleteErrors, + ThrowOnError + >({ + url: '/session/{sessionID}', ...options, ...params, - }) + }); } /** @@ -1395,9 +1464,9 @@ export class Session2 extends HeyApiClient { */ public get( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1406,18 +1475,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}", + ); + return (options?.client ?? this.client).get< + SessionGetResponses, + SessionGetErrors, + ThrowOnError + >({ + url: '/session/{sessionID}', ...options, ...params, - }) + }); } /** @@ -1427,13 +1500,13 @@ export class Session2 extends HeyApiClient { */ public update( parameters: { - sessionID: string - directory?: string - workspace?: string - title?: string + sessionID: string; + directory?: string; + workspace?: string; + title?: string; time?: { - archived?: number - } + archived?: number; + }; }, options?: Options, ) { @@ -1442,25 +1515,29 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "title" }, - { in: "body", key: "time" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'title' }, + { in: 'body', key: 'time' }, ], }, ], - ) - return (options?.client ?? this.client).patch({ - url: "/session/{sessionID}", + ); + return (options?.client ?? this.client).patch< + SessionUpdateResponses, + SessionUpdateErrors, + ThrowOnError + >({ + url: '/session/{sessionID}', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1470,9 +1547,9 @@ export class Session2 extends HeyApiClient { */ public children( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1481,18 +1558,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/children", + ); + return (options?.client ?? this.client).get< + SessionChildrenResponses, + SessionChildrenErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/children', ...options, ...params, - }) + }); } /** @@ -1502,9 +1583,9 @@ export class Session2 extends HeyApiClient { */ public todo( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1513,18 +1594,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/todo", + ); + return (options?.client ?? this.client).get< + SessionTodoResponses, + SessionTodoErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/todo', ...options, ...params, - }) + }); } /** @@ -1534,12 +1619,12 @@ export class Session2 extends HeyApiClient { */ public init( parameters: { - sessionID: string - directory?: string - workspace?: string - modelID?: string - providerID?: string - messageID?: string + sessionID: string; + directory?: string; + workspace?: string; + modelID?: string; + providerID?: string; + messageID?: string; }, options?: Options, ) { @@ -1548,26 +1633,30 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "modelID" }, - { in: "body", key: "providerID" }, - { in: "body", key: "messageID" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'modelID' }, + { in: 'body', key: 'providerID' }, + { in: 'body', key: 'messageID' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/init", + ); + return (options?.client ?? this.client).post< + SessionInitResponses, + SessionInitErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/init', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1577,10 +1666,10 @@ export class Session2 extends HeyApiClient { */ public fork( parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string + sessionID: string; + directory?: string; + workspace?: string; + messageID?: string; }, options?: Options, ) { @@ -1589,24 +1678,24 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'messageID' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/fork", + url: '/session/{sessionID}/fork', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1616,9 +1705,9 @@ export class Session2 extends HeyApiClient { */ public abort( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1627,18 +1716,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/abort", + ); + return (options?.client ?? this.client).post< + SessionAbortResponses, + SessionAbortErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/abort', ...options, ...params, - }) + }); } /** @@ -1648,9 +1741,9 @@ export class Session2 extends HeyApiClient { */ public unshare( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1659,18 +1752,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}/share", + ); + return (options?.client ?? this.client).delete< + SessionUnshareResponses, + SessionUnshareErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/share', ...options, ...params, - }) + }); } /** @@ -1680,9 +1777,9 @@ export class Session2 extends HeyApiClient { */ public share( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1691,18 +1788,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/share", + ); + return (options?.client ?? this.client).post< + SessionShareResponses, + SessionShareErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/share', ...options, ...params, - }) + }); } /** @@ -1712,10 +1813,10 @@ export class Session2 extends HeyApiClient { */ public diff( parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string + sessionID: string; + directory?: string; + workspace?: string; + messageID?: string; }, options?: Options, ) { @@ -1724,19 +1825,19 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "messageID" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'messageID' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/diff", + url: '/session/{sessionID}/diff', ...options, ...params, - }) + }); } /** @@ -1746,12 +1847,12 @@ export class Session2 extends HeyApiClient { */ public summarize( parameters: { - sessionID: string - directory?: string - workspace?: string - providerID?: string - modelID?: string - auto?: boolean + sessionID: string; + directory?: string; + workspace?: string; + providerID?: string; + modelID?: string; + auto?: boolean; }, options?: Options, ) { @@ -1760,26 +1861,30 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "providerID" }, - { in: "body", key: "modelID" }, - { in: "body", key: "auto" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'providerID' }, + { in: 'body', key: 'modelID' }, + { in: 'body', key: 'auto' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/summarize", + ); + return (options?.client ?? this.client).post< + SessionSummarizeResponses, + SessionSummarizeErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/summarize', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1789,11 +1894,11 @@ export class Session2 extends HeyApiClient { */ public messages( parameters: { - sessionID: string - directory?: string - workspace?: string - limit?: number - before?: string + sessionID: string; + directory?: string; + workspace?: string; + limit?: number; + before?: string; }, options?: Options, ) { @@ -1802,20 +1907,24 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "limit" }, - { in: "query", key: "before" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'limit' }, + { in: 'query', key: 'before' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/message", + ); + return (options?.client ?? this.client).get< + SessionMessagesResponses, + SessionMessagesErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/message', ...options, ...params, - }) + }); } /** @@ -1825,23 +1934,23 @@ export class Session2 extends HeyApiClient { */ public prompt( parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string + sessionID: string; + directory?: string; + workspace?: string; + messageID?: string; model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean + providerID: string; + modelID: string; + }; + agent?: string; + noReply?: boolean; tools?: { - [key: string]: boolean - } - format?: OutputFormat - system?: string - variant?: string - parts?: Array + [key: string]: boolean; + }; + format?: OutputFormat; + system?: string; + variant?: string; + parts?: Array; }, options?: Options, ) { @@ -1850,32 +1959,36 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "model" }, - { in: "body", key: "agent" }, - { in: "body", key: "noReply" }, - { in: "body", key: "tools" }, - { in: "body", key: "format" }, - { in: "body", key: "system" }, - { in: "body", key: "variant" }, - { in: "body", key: "parts" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'messageID' }, + { in: 'body', key: 'model' }, + { in: 'body', key: 'agent' }, + { in: 'body', key: 'noReply' }, + { in: 'body', key: 'tools' }, + { in: 'body', key: 'format' }, + { in: 'body', key: 'system' }, + { in: 'body', key: 'variant' }, + { in: 'body', key: 'parts' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/message", + ); + return (options?.client ?? this.client).post< + SessionPromptResponses, + SessionPromptErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/message', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1885,10 +1998,10 @@ export class Session2 extends HeyApiClient { */ public deleteMessage( parameters: { - sessionID: string - messageID: string - directory?: string - workspace?: string + sessionID: string; + messageID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1897,23 +2010,23 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "messageID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'path', key: 'messageID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).delete< SessionDeleteMessageResponses, SessionDeleteMessageErrors, ThrowOnError >({ - url: "/session/{sessionID}/message/{messageID}", + url: '/session/{sessionID}/message/{messageID}', ...options, ...params, - }) + }); } /** @@ -1923,10 +2036,10 @@ export class Session2 extends HeyApiClient { */ public message( parameters: { - sessionID: string - messageID: string - directory?: string - workspace?: string + sessionID: string; + messageID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1935,19 +2048,23 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "messageID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'path', key: 'messageID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/message/{messageID}", + ); + return (options?.client ?? this.client).get< + SessionMessageResponses, + SessionMessageErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/message/{messageID}', ...options, ...params, - }) + }); } /** @@ -1957,23 +2074,23 @@ export class Session2 extends HeyApiClient { */ public promptAsync( parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string + sessionID: string; + directory?: string; + workspace?: string; + messageID?: string; model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean + providerID: string; + modelID: string; + }; + agent?: string; + noReply?: boolean; tools?: { - [key: string]: boolean - } - format?: OutputFormat - system?: string - variant?: string - parts?: Array + [key: string]: boolean; + }; + format?: OutputFormat; + system?: string; + variant?: string; + parts?: Array; }, options?: Options, ) { @@ -1982,32 +2099,36 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "model" }, - { in: "body", key: "agent" }, - { in: "body", key: "noReply" }, - { in: "body", key: "tools" }, - { in: "body", key: "format" }, - { in: "body", key: "system" }, - { in: "body", key: "variant" }, - { in: "body", key: "parts" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'messageID' }, + { in: 'body', key: 'model' }, + { in: 'body', key: 'agent' }, + { in: 'body', key: 'noReply' }, + { in: 'body', key: 'tools' }, + { in: 'body', key: 'format' }, + { in: 'body', key: 'system' }, + { in: 'body', key: 'variant' }, + { in: 'body', key: 'parts' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/prompt_async", + ); + return (options?.client ?? this.client).post< + SessionPromptAsyncResponses, + SessionPromptAsyncErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/prompt_async', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2017,23 +2138,23 @@ export class Session2 extends HeyApiClient { */ public command( parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string - agent?: string - model?: string - arguments?: string - command?: string - variant?: string + sessionID: string; + directory?: string; + workspace?: string; + messageID?: string; + agent?: string; + model?: string; + arguments?: string; + command?: string; + variant?: string; parts?: Array<{ - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource - }> + id?: string; + type: 'file'; + mime: string; + filename?: string; + url: string; + source?: FilePartSource; + }>; }, options?: Options, ) { @@ -2042,30 +2163,34 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "agent" }, - { in: "body", key: "model" }, - { in: "body", key: "arguments" }, - { in: "body", key: "command" }, - { in: "body", key: "variant" }, - { in: "body", key: "parts" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'messageID' }, + { in: 'body', key: 'agent' }, + { in: 'body', key: 'model' }, + { in: 'body', key: 'arguments' }, + { in: 'body', key: 'command' }, + { in: 'body', key: 'variant' }, + { in: 'body', key: 'parts' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/command", + ); + return (options?.client ?? this.client).post< + SessionCommandResponses, + SessionCommandErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/command', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2075,15 +2200,15 @@ export class Session2 extends HeyApiClient { */ public shell( parameters: { - sessionID: string - directory?: string - workspace?: string - agent?: string + sessionID: string; + directory?: string; + workspace?: string; + agent?: string; model?: { - providerID: string - modelID: string - } - command?: string + providerID: string; + modelID: string; + }; + command?: string; }, options?: Options, ) { @@ -2092,26 +2217,30 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "agent" }, - { in: "body", key: "model" }, - { in: "body", key: "command" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'agent' }, + { in: 'body', key: 'model' }, + { in: 'body', key: 'command' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/shell", + ); + return (options?.client ?? this.client).post< + SessionShellResponses, + SessionShellErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/shell', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2121,11 +2250,11 @@ export class Session2 extends HeyApiClient { */ public revert( parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string - partID?: string + sessionID: string; + directory?: string; + workspace?: string; + messageID?: string; + partID?: string; }, options?: Options, ) { @@ -2134,25 +2263,29 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "partID" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'messageID' }, + { in: 'body', key: 'partID' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/revert", + ); + return (options?.client ?? this.client).post< + SessionRevertResponses, + SessionRevertErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/revert', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2162,9 +2295,9 @@ export class Session2 extends HeyApiClient { */ public unrevert( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2173,18 +2306,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/unrevert", + ); + return (options?.client ?? this.client).post< + SessionUnrevertResponses, + SessionUnrevertErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/unrevert', ...options, ...params, - }) + }); } } @@ -2194,11 +2331,11 @@ export class Part extends HeyApiClient { */ public delete( parameters: { - sessionID: string - messageID: string - partID: string - directory?: string - workspace?: string + sessionID: string; + messageID: string; + partID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2207,20 +2344,24 @@ export class Part extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "messageID" }, - { in: "path", key: "partID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'path', key: 'messageID' }, + { in: 'path', key: 'partID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}/message/{messageID}/part/{partID}", + ); + return (options?.client ?? this.client).delete< + PartDeleteResponses, + PartDeleteErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/message/{messageID}/part/{partID}', ...options, ...params, - }) + }); } /** @@ -2228,12 +2369,12 @@ export class Part extends HeyApiClient { */ public update( parameters: { - sessionID: string - messageID: string - partID: string - directory?: string - workspace?: string - part?: Part2 + sessionID: string; + messageID: string; + partID: string; + directory?: string; + workspace?: string; + part?: Part2; }, options?: Options, ) { @@ -2242,26 +2383,30 @@ export class Part extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "messageID" }, - { in: "path", key: "partID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "part", map: "body" }, + { in: 'path', key: 'sessionID' }, + { in: 'path', key: 'messageID' }, + { in: 'path', key: 'partID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'part', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).patch({ - url: "/session/{sessionID}/message/{messageID}/part/{partID}", + ); + return (options?.client ?? this.client).patch< + PartUpdateResponses, + PartUpdateErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/message/{messageID}/part/{partID}', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -2275,11 +2420,11 @@ export class Permission extends HeyApiClient { */ public respond( parameters: { - sessionID: string - permissionID: string - directory?: string - workspace?: string - response?: "once" | "always" | "reject" + sessionID: string; + permissionID: string; + directory?: string; + workspace?: string; + response?: 'once' | 'always' | 'reject'; }, options?: Options, ) { @@ -2288,25 +2433,29 @@ export class Permission extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "permissionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "response" }, + { in: 'path', key: 'sessionID' }, + { in: 'path', key: 'permissionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'response' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/permissions/{permissionID}", + ); + return (options?.client ?? this.client).post< + PermissionRespondResponses, + PermissionRespondErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/permissions/{permissionID}', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2316,11 +2465,11 @@ export class Permission extends HeyApiClient { */ public reply( parameters: { - requestID: string - directory?: string - workspace?: string - reply?: "once" | "always" | "reject" - message?: string + requestID: string; + directory?: string; + workspace?: string; + reply?: 'once' | 'always' | 'reject'; + message?: string; }, options?: Options, ) { @@ -2329,25 +2478,29 @@ export class Permission extends HeyApiClient { [ { args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "reply" }, - { in: "body", key: "message" }, + { in: 'path', key: 'requestID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'reply' }, + { in: 'body', key: 'message' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/permission/{requestID}/reply", + ); + return (options?.client ?? this.client).post< + PermissionReplyResponses, + PermissionReplyErrors, + ThrowOnError + >({ + url: '/permission/{requestID}/reply', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2357,8 +2510,8 @@ export class Permission extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2367,17 +2520,17 @@ export class Permission extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/permission", + url: '/permission', ...options, ...params, - }) + }); } } @@ -2389,8 +2542,8 @@ export class Question extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2399,17 +2552,17 @@ export class Question extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/question", + url: '/question', ...options, ...params, - }) + }); } /** @@ -2419,10 +2572,10 @@ export class Question extends HeyApiClient { */ public reply( parameters: { - requestID: string - directory?: string - workspace?: string - answers?: Array + requestID: string; + directory?: string; + workspace?: string; + answers?: Array; }, options?: Options, ) { @@ -2431,24 +2584,28 @@ export class Question extends HeyApiClient { [ { args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "answers" }, + { in: 'path', key: 'requestID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'answers' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reply", + ); + return (options?.client ?? this.client).post< + QuestionReplyResponses, + QuestionReplyErrors, + ThrowOnError + >({ + url: '/question/{requestID}/reply', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2458,9 +2615,9 @@ export class Question extends HeyApiClient { */ public reject( parameters: { - requestID: string - directory?: string - workspace?: string + requestID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2469,18 +2626,22 @@ export class Question extends HeyApiClient { [ { args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'requestID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reject", + ); + return (options?.client ?? this.client).post< + QuestionRejectResponses, + QuestionRejectErrors, + ThrowOnError + >({ + url: '/question/{requestID}/reject', ...options, ...params, - }) + }); } } @@ -2492,13 +2653,13 @@ export class Oauth extends HeyApiClient { */ public authorize( parameters: { - providerID: string - directory?: string - workspace?: string - method?: number + providerID: string; + directory?: string; + workspace?: string; + method?: number; inputs?: { - [key: string]: string - } + [key: string]: string; + }; }, options?: Options, ) { @@ -2507,29 +2668,29 @@ export class Oauth extends HeyApiClient { [ { args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "inputs" }, + { in: 'path', key: 'providerID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'method' }, + { in: 'body', key: 'inputs' }, ], }, ], - ) + ); return (options?.client ?? this.client).post< ProviderOauthAuthorizeResponses, ProviderOauthAuthorizeErrors, ThrowOnError >({ - url: "/provider/{providerID}/oauth/authorize", + url: '/provider/{providerID}/oauth/authorize', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2539,11 +2700,11 @@ export class Oauth extends HeyApiClient { */ public callback( parameters: { - providerID: string - directory?: string - workspace?: string - method?: number - code?: string + providerID: string; + directory?: string; + workspace?: string; + method?: number; + code?: string; }, options?: Options, ) { @@ -2552,29 +2713,29 @@ export class Oauth extends HeyApiClient { [ { args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "code" }, + { in: 'path', key: 'providerID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'method' }, + { in: 'body', key: 'code' }, ], }, ], - ) + ); return (options?.client ?? this.client).post< ProviderOauthCallbackResponses, ProviderOauthCallbackErrors, ThrowOnError >({ - url: "/provider/{providerID}/oauth/callback", + url: '/provider/{providerID}/oauth/callback', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -2586,8 +2747,8 @@ export class Provider extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2596,17 +2757,17 @@ export class Provider extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/provider", + url: '/provider', ...options, ...params, - }) + }); } /** @@ -2616,8 +2777,8 @@ export class Provider extends HeyApiClient { */ public auth( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2626,22 +2787,22 @@ export class Provider extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/provider/auth", + url: '/provider/auth', ...options, ...params, - }) + }); } - private _oauth?: Oauth + private _oauth?: Oauth; get oauth(): Oauth { - return (this._oauth ??= new Oauth({ client: this.client })) + return (this._oauth ??= new Oauth({ client: this.client })); } } @@ -2653,9 +2814,9 @@ export class Find extends HeyApiClient { */ public text( parameters: { - directory?: string - workspace?: string - pattern: string + directory?: string; + workspace?: string; + pattern: string; }, options?: Options, ) { @@ -2664,18 +2825,18 @@ export class Find extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "pattern" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'pattern' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/find", + url: '/find', ...options, ...params, - }) + }); } /** @@ -2685,12 +2846,12 @@ export class Find extends HeyApiClient { */ public files( parameters: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number + directory?: string; + workspace?: string; + query: string; + dirs?: 'true' | 'false'; + type?: 'file' | 'directory'; + limit?: number; }, options?: Options, ) { @@ -2699,21 +2860,21 @@ export class Find extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "query" }, - { in: "query", key: "dirs" }, - { in: "query", key: "type" }, - { in: "query", key: "limit" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'query' }, + { in: 'query', key: 'dirs' }, + { in: 'query', key: 'type' }, + { in: 'query', key: 'limit' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/find/file", + url: '/find/file', ...options, ...params, - }) + }); } /** @@ -2723,9 +2884,9 @@ export class Find extends HeyApiClient { */ public symbols( parameters: { - directory?: string - workspace?: string - query: string + directory?: string; + workspace?: string; + query: string; }, options?: Options, ) { @@ -2734,18 +2895,18 @@ export class Find extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "query" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'query' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/find/symbol", + url: '/find/symbol', ...options, ...params, - }) + }); } } @@ -2757,9 +2918,9 @@ export class File extends HeyApiClient { */ public list( parameters: { - directory?: string - workspace?: string - path: string + directory?: string; + workspace?: string; + path: string; }, options?: Options, ) { @@ -2768,18 +2929,18 @@ export class File extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "path" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'path' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/file", + url: '/file', ...options, ...params, - }) + }); } /** @@ -2789,9 +2950,9 @@ export class File extends HeyApiClient { */ public read( parameters: { - directory?: string - workspace?: string - path: string + directory?: string; + workspace?: string; + path: string; }, options?: Options, ) { @@ -2800,18 +2961,18 @@ export class File extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "path" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'path' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/file/content", + url: '/file/content', ...options, ...params, - }) + }); } /** @@ -2821,8 +2982,8 @@ export class File extends HeyApiClient { */ public status( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2831,17 +2992,17 @@ export class File extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/file/status", + url: '/file/status', ...options, ...params, - }) + }); } } @@ -2853,8 +3014,8 @@ export class Event extends HeyApiClient { */ public subscribe( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2863,17 +3024,19 @@ export class Event extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).sse.get({ - url: "/event", - ...options, - ...params, - }) + ); + return (options?.client ?? this.client).sse.get( + { + url: '/event', + ...options, + ...params, + }, + ); } } @@ -2885,9 +3048,9 @@ export class Auth2 extends HeyApiClient { */ public remove( parameters: { - name: string - directory?: string - workspace?: string + name: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2896,18 +3059,22 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'name' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).delete({ - url: "/mcp/{name}/auth", + ); + return (options?.client ?? this.client).delete< + McpAuthRemoveResponses, + McpAuthRemoveErrors, + ThrowOnError + >({ + url: '/mcp/{name}/auth', ...options, ...params, - }) + }); } /** @@ -2917,9 +3084,9 @@ export class Auth2 extends HeyApiClient { */ public start( parameters: { - name: string - directory?: string - workspace?: string + name: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2928,18 +3095,22 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'name' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth", + ); + return (options?.client ?? this.client).post< + McpAuthStartResponses, + McpAuthStartErrors, + ThrowOnError + >({ + url: '/mcp/{name}/auth', ...options, ...params, - }) + }); } /** @@ -2949,10 +3120,10 @@ export class Auth2 extends HeyApiClient { */ public callback( parameters: { - name: string - directory?: string - workspace?: string - code?: string + name: string; + directory?: string; + workspace?: string; + code?: string; }, options?: Options, ) { @@ -2961,24 +3132,28 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "code" }, + { in: 'path', key: 'name' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'code' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth/callback", + ); + return (options?.client ?? this.client).post< + McpAuthCallbackResponses, + McpAuthCallbackErrors, + ThrowOnError + >({ + url: '/mcp/{name}/auth/callback', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2988,9 +3163,9 @@ export class Auth2 extends HeyApiClient { */ public authenticate( parameters: { - name: string - directory?: string - workspace?: string + name: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2999,20 +3174,22 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'name' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).post( - { - url: "/mcp/{name}/auth/authenticate", - ...options, - ...params, - }, - ) + ); + return (options?.client ?? this.client).post< + McpAuthAuthenticateResponses, + McpAuthAuthenticateErrors, + ThrowOnError + >({ + url: '/mcp/{name}/auth/authenticate', + ...options, + ...params, + }); } } @@ -3024,8 +3201,8 @@ export class Mcp extends HeyApiClient { */ public status( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3034,17 +3211,17 @@ export class Mcp extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/mcp", + url: '/mcp', ...options, ...params, - }) + }); } /** @@ -3054,10 +3231,10 @@ export class Mcp extends HeyApiClient { */ public add( parameters?: { - directory?: string - workspace?: string - name?: string - config?: McpLocalConfig | McpRemoteConfig + directory?: string; + workspace?: string; + name?: string; + config?: McpLocalConfig | McpRemoteConfig; }, options?: Options, ) { @@ -3066,24 +3243,24 @@ export class Mcp extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "config" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'name' }, + { in: 'body', key: 'config' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/mcp", + url: '/mcp', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -3091,9 +3268,9 @@ export class Mcp extends HeyApiClient { */ public connect( parameters: { - name: string - directory?: string - workspace?: string + name: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3102,18 +3279,18 @@ export class Mcp extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'name' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/mcp/{name}/connect", + url: '/mcp/{name}/connect', ...options, ...params, - }) + }); } /** @@ -3121,9 +3298,9 @@ export class Mcp extends HeyApiClient { */ public disconnect( parameters: { - name: string - directory?: string - workspace?: string + name: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3132,23 +3309,23 @@ export class Mcp extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'name' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/mcp/{name}/disconnect", + url: '/mcp/{name}/disconnect', ...options, ...params, - }) + }); } - private _auth?: Auth2 + private _auth?: Auth2; get auth(): Auth2 { - return (this._auth ??= new Auth2({ client: this.client })) + return (this._auth ??= new Auth2({ client: this.client })); } } @@ -3160,8 +3337,8 @@ export class Control extends HeyApiClient { */ public next( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3170,17 +3347,17 @@ export class Control extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/tui/control/next", + url: '/tui/control/next', ...options, ...params, - }) + }); } /** @@ -3190,9 +3367,9 @@ export class Control extends HeyApiClient { */ public response( parameters?: { - directory?: string - workspace?: string - body?: unknown + directory?: string; + workspace?: string; + body?: unknown; }, options?: Options, ) { @@ -3201,23 +3378,27 @@ export class Control extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "body", map: "body" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'body', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/tui/control/response", + ); + return (options?.client ?? this.client).post< + TuiControlResponseResponses, + unknown, + ThrowOnError + >({ + url: '/tui/control/response', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -3229,9 +3410,9 @@ export class Tui extends HeyApiClient { */ public appendPrompt( parameters?: { - directory?: string - workspace?: string - text?: string + directory?: string; + workspace?: string; + text?: string; }, options?: Options, ) { @@ -3240,23 +3421,27 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "text" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'text' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/tui/append-prompt", + ); + return (options?.client ?? this.client).post< + TuiAppendPromptResponses, + TuiAppendPromptErrors, + ThrowOnError + >({ + url: '/tui/append-prompt', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -3266,8 +3451,8 @@ export class Tui extends HeyApiClient { */ public openHelp( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3276,17 +3461,17 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/open-help", + url: '/tui/open-help', ...options, ...params, - }) + }); } /** @@ -3296,8 +3481,8 @@ export class Tui extends HeyApiClient { */ public openSessions( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3306,17 +3491,17 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/open-sessions", + url: '/tui/open-sessions', ...options, ...params, - }) + }); } /** @@ -3326,8 +3511,8 @@ export class Tui extends HeyApiClient { */ public openThemes( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3336,17 +3521,17 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/open-themes", + url: '/tui/open-themes', ...options, ...params, - }) + }); } /** @@ -3356,8 +3541,8 @@ export class Tui extends HeyApiClient { */ public openModels( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3366,17 +3551,17 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/open-models", + url: '/tui/open-models', ...options, ...params, - }) + }); } /** @@ -3386,8 +3571,8 @@ export class Tui extends HeyApiClient { */ public submitPrompt( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3396,17 +3581,17 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/submit-prompt", + url: '/tui/submit-prompt', ...options, ...params, - }) + }); } /** @@ -3416,8 +3601,8 @@ export class Tui extends HeyApiClient { */ public clearPrompt( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3426,17 +3611,17 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/clear-prompt", + url: '/tui/clear-prompt', ...options, ...params, - }) + }); } /** @@ -3446,9 +3631,9 @@ export class Tui extends HeyApiClient { */ public executeCommand( parameters?: { - directory?: string - workspace?: string - command?: string + directory?: string; + workspace?: string; + command?: string; }, options?: Options, ) { @@ -3457,23 +3642,27 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "command" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'command' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/tui/execute-command", + ); + return (options?.client ?? this.client).post< + TuiExecuteCommandResponses, + TuiExecuteCommandErrors, + ThrowOnError + >({ + url: '/tui/execute-command', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -3483,12 +3672,12 @@ export class Tui extends HeyApiClient { */ public showToast( parameters?: { - directory?: string - workspace?: string - title?: string - message?: string - variant?: "info" | "success" | "warning" | "error" - duration?: number + directory?: string; + workspace?: string; + title?: string; + message?: string; + variant?: 'info' | 'success' | 'warning' | 'error'; + duration?: number; }, options?: Options, ) { @@ -3497,26 +3686,26 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "title" }, - { in: "body", key: "message" }, - { in: "body", key: "variant" }, - { in: "body", key: "duration" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'title' }, + { in: 'body', key: 'message' }, + { in: 'body', key: 'variant' }, + { in: 'body', key: 'duration' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/show-toast", + url: '/tui/show-toast', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -3526,9 +3715,13 @@ export class Tui extends HeyApiClient { */ public publish( parameters?: { - directory?: string - workspace?: string - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + directory?: string; + workspace?: string; + body?: + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect; }, options?: Options, ) { @@ -3537,23 +3730,27 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "body", map: "body" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'body', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/tui/publish", + ); + return (options?.client ?? this.client).post< + TuiPublishResponses, + TuiPublishErrors, + ThrowOnError + >({ + url: '/tui/publish', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -3563,9 +3760,9 @@ export class Tui extends HeyApiClient { */ public selectSession( parameters?: { - directory?: string - workspace?: string - sessionID?: string + directory?: string; + workspace?: string; + sessionID?: string; }, options?: Options, ) { @@ -3574,28 +3771,32 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "sessionID" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'sessionID' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/tui/select-session", + ); + return (options?.client ?? this.client).post< + TuiSelectSessionResponses, + TuiSelectSessionErrors, + ThrowOnError + >({ + url: '/tui/select-session', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } - private _control?: Control + private _control?: Control; get control(): Control { - return (this._control ??= new Control({ client: this.client })) + return (this._control ??= new Control({ client: this.client })); } } @@ -3607,8 +3808,8 @@ export class Instance extends HeyApiClient { */ public dispose( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3617,17 +3818,17 @@ export class Instance extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/instance/dispose", + url: '/instance/dispose', ...options, ...params, - }) + }); } } @@ -3639,8 +3840,8 @@ export class Path extends HeyApiClient { */ public get( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3649,17 +3850,17 @@ export class Path extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/path", + url: '/path', ...options, ...params, - }) + }); } } @@ -3671,8 +3872,8 @@ export class Vcs extends HeyApiClient { */ public get( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3681,17 +3882,17 @@ export class Vcs extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/vcs", + url: '/vcs', ...options, ...params, - }) + }); } } @@ -3703,8 +3904,8 @@ export class Command extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3713,17 +3914,17 @@ export class Command extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/command", + url: '/command', ...options, ...params, - }) + }); } } @@ -3735,14 +3936,14 @@ export class App extends HeyApiClient { */ public log( parameters?: { - directory?: string - workspace?: string - service?: string - level?: "debug" | "info" | "error" | "warn" - message?: string + directory?: string; + workspace?: string; + service?: string; + level?: 'debug' | 'info' | 'error' | 'warn'; + message?: string; extra?: { - [key: string]: unknown - } + [key: string]: unknown; + }; }, options?: Options, ) { @@ -3751,26 +3952,26 @@ export class App extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "service" }, - { in: "body", key: "level" }, - { in: "body", key: "message" }, - { in: "body", key: "extra" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'service' }, + { in: 'body', key: 'level' }, + { in: 'body', key: 'message' }, + { in: 'body', key: 'extra' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/log", + url: '/log', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -3780,8 +3981,8 @@ export class App extends HeyApiClient { */ public agents( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3790,17 +3991,17 @@ export class App extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/agent", + url: '/agent', ...options, ...params, - }) + }); } /** @@ -3810,8 +4011,8 @@ export class App extends HeyApiClient { */ public skills( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3820,17 +4021,17 @@ export class App extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/skill", + url: '/skill', ...options, ...params, - }) + }); } } @@ -3842,8 +4043,8 @@ export class Lsp extends HeyApiClient { */ public status( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3852,17 +4053,17 @@ export class Lsp extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/lsp", + url: '/lsp', ...options, ...params, - }) + }); } } @@ -3874,8 +4075,8 @@ export class Formatter extends HeyApiClient { */ public status( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3884,150 +4085,150 @@ export class Formatter extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/formatter", + url: '/formatter', ...options, ...params, - }) + }); } } export class OpencodeClient extends HeyApiClient { - public static readonly __registry = new HeyApiRegistry() + public static readonly __registry = new HeyApiRegistry(); constructor(args?: { client?: Client; key?: string }) { - super(args) - OpencodeClient.__registry.set(this, args?.key) + super(args); + OpencodeClient.__registry.set(this, args?.key); } - private _global?: Global + private _global?: Global; get global(): Global { - return (this._global ??= new Global({ client: this.client })) + return (this._global ??= new Global({ client: this.client })); } - private _auth?: Auth + private _auth?: Auth; get auth(): Auth { - return (this._auth ??= new Auth({ client: this.client })) + return (this._auth ??= new Auth({ client: this.client })); } - private _project?: Project + private _project?: Project; get project(): Project { - return (this._project ??= new Project({ client: this.client })) + return (this._project ??= new Project({ client: this.client })); } - private _pty?: Pty + private _pty?: Pty; get pty(): Pty { - return (this._pty ??= new Pty({ client: this.client })) + return (this._pty ??= new Pty({ client: this.client })); } - private _config?: Config2 + private _config?: Config2; get config(): Config2 { - return (this._config ??= new Config2({ client: this.client })) + return (this._config ??= new Config2({ client: this.client })); } - private _tool?: Tool + private _tool?: Tool; get tool(): Tool { - return (this._tool ??= new Tool({ client: this.client })) + return (this._tool ??= new Tool({ client: this.client })); } - private _experimental?: Experimental + private _experimental?: Experimental; get experimental(): Experimental { - return (this._experimental ??= new Experimental({ client: this.client })) + return (this._experimental ??= new Experimental({ client: this.client })); } - private _worktree?: Worktree + private _worktree?: Worktree; get worktree(): Worktree { - return (this._worktree ??= new Worktree({ client: this.client })) + return (this._worktree ??= new Worktree({ client: this.client })); } - private _session?: Session2 + private _session?: Session2; get session(): Session2 { - return (this._session ??= new Session2({ client: this.client })) + return (this._session ??= new Session2({ client: this.client })); } - private _part?: Part + private _part?: Part; get part(): Part { - return (this._part ??= new Part({ client: this.client })) + return (this._part ??= new Part({ client: this.client })); } - private _permission?: Permission + private _permission?: Permission; get permission(): Permission { - return (this._permission ??= new Permission({ client: this.client })) + return (this._permission ??= new Permission({ client: this.client })); } - private _question?: Question + private _question?: Question; get question(): Question { - return (this._question ??= new Question({ client: this.client })) + return (this._question ??= new Question({ client: this.client })); } - private _provider?: Provider + private _provider?: Provider; get provider(): Provider { - return (this._provider ??= new Provider({ client: this.client })) + return (this._provider ??= new Provider({ client: this.client })); } - private _find?: Find + private _find?: Find; get find(): Find { - return (this._find ??= new Find({ client: this.client })) + return (this._find ??= new Find({ client: this.client })); } - private _file?: File + private _file?: File; get file(): File { - return (this._file ??= new File({ client: this.client })) + return (this._file ??= new File({ client: this.client })); } - private _event?: Event + private _event?: Event; get event(): Event { - return (this._event ??= new Event({ client: this.client })) + return (this._event ??= new Event({ client: this.client })); } - private _mcp?: Mcp + private _mcp?: Mcp; get mcp(): Mcp { - return (this._mcp ??= new Mcp({ client: this.client })) + return (this._mcp ??= new Mcp({ client: this.client })); } - private _tui?: Tui + private _tui?: Tui; get tui(): Tui { - return (this._tui ??= new Tui({ client: this.client })) + return (this._tui ??= new Tui({ client: this.client })); } - private _instance?: Instance + private _instance?: Instance; get instance(): Instance { - return (this._instance ??= new Instance({ client: this.client })) + return (this._instance ??= new Instance({ client: this.client })); } - private _path?: Path + private _path?: Path; get path(): Path { - return (this._path ??= new Path({ client: this.client })) + return (this._path ??= new Path({ client: this.client })); } - private _vcs?: Vcs + private _vcs?: Vcs; get vcs(): Vcs { - return (this._vcs ??= new Vcs({ client: this.client })) + return (this._vcs ??= new Vcs({ client: this.client })); } - private _command?: Command + private _command?: Command; get command(): Command { - return (this._command ??= new Command({ client: this.client })) + return (this._command ??= new Command({ client: this.client })); } - private _app?: App + private _app?: App; get app(): App { - return (this._app ??= new App({ client: this.client })) + return (this._app ??= new App({ client: this.client })); } - private _lsp?: Lsp + private _lsp?: Lsp; get lsp(): Lsp { - return (this._lsp ??= new Lsp({ client: this.client })) + return (this._lsp ??= new Lsp({ client: this.client })); } - private _formatter?: Formatter + private _formatter?: Formatter; get formatter(): Formatter { - return (this._formatter ??= new Formatter({ client: this.client })) + return (this._formatter ??= new Formatter({ client: this.client })); } } diff --git a/apps/web/server/opencode/gen/types.gen.ts b/apps/web/server/opencode/gen/types.gen.ts index ec797f2b..3e5f4dbb 100644 --- a/apps/web/server/opencode/gen/types.gen.ts +++ b/apps/web/server/opencode/gen/types.gen.ts @@ -1,329 +1,329 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: `${string}://${string}` | (string & {}) -} + baseUrl: `${string}://${string}` | (string & {}); +}; export type EventInstallationUpdated = { - type: "installation.updated" + type: 'installation.updated'; properties: { - version: string - } -} + version: string; + }; +}; export type EventInstallationUpdateAvailable = { - type: "installation.update-available" + type: 'installation.update-available'; properties: { - version: string - } -} + version: string; + }; +}; export type Project = { - id: string - worktree: string - vcs?: "git" - name?: string + id: string; + worktree: string; + vcs?: 'git'; + name?: string; icon?: { - url?: string - override?: string - color?: string - } + url?: string; + override?: string; + color?: string; + }; commands?: { /** * Startup script to run when creating a new workspace (worktree) */ - start?: string - } + start?: string; + }; time: { - created: number - updated: number - initialized?: number - } - sandboxes: Array -} + created: number; + updated: number; + initialized?: number; + }; + sandboxes: Array; +}; export type EventProjectUpdated = { - type: "project.updated" - properties: Project -} + type: 'project.updated'; + properties: Project; +}; export type EventFileEdited = { - type: "file.edited" + type: 'file.edited'; properties: { - file: string - } -} + file: string; + }; +}; export type EventServerInstanceDisposed = { - type: "server.instance.disposed" + type: 'server.instance.disposed'; properties: { - directory: string - } -} + directory: string; + }; +}; export type EventFileWatcherUpdated = { - type: "file.watcher.updated" + type: 'file.watcher.updated'; properties: { - file: string - event: "add" | "change" | "unlink" - } -} + file: string; + event: 'add' | 'change' | 'unlink'; + }; +}; export type PermissionRequest = { - id: string - sessionID: string - permission: string - patterns: Array + id: string; + sessionID: string; + permission: string; + patterns: Array; metadata: { - [key: string]: unknown - } - always: Array + [key: string]: unknown; + }; + always: Array; tool?: { - messageID: string - callID: string - } -} + messageID: string; + callID: string; + }; +}; export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} + type: 'permission.asked'; + properties: PermissionRequest; +}; export type EventPermissionReplied = { - type: "permission.replied" + type: 'permission.replied'; properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} + sessionID: string; + requestID: string; + reply: 'once' | 'always' | 'reject'; + }; +}; export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" + type: 'vcs.branch.updated'; properties: { - branch?: string - } -} + branch?: string; + }; +}; export type QuestionOption = { /** * Display text (1-5 words, concise) */ - label: string + label: string; /** * Explanation of choice */ - description: string -} + description: string; +}; export type QuestionInfo = { /** * Complete question */ - question: string + question: string; /** * Very short label (max 30 chars) */ - header: string + header: string; /** * Available choices */ - options: Array + options: Array; /** * Allow selecting multiple choices */ - multiple?: boolean + multiple?: boolean; /** * Allow typing a custom answer (default: true) */ - custom?: boolean -} + custom?: boolean; +}; export type QuestionRequest = { - id: string - sessionID: string + id: string; + sessionID: string; /** * Questions to ask */ - questions: Array + questions: Array; tool?: { - messageID: string - callID: string - } -} + messageID: string; + callID: string; + }; +}; export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} + type: 'question.asked'; + properties: QuestionRequest; +}; -export type QuestionAnswer = Array +export type QuestionAnswer = Array; export type EventQuestionReplied = { - type: "question.replied" + type: 'question.replied'; properties: { - sessionID: string - requestID: string - answers: Array - } -} + sessionID: string; + requestID: string; + answers: Array; + }; +}; export type EventQuestionRejected = { - type: "question.rejected" + type: 'question.rejected'; properties: { - sessionID: string - requestID: string - } -} + sessionID: string; + requestID: string; + }; +}; export type EventServerConnected = { - type: "server.connected" + type: 'server.connected'; properties: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type EventGlobalDisposed = { - type: "global.disposed" + type: 'global.disposed'; properties: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type EventLspClientDiagnostics = { - type: "lsp.client.diagnostics" + type: 'lsp.client.diagnostics'; properties: { - serverID: string - path: string - } -} + serverID: string; + path: string; + }; +}; export type EventLspUpdated = { - type: "lsp.updated" + type: 'lsp.updated'; properties: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type OutputFormatText = { - type: "text" -} + type: 'text'; +}; export type JsonSchema = { - [key: string]: unknown -} + [key: string]: unknown; +}; export type OutputFormatJsonSchema = { - type: "json_schema" - schema: JsonSchema - retryCount?: number -} + type: 'json_schema'; + schema: JsonSchema; + retryCount?: number; +}; -export type OutputFormat = OutputFormatText | OutputFormatJsonSchema +export type OutputFormat = OutputFormatText | OutputFormatJsonSchema; export type FileDiff = { - file: string - before: string - after: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} + file: string; + before: string; + after: string; + additions: number; + deletions: number; + status?: 'added' | 'deleted' | 'modified'; +}; export type UserMessage = { - id: string - sessionID: string - role: "user" + id: string; + sessionID: string; + role: 'user'; time: { - created: number - } - format?: OutputFormat + created: number; + }; + format?: OutputFormat; summary?: { - title?: string - body?: string - diffs: Array - } - agent: string + title?: string; + body?: string; + diffs: Array; + }; + agent: string; model: { - providerID: string - modelID: string - } - system?: string + providerID: string; + modelID: string; + }; + system?: string; tools?: { - [key: string]: boolean - } - variant?: string -} + [key: string]: boolean; + }; + variant?: string; +}; export type ProviderAuthError = { - name: "ProviderAuthError" + name: 'ProviderAuthError'; data: { - providerID: string - message: string - } -} + providerID: string; + message: string; + }; +}; export type UnknownError = { - name: "UnknownError" + name: 'UnknownError'; data: { - message: string - } -} + message: string; + }; +}; export type MessageOutputLengthError = { - name: "MessageOutputLengthError" + name: 'MessageOutputLengthError'; data: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type MessageAbortedError = { - name: "MessageAbortedError" + name: 'MessageAbortedError'; data: { - message: string - } -} + message: string; + }; +}; export type StructuredOutputError = { - name: "StructuredOutputError" + name: 'StructuredOutputError'; data: { - message: string - retries: number - } -} + message: string; + retries: number; + }; +}; export type ContextOverflowError = { - name: "ContextOverflowError" + name: 'ContextOverflowError'; data: { - message: string - responseBody?: string - } -} + message: string; + responseBody?: string; + }; +}; export type ApiError = { - name: "APIError" + name: 'APIError'; data: { - message: string - statusCode?: number - isRetryable: boolean + message: string; + statusCode?: number; + isRetryable: boolean; responseHeaders?: { - [key: string]: string - } - responseBody?: string + [key: string]: string; + }; + responseBody?: string; metadata?: { - [key: string]: string - } - } -} + [key: string]: string; + }; + }; +}; export type AssistantMessage = { - id: string - sessionID: string - role: "assistant" + id: string; + sessionID: string; + role: 'assistant'; time: { - created: number - completed?: number - } + created: number; + completed?: number; + }; error?: | ProviderAuthError | UnknownError @@ -331,297 +331,297 @@ export type AssistantMessage = { | MessageAbortedError | StructuredOutputError | ContextOverflowError - | ApiError - parentID: string - modelID: string - providerID: string - mode: string - agent: string + | ApiError; + parentID: string; + modelID: string; + providerID: string; + mode: string; + agent: string; path: { - cwd: string - root: string - } - summary?: boolean - cost: number + cwd: string; + root: string; + }; + summary?: boolean; + cost: number; tokens: { - total?: number - input: number - output: number - reasoning: number + total?: number; + input: number; + output: number; + reasoning: number; cache: { - read: number - write: number - } - } - structured?: unknown - variant?: string - finish?: string -} + read: number; + write: number; + }; + }; + structured?: unknown; + variant?: string; + finish?: string; +}; -export type Message = UserMessage | AssistantMessage +export type Message = UserMessage | AssistantMessage; export type EventMessageUpdated = { - type: "message.updated" + type: 'message.updated'; properties: { - info: Message - } -} + info: Message; + }; +}; export type EventMessageRemoved = { - type: "message.removed" + type: 'message.removed'; properties: { - sessionID: string - messageID: string - } -} + sessionID: string; + messageID: string; + }; +}; export type TextPart = { - id: string - sessionID: string - messageID: string - type: "text" - text: string - synthetic?: boolean - ignored?: boolean + id: string; + sessionID: string; + messageID: string; + type: 'text'; + text: string; + synthetic?: boolean; + ignored?: boolean; time?: { - start: number - end?: number - } + start: number; + end?: number; + }; metadata?: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type SubtaskPart = { - id: string - sessionID: string - messageID: string - type: "subtask" - prompt: string - description: string - agent: string + id: string; + sessionID: string; + messageID: string; + type: 'subtask'; + prompt: string; + description: string; + agent: string; model?: { - providerID: string - modelID: string - } - command?: string -} + providerID: string; + modelID: string; + }; + command?: string; +}; export type ReasoningPart = { - id: string - sessionID: string - messageID: string - type: "reasoning" - text: string + id: string; + sessionID: string; + messageID: string; + type: 'reasoning'; + text: string; metadata?: { - [key: string]: unknown - } + [key: string]: unknown; + }; time: { - start: number - end?: number - } -} + start: number; + end?: number; + }; +}; export type FilePartSourceText = { - value: string - start: number - end: number -} + value: string; + start: number; + end: number; +}; export type FileSource = { - text: FilePartSourceText - type: "file" - path: string -} + text: FilePartSourceText; + type: 'file'; + path: string; +}; export type Range = { start: { - line: number - character: number - } + line: number; + character: number; + }; end: { - line: number - character: number - } -} + line: number; + character: number; + }; +}; export type SymbolSource = { - text: FilePartSourceText - type: "symbol" - path: string - range: Range - name: string - kind: number -} + text: FilePartSourceText; + type: 'symbol'; + path: string; + range: Range; + name: string; + kind: number; +}; export type ResourceSource = { - text: FilePartSourceText - type: "resource" - clientName: string - uri: string -} + text: FilePartSourceText; + type: 'resource'; + clientName: string; + uri: string; +}; -export type FilePartSource = FileSource | SymbolSource | ResourceSource +export type FilePartSource = FileSource | SymbolSource | ResourceSource; export type FilePart = { - id: string - sessionID: string - messageID: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource -} + id: string; + sessionID: string; + messageID: string; + type: 'file'; + mime: string; + filename?: string; + url: string; + source?: FilePartSource; +}; export type ToolStatePending = { - status: "pending" + status: 'pending'; input: { - [key: string]: unknown - } - raw: string -} + [key: string]: unknown; + }; + raw: string; +}; export type ToolStateRunning = { - status: "running" + status: 'running'; input: { - [key: string]: unknown - } - title?: string + [key: string]: unknown; + }; + title?: string; metadata?: { - [key: string]: unknown - } + [key: string]: unknown; + }; time: { - start: number - } -} + start: number; + }; +}; export type ToolStateCompleted = { - status: "completed" + status: 'completed'; input: { - [key: string]: unknown - } - output: string - title: string + [key: string]: unknown; + }; + output: string; + title: string; metadata: { - [key: string]: unknown - } + [key: string]: unknown; + }; time: { - start: number - end: number - compacted?: number - } - attachments?: Array -} + start: number; + end: number; + compacted?: number; + }; + attachments?: Array; +}; export type ToolStateError = { - status: "error" + status: 'error'; input: { - [key: string]: unknown - } - error: string + [key: string]: unknown; + }; + error: string; metadata?: { - [key: string]: unknown - } + [key: string]: unknown; + }; time: { - start: number - end: number - } -} + start: number; + end: number; + }; +}; -export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError; export type ToolPart = { - id: string - sessionID: string - messageID: string - type: "tool" - callID: string - tool: string - state: ToolState + id: string; + sessionID: string; + messageID: string; + type: 'tool'; + callID: string; + tool: string; + state: ToolState; metadata?: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type StepStartPart = { - id: string - sessionID: string - messageID: string - type: "step-start" - snapshot?: string -} + id: string; + sessionID: string; + messageID: string; + type: 'step-start'; + snapshot?: string; +}; export type StepFinishPart = { - id: string - sessionID: string - messageID: string - type: "step-finish" - reason: string - snapshot?: string - cost: number + id: string; + sessionID: string; + messageID: string; + type: 'step-finish'; + reason: string; + snapshot?: string; + cost: number; tokens: { - total?: number - input: number - output: number - reasoning: number + total?: number; + input: number; + output: number; + reasoning: number; cache: { - read: number - write: number - } - } -} + read: number; + write: number; + }; + }; +}; export type SnapshotPart = { - id: string - sessionID: string - messageID: string - type: "snapshot" - snapshot: string -} + id: string; + sessionID: string; + messageID: string; + type: 'snapshot'; + snapshot: string; +}; export type PatchPart = { - id: string - sessionID: string - messageID: string - type: "patch" - hash: string - files: Array -} + id: string; + sessionID: string; + messageID: string; + type: 'patch'; + hash: string; + files: Array; +}; export type AgentPart = { - id: string - sessionID: string - messageID: string - type: "agent" - name: string + id: string; + sessionID: string; + messageID: string; + type: 'agent'; + name: string; source?: { - value: string - start: number - end: number - } -} + value: string; + start: number; + end: number; + }; +}; export type RetryPart = { - id: string - sessionID: string - messageID: string - type: "retry" - attempt: number - error: ApiError + id: string; + sessionID: string; + messageID: string; + type: 'retry'; + attempt: number; + error: ApiError; time: { - created: number - } -} + created: number; + }; +}; export type CompactionPart = { - id: string - sessionID: string - messageID: string - type: "compaction" - auto: boolean - overflow?: boolean -} + id: string; + sessionID: string; + messageID: string; + type: 'compaction'; + auto: boolean; + overflow?: boolean; +}; export type Part = | TextPart @@ -635,249 +635,249 @@ export type Part = | PatchPart | AgentPart | RetryPart - | CompactionPart + | CompactionPart; export type EventMessagePartUpdated = { - type: "message.part.updated" + type: 'message.part.updated'; properties: { - part: Part - } -} + part: Part; + }; +}; export type EventMessagePartDelta = { - type: "message.part.delta" + type: 'message.part.delta'; properties: { - sessionID: string - messageID: string - partID: string - field: string - delta: string - } -} + sessionID: string; + messageID: string; + partID: string; + field: string; + delta: string; + }; +}; export type EventMessagePartRemoved = { - type: "message.part.removed" + type: 'message.part.removed'; properties: { - sessionID: string - messageID: string - partID: string - } -} + sessionID: string; + messageID: string; + partID: string; + }; +}; export type SessionStatus = | { - type: "idle" + type: 'idle'; } | { - type: "retry" - attempt: number - message: string - next: number + type: 'retry'; + attempt: number; + message: string; + next: number; } | { - type: "busy" - } + type: 'busy'; + }; export type EventSessionStatus = { - type: "session.status" + type: 'session.status'; properties: { - sessionID: string - status: SessionStatus - } -} + sessionID: string; + status: SessionStatus; + }; +}; export type EventSessionIdle = { - type: "session.idle" + type: 'session.idle'; properties: { - sessionID: string - } -} + sessionID: string; + }; +}; export type EventSessionCompacted = { - type: "session.compacted" + type: 'session.compacted'; properties: { - sessionID: string - } -} + sessionID: string; + }; +}; export type Todo = { /** * Brief description of the task */ - content: string + content: string; /** * Current status of the task: pending, in_progress, completed, cancelled */ - status: string + status: string; /** * Priority level of the task: high, medium, low */ - priority: string -} + priority: string; +}; export type EventTodoUpdated = { - type: "todo.updated" + type: 'todo.updated'; properties: { - sessionID: string - todos: Array - } -} + sessionID: string; + todos: Array; + }; +}; export type EventTuiPromptAppend = { - type: "tui.prompt.append" + type: 'tui.prompt.append'; properties: { - text: string - } -} + text: string; + }; +}; export type EventTuiCommandExecute = { - type: "tui.command.execute" + type: 'tui.command.execute'; properties: { command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} + | 'session.list' + | 'session.new' + | 'session.share' + | 'session.interrupt' + | 'session.compact' + | 'session.page.up' + | 'session.page.down' + | 'session.line.up' + | 'session.line.down' + | 'session.half.page.up' + | 'session.half.page.down' + | 'session.first' + | 'session.last' + | 'prompt.clear' + | 'prompt.submit' + | 'agent.cycle' + | string; + }; +}; export type EventTuiToastShow = { - type: "tui.toast.show" + type: 'tui.toast.show'; properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" + title?: string; + message: string; + variant: 'info' | 'success' | 'warning' | 'error'; /** * Duration in milliseconds */ - duration?: number - } -} + duration?: number; + }; +}; export type EventTuiSessionSelect = { - type: "tui.session.select" + type: 'tui.session.select'; properties: { /** * Session ID to navigate to */ - sessionID: string - } -} + sessionID: string; + }; +}; export type EventMcpToolsChanged = { - type: "mcp.tools.changed" + type: 'mcp.tools.changed'; properties: { - server: string - } -} + server: string; + }; +}; export type EventMcpBrowserOpenFailed = { - type: "mcp.browser.open.failed" + type: 'mcp.browser.open.failed'; properties: { - mcpName: string - url: string - } -} + mcpName: string; + url: string; + }; +}; export type EventCommandExecuted = { - type: "command.executed" + type: 'command.executed'; properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} + name: string; + sessionID: string; + arguments: string; + messageID: string; + }; +}; -export type PermissionAction = "allow" | "deny" | "ask" +export type PermissionAction = 'allow' | 'deny' | 'ask'; export type PermissionRule = { - permission: string - pattern: string - action: PermissionAction -} + permission: string; + pattern: string; + action: PermissionAction; +}; -export type PermissionRuleset = Array +export type PermissionRuleset = Array; export type Session = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - parentID?: string + id: string; + slug: string; + projectID: string; + workspaceID?: string; + directory: string; + parentID?: string; summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } + additions: number; + deletions: number; + files: number; + diffs?: Array; + }; share?: { - url: string - } - title: string - version: string + url: string; + }; + title: string; + version: string; time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset + created: number; + updated: number; + compacting?: number; + archived?: number; + }; + permission?: PermissionRuleset; revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} + messageID: string; + partID?: string; + snapshot?: string; + diff?: string; + }; +}; export type EventSessionCreated = { - type: "session.created" + type: 'session.created'; properties: { - info: Session - } -} + info: Session; + }; +}; export type EventSessionUpdated = { - type: "session.updated" + type: 'session.updated'; properties: { - info: Session - } -} + info: Session; + }; +}; export type EventSessionDeleted = { - type: "session.deleted" + type: 'session.deleted'; properties: { - info: Session - } -} + info: Session; + }; +}; export type EventSessionDiff = { - type: "session.diff" + type: 'session.diff'; properties: { - sessionID: string - diff: Array - } -} + sessionID: string; + diff: Array; + }; +}; export type EventSessionError = { - type: "session.error" + type: 'session.error'; properties: { - sessionID?: string + sessionID?: string; error?: | ProviderAuthError | UnknownError @@ -885,77 +885,77 @@ export type EventSessionError = { | MessageAbortedError | StructuredOutputError | ContextOverflowError - | ApiError - } -} + | ApiError; + }; +}; export type EventWorkspaceReady = { - type: "workspace.ready" + type: 'workspace.ready'; properties: { - name: string - } -} + name: string; + }; +}; export type EventWorkspaceFailed = { - type: "workspace.failed" + type: 'workspace.failed'; properties: { - message: string - } -} + message: string; + }; +}; export type Pty = { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number -} + id: string; + title: string; + command: string; + args: Array; + cwd: string; + status: 'running' | 'exited'; + pid: number; +}; export type EventPtyCreated = { - type: "pty.created" + type: 'pty.created'; properties: { - info: Pty - } -} + info: Pty; + }; +}; export type EventPtyUpdated = { - type: "pty.updated" + type: 'pty.updated'; properties: { - info: Pty - } -} + info: Pty; + }; +}; export type EventPtyExited = { - type: "pty.exited" + type: 'pty.exited'; properties: { - id: string - exitCode: number - } -} + id: string; + exitCode: number; + }; +}; export type EventPtyDeleted = { - type: "pty.deleted" + type: 'pty.deleted'; properties: { - id: string - } -} + id: string; + }; +}; export type EventWorktreeReady = { - type: "worktree.ready" + type: 'worktree.ready'; properties: { - name: string - branch: string - } -} + name: string; + branch: string; + }; +}; export type EventWorktreeFailed = { - type: "worktree.failed" + type: 'worktree.failed'; properties: { - message: string - } -} + message: string; + }; +}; export type Event = | EventInstallationUpdated @@ -1002,17 +1002,17 @@ export type Event = | EventPtyExited | EventPtyDeleted | EventWorktreeReady - | EventWorktreeFailed + | EventWorktreeFailed; export type GlobalEvent = { - directory: string - payload: Event -} + directory: string; + payload: Event; +}; /** * Log level */ -export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" +export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; /** * Server configuration for opencode serve and web commands @@ -1021,179 +1021,179 @@ export type ServerConfig = { /** * Port to listen on */ - port?: number + port?: number; /** * Hostname to listen on */ - hostname?: string + hostname?: string; /** * Enable mDNS service discovery */ - mdns?: boolean + mdns?: boolean; /** * Custom domain name for mDNS service (default: opencode.local) */ - mdnsDomain?: string + mdnsDomain?: string; /** * Additional domains to allow for CORS */ - cors?: Array -} + cors?: Array; +}; -export type PermissionActionConfig = "ask" | "allow" | "deny" +export type PermissionActionConfig = 'ask' | 'allow' | 'deny'; export type PermissionObjectConfig = { - [key: string]: PermissionActionConfig -} + [key: string]: PermissionActionConfig; +}; -export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig +export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig; export type PermissionConfig = | { - __originalKeys?: Array - read?: PermissionRuleConfig - edit?: PermissionRuleConfig - glob?: PermissionRuleConfig - grep?: PermissionRuleConfig - list?: PermissionRuleConfig - bash?: PermissionRuleConfig - task?: PermissionRuleConfig - external_directory?: PermissionRuleConfig - todowrite?: PermissionActionConfig - todoread?: PermissionActionConfig - question?: PermissionActionConfig - webfetch?: PermissionActionConfig - websearch?: PermissionActionConfig - codesearch?: PermissionActionConfig - lsp?: PermissionRuleConfig - doom_loop?: PermissionActionConfig - skill?: PermissionRuleConfig - [key: string]: PermissionRuleConfig | Array | PermissionActionConfig | undefined + __originalKeys?: Array; + read?: PermissionRuleConfig; + edit?: PermissionRuleConfig; + glob?: PermissionRuleConfig; + grep?: PermissionRuleConfig; + list?: PermissionRuleConfig; + bash?: PermissionRuleConfig; + task?: PermissionRuleConfig; + external_directory?: PermissionRuleConfig; + todowrite?: PermissionActionConfig; + todoread?: PermissionActionConfig; + question?: PermissionActionConfig; + webfetch?: PermissionActionConfig; + websearch?: PermissionActionConfig; + codesearch?: PermissionActionConfig; + lsp?: PermissionRuleConfig; + doom_loop?: PermissionActionConfig; + skill?: PermissionRuleConfig; + [key: string]: PermissionRuleConfig | Array | PermissionActionConfig | undefined; } - | PermissionActionConfig + | PermissionActionConfig; export type AgentConfig = { - model?: string + model?: string; /** * Default model variant for this agent (applies only when using the agent's configured model). */ - variant?: string - temperature?: number - top_p?: number - prompt?: string + variant?: string; + temperature?: number; + top_p?: number; + prompt?: string; /** * @deprecated Use 'permission' field instead */ tools?: { - [key: string]: boolean - } - disable?: boolean + [key: string]: boolean; + }; + disable?: boolean; /** * Description of when to use the agent */ - description?: string - mode?: "subagent" | "primary" | "all" + description?: string; + mode?: 'subagent' | 'primary' | 'all'; /** * Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent) */ - hidden?: boolean + hidden?: boolean; options?: { - [key: string]: unknown - } + [key: string]: unknown; + }; /** * Hex color code (e.g., #FF5733) or theme color (e.g., primary) */ - color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + color?: string | 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'error' | 'info'; /** * Maximum number of agentic iterations before forcing text-only response */ - steps?: number + steps?: number; /** * @deprecated Use 'steps' field instead. */ - maxSteps?: number - permission?: PermissionConfig + maxSteps?: number; + permission?: PermissionConfig; [key: string]: | unknown | string | number | { - [key: string]: boolean + [key: string]: boolean; } | boolean - | "subagent" - | "primary" - | "all" + | 'subagent' + | 'primary' + | 'all' | { - [key: string]: unknown + [key: string]: unknown; } | string - | "primary" - | "secondary" - | "accent" - | "success" - | "warning" - | "error" - | "info" + | 'primary' + | 'secondary' + | 'accent' + | 'success' + | 'warning' + | 'error' + | 'info' | number | PermissionConfig - | undefined -} + | undefined; +}; export type ProviderConfig = { - api?: string - name?: string - env?: Array - id?: string - npm?: string + api?: string; + name?: string; + env?: Array; + id?: string; + npm?: string; models?: { [key: string]: { - id?: string - name?: string - family?: string - release_date?: string - attachment?: boolean - reasoning?: boolean - temperature?: boolean - tool_call?: boolean + id?: string; + name?: string; + family?: string; + release_date?: string; + attachment?: boolean; + reasoning?: boolean; + temperature?: boolean; + tool_call?: boolean; interleaved?: | true | { - field: "reasoning_content" | "reasoning_details" - } + field: 'reasoning_content' | 'reasoning_details'; + }; cost?: { - input: number - output: number - cache_read?: number - cache_write?: number + input: number; + output: number; + cache_read?: number; + cache_write?: number; context_over_200k?: { - input: number - output: number - cache_read?: number - cache_write?: number - } - } + input: number; + output: number; + cache_read?: number; + cache_write?: number; + }; + }; limit?: { - context: number - input?: number - output: number - } + context: number; + input?: number; + output: number; + }; modalities?: { - input: Array<"text" | "audio" | "image" | "video" | "pdf"> - output: Array<"text" | "audio" | "image" | "video" | "pdf"> - } - experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + input: Array<'text' | 'audio' | 'image' | 'video' | 'pdf'>; + output: Array<'text' | 'audio' | 'image' | 'video' | 'pdf'>; + }; + experimental?: boolean; + status?: 'alpha' | 'beta' | 'deprecated'; options?: { - [key: string]: unknown - } + [key: string]: unknown; + }; headers?: { - [key: string]: string - } + [key: string]: string; + }; provider?: { - npm?: string - api?: string - } + npm?: string; + api?: string; + }; /** * Variant-specific configuration */ @@ -1202,130 +1202,130 @@ export type ProviderConfig = { /** * Disable this variant for the model */ - disabled?: boolean - [key: string]: unknown | boolean | undefined - } - } - } - } - whitelist?: Array - blacklist?: Array + disabled?: boolean; + [key: string]: unknown | boolean | undefined; + }; + }; + }; + }; + whitelist?: Array; + blacklist?: Array; options?: { - apiKey?: string - baseURL?: string + apiKey?: string; + baseURL?: string; /** * GitHub Enterprise URL for copilot authentication */ - enterpriseUrl?: string + enterpriseUrl?: string; /** * Enable promptCacheKey for this provider (default false) */ - setCacheKey?: boolean + setCacheKey?: boolean; /** * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. */ - timeout?: number | false + timeout?: number | false; /** * Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. */ - chunkTimeout?: number - [key: string]: unknown | string | boolean | number | false | number | undefined - } -} + chunkTimeout?: number; + [key: string]: unknown | string | boolean | number | false | number | undefined; + }; +}; export type McpLocalConfig = { /** * Type of MCP server connection */ - type: "local" + type: 'local'; /** * Command and arguments to run the MCP server */ - command: Array + command: Array; /** * Environment variables to set when running the MCP server */ environment?: { - [key: string]: string - } + [key: string]: string; + }; /** * Enable or disable the MCP server on startup */ - enabled?: boolean + enabled?: boolean; /** * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. */ - timeout?: number -} + timeout?: number; +}; export type McpOAuthConfig = { /** * OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted. */ - clientId?: string + clientId?: string; /** * OAuth client secret (if required by the authorization server) */ - clientSecret?: string + clientSecret?: string; /** * OAuth scopes to request during authorization */ - scope?: string -} + scope?: string; +}; export type McpRemoteConfig = { /** * Type of MCP server connection */ - type: "remote" + type: 'remote'; /** * URL of the remote MCP server */ - url: string + url: string; /** * Enable or disable the MCP server on startup */ - enabled?: boolean + enabled?: boolean; /** * Headers to send with the request */ headers?: { - [key: string]: string - } + [key: string]: string; + }; /** * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. */ - oauth?: McpOAuthConfig | false + oauth?: McpOAuthConfig | false; /** * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. */ - timeout?: number -} + timeout?: number; +}; /** * @deprecated Always uses stretch layout. */ -export type LayoutConfig = "auto" | "stretch" +export type LayoutConfig = 'auto' | 'stretch'; export type Config = { /** * JSON schema reference for configuration validation */ - $schema?: string - logLevel?: LogLevel - server?: ServerConfig + $schema?: string; + logLevel?: LogLevel; + server?: ServerConfig; /** * Command configuration, see https://opencode.ai/docs/commands */ command?: { [key: string]: { - template: string - description?: string - agent?: string - model?: string - subtask?: boolean - } - } + template: string; + description?: string; + agent?: string; + model?: string; + subtask?: boolean; + }; + }; /** * Additional skill folder paths */ @@ -1333,83 +1333,83 @@ export type Config = { /** * Additional paths to skill folders */ - paths?: Array + paths?: Array; /** * URLs to fetch skills from (e.g., https://example.com/.well-known/skills/) */ - urls?: Array - } + urls?: Array; + }; watcher?: { - ignore?: Array - } - plugin?: Array + ignore?: Array; + }; + plugin?: Array; /** * Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true. */ - snapshot?: boolean + snapshot?: boolean; /** * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing */ - share?: "manual" | "auto" | "disabled" + share?: 'manual' | 'auto' | 'disabled'; /** * @deprecated Use 'share' field instead. Share newly created sessions automatically */ - autoshare?: boolean + autoshare?: boolean; /** * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications */ - autoupdate?: boolean | "notify" + autoupdate?: boolean | 'notify'; /** * Disable providers that are loaded automatically */ - disabled_providers?: Array + disabled_providers?: Array; /** * When set, ONLY these providers will be enabled. All other providers will be ignored */ - enabled_providers?: Array + enabled_providers?: Array; /** * Model to use in the format of provider/model, eg anthropic/claude-2 */ - model?: string + model?: string; /** * Small model to use for tasks like title generation in the format of provider/model */ - small_model?: string + small_model?: string; /** * Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid. */ - default_agent?: string + default_agent?: string; /** * Custom username to display in conversations instead of system username */ - username?: string + username?: string; /** * @deprecated Use `agent` field instead. */ mode?: { - build?: AgentConfig - plan?: AgentConfig - [key: string]: AgentConfig | undefined - } + build?: AgentConfig; + plan?: AgentConfig; + [key: string]: AgentConfig | undefined; + }; /** * Agent configuration, see https://opencode.ai/docs/agents */ agent?: { - plan?: AgentConfig - build?: AgentConfig - general?: AgentConfig - explore?: AgentConfig - title?: AgentConfig - summary?: AgentConfig - compaction?: AgentConfig - [key: string]: AgentConfig | undefined - } + plan?: AgentConfig; + build?: AgentConfig; + general?: AgentConfig; + explore?: AgentConfig; + title?: AgentConfig; + summary?: AgentConfig; + compaction?: AgentConfig; + [key: string]: AgentConfig | undefined; + }; /** * Custom provider configurations and model overrides */ provider?: { - [key: string]: ProviderConfig - } + [key: string]: ProviderConfig; + }; /** * MCP (Model Context Protocol) server configurations */ @@ -1418,2495 +1418,2501 @@ export type Config = { | McpLocalConfig | McpRemoteConfig | { - enabled: boolean - } - } + enabled: boolean; + }; + }; formatter?: | false | { [key: string]: { - disabled?: boolean - command?: Array + disabled?: boolean; + command?: Array; environment?: { - [key: string]: string - } - extensions?: Array - } - } + [key: string]: string; + }; + extensions?: Array; + }; + }; lsp?: | false | { [key: string]: | { - disabled: true + disabled: true; } | { - command: Array - extensions?: Array - disabled?: boolean + command: Array; + extensions?: Array; + disabled?: boolean; env?: { - [key: string]: string - } + [key: string]: string; + }; initialization?: { - [key: string]: unknown - } - } - } + [key: string]: unknown; + }; + }; + }; /** * Additional instruction files or patterns to include */ - instructions?: Array - layout?: LayoutConfig - permission?: PermissionConfig + instructions?: Array; + layout?: LayoutConfig; + permission?: PermissionConfig; tools?: { - [key: string]: boolean - } + [key: string]: boolean; + }; enterprise?: { /** * Enterprise URL */ - url?: string - } + url?: string; + }; compaction?: { /** * Enable automatic compaction when context is full (default: true) */ - auto?: boolean + auto?: boolean; /** * Enable pruning of old tool outputs (default: true) */ - prune?: boolean + prune?: boolean; /** * Token buffer for compaction. Leaves enough window to avoid overflow during compaction. */ - reserved?: number - } + reserved?: number; + }; experimental?: { - disable_paste_summary?: boolean + disable_paste_summary?: boolean; /** * Enable the batch tool */ - batch_tool?: boolean + batch_tool?: boolean; /** * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) */ - openTelemetry?: boolean + openTelemetry?: boolean; /** * Tools that should only be available to primary agents. */ - primary_tools?: Array + primary_tools?: Array; /** * Continue the agent loop when a tool call is denied */ - continue_loop_on_deny?: boolean + continue_loop_on_deny?: boolean; /** * Timeout in milliseconds for model context protocol (MCP) requests */ - mcp_timeout?: number - } -} + mcp_timeout?: number; + }; +}; export type BadRequestError = { - data: unknown + data: unknown; errors: Array<{ - [key: string]: unknown - }> - success: false -} + [key: string]: unknown; + }>; + success: false; +}; export type OAuth = { - type: "oauth" - refresh: string - access: string - expires: number - accountId?: string - enterpriseUrl?: string -} + type: 'oauth'; + refresh: string; + access: string; + expires: number; + accountId?: string; + enterpriseUrl?: string; +}; export type ApiAuth = { - type: "api" - key: string -} + type: 'api'; + key: string; +}; export type WellKnownAuth = { - type: "wellknown" - key: string - token: string -} + type: 'wellknown'; + key: string; + token: string; +}; -export type Auth = OAuth | ApiAuth | WellKnownAuth +export type Auth = OAuth | ApiAuth | WellKnownAuth; export type NotFoundError = { - name: "NotFoundError" + name: 'NotFoundError'; data: { - message: string - } -} + message: string; + }; +}; export type Model = { - id: string - providerID: string + id: string; + providerID: string; api: { - id: string - url: string - npm: string - } - name: string - family?: string + id: string; + url: string; + npm: string; + }; + name: string; + family?: string; capabilities: { - temperature: boolean - reasoning: boolean - attachment: boolean - toolcall: boolean + temperature: boolean; + reasoning: boolean; + attachment: boolean; + toolcall: boolean; input: { - text: boolean - audio: boolean - image: boolean - video: boolean - pdf: boolean - } + text: boolean; + audio: boolean; + image: boolean; + video: boolean; + pdf: boolean; + }; output: { - text: boolean - audio: boolean - image: boolean - video: boolean - pdf: boolean - } + text: boolean; + audio: boolean; + image: boolean; + video: boolean; + pdf: boolean; + }; interleaved: | boolean | { - field: "reasoning_content" | "reasoning_details" - } - } + field: 'reasoning_content' | 'reasoning_details'; + }; + }; cost: { - input: number - output: number + input: number; + output: number; cache: { - read: number - write: number - } + read: number; + write: number; + }; experimentalOver200K?: { - input: number - output: number + input: number; + output: number; cache: { - read: number - write: number - } - } - } + read: number; + write: number; + }; + }; + }; limit: { - context: number - input?: number - output: number - } - status: "alpha" | "beta" | "deprecated" | "active" + context: number; + input?: number; + output: number; + }; + status: 'alpha' | 'beta' | 'deprecated' | 'active'; options: { - [key: string]: unknown - } + [key: string]: unknown; + }; headers: { - [key: string]: string - } - release_date: string + [key: string]: string; + }; + release_date: string; variants?: { [key: string]: { - [key: string]: unknown - } - } -} + [key: string]: unknown; + }; + }; +}; export type Provider = { - id: string - name: string - source: "env" | "config" | "custom" | "api" - env: Array - key?: string + id: string; + name: string; + source: 'env' | 'config' | 'custom' | 'api'; + env: Array; + key?: string; options: { - [key: string]: unknown - } + [key: string]: unknown; + }; models: { - [key: string]: Model - } -} + [key: string]: Model; + }; +}; -export type ToolIds = Array +export type ToolIds = Array; export type ToolListItem = { - id: string - description: string - parameters: unknown -} + id: string; + description: string; + parameters: unknown; +}; -export type ToolList = Array +export type ToolList = Array; export type Workspace = { - id: string - type: string - branch: string | null - name: string | null - directory: string | null - extra: unknown | null - projectID: string -} + id: string; + type: string; + branch: string | null; + name: string | null; + directory: string | null; + extra: unknown | null; + projectID: string; +}; export type Worktree = { - name: string - branch: string - directory: string -} + name: string; + branch: string; + directory: string; +}; export type WorktreeCreateInput = { - name?: string + name?: string; /** * Additional startup script to run after the project's start command */ - startCommand?: string -} + startCommand?: string; +}; export type WorktreeRemoveInput = { - directory: string -} + directory: string; +}; export type WorktreeResetInput = { - directory: string -} + directory: string; +}; export type ProjectSummary = { - id: string - name?: string - worktree: string -} + id: string; + name?: string; + worktree: string; +}; export type GlobalSession = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - parentID?: string + id: string; + slug: string; + projectID: string; + workspaceID?: string; + directory: string; + parentID?: string; summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } + additions: number; + deletions: number; + files: number; + diffs?: Array; + }; share?: { - url: string - } - title: string - version: string + url: string; + }; + title: string; + version: string; time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset + created: number; + updated: number; + compacting?: number; + archived?: number; + }; + permission?: PermissionRuleset; revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } - project: ProjectSummary | null -} + messageID: string; + partID?: string; + snapshot?: string; + diff?: string; + }; + project: ProjectSummary | null; +}; export type McpResource = { - name: string - uri: string - description?: string - mimeType?: string - client: string -} + name: string; + uri: string; + description?: string; + mimeType?: string; + client: string; +}; export type TextPartInput = { - id?: string - type: "text" - text: string - synthetic?: boolean - ignored?: boolean + id?: string; + type: 'text'; + text: string; + synthetic?: boolean; + ignored?: boolean; time?: { - start: number - end?: number - } + start: number; + end?: number; + }; metadata?: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type FilePartInput = { - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource -} + id?: string; + type: 'file'; + mime: string; + filename?: string; + url: string; + source?: FilePartSource; +}; export type AgentPartInput = { - id?: string - type: "agent" - name: string + id?: string; + type: 'agent'; + name: string; source?: { - value: string - start: number - end: number - } -} + value: string; + start: number; + end: number; + }; +}; export type SubtaskPartInput = { - id?: string - type: "subtask" - prompt: string - description: string - agent: string + id?: string; + type: 'subtask'; + prompt: string; + description: string; + agent: string; model?: { - providerID: string - modelID: string - } - command?: string -} + providerID: string; + modelID: string; + }; + command?: string; +}; export type ProviderAuthMethod = { - type: "oauth" | "api" - label: string + type: 'oauth' | 'api'; + label: string; prompts?: Array< | { - type: "text" - key: string - message: string - placeholder?: string + type: 'text'; + key: string; + message: string; + placeholder?: string; when?: { - key: string - op: "eq" | "neq" - value: string - } + key: string; + op: 'eq' | 'neq'; + value: string; + }; } | { - type: "select" - key: string - message: string + type: 'select'; + key: string; + message: string; options: Array<{ - label: string - value: string - hint?: string - }> + label: string; + value: string; + hint?: string; + }>; when?: { - key: string - op: "eq" | "neq" - value: string - } + key: string; + op: 'eq' | 'neq'; + value: string; + }; } - > -} + >; +}; export type ProviderAuthAuthorization = { - url: string - method: "auto" | "code" - instructions: string -} + url: string; + method: 'auto' | 'code'; + instructions: string; +}; export type Symbol = { - name: string - kind: number + name: string; + kind: number; location: { - uri: string - range: Range - } -} + uri: string; + range: Range; + }; +}; export type FileNode = { - name: string - path: string - absolute: string - type: "file" | "directory" - ignored: boolean -} + name: string; + path: string; + absolute: string; + type: 'file' | 'directory'; + ignored: boolean; +}; export type FileContent = { - type: "text" | "binary" - content: string - diff?: string + type: 'text' | 'binary'; + content: string; + diff?: string; patch?: { - oldFileName: string - newFileName: string - oldHeader?: string - newHeader?: string + oldFileName: string; + newFileName: string; + oldHeader?: string; + newHeader?: string; hunks: Array<{ - oldStart: number - oldLines: number - newStart: number - newLines: number - lines: Array - }> - index?: string - } - encoding?: "base64" - mimeType?: string -} + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: Array; + }>; + index?: string; + }; + encoding?: 'base64'; + mimeType?: string; +}; export type File = { - path: string - added: number - removed: number - status: "added" | "deleted" | "modified" -} + path: string; + added: number; + removed: number; + status: 'added' | 'deleted' | 'modified'; +}; export type McpStatusConnected = { - status: "connected" -} + status: 'connected'; +}; export type McpStatusDisabled = { - status: "disabled" -} + status: 'disabled'; +}; export type McpStatusFailed = { - status: "failed" - error: string -} + status: 'failed'; + error: string; +}; export type McpStatusNeedsAuth = { - status: "needs_auth" -} + status: 'needs_auth'; +}; export type McpStatusNeedsClientRegistration = { - status: "needs_client_registration" - error: string -} + status: 'needs_client_registration'; + error: string; +}; export type McpStatus = | McpStatusConnected | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth - | McpStatusNeedsClientRegistration + | McpStatusNeedsClientRegistration; export type Path = { - home: string - state: string - config: string - worktree: string - directory: string -} + home: string; + state: string; + config: string; + worktree: string; + directory: string; +}; export type VcsInfo = { - branch: string -} + branch: string; +}; export type Command = { - name: string - description?: string - agent?: string - model?: string - source?: "command" | "mcp" | "skill" - template: string - subtask?: boolean - hints: Array -} + name: string; + description?: string; + agent?: string; + model?: string; + source?: 'command' | 'mcp' | 'skill'; + template: string; + subtask?: boolean; + hints: Array; +}; export type Agent = { - name: string - description?: string - mode: "subagent" | "primary" | "all" - native?: boolean - hidden?: boolean - topP?: number - temperature?: number - color?: string - permission: PermissionRuleset + name: string; + description?: string; + mode: 'subagent' | 'primary' | 'all'; + native?: boolean; + hidden?: boolean; + topP?: number; + temperature?: number; + color?: string; + permission: PermissionRuleset; model?: { - modelID: string - providerID: string - } - variant?: string - prompt?: string + modelID: string; + providerID: string; + }; + variant?: string; + prompt?: string; options: { - [key: string]: unknown - } - steps?: number -} + [key: string]: unknown; + }; + steps?: number; +}; export type LspStatus = { - id: string - name: string - root: string - status: "connected" | "error" -} + id: string; + name: string; + root: string; + status: 'connected' | 'error'; +}; export type FormatterStatus = { - name: string - extensions: Array - enabled: boolean -} + name: string; + extensions: Array; + enabled: boolean; +}; export type GlobalHealthData = { - body?: never - path?: never - query?: never - url: "/global/health" -} + body?: never; + path?: never; + query?: never; + url: '/global/health'; +}; export type GlobalHealthResponses = { /** * Health information */ 200: { - healthy: true - version: string - } -} + healthy: true; + version: string; + }; +}; -export type GlobalHealthResponse = GlobalHealthResponses[keyof GlobalHealthResponses] +export type GlobalHealthResponse = GlobalHealthResponses[keyof GlobalHealthResponses]; export type GlobalEventData = { - body?: never - path?: never - query?: never - url: "/global/event" -} + body?: never; + path?: never; + query?: never; + url: '/global/event'; +}; export type GlobalEventResponses = { /** * Event stream */ - 200: GlobalEvent -} + 200: GlobalEvent; +}; -export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses] +export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses]; export type GlobalConfigGetData = { - body?: never - path?: never - query?: never - url: "/global/config" -} + body?: never; + path?: never; + query?: never; + url: '/global/config'; +}; export type GlobalConfigGetResponses = { /** * Get global config info */ - 200: Config -} + 200: Config; +}; -export type GlobalConfigGetResponse = GlobalConfigGetResponses[keyof GlobalConfigGetResponses] +export type GlobalConfigGetResponse = GlobalConfigGetResponses[keyof GlobalConfigGetResponses]; export type GlobalConfigUpdateData = { - body?: Config - path?: never - query?: never - url: "/global/config" -} + body?: Config; + path?: never; + query?: never; + url: '/global/config'; +}; export type GlobalConfigUpdateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type GlobalConfigUpdateError = GlobalConfigUpdateErrors[keyof GlobalConfigUpdateErrors] +export type GlobalConfigUpdateError = GlobalConfigUpdateErrors[keyof GlobalConfigUpdateErrors]; export type GlobalConfigUpdateResponses = { /** * Successfully updated global config */ - 200: Config -} + 200: Config; +}; -export type GlobalConfigUpdateResponse = GlobalConfigUpdateResponses[keyof GlobalConfigUpdateResponses] +export type GlobalConfigUpdateResponse = + GlobalConfigUpdateResponses[keyof GlobalConfigUpdateResponses]; export type GlobalDisposeData = { - body?: never - path?: never - query?: never - url: "/global/dispose" -} + body?: never; + path?: never; + query?: never; + url: '/global/dispose'; +}; export type GlobalDisposeResponses = { /** * Global disposed */ - 200: boolean -} + 200: boolean; +}; -export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses] +export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses]; export type AuthRemoveData = { - body?: never + body?: never; path: { - providerID: string - } - query?: never - url: "/auth/{providerID}" -} + providerID: string; + }; + query?: never; + url: '/auth/{providerID}'; +}; export type AuthRemoveErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] +export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors]; export type AuthRemoveResponses = { /** * Successfully removed authentication credentials */ - 200: boolean -} + 200: boolean; +}; -export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] +export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses]; export type AuthSetData = { - body?: Auth + body?: Auth; path: { - providerID: string - } - query?: never - url: "/auth/{providerID}" -} + providerID: string; + }; + query?: never; + url: '/auth/{providerID}'; +}; export type AuthSetErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] +export type AuthSetError = AuthSetErrors[keyof AuthSetErrors]; export type AuthSetResponses = { /** * Successfully set authentication credentials */ - 200: boolean -} + 200: boolean; +}; -export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] +export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]; export type ProjectListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/project" -} + directory?: string; + workspace?: string; + }; + url: '/project'; +}; export type ProjectListResponses = { /** * List of projects */ - 200: Array -} + 200: Array; +}; -export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses] +export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses]; export type ProjectCurrentData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/project/current" -} + directory?: string; + workspace?: string; + }; + url: '/project/current'; +}; export type ProjectCurrentResponses = { /** * Current project information */ - 200: Project -} + 200: Project; +}; -export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] +export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses]; export type ProjectInitGitData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/project/git/init" -} + directory?: string; + workspace?: string; + }; + url: '/project/git/init'; +}; export type ProjectInitGitResponses = { /** * Project information after git initialization */ - 200: Project -} + 200: Project; +}; -export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses] +export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses]; export type ProjectUpdateData = { body?: { - name?: string + name?: string; icon?: { - url?: string - override?: string - color?: string - } + url?: string; + override?: string; + color?: string; + }; commands?: { /** * Startup script to run when creating a new workspace (worktree) */ - start?: string - } - } + start?: string; + }; + }; path: { - projectID: string - } + projectID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/project/{projectID}" -} + directory?: string; + workspace?: string; + }; + url: '/project/{projectID}'; +}; export type ProjectUpdateErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors] +export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors]; export type ProjectUpdateResponses = { /** * Updated project information */ - 200: Project -} + 200: Project; +}; -export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses] +export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses]; export type PtyListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/pty" -} + directory?: string; + workspace?: string; + }; + url: '/pty'; +}; export type PtyListResponses = { /** * List of sessions */ - 200: Array -} + 200: Array; +}; -export type PtyListResponse = PtyListResponses[keyof PtyListResponses] +export type PtyListResponse = PtyListResponses[keyof PtyListResponses]; export type PtyCreateData = { body?: { - command?: string - args?: Array - cwd?: string - title?: string + command?: string; + args?: Array; + cwd?: string; + title?: string; env?: { - [key: string]: string - } - } - path?: never + [key: string]: string; + }; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/pty" -} + directory?: string; + workspace?: string; + }; + url: '/pty'; +}; export type PtyCreateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors] +export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors]; export type PtyCreateResponses = { /** * Created session */ - 200: Pty -} + 200: Pty; +}; -export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses] +export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses]; export type PtyRemoveData = { - body?: never + body?: never; path: { - ptyID: string - } + ptyID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/pty/{ptyID}" -} + directory?: string; + workspace?: string; + }; + url: '/pty/{ptyID}'; +}; export type PtyRemoveErrors = { /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors] +export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors]; export type PtyRemoveResponses = { /** * Session removed */ - 200: boolean -} + 200: boolean; +}; -export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses] +export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses]; export type PtyGetData = { - body?: never + body?: never; path: { - ptyID: string - } + ptyID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/pty/{ptyID}" -} + directory?: string; + workspace?: string; + }; + url: '/pty/{ptyID}'; +}; export type PtyGetErrors = { /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PtyGetError = PtyGetErrors[keyof PtyGetErrors] +export type PtyGetError = PtyGetErrors[keyof PtyGetErrors]; export type PtyGetResponses = { /** * Session info */ - 200: Pty -} + 200: Pty; +}; -export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses] +export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses]; export type PtyUpdateData = { body?: { - title?: string + title?: string; size?: { - rows: number - cols: number - } - } + rows: number; + cols: number; + }; + }; path: { - ptyID: string - } + ptyID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/pty/{ptyID}" -} + directory?: string; + workspace?: string; + }; + url: '/pty/{ptyID}'; +}; export type PtyUpdateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors] +export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors]; export type PtyUpdateResponses = { /** * Updated session */ - 200: Pty -} + 200: Pty; +}; -export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] +export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses]; export type PtyConnectData = { - body?: never + body?: never; path: { - ptyID: string - } + ptyID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/pty/{ptyID}/connect" -} + directory?: string; + workspace?: string; + }; + url: '/pty/{ptyID}/connect'; +}; export type PtyConnectErrors = { /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] +export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors]; export type PtyConnectResponses = { /** * Connected session */ - 200: boolean -} + 200: boolean; +}; -export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] +export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses]; export type ConfigGetData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/config" -} + directory?: string; + workspace?: string; + }; + url: '/config'; +}; export type ConfigGetResponses = { /** * Get config info */ - 200: Config -} + 200: Config; +}; -export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] +export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses]; export type ConfigUpdateData = { - body?: Config - path?: never + body?: Config; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/config" -} + directory?: string; + workspace?: string; + }; + url: '/config'; +}; export type ConfigUpdateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] +export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors]; export type ConfigUpdateResponses = { /** * Successfully updated config */ - 200: Config -} + 200: Config; +}; -export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] +export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses]; export type ConfigProvidersData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/config/providers" -} + directory?: string; + workspace?: string; + }; + url: '/config/providers'; +}; export type ConfigProvidersResponses = { /** * List of providers */ 200: { - providers: Array + providers: Array; default: { - [key: string]: string - } - } -} + [key: string]: string; + }; + }; +}; -export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses]; export type ToolIdsData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/tool/ids" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/tool/ids'; +}; export type ToolIdsErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] +export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors]; export type ToolIdsResponses = { /** * Tool IDs */ - 200: ToolIds -} + 200: ToolIds; +}; -export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] +export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses]; export type ToolListData = { - body?: never - path?: never + body?: never; + path?: never; query: { - directory?: string - workspace?: string - provider: string - model: string - } - url: "/experimental/tool" -} + directory?: string; + workspace?: string; + provider: string; + model: string; + }; + url: '/experimental/tool'; +}; export type ToolListErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type ToolListError = ToolListErrors[keyof ToolListErrors] +export type ToolListError = ToolListErrors[keyof ToolListErrors]; export type ToolListResponses = { /** * Tools */ - 200: ToolList -} + 200: ToolList; +}; -export type ToolListResponse = ToolListResponses[keyof ToolListResponses] +export type ToolListResponse = ToolListResponses[keyof ToolListResponses]; export type ExperimentalWorkspaceListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/workspace'; +}; export type ExperimentalWorkspaceListResponses = { /** * Workspaces */ - 200: Array -} + 200: Array; +}; export type ExperimentalWorkspaceListResponse = - ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] + ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses]; export type ExperimentalWorkspaceCreateData = { body?: { - id?: string - type: string - branch: string | null - extra: unknown | null - } - path?: never + id?: string; + type: string; + branch: string | null; + extra: unknown | null; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/workspace'; +}; export type ExperimentalWorkspaceCreateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; export type ExperimentalWorkspaceCreateError = - ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] + ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors]; export type ExperimentalWorkspaceCreateResponses = { /** * Workspace created */ - 200: Workspace -} + 200: Workspace; +}; export type ExperimentalWorkspaceCreateResponse = - ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] + ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]; export type ExperimentalWorkspaceRemoveData = { - body?: never + body?: never; path: { - id: string - } + id: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/workspace/{id}'; +}; export type ExperimentalWorkspaceRemoveErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; export type ExperimentalWorkspaceRemoveError = - ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] + ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors]; export type ExperimentalWorkspaceRemoveResponses = { /** * Workspace removed */ - 200: Workspace -} + 200: Workspace; +}; export type ExperimentalWorkspaceRemoveResponse = - ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] + ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]; export type WorktreeRemoveData = { - body?: WorktreeRemoveInput - path?: never + body?: WorktreeRemoveInput; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/worktree'; +}; export type WorktreeRemoveErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] +export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors]; export type WorktreeRemoveResponses = { /** * Worktree removed */ - 200: boolean -} + 200: boolean; +}; -export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] +export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses]; export type WorktreeListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/worktree'; +}; export type WorktreeListResponses = { /** * List of worktree directories */ - 200: Array -} + 200: Array; +}; -export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] +export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses]; export type WorktreeCreateData = { - body?: WorktreeCreateInput - path?: never + body?: WorktreeCreateInput; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/worktree'; +}; export type WorktreeCreateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] +export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors]; export type WorktreeCreateResponses = { /** * Worktree created */ - 200: Worktree -} + 200: Worktree; +}; -export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] +export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses]; export type WorktreeResetData = { - body?: WorktreeResetInput - path?: never + body?: WorktreeResetInput; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree/reset" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/worktree/reset'; +}; export type WorktreeResetErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] +export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors]; export type WorktreeResetResponses = { /** * Worktree reset */ - 200: boolean -} + 200: boolean; +}; -export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] +export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses]; export type ExperimentalSessionListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { /** * Filter sessions by project directory */ - directory?: string - workspace?: string + directory?: string; + workspace?: string; /** * Only return root sessions (no parentID) */ - roots?: boolean + roots?: boolean; /** * Filter sessions updated on or after this timestamp (milliseconds since epoch) */ - start?: number + start?: number; /** * Return sessions updated before this timestamp (milliseconds since epoch) */ - cursor?: number + cursor?: number; /** * Filter sessions by title (case-insensitive) */ - search?: string + search?: string; /** * Maximum number of sessions to return */ - limit?: number + limit?: number; /** * Include archived sessions (default false) */ - archived?: boolean - } - url: "/experimental/session" -} + archived?: boolean; + }; + url: '/experimental/session'; +}; export type ExperimentalSessionListResponses = { /** * List of sessions */ - 200: Array -} + 200: Array; +}; -export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] +export type ExperimentalSessionListResponse = + ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses]; export type ExperimentalResourceListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/resource" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/resource'; +}; export type ExperimentalResourceListResponses = { /** * MCP resources */ 200: { - [key: string]: McpResource - } -} + [key: string]: McpResource; + }; +}; export type ExperimentalResourceListResponse = - ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] + ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses]; export type SessionListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { /** * Filter sessions by project directory */ - directory?: string - workspace?: string + directory?: string; + workspace?: string; /** * Only return root sessions (no parentID) */ - roots?: boolean + roots?: boolean; /** * Filter sessions updated on or after this timestamp (milliseconds since epoch) */ - start?: number + start?: number; /** * Filter sessions by title (case-insensitive) */ - search?: string + search?: string; /** * Maximum number of sessions to return */ - limit?: number - } - url: "/session" -} + limit?: number; + }; + url: '/session'; +}; export type SessionListResponses = { /** * List of sessions */ - 200: Array -} + 200: Array; +}; -export type SessionListResponse = SessionListResponses[keyof SessionListResponses] +export type SessionListResponse = SessionListResponses[keyof SessionListResponses]; export type SessionCreateData = { body?: { - parentID?: string - title?: string - permission?: PermissionRuleset - workspaceID?: string - } - path?: never + parentID?: string; + title?: string; + permission?: PermissionRuleset; + workspaceID?: string; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/session" -} + directory?: string; + workspace?: string; + }; + url: '/session'; +}; export type SessionCreateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type SessionCreateError = SessionCreateErrors[keyof SessionCreateErrors] +export type SessionCreateError = SessionCreateErrors[keyof SessionCreateErrors]; export type SessionCreateResponses = { /** * Successfully created session */ - 200: Session -} + 200: Session; +}; -export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses] +export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses]; export type SessionStatusData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/session/status" -} + directory?: string; + workspace?: string; + }; + url: '/session/status'; +}; export type SessionStatusErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type SessionStatusError = SessionStatusErrors[keyof SessionStatusErrors] +export type SessionStatusError = SessionStatusErrors[keyof SessionStatusErrors]; export type SessionStatusResponses = { /** * Get session status */ 200: { - [key: string]: SessionStatus - } -} + [key: string]: SessionStatus; + }; +}; -export type SessionStatusResponse = SessionStatusResponses[keyof SessionStatusResponses] +export type SessionStatusResponse = SessionStatusResponses[keyof SessionStatusResponses]; export type SessionDeleteData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}'; +}; export type SessionDeleteErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionDeleteError = SessionDeleteErrors[keyof SessionDeleteErrors] +export type SessionDeleteError = SessionDeleteErrors[keyof SessionDeleteErrors]; export type SessionDeleteResponses = { /** * Successfully deleted session */ - 200: boolean -} + 200: boolean; +}; -export type SessionDeleteResponse = SessionDeleteResponses[keyof SessionDeleteResponses] +export type SessionDeleteResponse = SessionDeleteResponses[keyof SessionDeleteResponses]; export type SessionGetData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}'; +}; export type SessionGetErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionGetError = SessionGetErrors[keyof SessionGetErrors] +export type SessionGetError = SessionGetErrors[keyof SessionGetErrors]; export type SessionGetResponses = { /** * Get session */ - 200: Session -} + 200: Session; +}; -export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] +export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses]; export type SessionUpdateData = { body?: { - title?: string + title?: string; time?: { - archived?: number - } - } + archived?: number; + }; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}'; +}; export type SessionUpdateErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionUpdateError = SessionUpdateErrors[keyof SessionUpdateErrors] +export type SessionUpdateError = SessionUpdateErrors[keyof SessionUpdateErrors]; export type SessionUpdateResponses = { /** * Successfully updated session */ - 200: Session -} + 200: Session; +}; -export type SessionUpdateResponse = SessionUpdateResponses[keyof SessionUpdateResponses] +export type SessionUpdateResponse = SessionUpdateResponses[keyof SessionUpdateResponses]; export type SessionChildrenData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/children" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/children'; +}; export type SessionChildrenErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionChildrenError = SessionChildrenErrors[keyof SessionChildrenErrors] +export type SessionChildrenError = SessionChildrenErrors[keyof SessionChildrenErrors]; export type SessionChildrenResponses = { /** * List of children */ - 200: Array -} + 200: Array; +}; -export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses] +export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses]; export type SessionTodoData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/todo" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/todo'; +}; export type SessionTodoErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionTodoError = SessionTodoErrors[keyof SessionTodoErrors] +export type SessionTodoError = SessionTodoErrors[keyof SessionTodoErrors]; export type SessionTodoResponses = { /** * Todo list */ - 200: Array -} + 200: Array; +}; -export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses] +export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses]; export type SessionInitData = { body?: { - modelID: string - providerID: string - messageID: string - } + modelID: string; + providerID: string; + messageID: string; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/init" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/init'; +}; export type SessionInitErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] +export type SessionInitError = SessionInitErrors[keyof SessionInitErrors]; export type SessionInitResponses = { /** * 200 */ - 200: boolean -} + 200: boolean; +}; -export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] +export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses]; export type SessionForkData = { body?: { - messageID?: string - } + messageID?: string; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/fork" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/fork'; +}; export type SessionForkResponses = { /** * 200 */ - 200: Session -} + 200: Session; +}; -export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] +export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses]; export type SessionAbortData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/abort" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/abort'; +}; export type SessionAbortErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] +export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors]; export type SessionAbortResponses = { /** * Aborted session */ - 200: boolean -} + 200: boolean; +}; -export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] +export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses]; export type SessionUnshareData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/share" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/share'; +}; export type SessionUnshareErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] +export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors]; export type SessionUnshareResponses = { /** * Successfully unshared session */ - 200: Session -} + 200: Session; +}; -export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] +export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses]; export type SessionShareData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/share" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/share'; +}; export type SessionShareErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] +export type SessionShareError = SessionShareErrors[keyof SessionShareErrors]; export type SessionShareResponses = { /** * Successfully shared session */ - 200: Session -} + 200: Session; +}; -export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] +export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses]; export type SessionDiffData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - messageID?: string - } - url: "/session/{sessionID}/diff" -} + directory?: string; + workspace?: string; + messageID?: string; + }; + url: '/session/{sessionID}/diff'; +}; export type SessionDiffResponses = { /** * Successfully retrieved diff */ - 200: Array -} + 200: Array; +}; -export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] +export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses]; export type SessionSummarizeData = { body?: { - providerID: string - modelID: string - auto?: boolean - } + providerID: string; + modelID: string; + auto?: boolean; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/summarize" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/summarize'; +}; export type SessionSummarizeErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] +export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors]; export type SessionSummarizeResponses = { /** * Summarized session */ - 200: boolean -} + 200: boolean; +}; -export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] +export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses]; export type SessionMessagesData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; /** * Maximum number of messages to return */ - limit?: number - before?: string - } - url: "/session/{sessionID}/message" -} + limit?: number; + before?: string; + }; + url: '/session/{sessionID}/message'; +}; export type SessionMessagesErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionMessagesError = SessionMessagesErrors[keyof SessionMessagesErrors] +export type SessionMessagesError = SessionMessagesErrors[keyof SessionMessagesErrors]; export type SessionMessagesResponses = { /** * List of messages */ 200: Array<{ - info: Message - parts: Array - }> -} + info: Message; + parts: Array; + }>; +}; -export type SessionMessagesResponse = SessionMessagesResponses[keyof SessionMessagesResponses] +export type SessionMessagesResponse = SessionMessagesResponses[keyof SessionMessagesResponses]; export type SessionPromptData = { body?: { - messageID?: string + messageID?: string; model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean + providerID: string; + modelID: string; + }; + agent?: string; + noReply?: boolean; /** * @deprecated tools and permissions have been merged, you can set permissions on the session itself now */ tools?: { - [key: string]: boolean - } - format?: OutputFormat - system?: string - variant?: string - parts: Array - } + [key: string]: boolean; + }; + format?: OutputFormat; + system?: string; + variant?: string; + parts: Array; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/message" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/message'; +}; export type SessionPromptErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionPromptError = SessionPromptErrors[keyof SessionPromptErrors] +export type SessionPromptError = SessionPromptErrors[keyof SessionPromptErrors]; export type SessionPromptResponses = { /** * Created message */ 200: { - info: AssistantMessage - parts: Array - } -} + info: AssistantMessage; + parts: Array; + }; +}; -export type SessionPromptResponse = SessionPromptResponses[keyof SessionPromptResponses] +export type SessionPromptResponse = SessionPromptResponses[keyof SessionPromptResponses]; export type SessionDeleteMessageData = { - body?: never + body?: never; path: { - sessionID: string - messageID: string - } + sessionID: string; + messageID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/message/{messageID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/message/{messageID}'; +}; export type SessionDeleteMessageErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionDeleteMessageError = SessionDeleteMessageErrors[keyof SessionDeleteMessageErrors] +export type SessionDeleteMessageError = + SessionDeleteMessageErrors[keyof SessionDeleteMessageErrors]; export type SessionDeleteMessageResponses = { /** * Successfully deleted message */ - 200: boolean -} + 200: boolean; +}; -export type SessionDeleteMessageResponse = SessionDeleteMessageResponses[keyof SessionDeleteMessageResponses] +export type SessionDeleteMessageResponse = + SessionDeleteMessageResponses[keyof SessionDeleteMessageResponses]; export type SessionMessageData = { - body?: never + body?: never; path: { - sessionID: string - messageID: string - } + sessionID: string; + messageID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/message/{messageID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/message/{messageID}'; +}; export type SessionMessageErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionMessageError = SessionMessageErrors[keyof SessionMessageErrors] +export type SessionMessageError = SessionMessageErrors[keyof SessionMessageErrors]; export type SessionMessageResponses = { /** * Message */ 200: { - info: Message - parts: Array - } -} + info: Message; + parts: Array; + }; +}; -export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses] +export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses]; export type PartDeleteData = { - body?: never + body?: never; path: { - sessionID: string - messageID: string - partID: string - } + sessionID: string; + messageID: string; + partID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/message/{messageID}/part/{partID}'; +}; export type PartDeleteErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] +export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors]; export type PartDeleteResponses = { /** * Successfully deleted part */ - 200: boolean -} + 200: boolean; +}; -export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] +export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses]; export type PartUpdateData = { - body?: Part + body?: Part; path: { - sessionID: string - messageID: string - partID: string - } + sessionID: string; + messageID: string; + partID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/message/{messageID}/part/{partID}'; +}; export type PartUpdateErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] +export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors]; export type PartUpdateResponses = { /** * Successfully updated part */ - 200: Part -} + 200: Part; +}; -export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] +export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses]; export type SessionPromptAsyncData = { body?: { - messageID?: string + messageID?: string; model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean + providerID: string; + modelID: string; + }; + agent?: string; + noReply?: boolean; /** * @deprecated tools and permissions have been merged, you can set permissions on the session itself now */ tools?: { - [key: string]: boolean - } - format?: OutputFormat - system?: string - variant?: string - parts: Array - } + [key: string]: boolean; + }; + format?: OutputFormat; + system?: string; + variant?: string; + parts: Array; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/prompt_async" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/prompt_async'; +}; export type SessionPromptAsyncErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionPromptAsyncError = SessionPromptAsyncErrors[keyof SessionPromptAsyncErrors] +export type SessionPromptAsyncError = SessionPromptAsyncErrors[keyof SessionPromptAsyncErrors]; export type SessionPromptAsyncResponses = { /** * Prompt accepted */ - 204: void -} + 204: void; +}; -export type SessionPromptAsyncResponse = SessionPromptAsyncResponses[keyof SessionPromptAsyncResponses] +export type SessionPromptAsyncResponse = + SessionPromptAsyncResponses[keyof SessionPromptAsyncResponses]; export type SessionCommandData = { body?: { - messageID?: string - agent?: string - model?: string - arguments: string - command: string - variant?: string + messageID?: string; + agent?: string; + model?: string; + arguments: string; + command: string; + variant?: string; parts?: Array<{ - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource - }> - } + id?: string; + type: 'file'; + mime: string; + filename?: string; + url: string; + source?: FilePartSource; + }>; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/command" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/command'; +}; export type SessionCommandErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionCommandError = SessionCommandErrors[keyof SessionCommandErrors] +export type SessionCommandError = SessionCommandErrors[keyof SessionCommandErrors]; export type SessionCommandResponses = { /** * Created message */ 200: { - info: AssistantMessage - parts: Array - } -} + info: AssistantMessage; + parts: Array; + }; +}; -export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses] +export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses]; export type SessionShellData = { body?: { - agent: string + agent: string; model?: { - providerID: string - modelID: string - } - command: string - } + providerID: string; + modelID: string; + }; + command: string; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/shell" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/shell'; +}; export type SessionShellErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionShellError = SessionShellErrors[keyof SessionShellErrors] +export type SessionShellError = SessionShellErrors[keyof SessionShellErrors]; export type SessionShellResponses = { /** * Created message */ - 200: AssistantMessage -} + 200: AssistantMessage; +}; -export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses] +export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses]; export type SessionRevertData = { body?: { - messageID: string - partID?: string - } + messageID: string; + partID?: string; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/revert" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/revert'; +}; export type SessionRevertErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionRevertError = SessionRevertErrors[keyof SessionRevertErrors] +export type SessionRevertError = SessionRevertErrors[keyof SessionRevertErrors]; export type SessionRevertResponses = { /** * Updated session */ - 200: Session -} + 200: Session; +}; -export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses] +export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses]; export type SessionUnrevertData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/unrevert" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/unrevert'; +}; export type SessionUnrevertErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionUnrevertError = SessionUnrevertErrors[keyof SessionUnrevertErrors] +export type SessionUnrevertError = SessionUnrevertErrors[keyof SessionUnrevertErrors]; export type SessionUnrevertResponses = { /** * Updated session */ - 200: Session -} + 200: Session; +}; -export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses] +export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses]; export type PermissionRespondData = { body?: { - response: "once" | "always" | "reject" - } + response: 'once' | 'always' | 'reject'; + }; path: { - sessionID: string - permissionID: string - } + sessionID: string; + permissionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/permissions/{permissionID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/permissions/{permissionID}'; +}; export type PermissionRespondErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PermissionRespondError = PermissionRespondErrors[keyof PermissionRespondErrors] +export type PermissionRespondError = PermissionRespondErrors[keyof PermissionRespondErrors]; export type PermissionRespondResponses = { /** * Permission processed successfully */ - 200: boolean -} + 200: boolean; +}; -export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] +export type PermissionRespondResponse = + PermissionRespondResponses[keyof PermissionRespondResponses]; export type PermissionReplyData = { body?: { - reply: "once" | "always" | "reject" - message?: string - } + reply: 'once' | 'always' | 'reject'; + message?: string; + }; path: { - requestID: string - } + requestID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/permission/{requestID}/reply" -} + directory?: string; + workspace?: string; + }; + url: '/permission/{requestID}/reply'; +}; export type PermissionReplyErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] +export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors]; export type PermissionReplyResponses = { /** * Permission processed successfully */ - 200: boolean -} + 200: boolean; +}; -export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] +export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses]; export type PermissionListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/permission" -} + directory?: string; + workspace?: string; + }; + url: '/permission'; +}; export type PermissionListResponses = { /** * List of pending permissions */ - 200: Array -} + 200: Array; +}; -export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] +export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses]; export type QuestionListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/question" -} + directory?: string; + workspace?: string; + }; + url: '/question'; +}; export type QuestionListResponses = { /** * List of pending questions */ - 200: Array -} + 200: Array; +}; -export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] +export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses]; export type QuestionReplyData = { body?: { /** * User answers in order of questions (each answer is an array of selected labels) */ - answers: Array - } + answers: Array; + }; path: { - requestID: string - } + requestID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/question/{requestID}/reply" -} + directory?: string; + workspace?: string; + }; + url: '/question/{requestID}/reply'; +}; export type QuestionReplyErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] +export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors]; export type QuestionReplyResponses = { /** * Question answered successfully */ - 200: boolean -} + 200: boolean; +}; -export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] +export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses]; export type QuestionRejectData = { - body?: never + body?: never; path: { - requestID: string - } + requestID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/question/{requestID}/reject" -} + directory?: string; + workspace?: string; + }; + url: '/question/{requestID}/reject'; +}; export type QuestionRejectErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] +export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors]; export type QuestionRejectResponses = { /** * Question rejected successfully */ - 200: boolean -} + 200: boolean; +}; -export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] +export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses]; export type ProviderListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/provider" -} + directory?: string; + workspace?: string; + }; + url: '/provider'; +}; export type ProviderListResponses = { /** @@ -3914,193 +3920,197 @@ export type ProviderListResponses = { */ 200: { all: Array<{ - api?: string - name: string - env: Array - id: string - npm?: string + api?: string; + name: string; + env: Array; + id: string; + npm?: string; models: { [key: string]: { - id: string - name: string - family?: string - release_date: string - attachment: boolean - reasoning: boolean - temperature: boolean - tool_call: boolean + id: string; + name: string; + family?: string; + release_date: string; + attachment: boolean; + reasoning: boolean; + temperature: boolean; + tool_call: boolean; interleaved?: | true | { - field: "reasoning_content" | "reasoning_details" - } + field: 'reasoning_content' | 'reasoning_details'; + }; cost?: { - input: number - output: number - cache_read?: number - cache_write?: number + input: number; + output: number; + cache_read?: number; + cache_write?: number; context_over_200k?: { - input: number - output: number - cache_read?: number - cache_write?: number - } - } + input: number; + output: number; + cache_read?: number; + cache_write?: number; + }; + }; limit: { - context: number - input?: number - output: number - } + context: number; + input?: number; + output: number; + }; modalities?: { - input: Array<"text" | "audio" | "image" | "video" | "pdf"> - output: Array<"text" | "audio" | "image" | "video" | "pdf"> - } - experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + input: Array<'text' | 'audio' | 'image' | 'video' | 'pdf'>; + output: Array<'text' | 'audio' | 'image' | 'video' | 'pdf'>; + }; + experimental?: boolean; + status?: 'alpha' | 'beta' | 'deprecated'; options: { - [key: string]: unknown - } + [key: string]: unknown; + }; headers?: { - [key: string]: string - } + [key: string]: string; + }; provider?: { - npm?: string - api?: string - } + npm?: string; + api?: string; + }; variants?: { [key: string]: { - [key: string]: unknown - } - } - } - } - }> + [key: string]: unknown; + }; + }; + }; + }; + }>; default: { - [key: string]: string - } - connected: Array - } -} + [key: string]: string; + }; + connected: Array; + }; +}; -export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] +export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses]; export type ProviderAuthData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/provider/auth" -} + directory?: string; + workspace?: string; + }; + url: '/provider/auth'; +}; export type ProviderAuthResponses = { /** * Provider auth methods */ 200: { - [key: string]: Array - } -} + [key: string]: Array; + }; +}; -export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] +export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses]; export type ProviderOauthAuthorizeData = { body?: { /** * Auth method index */ - method: number + method: number; /** * Prompt inputs */ inputs?: { - [key: string]: string - } - } + [key: string]: string; + }; + }; path: { /** * Provider ID */ - providerID: string - } + providerID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/provider/{providerID}/oauth/authorize" -} + directory?: string; + workspace?: string; + }; + url: '/provider/{providerID}/oauth/authorize'; +}; export type ProviderOauthAuthorizeErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] +export type ProviderOauthAuthorizeError = + ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors]; export type ProviderOauthAuthorizeResponses = { /** * Authorization URL and method */ - 200: ProviderAuthAuthorization -} + 200: ProviderAuthAuthorization; +}; -export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] +export type ProviderOauthAuthorizeResponse = + ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses]; export type ProviderOauthCallbackData = { body?: { /** * Auth method index */ - method: number + method: number; /** * OAuth authorization code */ - code?: string - } + code?: string; + }; path: { /** * Provider ID */ - providerID: string - } + providerID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/provider/{providerID}/oauth/callback" -} + directory?: string; + workspace?: string; + }; + url: '/provider/{providerID}/oauth/callback'; +}; export type ProviderOauthCallbackErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] +export type ProviderOauthCallbackError = + ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors]; export type ProviderOauthCallbackResponses = { /** * OAuth callback processed successfully */ - 200: boolean -} + 200: boolean; +}; -export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] +export type ProviderOauthCallbackResponse = + ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]; export type FindTextData = { - body?: never - path?: never + body?: never; + path?: never; query: { - directory?: string - workspace?: string - pattern: string - } - url: "/find" -} + directory?: string; + workspace?: string; + pattern: string; + }; + url: '/find'; +}; export type FindTextResponses = { /** @@ -4108,256 +4118,256 @@ export type FindTextResponses = { */ 200: Array<{ path: { - text: string - } + text: string; + }; lines: { - text: string - } - line_number: number - absolute_offset: number + text: string; + }; + line_number: number; + absolute_offset: number; submatches: Array<{ match: { - text: string - } - start: number - end: number - }> - }> -} + text: string; + }; + start: number; + end: number; + }>; + }>; +}; -export type FindTextResponse = FindTextResponses[keyof FindTextResponses] +export type FindTextResponse = FindTextResponses[keyof FindTextResponses]; export type FindFilesData = { - body?: never - path?: never + body?: never; + path?: never; query: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number - } - url: "/find/file" -} + directory?: string; + workspace?: string; + query: string; + dirs?: 'true' | 'false'; + type?: 'file' | 'directory'; + limit?: number; + }; + url: '/find/file'; +}; export type FindFilesResponses = { /** * File paths */ - 200: Array -} + 200: Array; +}; -export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] +export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses]; export type FindSymbolsData = { - body?: never - path?: never + body?: never; + path?: never; query: { - directory?: string - workspace?: string - query: string - } - url: "/find/symbol" -} + directory?: string; + workspace?: string; + query: string; + }; + url: '/find/symbol'; +}; export type FindSymbolsResponses = { /** * Symbols */ - 200: Array -} + 200: Array; +}; -export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] +export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses]; export type FileListData = { - body?: never - path?: never + body?: never; + path?: never; query: { - directory?: string - workspace?: string - path: string - } - url: "/file" -} + directory?: string; + workspace?: string; + path: string; + }; + url: '/file'; +}; export type FileListResponses = { /** * Files and directories */ - 200: Array -} + 200: Array; +}; -export type FileListResponse = FileListResponses[keyof FileListResponses] +export type FileListResponse = FileListResponses[keyof FileListResponses]; export type FileReadData = { - body?: never - path?: never + body?: never; + path?: never; query: { - directory?: string - workspace?: string - path: string - } - url: "/file/content" -} + directory?: string; + workspace?: string; + path: string; + }; + url: '/file/content'; +}; export type FileReadResponses = { /** * File content */ - 200: FileContent -} + 200: FileContent; +}; -export type FileReadResponse = FileReadResponses[keyof FileReadResponses] +export type FileReadResponse = FileReadResponses[keyof FileReadResponses]; export type FileStatusData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/file/status" -} + directory?: string; + workspace?: string; + }; + url: '/file/status'; +}; export type FileStatusResponses = { /** * File status */ - 200: Array -} + 200: Array; +}; -export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] +export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses]; export type EventSubscribeData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/event" -} + directory?: string; + workspace?: string; + }; + url: '/event'; +}; export type EventSubscribeResponses = { /** * Event stream */ - 200: Event -} + 200: Event; +}; -export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]; export type McpStatusData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/mcp" -} + directory?: string; + workspace?: string; + }; + url: '/mcp'; +}; export type McpStatusResponses = { /** * MCP server status */ 200: { - [key: string]: McpStatus - } -} + [key: string]: McpStatus; + }; +}; -export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] +export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses]; export type McpAddData = { body?: { - name: string - config: McpLocalConfig | McpRemoteConfig - } - path?: never + name: string; + config: McpLocalConfig | McpRemoteConfig; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/mcp" -} + directory?: string; + workspace?: string; + }; + url: '/mcp'; +}; export type McpAddErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type McpAddError = McpAddErrors[keyof McpAddErrors] +export type McpAddError = McpAddErrors[keyof McpAddErrors]; export type McpAddResponses = { /** * MCP server added successfully */ 200: { - [key: string]: McpStatus - } -} + [key: string]: McpStatus; + }; +}; -export type McpAddResponse = McpAddResponses[keyof McpAddResponses] +export type McpAddResponse = McpAddResponses[keyof McpAddResponses]; export type McpAuthRemoveData = { - body?: never + body?: never; path: { - name: string - } + name: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth" -} + directory?: string; + workspace?: string; + }; + url: '/mcp/{name}/auth'; +}; export type McpAuthRemoveErrors = { /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] +export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors]; export type McpAuthRemoveResponses = { /** * OAuth credentials removed */ 200: { - success: true - } -} + success: true; + }; +}; -export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] +export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses]; export type McpAuthStartData = { - body?: never + body?: never; path: { - name: string - } + name: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth" -} + directory?: string; + workspace?: string; + }; + url: '/mcp/{name}/auth'; +}; export type McpAuthStartErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] +export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors]; export type McpAuthStartResponses = { /** @@ -4367,634 +4377,637 @@ export type McpAuthStartResponses = { /** * URL to open in browser for authorization */ - authorizationUrl: string - } -} + authorizationUrl: string; + }; +}; -export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] +export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses]; export type McpAuthCallbackData = { body?: { /** * Authorization code from OAuth callback */ - code: string - } + code: string; + }; path: { - name: string - } + name: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth/callback" -} + directory?: string; + workspace?: string; + }; + url: '/mcp/{name}/auth/callback'; +}; export type McpAuthCallbackErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] +export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors]; export type McpAuthCallbackResponses = { /** * OAuth authentication completed */ - 200: McpStatus -} + 200: McpStatus; +}; -export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] +export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses]; export type McpAuthAuthenticateData = { - body?: never + body?: never; path: { - name: string - } + name: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth/authenticate" -} + directory?: string; + workspace?: string; + }; + url: '/mcp/{name}/auth/authenticate'; +}; export type McpAuthAuthenticateErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] +export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors]; export type McpAuthAuthenticateResponses = { /** * OAuth authentication completed */ - 200: McpStatus -} + 200: McpStatus; +}; -export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] +export type McpAuthAuthenticateResponse = + McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses]; export type McpConnectData = { - body?: never + body?: never; path: { - name: string - } + name: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/connect" -} + directory?: string; + workspace?: string; + }; + url: '/mcp/{name}/connect'; +}; export type McpConnectResponses = { /** * MCP server connected successfully */ - 200: boolean -} + 200: boolean; +}; -export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] +export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses]; export type McpDisconnectData = { - body?: never + body?: never; path: { - name: string - } + name: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/disconnect" -} + directory?: string; + workspace?: string; + }; + url: '/mcp/{name}/disconnect'; +}; export type McpDisconnectResponses = { /** * MCP server disconnected successfully */ - 200: boolean -} + 200: boolean; +}; -export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] +export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses]; export type TuiAppendPromptData = { body?: { - text: string - } - path?: never + text: string; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/append-prompt" -} + directory?: string; + workspace?: string; + }; + url: '/tui/append-prompt'; +}; export type TuiAppendPromptErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type TuiAppendPromptError = TuiAppendPromptErrors[keyof TuiAppendPromptErrors] +export type TuiAppendPromptError = TuiAppendPromptErrors[keyof TuiAppendPromptErrors]; export type TuiAppendPromptResponses = { /** * Prompt processed successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiAppendPromptResponse = TuiAppendPromptResponses[keyof TuiAppendPromptResponses] +export type TuiAppendPromptResponse = TuiAppendPromptResponses[keyof TuiAppendPromptResponses]; export type TuiOpenHelpData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/open-help" -} + directory?: string; + workspace?: string; + }; + url: '/tui/open-help'; +}; export type TuiOpenHelpResponses = { /** * Help dialog opened successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiOpenHelpResponse = TuiOpenHelpResponses[keyof TuiOpenHelpResponses] +export type TuiOpenHelpResponse = TuiOpenHelpResponses[keyof TuiOpenHelpResponses]; export type TuiOpenSessionsData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/open-sessions" -} + directory?: string; + workspace?: string; + }; + url: '/tui/open-sessions'; +}; export type TuiOpenSessionsResponses = { /** * Session dialog opened successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiOpenSessionsResponse = TuiOpenSessionsResponses[keyof TuiOpenSessionsResponses] +export type TuiOpenSessionsResponse = TuiOpenSessionsResponses[keyof TuiOpenSessionsResponses]; export type TuiOpenThemesData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/open-themes" -} + directory?: string; + workspace?: string; + }; + url: '/tui/open-themes'; +}; export type TuiOpenThemesResponses = { /** * Theme dialog opened successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiOpenThemesResponse = TuiOpenThemesResponses[keyof TuiOpenThemesResponses] +export type TuiOpenThemesResponse = TuiOpenThemesResponses[keyof TuiOpenThemesResponses]; export type TuiOpenModelsData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/open-models" -} + directory?: string; + workspace?: string; + }; + url: '/tui/open-models'; +}; export type TuiOpenModelsResponses = { /** * Model dialog opened successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiOpenModelsResponse = TuiOpenModelsResponses[keyof TuiOpenModelsResponses] +export type TuiOpenModelsResponse = TuiOpenModelsResponses[keyof TuiOpenModelsResponses]; export type TuiSubmitPromptData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/submit-prompt" -} + directory?: string; + workspace?: string; + }; + url: '/tui/submit-prompt'; +}; export type TuiSubmitPromptResponses = { /** * Prompt submitted successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiSubmitPromptResponse = TuiSubmitPromptResponses[keyof TuiSubmitPromptResponses] +export type TuiSubmitPromptResponse = TuiSubmitPromptResponses[keyof TuiSubmitPromptResponses]; export type TuiClearPromptData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/clear-prompt" -} + directory?: string; + workspace?: string; + }; + url: '/tui/clear-prompt'; +}; export type TuiClearPromptResponses = { /** * Prompt cleared successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiClearPromptResponse = TuiClearPromptResponses[keyof TuiClearPromptResponses] +export type TuiClearPromptResponse = TuiClearPromptResponses[keyof TuiClearPromptResponses]; export type TuiExecuteCommandData = { body?: { - command: string - } - path?: never + command: string; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/execute-command" -} + directory?: string; + workspace?: string; + }; + url: '/tui/execute-command'; +}; export type TuiExecuteCommandErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type TuiExecuteCommandError = TuiExecuteCommandErrors[keyof TuiExecuteCommandErrors] +export type TuiExecuteCommandError = TuiExecuteCommandErrors[keyof TuiExecuteCommandErrors]; export type TuiExecuteCommandResponses = { /** * Command executed successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiExecuteCommandResponse = TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses] +export type TuiExecuteCommandResponse = + TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses]; export type TuiShowToastData = { body?: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" + title?: string; + message: string; + variant: 'info' | 'success' | 'warning' | 'error'; /** * Duration in milliseconds */ - duration?: number - } - path?: never + duration?: number; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/show-toast" -} + directory?: string; + workspace?: string; + }; + url: '/tui/show-toast'; +}; export type TuiShowToastResponses = { /** * Toast notification shown successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses] +export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses]; export type TuiPublishData = { - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect - path?: never + body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/publish" -} + directory?: string; + workspace?: string; + }; + url: '/tui/publish'; +}; export type TuiPublishErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type TuiPublishError = TuiPublishErrors[keyof TuiPublishErrors] +export type TuiPublishError = TuiPublishErrors[keyof TuiPublishErrors]; export type TuiPublishResponses = { /** * Event published successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiPublishResponse = TuiPublishResponses[keyof TuiPublishResponses] +export type TuiPublishResponse = TuiPublishResponses[keyof TuiPublishResponses]; export type TuiSelectSessionData = { body?: { /** * Session ID to navigate to */ - sessionID: string - } - path?: never + sessionID: string; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/select-session" -} + directory?: string; + workspace?: string; + }; + url: '/tui/select-session'; +}; export type TuiSelectSessionErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type TuiSelectSessionError = TuiSelectSessionErrors[keyof TuiSelectSessionErrors] +export type TuiSelectSessionError = TuiSelectSessionErrors[keyof TuiSelectSessionErrors]; export type TuiSelectSessionResponses = { /** * Session selected successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiSelectSessionResponse = TuiSelectSessionResponses[keyof TuiSelectSessionResponses] +export type TuiSelectSessionResponse = TuiSelectSessionResponses[keyof TuiSelectSessionResponses]; export type TuiControlNextData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/control/next" -} + directory?: string; + workspace?: string; + }; + url: '/tui/control/next'; +}; export type TuiControlNextResponses = { /** * Next TUI request */ 200: { - path: string - body: unknown - } -} + path: string; + body: unknown; + }; +}; -export type TuiControlNextResponse = TuiControlNextResponses[keyof TuiControlNextResponses] +export type TuiControlNextResponse = TuiControlNextResponses[keyof TuiControlNextResponses]; export type TuiControlResponseData = { - body?: unknown - path?: never + body?: unknown; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/control/response" -} + directory?: string; + workspace?: string; + }; + url: '/tui/control/response'; +}; export type TuiControlResponseResponses = { /** * Response submitted successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses] +export type TuiControlResponseResponse = + TuiControlResponseResponses[keyof TuiControlResponseResponses]; export type InstanceDisposeData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/instance/dispose" -} + directory?: string; + workspace?: string; + }; + url: '/instance/dispose'; +}; export type InstanceDisposeResponses = { /** * Instance disposed */ - 200: boolean -} + 200: boolean; +}; -export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] +export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses]; export type PathGetData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/path" -} + directory?: string; + workspace?: string; + }; + url: '/path'; +}; export type PathGetResponses = { /** * Path */ - 200: Path -} + 200: Path; +}; -export type PathGetResponse = PathGetResponses[keyof PathGetResponses] +export type PathGetResponse = PathGetResponses[keyof PathGetResponses]; export type VcsGetData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/vcs" -} + directory?: string; + workspace?: string; + }; + url: '/vcs'; +}; export type VcsGetResponses = { /** * VCS info */ - 200: VcsInfo -} + 200: VcsInfo; +}; -export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] +export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]; export type CommandListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/command" -} + directory?: string; + workspace?: string; + }; + url: '/command'; +}; export type CommandListResponses = { /** * List of commands */ - 200: Array -} + 200: Array; +}; -export type CommandListResponse = CommandListResponses[keyof CommandListResponses] +export type CommandListResponse = CommandListResponses[keyof CommandListResponses]; export type AppLogData = { body?: { /** * Service name for the log entry */ - service: string + service: string; /** * Log level */ - level: "debug" | "info" | "error" | "warn" + level: 'debug' | 'info' | 'error' | 'warn'; /** * Log message */ - message: string + message: string; /** * Additional metadata for the log entry */ extra?: { - [key: string]: unknown - } - } - path?: never + [key: string]: unknown; + }; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/log" -} + directory?: string; + workspace?: string; + }; + url: '/log'; +}; export type AppLogErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type AppLogError = AppLogErrors[keyof AppLogErrors] +export type AppLogError = AppLogErrors[keyof AppLogErrors]; export type AppLogResponses = { /** * Log entry written successfully */ - 200: boolean -} + 200: boolean; +}; -export type AppLogResponse = AppLogResponses[keyof AppLogResponses] +export type AppLogResponse = AppLogResponses[keyof AppLogResponses]; export type AppAgentsData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/agent" -} + directory?: string; + workspace?: string; + }; + url: '/agent'; +}; export type AppAgentsResponses = { /** * List of agents */ - 200: Array -} + 200: Array; +}; -export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] +export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses]; export type AppSkillsData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/skill" -} + directory?: string; + workspace?: string; + }; + url: '/skill'; +}; export type AppSkillsResponses = { /** * List of skills */ 200: Array<{ - name: string - description: string - location: string - content: string - }> -} + name: string; + description: string; + location: string; + content: string; + }>; +}; -export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] +export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses]; export type LspStatusData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/lsp" -} + directory?: string; + workspace?: string; + }; + url: '/lsp'; +}; export type LspStatusResponses = { /** * LSP server status */ - 200: Array -} + 200: Array; +}; -export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] +export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses]; export type FormatterStatusData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/formatter" -} + directory?: string; + workspace?: string; + }; + url: '/formatter'; +}; export type FormatterStatusResponses = { /** * Formatter status */ - 200: Array -} + 200: Array; +}; -export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] +export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]; diff --git a/apps/web/server/opencode/index.ts b/apps/web/server/opencode/index.ts index 8078b467..09477d4a 100644 --- a/apps/web/server/opencode/index.ts +++ b/apps/web/server/opencode/index.ts @@ -1,21 +1,21 @@ -export * from "./client" -export * from "./server" +export * from './client'; +export * from './server'; -import { createOpencodeClient } from "./client" -import { createOpencodeServer } from "./server" -import type { ServerOptions } from "./server" +import { createOpencodeClient } from './client'; +import { createOpencodeServer } from './server'; +import type { ServerOptions } from './server'; export async function createOpencode(options?: ServerOptions) { const server = await createOpencodeServer({ ...options, - }) + }); const client = createOpencodeClient({ baseUrl: server.url, - }) + }); return { client, server, - } + }; } diff --git a/apps/web/server/opencode/server.ts b/apps/web/server/opencode/server.ts index 85103298..06e3afe9 100644 --- a/apps/web/server/opencode/server.ts +++ b/apps/web/server/opencode/server.ts @@ -1,39 +1,39 @@ -import { spawn } from "node:child_process" -import { type Config } from "./gen/types.gen" +import { spawn } from 'node:child_process'; +import { type Config } from './gen/types.gen'; export type ServerOptions = { - hostname?: string - port?: number - signal?: AbortSignal - timeout?: number - config?: Config + hostname?: string; + port?: number; + signal?: AbortSignal; + timeout?: number; + config?: Config; /** Absolute path to the opencode binary (avoids PATH lookup issues in Electron). */ - binaryPath?: string -} + binaryPath?: string; +}; export type TuiOptions = { - project?: string - model?: string - session?: string - agent?: string - signal?: AbortSignal - config?: Config -} + project?: string; + model?: string; + session?: string; + agent?: string; + signal?: AbortSignal; + config?: Config; +}; export async function createOpencodeServer(options?: ServerOptions) { options = Object.assign( { - hostname: "127.0.0.1", + hostname: '127.0.0.1', port: 4096, timeout: 5000, }, options ?? {}, - ) + ); - const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`] - if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`) + const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]; + if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`); - const cmd = options.binaryPath ?? 'opencode' + const cmd = options.binaryPath ?? 'opencode'; const proc = spawn(cmd, args, { shell: process.platform === 'win32', signal: options.signal, @@ -41,88 +41,88 @@ export async function createOpencodeServer(options?: ServerOptions) { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}), }, - }) + }); const url = await new Promise((resolve, reject) => { const id = setTimeout(() => { - reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`)) - }, options.timeout) - let output = "" - proc.stdout?.on("data", (chunk) => { - output += chunk.toString() - const lines = output.split("\n") + reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`)); + }, options.timeout); + let output = ''; + proc.stdout?.on('data', (chunk) => { + output += chunk.toString(); + const lines = output.split('\n'); for (const line of lines) { - if (line.startsWith("opencode server listening")) { - const match = line.match(/on\s+(https?:\/\/[^\s]+)/) + if (line.startsWith('opencode server listening')) { + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); if (!match) { - throw new Error(`Failed to parse server url from output: ${line}`) + throw new Error(`Failed to parse server url from output: ${line}`); } - clearTimeout(id) - resolve(match[1]!) - return + clearTimeout(id); + resolve(match[1]!); + return; } } - }) - proc.stderr?.on("data", (chunk) => { - output += chunk.toString() - }) - proc.on("exit", (code) => { - clearTimeout(id) - let msg = `Server exited with code ${code}` + }); + proc.stderr?.on('data', (chunk) => { + output += chunk.toString(); + }); + proc.on('exit', (code) => { + clearTimeout(id); + let msg = `Server exited with code ${code}`; if (output.trim()) { - msg += `\nServer output: ${output}` + msg += `\nServer output: ${output}`; } - reject(new Error(msg)) - }) - proc.on("error", (error) => { - clearTimeout(id) - reject(error) - }) + reject(new Error(msg)); + }); + proc.on('error', (error) => { + clearTimeout(id); + reject(error); + }); if (options.signal) { - options.signal.addEventListener("abort", () => { - clearTimeout(id) - reject(new Error("Aborted")) - }) + options.signal.addEventListener('abort', () => { + clearTimeout(id); + reject(new Error('Aborted')); + }); } - }) + }); return { url, close() { - proc.kill() + proc.kill(); }, - } + }; } export function createOpencodeTui(options?: TuiOptions) { - const args = [] + const args = []; if (options?.project) { - args.push(`--project=${options.project}`) + args.push(`--project=${options.project}`); } if (options?.model) { - args.push(`--model=${options.model}`) + args.push(`--model=${options.model}`); } if (options?.session) { - args.push(`--session=${options.session}`) + args.push(`--session=${options.session}`); } if (options?.agent) { - args.push(`--agent=${options.agent}`) + args.push(`--agent=${options.agent}`); } const proc = spawn(`opencode`, args, { shell: process.platform === 'win32', signal: options?.signal, - stdio: "inherit", + stdio: 'inherit', env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}), }, - }) + }); return { close() { - proc.kill() + proc.kill(); }, - } + }; } diff --git a/apps/web/server/opencode/v2/client.ts b/apps/web/server/opencode/v2/client.ts index ad956dd4..f76b8867 100644 --- a/apps/web/server/opencode/v2/client.ts +++ b/apps/web/server/opencode/v2/client.ts @@ -1,39 +1,41 @@ -export * from "./gen/types.gen.js" +export * from './gen/types.gen.js'; -import { createClient } from "./gen/client/client.gen.js" -import { type Config } from "./gen/client/types.gen.js" -import { OpencodeClient } from "./gen/sdk.gen.js" -export { type Config as OpencodeClientConfig, OpencodeClient } +import { createClient } from './gen/client/client.gen.js'; +import { type Config } from './gen/client/types.gen.js'; +import { OpencodeClient } from './gen/sdk.gen.js'; +export { type Config as OpencodeClientConfig, OpencodeClient }; -export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) { +export function createOpencodeClient( + config?: Config & { directory?: string; experimental_workspaceID?: string }, +) { if (!config?.fetch) { const customFetch: any = (req: any) => { // @ts-ignore - req.timeout = false - return fetch(req) - } + req.timeout = false; + return fetch(req); + }; config = { ...config, fetch: customFetch, - } + }; } if (config?.directory) { - const isNonASCII = /[^\x00-\x7F]/.test(config.directory) - const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory + const isNonASCII = /[^\x00-\x7F]/.test(config.directory); + const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory; config.headers = { ...config.headers, - "x-opencode-directory": encodedDirectory, - } + 'x-opencode-directory': encodedDirectory, + }; } if (config?.experimental_workspaceID) { config.headers = { ...config.headers, - "x-opencode-workspace": config.experimental_workspaceID, - } + 'x-opencode-workspace': config.experimental_workspaceID, + }; } - const client = createClient(config) - return new OpencodeClient({ client }) + const client = createClient(config); + return new OpencodeClient({ client }); } diff --git a/apps/web/server/opencode/v2/gen/client.gen.ts b/apps/web/server/opencode/v2/gen/client.gen.ts index 0c110eca..cd6033f9 100644 --- a/apps/web/server/opencode/v2/gen/client.gen.ts +++ b/apps/web/server/opencode/v2/gen/client.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts -import { type ClientOptions, type Config, createClient, createConfig } from "./client/index.js" -import type { ClientOptions as ClientOptions2 } from "./types.gen.js" +import { type ClientOptions, type Config, createClient, createConfig } from './client/index.js'; +import type { ClientOptions as ClientOptions2 } from './types.gen.js'; /** * The `createClientConfig()` function will be called on client initialization @@ -13,6 +13,8 @@ import type { ClientOptions as ClientOptions2 } from "./types.gen.js" */ export type CreateClientConfig = ( override?: Config, -) => Config & T> +) => Config & T>; -export const client = createClient(createConfig({ baseUrl: "http://localhost:4096" })) +export const client = createClient( + createConfig({ baseUrl: 'http://localhost:4096' }), +); diff --git a/apps/web/server/opencode/v2/gen/client/client.gen.ts b/apps/web/server/opencode/v2/gen/client/client.gen.ts index 627e98ec..510187f2 100644 --- a/apps/web/server/opencode/v2/gen/client/client.gen.ts +++ b/apps/web/server/opencode/v2/gen/client/client.gen.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @hey-api/openapi-ts -import { createSseClient } from "../core/serverSentEvents.gen.js" -import type { HttpMethod } from "../core/types.gen.js" -import { getValidRequestBody } from "../core/utils.gen.js" -import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen.js" +import { createSseClient } from '../core/serverSentEvents.gen.js'; +import type { HttpMethod } from '../core/types.gen.js'; +import { getValidRequestBody } from '../core/utils.gen.js'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen.js'; import { buildUrl, createConfig, @@ -12,24 +12,24 @@ import { mergeConfigs, mergeHeaders, setAuthParams, -} from "./utils.gen.js" +} from './utils.gen.js'; -type ReqInit = Omit & { - body?: any - headers: ReturnType -} +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; export const createClient = (config: Config = {}): Client => { - let _config = mergeConfigs(createConfig(), config) + let _config = mergeConfigs(createConfig(), config); - const getConfig = (): Config => ({ ..._config }) + const getConfig = (): Config => ({ ..._config }); const setConfig = (config: Config): Config => { - _config = mergeConfigs(_config, config) - return getConfig() - } + _config = mergeConfigs(_config, config); + return getConfig(); + }; - const interceptors = createInterceptors() + const interceptors = createInterceptors(); const beforeRequest = async (options: RequestOptions) => { const opts = { @@ -38,248 +38,251 @@ export const createClient = (config: Config = {}): Client => { fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, headers: mergeHeaders(_config.headers, options.headers), serializedBody: undefined, - } + }; if (opts.security) { await setAuthParams({ ...opts, security: opts.security, - }) + }); } if (opts.requestValidator) { - await opts.requestValidator(opts) + await opts.requestValidator(opts); } if (opts.body !== undefined && opts.bodySerializer) { - opts.serializedBody = opts.bodySerializer(opts.body) + opts.serializedBody = opts.bodySerializer(opts.body); } // remove Content-Type header if body is empty to avoid sending invalid requests - if (opts.body === undefined || opts.serializedBody === "") { - opts.headers.delete("Content-Type") + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); } - const url = buildUrl(opts) + const url = buildUrl(opts); - return { opts, url } - } + return { opts, url }; + }; - const request: Client["request"] = async (options) => { + const request: Client['request'] = async (options) => { // @ts-expect-error - const { opts, url } = await beforeRequest(options) + const { opts, url } = await beforeRequest(options); const requestInit: ReqInit = { - redirect: "follow", + redirect: 'follow', ...opts, body: getValidRequestBody(opts), - } + }; - let request = new Request(url, requestInit) + let request = new Request(url, requestInit); for (const fn of interceptors.request.fns) { if (fn) { - request = await fn(request, opts) + request = await fn(request, opts); } } // fetch must be assigned here, otherwise it would throw the error: // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = opts.fetch! - let response: Response + const _fetch = opts.fetch!; + let response: Response; try { - response = await _fetch(request) + response = await _fetch(request); } catch (error) { // Handle fetch exceptions (AbortError, network errors, etc.) - let finalError = error + let finalError = error; for (const fn of interceptors.error.fns) { if (fn) { - finalError = (await fn(error, undefined as any, request, opts)) as unknown + finalError = (await fn(error, undefined as any, request, opts)) as unknown; } } - finalError = finalError || ({} as unknown) + finalError = finalError || ({} as unknown); if (opts.throwOnError) { - throw finalError + throw finalError; } // Return error response - return opts.responseStyle === "data" + return opts.responseStyle === 'data' ? undefined : { error: finalError, request, response: undefined as any, - } + }; } for (const fn of interceptors.response.fns) { if (fn) { - response = await fn(response, request, opts) + response = await fn(response, request, opts); } } const result = { request, response, - } + }; if (response.ok) { const parseAs = - (opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json" + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; - if (response.status === 204 || response.headers.get("Content-Length") === "0") { - let emptyData: any + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; switch (parseAs) { - case "arrayBuffer": - case "blob": - case "text": - emptyData = await response[parseAs]() - break - case "formData": - emptyData = new FormData() - break - case "stream": - emptyData = response.body - break - case "json": + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': default: - emptyData = {} - break + emptyData = {}; + break; } - return opts.responseStyle === "data" + return opts.responseStyle === 'data' ? emptyData : { data: emptyData, ...result, - } + }; } - let data: any + let data: any; switch (parseAs) { - case "arrayBuffer": - case "blob": - case "formData": - case "text": - data = await response[parseAs]() - break - case "json": { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { // Some servers return 200 with no Content-Length and empty body. // response.json() would throw; read as text and parse if non-empty. - const text = await response.text() - data = text ? JSON.parse(text) : {} - break + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; } - case "stream": - return opts.responseStyle === "data" + case 'stream': + return opts.responseStyle === 'data' ? response.body : { data: response.body, ...result, - } + }; } - if (parseAs === "json") { + if (parseAs === 'json') { if (opts.responseValidator) { - await opts.responseValidator(data) + await opts.responseValidator(data); } if (opts.responseTransformer) { - data = await opts.responseTransformer(data) + data = await opts.responseTransformer(data); } } - return opts.responseStyle === "data" + return opts.responseStyle === 'data' ? data : { data, ...result, - } + }; } - const textError = await response.text() - let jsonError: unknown + const textError = await response.text(); + let jsonError: unknown; try { - jsonError = JSON.parse(textError) + jsonError = JSON.parse(textError); } catch { // noop } - const error = jsonError ?? textError - let finalError = error + const error = jsonError ?? textError; + let finalError = error; for (const fn of interceptors.error.fns) { if (fn) { - finalError = (await fn(error, response, request, opts)) as string + finalError = (await fn(error, response, request, opts)) as string; } } - finalError = finalError || ({} as string) + finalError = finalError || ({} as string); if (opts.throwOnError) { - throw finalError + throw finalError; } // TODO: we probably want to return error and improve types - return opts.responseStyle === "data" + return opts.responseStyle === 'data' ? undefined : { error: finalError, ...result, - } - } + }; + }; - const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => request({ ...options, method }) + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { - const { opts, url } = await beforeRequest(options) + const { opts, url } = await beforeRequest(options); return createSseClient({ ...opts, body: opts.body as BodyInit | null | undefined, headers: opts.headers as unknown as Record, method, onRequest: async (url, init) => { - let request = new Request(url, init) + let request = new Request(url, init); for (const fn of interceptors.request.fns) { if (fn) { - request = await fn(request, opts) + request = await fn(request, opts); } } - return request + return request; }, serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, url, - }) - } + }); + }; return { buildUrl, - connect: makeMethodFn("CONNECT"), - delete: makeMethodFn("DELETE"), - get: makeMethodFn("GET"), + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), getConfig, - head: makeMethodFn("HEAD"), + head: makeMethodFn('HEAD'), interceptors, - options: makeMethodFn("OPTIONS"), - patch: makeMethodFn("PATCH"), - post: makeMethodFn("POST"), - put: makeMethodFn("PUT"), + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), request, setConfig, sse: { - connect: makeSseFn("CONNECT"), - delete: makeSseFn("DELETE"), - get: makeSseFn("GET"), - head: makeSseFn("HEAD"), - options: makeSseFn("OPTIONS"), - patch: makeSseFn("PATCH"), - post: makeSseFn("POST"), - put: makeSseFn("PUT"), - trace: makeSseFn("TRACE"), + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), }, - trace: makeMethodFn("TRACE"), - } as Client -} + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/apps/web/server/opencode/v2/gen/client/index.ts b/apps/web/server/opencode/v2/gen/client/index.ts index 0af63f33..50acaa57 100644 --- a/apps/web/server/opencode/v2/gen/client/index.ts +++ b/apps/web/server/opencode/v2/gen/client/index.ts @@ -1,15 +1,15 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { Auth } from "../core/auth.gen.js" -export type { QuerySerializerOptions } from "../core/bodySerializer.gen.js" +export type { Auth } from '../core/auth.gen.js'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen.js'; export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, -} from "../core/bodySerializer.gen.js" -export { buildClientParams } from "../core/params.gen.js" -export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen.js" -export { createClient } from "./client.gen.js" +} from '../core/bodySerializer.gen.js'; +export { buildClientParams } from '../core/params.gen.js'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen.js'; +export { createClient } from './client.gen.js'; export type { Client, ClientOptions, @@ -21,5 +21,5 @@ export type { ResolvedRequestOptions, ResponseStyle, TDataShape, -} from "./types.gen.js" -export { createConfig, mergeHeaders } from "./utils.gen.js" +} from './types.gen.js'; +export { createConfig, mergeHeaders } from './utils.gen.js'; diff --git a/apps/web/server/opencode/v2/gen/client/types.gen.ts b/apps/web/server/opencode/v2/gen/client/types.gen.ts index e053aa40..fbed1cc0 100644 --- a/apps/web/server/opencode/v2/gen/client/types.gen.ts +++ b/apps/web/server/opencode/v2/gen/client/types.gen.ts @@ -1,33 +1,35 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Auth } from "../core/auth.gen.js" -import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen.js" -import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen.js" -import type { Middleware } from "./utils.gen.js" +import type { Auth } from '../core/auth.gen.js'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen.js'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen.js'; +import type { Middleware } from './utils.gen.js'; -export type ResponseStyle = "data" | "fields" +export type ResponseStyle = 'data' | 'fields'; export interface Config - extends Omit, - CoreConfig { + extends Omit, CoreConfig { /** * Base URL for all requests made by this client. */ - baseUrl?: T["baseUrl"] + baseUrl?: T['baseUrl']; /** * Fetch API implementation. You can use this option to provide a custom * fetch instance. * * @default globalThis.fetch */ - fetch?: typeof fetch + fetch?: typeof fetch; /** * Please don't use the Fetch client for Next.js applications. The `next` * options won't have any effect. * * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. */ - next?: never + next?: never; /** * Return the response data parsed in a specified format. By default, `auto` * will infer the appropriate method from the `Content-Type` response header. @@ -36,140 +38,146 @@ export interface Config * * @default 'auto' */ - parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text" + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; /** * Should we return only data or multiple fields (data, error, response, etc.)? * * @default 'fields' */ - responseStyle?: ResponseStyle + responseStyle?: ResponseStyle; /** * Throw an error instead of returning it in the response? * * @default false */ - throwOnError?: T["throwOnError"] + throwOnError?: T['throwOnError']; } export interface RequestOptions< TData = unknown, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', ThrowOnError extends boolean = boolean, Url extends string = string, -> extends Config<{ - responseStyle: TResponseStyle - throwOnError: ThrowOnError +> + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; }>, Pick< ServerSentEventsOptions, - "onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay" + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' > { /** * Any body that you want to add to your request. * * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} */ - body?: unknown - path?: Record - query?: Record + body?: unknown; + path?: Record; + query?: Record; /** * Security mechanism(s) to use for the request. */ - security?: ReadonlyArray - url: Url + security?: ReadonlyArray; + url: Url; } export interface ResolvedRequestOptions< - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', ThrowOnError extends boolean = boolean, Url extends string = string, > extends RequestOptions { - serializedBody?: string + serializedBody?: string; } export type RequestResult< TData = unknown, TError = unknown, ThrowOnError extends boolean = boolean, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', > = ThrowOnError extends true ? Promise< - TResponseStyle extends "data" + TResponseStyle extends 'data' ? TData extends Record ? TData[keyof TData] : TData : { - data: TData extends Record ? TData[keyof TData] : TData - request: Request - response: Response + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; } > : Promise< - TResponseStyle extends "data" + TResponseStyle extends 'data' ? (TData extends Record ? TData[keyof TData] : TData) | undefined : ( | { - data: TData extends Record ? TData[keyof TData] : TData - error: undefined + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; } | { - data: undefined - error: TError extends Record ? TError[keyof TError] : TError + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; } ) & { - request: Request - response: Response + request: Request; + response: Response; } - > + >; export interface ClientOptions { - baseUrl?: string - responseStyle?: ResponseStyle - throwOnError?: boolean + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; } type MethodFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, "method">, -) => RequestResult + options: Omit, 'method'>, +) => RequestResult; type SseFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, "method">, -) => Promise> + options: Omit, 'method'>, +) => Promise>; type RequestFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = "fields", + TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, "method"> & - Pick>, "method">, -) => RequestResult + options: Omit, 'method'> & + Pick>, 'method'>, +) => RequestResult; type BuildUrlFn = < TData extends { - body?: unknown - path?: Record - query?: Record - url: string + body?: unknown; + path?: Record; + query?: Record; + url: string; }, >( options: TData & Options, -) => string +) => string; export type Client = CoreClient & { - interceptors: Middleware -} + interceptors: Middleware; +}; /** * The `createClientConfig()` function will be called on client initialization @@ -181,22 +189,25 @@ export type Client = CoreClient */ export type CreateClientConfig = ( override?: Config, -) => Config & T> +) => Config & T>; export interface TDataShape { - body?: unknown - headers?: unknown - path?: unknown - query?: unknown - url: string + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; } -type OmitKeys = Pick> +type OmitKeys = Pick>; export type Options< TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown, - TResponseStyle extends ResponseStyle = "fields", -> = OmitKeys, "body" | "path" | "query" | "url"> & - ([TData] extends [never] ? unknown : Omit) + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/apps/web/server/opencode/v2/gen/client/utils.gen.ts b/apps/web/server/opencode/v2/gen/client/utils.gen.ts index 3b1dfb78..0738ec86 100644 --- a/apps/web/server/opencode/v2/gen/client/utils.gen.ts +++ b/apps/web/server/opencode/v2/gen/client/utils.gen.ts @@ -1,289 +1,316 @@ // This file is auto-generated by @hey-api/openapi-ts -import { getAuthToken } from "../core/auth.gen.js" -import type { QuerySerializerOptions } from "../core/bodySerializer.gen.js" -import { jsonBodySerializer } from "../core/bodySerializer.gen.js" -import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen.js" -import { getUrl } from "../core/utils.gen.js" -import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen.js" +import { getAuthToken } from '../core/auth.gen.js'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen.js'; +import { jsonBodySerializer } from '../core/bodySerializer.gen.js'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen.js'; +import { getUrl } from '../core/utils.gen.js'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen.js'; -export const createQuerySerializer = ({ parameters = {}, ...args }: QuerySerializerOptions = {}) => { +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}) => { const querySerializer = (queryParams: T) => { - const search: string[] = [] - if (queryParams && typeof queryParams === "object") { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { for (const name in queryParams) { - const value = queryParams[name] + const value = queryParams[name]; if (value === undefined || value === null) { - continue + continue; } - const options = parameters[name] || args + const options = parameters[name] || args; if (Array.isArray(value)) { const serializedArray = serializeArrayParam({ allowReserved: options.allowReserved, explode: true, name, - style: "form", + style: 'form', value, ...options.array, - }) - if (serializedArray) search.push(serializedArray) - } else if (typeof value === "object") { + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { const serializedObject = serializeObjectParam({ allowReserved: options.allowReserved, explode: true, name, - style: "deepObject", + style: 'deepObject', value: value as Record, ...options.object, - }) - if (serializedObject) search.push(serializedObject) + }); + if (serializedObject) search.push(serializedObject); } else { const serializedPrimitive = serializePrimitiveParam({ allowReserved: options.allowReserved, name, value: value as string, - }) - if (serializedPrimitive) search.push(serializedPrimitive) + }); + if (serializedPrimitive) search.push(serializedPrimitive); } } } - return search.join("&") - } - return querySerializer -} + return search.join('&'); + }; + return querySerializer; +}; /** * Infers parseAs value from provided Content-Type header. */ -export const getParseAs = (contentType: string | null): Exclude => { +export const getParseAs = (contentType: string | null): Exclude => { if (!contentType) { // If no Content-Type header is provided, the best we can do is return the raw response body, // which is effectively the same as the 'stream' option. - return "stream" + return 'stream'; } - const cleanContent = contentType.split(";")[0]?.trim() + const cleanContent = contentType.split(';')[0]?.trim(); if (!cleanContent) { - return + return; } - if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) { - return "json" + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { + return 'json'; } - if (cleanContent === "multipart/form-data") { - return "formData" + if (cleanContent === 'multipart/form-data') { + return 'formData'; } - if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) { - return "blob" + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) + ) { + return 'blob'; } - if (cleanContent.startsWith("text/")) { - return "text" + if (cleanContent.startsWith('text/')) { + return 'text'; } - return -} + return; +}; const checkForExistence = ( - options: Pick & { - headers: Headers + options: Pick & { + headers: Headers; }, name?: string, ): boolean => { if (!name) { - return false + return false; } - if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) { - return true + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; } - return false -} + return false; +}; export const setAuthParams = async ({ security, ...options -}: Pick, "security"> & - Pick & { - headers: Headers +}: Pick, 'security'> & + Pick & { + headers: Headers; }) => { for (const auth of security) { if (checkForExistence(options, auth.name)) { - continue + continue; } - const token = await getAuthToken(auth, options.auth) + const token = await getAuthToken(auth, options.auth); if (!token) { - continue + continue; } - const name = auth.name ?? "Authorization" + const name = auth.name ?? 'Authorization'; switch (auth.in) { - case "query": + case 'query': if (!options.query) { - options.query = {} + options.query = {}; } - options.query[name] = token - break - case "cookie": - options.headers.append("Cookie", `${name}=${token}`) - break - case "header": + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': default: - options.headers.set(name, token) - break + options.headers.set(name, token); + break; } } -} +}; -export const buildUrl: Client["buildUrl"] = (options) => +export const buildUrl: Client['buildUrl'] = (options) => getUrl({ baseUrl: options.baseUrl as string, path: options.path, query: options.query, querySerializer: - typeof options.querySerializer === "function" + typeof options.querySerializer === 'function' ? options.querySerializer : createQuerySerializer(options.querySerializer), url: options.url, - }) + }); export const mergeConfigs = (a: Config, b: Config): Config => { - const config = { ...a, ...b } - if (config.baseUrl?.endsWith("/")) { - config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1) + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); } - config.headers = mergeHeaders(a.headers, b.headers) - return config -} + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; const headersEntries = (headers: Headers): Array<[string, string]> => { - const entries: Array<[string, string]> = [] + const entries: Array<[string, string]> = []; headers.forEach((value, key) => { - entries.push([key, value]) - }) - return entries -} + entries.push([key, value]); + }); + return entries; +}; -export const mergeHeaders = (...headers: Array["headers"] | undefined>): Headers => { - const mergedHeaders = new Headers() +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); for (const header of headers) { if (!header) { - continue + continue; } - const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header) + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); for (const [key, value] of iterator) { if (value === null) { - mergedHeaders.delete(key) + mergedHeaders.delete(key); } else if (Array.isArray(value)) { for (const v of value) { - mergedHeaders.append(key, v as string) + mergedHeaders.append(key, v as string); } } else if (value !== undefined) { // assume object headers are meant to be JSON stringified, i.e. their // content value in OpenAPI specification is 'application/json' - mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string)) + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); } } } - return mergedHeaders -} + return mergedHeaders; +}; type ErrInterceptor = ( error: Err, response: Res, request: Req, options: Options, -) => Err | Promise +) => Err | Promise; -type ReqInterceptor = (request: Req, options: Options) => Req | Promise +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; -type ResInterceptor = (response: Res, request: Req, options: Options) => Res | Promise +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; class Interceptors { - fns: Array = [] + fns: Array = []; clear(): void { - this.fns = [] + this.fns = []; } eject(id: number | Interceptor): void { - const index = this.getInterceptorIndex(id) + const index = this.getInterceptorIndex(id); if (this.fns[index]) { - this.fns[index] = null + this.fns[index] = null; } } exists(id: number | Interceptor): boolean { - const index = this.getInterceptorIndex(id) - return Boolean(this.fns[index]) + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); } getInterceptorIndex(id: number | Interceptor): number { - if (typeof id === "number") { - return this.fns[id] ? id : -1 + if (typeof id === 'number') { + return this.fns[id] ? id : -1; } - return this.fns.indexOf(id) + return this.fns.indexOf(id); } update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { - const index = this.getInterceptorIndex(id) + const index = this.getInterceptorIndex(id); if (this.fns[index]) { - this.fns[index] = fn - return id + this.fns[index] = fn; + return id; } - return false + return false; } use(fn: Interceptor): number { - this.fns.push(fn) - return this.fns.length - 1 + this.fns.push(fn); + return this.fns.length - 1; } } export interface Middleware { - error: Interceptors> - request: Interceptors> - response: Interceptors> + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; } -export const createInterceptors = (): Middleware => ({ +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ error: new Interceptors>(), request: new Interceptors>(), response: new Interceptors>(), -}) +}); const defaultQuerySerializer = createQuerySerializer({ allowReserved: false, array: { explode: true, - style: "form", + style: 'form', }, object: { explode: true, - style: "deepObject", + style: 'deepObject', }, -}) +}); const defaultHeaders = { - "Content-Type": "application/json", -} + 'Content-Type': 'application/json', +}; export const createConfig = ( override: Config & T> = {}, ): Config & T> => ({ ...jsonBodySerializer, headers: defaultHeaders, - parseAs: "auto", + parseAs: 'auto', querySerializer: defaultQuerySerializer, ...override, -}) +}); diff --git a/apps/web/server/opencode/v2/gen/core/auth.gen.ts b/apps/web/server/opencode/v2/gen/core/auth.gen.ts index bc7b230f..3ebf9947 100644 --- a/apps/web/server/opencode/v2/gen/core/auth.gen.ts +++ b/apps/web/server/opencode/v2/gen/core/auth.gen.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type AuthToken = string | undefined +export type AuthToken = string | undefined; export interface Auth { /** @@ -8,34 +8,34 @@ export interface Auth { * * @default 'header' */ - in?: "header" | "query" | "cookie" + in?: 'header' | 'query' | 'cookie'; /** * Header or query parameter name. * * @default 'Authorization' */ - name?: string - scheme?: "basic" | "bearer" - type: "apiKey" | "http" + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; } export const getAuthToken = async ( auth: Auth, callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, ): Promise => { - const token = typeof callback === "function" ? await callback(auth) : callback + const token = typeof callback === 'function' ? await callback(auth) : callback; if (!token) { - return + return; } - if (auth.scheme === "bearer") { - return `Bearer ${token}` + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; } - if (auth.scheme === "basic") { - return `Basic ${btoa(token)}` + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; } - return token -} + return token; +}; diff --git a/apps/web/server/opencode/v2/gen/core/bodySerializer.gen.ts b/apps/web/server/opencode/v2/gen/core/bodySerializer.gen.ts index 9678fb08..1345e7b4 100644 --- a/apps/web/server/opencode/v2/gen/core/bodySerializer.gen.ts +++ b/apps/web/server/opencode/v2/gen/core/bodySerializer.gen.ts @@ -1,82 +1,84 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen.js" +import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen.js'; -export type QuerySerializer = (query: Record) => string +export type QuerySerializer = (query: Record) => string; -export type BodySerializer = (body: any) => any +export type BodySerializer = (body: any) => any; type QuerySerializerOptionsObject = { - allowReserved?: boolean - array?: Partial> - object?: Partial> -} + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; export type QuerySerializerOptions = QuerySerializerOptionsObject & { /** * Per-parameter serialization overrides. When provided, these settings * override the global array/object settings for specific parameter names. */ - parameters?: Record -} + parameters?: Record; +}; const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { - if (typeof value === "string" || value instanceof Blob) { - data.append(key, value) + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); } else if (value instanceof Date) { - data.append(key, value.toISOString()) + data.append(key, value.toISOString()); } else { - data.append(key, JSON.stringify(value)) + data.append(key, JSON.stringify(value)); } -} +}; const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { - if (typeof value === "string") { - data.append(key, value) + if (typeof value === 'string') { + data.append(key, value); } else { - data.append(key, JSON.stringify(value)) + data.append(key, JSON.stringify(value)); } -} +}; export const formDataBodySerializer = { - bodySerializer: | Array>>(body: T): FormData => { - const data = new FormData() + bodySerializer: | Array>>( + body: T, + ): FormData => { + const data = new FormData(); Object.entries(body).forEach(([key, value]) => { if (value === undefined || value === null) { - return + return; } if (Array.isArray(value)) { - value.forEach((v) => serializeFormDataPair(data, key, v)) + value.forEach((v) => serializeFormDataPair(data, key, v)); } else { - serializeFormDataPair(data, key, value) + serializeFormDataPair(data, key, value); } - }) + }); - return data + return data; }, -} +}; export const jsonBodySerializer = { bodySerializer: (body: T): string => - JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)), -} + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), +}; export const urlSearchParamsBodySerializer = { bodySerializer: | Array>>(body: T): string => { - const data = new URLSearchParams() + const data = new URLSearchParams(); Object.entries(body).forEach(([key, value]) => { if (value === undefined || value === null) { - return + return; } if (Array.isArray(value)) { - value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)) + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); } else { - serializeUrlSearchParamsPair(data, key, value) + serializeUrlSearchParamsPair(data, key, value); } - }) + }); - return data.toString() + return data.toString(); }, -} +}; diff --git a/apps/web/server/opencode/v2/gen/core/params.gen.ts b/apps/web/server/opencode/v2/gen/core/params.gen.ts index 6e9d0b9a..6099cab1 100644 --- a/apps/web/server/opencode/v2/gen/core/params.gen.ts +++ b/apps/web/server/opencode/v2/gen/core/params.gen.ts @@ -1,106 +1,106 @@ // This file is auto-generated by @hey-api/openapi-ts -type Slot = "body" | "headers" | "path" | "query" +type Slot = 'body' | 'headers' | 'path' | 'query'; export type Field = | { - in: Exclude + in: Exclude; /** * Field name. This is the name we want the user to see and use. */ - key: string + key: string; /** * Field mapped name. This is the name we want to use in the request. * If omitted, we use the same value as `key`. */ - map?: string + map?: string; } | { - in: Extract + in: Extract; /** * Key isn't required for bodies. */ - key?: string - map?: string + key?: string; + map?: string; } | { /** * Field name. This is the name we want the user to see and use. */ - key: string + key: string; /** * Field mapped name. This is the name we want to use in the request. * If `in` is omitted, `map` aliases `key` to the transport layer. */ - map: Slot - } + map: Slot; + }; export interface Fields { - allowExtra?: Partial> - args?: ReadonlyArray + allowExtra?: Partial>; + args?: ReadonlyArray; } -export type FieldsConfig = ReadonlyArray +export type FieldsConfig = ReadonlyArray; const extraPrefixesMap: Record = { - $body_: "body", - $headers_: "headers", - $path_: "path", - $query_: "query", -} -const extraPrefixes = Object.entries(extraPrefixesMap) + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); type KeyMap = Map< string, | { - in: Slot - map?: string + in: Slot; + map?: string; } | { - in?: never - map: Slot + in?: never; + map: Slot; } -> +>; const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { if (!map) { - map = new Map() + map = new Map(); } for (const config of fields) { - if ("in" in config) { + if ('in' in config) { if (config.key) { map.set(config.key, { in: config.in, map: config.map, - }) + }); } - } else if ("key" in config) { + } else if ('key' in config) { map.set(config.key, { map: config.map, - }) + }); } else if (config.args) { - buildKeyMap(config.args, map) + buildKeyMap(config.args, map); } } - return map -} + return map; +}; interface Params { - body: unknown - headers: Record - path: Record - query: Record + body: unknown; + headers: Record; + path: Record; + query: Record; } const stripEmptySlots = (params: Params) => { for (const [slot, value] of Object.entries(params)) { - if (value && typeof value === "object" && !Object.keys(value).length) { - delete params[slot as Slot] + if (value && typeof value === 'object' && !Object.keys(value).length) { + delete params[slot as Slot]; } } -} +}; export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { const params: Params = { @@ -108,53 +108,53 @@ export const buildClientParams = (args: ReadonlyArray, fields: FieldsCo headers: {}, path: {}, query: {}, - } + }; - const map = buildKeyMap(fields) + const map = buildKeyMap(fields); - let config: FieldsConfig[number] | undefined + let config: FieldsConfig[number] | undefined; for (const [index, arg] of args.entries()) { if (fields[index]) { - config = fields[index] + config = fields[index]; } if (!config) { - continue + continue; } - if ("in" in config) { + if ('in' in config) { if (config.key) { - const field = map.get(config.key)! - const name = field.map || config.key + const field = map.get(config.key)!; + const name = field.map || config.key; if (field.in) { - ;(params[field.in] as Record)[name] = arg + (params[field.in] as Record)[name] = arg; } } else { - params.body = arg + params.body = arg; } } else { for (const [key, value] of Object.entries(arg ?? {})) { - const field = map.get(key) + const field = map.get(key); if (field) { if (field.in) { - const name = field.map || key - ;(params[field.in] as Record)[name] = value + const name = field.map || key; + (params[field.in] as Record)[name] = value; } else { - params[field.map] = value + params[field.map] = value; } } else { - const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)) + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); if (extra) { - const [prefix, slot] = extra - ;(params[slot] as Record)[key.slice(prefix.length)] = value - } else if ("allowExtra" in config && config.allowExtra) { + const [prefix, slot] = extra; + (params[slot] as Record)[key.slice(prefix.length)] = value; + } else if ('allowExtra' in config && config.allowExtra) { for (const [slot, allowed] of Object.entries(config.allowExtra)) { if (allowed) { - ;(params[slot as Slot] as Record)[key] = value - break + (params[slot as Slot] as Record)[key] = value; + break; } } } @@ -163,7 +163,7 @@ export const buildClientParams = (args: ReadonlyArray, fields: FieldsCo } } - stripEmptySlots(params) + stripEmptySlots(params); - return params -} + return params; +}; diff --git a/apps/web/server/opencode/v2/gen/core/pathSerializer.gen.ts b/apps/web/server/opencode/v2/gen/core/pathSerializer.gen.ts index 96be3bc5..994b2848 100644 --- a/apps/web/server/opencode/v2/gen/core/pathSerializer.gen.ts +++ b/apps/web/server/opencode/v2/gen/core/pathSerializer.gen.ts @@ -3,66 +3,66 @@ interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} interface SerializePrimitiveOptions { - allowReserved?: boolean - name: string + allowReserved?: boolean; + name: string; } export interface SerializerOptions { /** * @default true */ - explode: boolean - style: T + explode: boolean; + style: T; } -export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited" -export type ArraySeparatorStyle = ArrayStyle | MatrixStyle -type MatrixStyle = "label" | "matrix" | "simple" -export type ObjectStyle = "form" | "deepObject" -type ObjectSeparatorStyle = ObjectStyle | MatrixStyle +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; interface SerializePrimitiveParam extends SerializePrimitiveOptions { - value: string + value: string; } export const separatorArrayExplode = (style: ArraySeparatorStyle) => { switch (style) { - case "label": - return "." - case "matrix": - return ";" - case "simple": - return "," + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; default: - return "&" + return '&'; } -} +}; export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { switch (style) { - case "form": - return "," - case "pipeDelimited": - return "|" - case "spaceDelimited": - return "%20" + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; default: - return "," + return ','; } -} +}; export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { switch (style) { - case "label": - return "." - case "matrix": - return ";" - case "simple": - return "," + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; default: - return "&" + return '&'; } -} +}; export const serializeArrayParam = ({ allowReserved, @@ -71,54 +71,58 @@ export const serializeArrayParam = ({ style, value, }: SerializeOptions & { - value: unknown[] + value: unknown[]; }) => { if (!explode) { - const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join( - separatorArrayNoExplode(style), - ) + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); switch (style) { - case "label": - return `.${joinedValues}` - case "matrix": - return `;${name}=${joinedValues}` - case "simple": - return joinedValues + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; default: - return `${name}=${joinedValues}` + return `${name}=${joinedValues}`; } } - const separator = separatorArrayExplode(style) + const separator = separatorArrayExplode(style); const joinedValues = value .map((v) => { - if (style === "label" || style === "simple") { - return allowReserved ? v : encodeURIComponent(v as string) + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); } return serializePrimitiveParam({ allowReserved, name, value: v as string, - }) + }); }) - .join(separator) - return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues -} + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; -export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => { +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { if (value === undefined || value === null) { - return "" + return ''; } - if (typeof value === "object") { + if (typeof value === 'object') { throw new Error( - "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", - ) + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); } - return `${name}=${allowReserved ? value : encodeURIComponent(value)}` -} + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; export const serializeObjectParam = ({ allowReserved, @@ -128,40 +132,40 @@ export const serializeObjectParam = ({ value, valueOnly, }: SerializeOptions & { - value: Record | Date - valueOnly?: boolean + value: Record | Date; + valueOnly?: boolean; }) => { if (value instanceof Date) { - return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}` + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; } - if (style !== "deepObject" && !explode) { - let values: string[] = [] + if (style !== 'deepObject' && !explode) { + let values: string[] = []; Object.entries(value).forEach(([key, v]) => { - values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)] - }) - const joinedValues = values.join(",") + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(','); switch (style) { - case "form": - return `${name}=${joinedValues}` - case "label": - return `.${joinedValues}` - case "matrix": - return `;${name}=${joinedValues}` + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; default: - return joinedValues + return joinedValues; } } - const separator = separatorObjectExplode(style) + const separator = separatorObjectExplode(style); const joinedValues = Object.entries(value) .map(([key, v]) => serializePrimitiveParam({ allowReserved, - name: style === "deepObject" ? `${name}[${key}]` : key, + name: style === 'deepObject' ? `${name}[${key}]` : key, value: v as string, }), ) - .join(separator) - return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues -} + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; diff --git a/apps/web/server/opencode/v2/gen/core/queryKeySerializer.gen.ts b/apps/web/server/opencode/v2/gen/core/queryKeySerializer.gen.ts index 320204ae..5000df60 100644 --- a/apps/web/server/opencode/v2/gen/core/queryKeySerializer.gen.ts +++ b/apps/web/server/opencode/v2/gen/core/queryKeySerializer.gen.ts @@ -3,109 +3,115 @@ /** * JSON-friendly union that mirrors what Pinia Colada can hash. */ -export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue } +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; /** * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. */ export const queryKeyJsonReplacer = (_key: string, value: unknown) => { - if (value === undefined || typeof value === "function" || typeof value === "symbol") { - return undefined + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; } - if (typeof value === "bigint") { - return value.toString() + if (typeof value === 'bigint') { + return value.toString(); } if (value instanceof Date) { - return value.toISOString() + return value.toISOString(); } - return value -} + return value; +}; /** * Safely stringifies a value and parses it back into a JsonValue. */ export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { try { - const json = JSON.stringify(input, queryKeyJsonReplacer) + const json = JSON.stringify(input, queryKeyJsonReplacer); if (json === undefined) { - return undefined + return undefined; } - return JSON.parse(json) as JsonValue + return JSON.parse(json) as JsonValue; } catch { - return undefined + return undefined; } -} +}; /** * Detects plain objects (including objects with a null prototype). */ const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== "object") { - return false + if (value === null || typeof value !== 'object') { + return false; } - const prototype = Object.getPrototypeOf(value as object) - return prototype === Object.prototype || prototype === null -} + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; /** * Turns URLSearchParams into a sorted JSON object for deterministic keys. */ const serializeSearchParams = (params: URLSearchParams): JsonValue => { - const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)) - const result: Record = {} + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; for (const [key, value] of entries) { - const existing = result[key] + const existing = result[key]; if (existing === undefined) { - result[key] = value - continue + result[key] = value; + continue; } if (Array.isArray(existing)) { - ;(existing as string[]).push(value) + (existing as string[]).push(value); } else { - result[key] = [existing, value] + result[key] = [existing, value]; } } - return result -} + return result; +}; /** * Normalizes any accepted value into a JSON-friendly shape for query keys. */ export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { if (value === null) { - return null + return null; } - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - return value + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; } - if (value === undefined || typeof value === "function" || typeof value === "symbol") { - return undefined + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; } - if (typeof value === "bigint") { - return value.toString() + if (typeof value === 'bigint') { + return value.toString(); } if (value instanceof Date) { - return value.toISOString() + return value.toISOString(); } if (Array.isArray(value)) { - return stringifyToJsonValue(value) + return stringifyToJsonValue(value); } - if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) { - return serializeSearchParams(value) + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { + return serializeSearchParams(value); } if (isPlainObject(value)) { - return stringifyToJsonValue(value) + return stringifyToJsonValue(value); } - return undefined -} + return undefined; +}; diff --git a/apps/web/server/opencode/v2/gen/core/serverSentEvents.gen.ts b/apps/web/server/opencode/v2/gen/core/serverSentEvents.gen.ts index 056a8125..1592438b 100644 --- a/apps/web/server/opencode/v2/gen/core/serverSentEvents.gen.ts +++ b/apps/web/server/opencode/v2/gen/core/serverSentEvents.gen.ts @@ -1,20 +1,20 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Config } from "./types.gen.js" +import type { Config } from './types.gen.js'; -export type ServerSentEventsOptions = Omit & - Pick & { +export type ServerSentEventsOptions = Omit & + Pick & { /** * Fetch API implementation. You can use this option to provide a custom * fetch instance. * * @default globalThis.fetch */ - fetch?: typeof fetch + fetch?: typeof fetch; /** * Implementing clients can call request interceptors inside this hook. */ - onRequest?: (url: string, init: RequestInit) => Promise + onRequest?: (url: string, init: RequestInit) => Promise; /** * Callback invoked when a network or parsing error occurs during streaming. * @@ -22,7 +22,7 @@ export type ServerSentEventsOptions = Omit void + onSseError?: (error: unknown) => void; /** * Callback invoked when an event is streamed from the server. * @@ -31,8 +31,8 @@ export type ServerSentEventsOptions = Omit) => void - serializedBody?: RequestInit["body"] + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; /** * Default retry delay in milliseconds. * @@ -40,11 +40,11 @@ export type ServerSentEventsOptions = Omit = Omit Promise - url: string - } + sseSleepFn?: (ms: number) => Promise; + url: string; + }; export interface StreamEvent { - data: TData - event?: string - id?: string - retry?: number + data: TData; + event?: string; + id?: string; + retry?: number; } export type ServerSentEventsResult = { - stream: AsyncGenerator ? TData[keyof TData] : TData, TReturn, TNext> -} + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; export const createSseClient = ({ onRequest, @@ -88,115 +92,115 @@ export const createSseClient = ({ url, ...options }: ServerSentEventsOptions): ServerSentEventsResult => { - let lastEventId: string | undefined + let lastEventId: string | undefined; - const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); const createStream = async function* () { - let retryDelay: number = sseDefaultRetryDelay ?? 3000 - let attempt = 0 - const signal = options.signal ?? new AbortController().signal + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; while (true) { - if (signal.aborted) break + if (signal.aborted) break; - attempt++ + attempt++; const headers = options.headers instanceof Headers ? options.headers - : new Headers(options.headers as Record | undefined) + : new Headers(options.headers as Record | undefined); if (lastEventId !== undefined) { - headers.set("Last-Event-ID", lastEventId) + headers.set('Last-Event-ID', lastEventId); } try { const requestInit: RequestInit = { - redirect: "follow", + redirect: 'follow', ...options, body: options.serializedBody, headers, signal, - } - let request = new Request(url, requestInit) + }; + let request = new Request(url, requestInit); if (onRequest) { - request = await onRequest(url, requestInit) + request = await onRequest(url, requestInit); } // fetch must be assigned here, otherwise it would throw the error: // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = options.fetch ?? globalThis.fetch - const response = await _fetch(request) + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); - if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`) + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); - if (!response.body) throw new Error("No body in SSE response") + if (!response.body) throw new Error('No body in SSE response'); - const reader = response.body.pipeThrough(new TextDecoderStream()).getReader() + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); - let buffer = "" + let buffer = ''; const abortHandler = () => { try { - reader.cancel() + reader.cancel(); } catch { // noop } - } + }; - signal.addEventListener("abort", abortHandler) + signal.addEventListener('abort', abortHandler); try { while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += value + const { done, value } = await reader.read(); + if (done) break; + buffer += value; // Normalize line endings: CRLF -> LF, then CR -> LF - buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const chunks = buffer.split("\n\n") - buffer = chunks.pop() ?? "" + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; for (const chunk of chunks) { - const lines = chunk.split("\n") - const dataLines: Array = [] - let eventName: string | undefined + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; for (const line of lines) { - if (line.startsWith("data:")) { - dataLines.push(line.replace(/^data:\s*/, "")) - } else if (line.startsWith("event:")) { - eventName = line.replace(/^event:\s*/, "") - } else if (line.startsWith("id:")) { - lastEventId = line.replace(/^id:\s*/, "") - } else if (line.startsWith("retry:")) { - const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10) + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); if (!Number.isNaN(parsed)) { - retryDelay = parsed + retryDelay = parsed; } } } - let data: unknown - let parsedJson = false + let data: unknown; + let parsedJson = false; if (dataLines.length) { - const rawData = dataLines.join("\n") + const rawData = dataLines.join('\n'); try { - data = JSON.parse(rawData) - parsedJson = true + data = JSON.parse(rawData); + parsedJson = true; } catch { - data = rawData + data = rawData; } } if (parsedJson) { if (responseValidator) { - await responseValidator(data) + await responseValidator(data); } if (responseTransformer) { - data = await responseTransformer(data) + data = await responseTransformer(data); } } @@ -205,35 +209,35 @@ export const createSseClient = ({ event: eventName, id: lastEventId, retry: retryDelay, - }) + }); if (dataLines.length) { - yield data as any + yield data as any; } } } } finally { - signal.removeEventListener("abort", abortHandler) - reader.releaseLock() + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); } - break // exit loop on normal completion + break; // exit loop on normal completion } catch (error) { // connection failed or aborted; retry after delay - onSseError?.(error) + onSseError?.(error); if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { - break // stop after firing error + break; // stop after firing error } // exponential backoff: double retry each attempt, cap at 30s - const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000) - await sleep(backoff) + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); } } - } + }; - const stream = createStream() + const stream = createStream(); - return { stream } -} + return { stream }; +}; diff --git a/apps/web/server/opencode/v2/gen/core/types.gen.ts b/apps/web/server/opencode/v2/gen/core/types.gen.ts index bfa77b8a..3e50e04c 100644 --- a/apps/web/server/opencode/v2/gen/core/types.gen.ts +++ b/apps/web/server/opencode/v2/gen/core/types.gen.ts @@ -1,33 +1,52 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Auth, AuthToken } from "./auth.gen.js" -import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen.js" +import type { Auth, AuthToken } from './auth.gen.js'; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from './bodySerializer.gen.js'; -export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace" +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; -export type Client = { +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { /** * Returns the final request URL. */ - buildUrl: BuildUrlFn - getConfig: () => Config - request: RequestFn - setConfig: (config: Config) => Config + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; } & { - [K in HttpMethod]: MethodFn -} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }) + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); export interface Config { /** * Auth token or a function returning auth token. The resolved value will be * added to the request payload as defined by its `security` array. */ - auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; /** * A function for serializing request body parameter. By default, * {@link JSON.stringify()} will be used. */ - bodySerializer?: BodySerializer | null + bodySerializer?: BodySerializer | null; /** * An object containing any HTTP headers that you want to pre-populate your * `Headers` object with. @@ -35,14 +54,17 @@ export interface Config { * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} */ headers?: - | RequestInit["headers"] - | Record + | RequestInit['headers'] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; /** * The request method. * * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} */ - method?: Uppercase + method?: Uppercase; /** * A function for serializing request query parameters. By default, arrays * will be exploded in form style, objects will be exploded in deepObject @@ -53,24 +75,24 @@ export interface Config { * * {@link https://swagger.io/docs/specification/serialization/#query View examples} */ - querySerializer?: QuerySerializer | QuerySerializerOptions + querySerializer?: QuerySerializer | QuerySerializerOptions; /** * A function validating request data. This is useful if you want to ensure * the request conforms to the desired shape, so it can be safely sent to * the server. */ - requestValidator?: (data: unknown) => Promise + requestValidator?: (data: unknown) => Promise; /** * A function transforming response data before it's returned. This is useful * for post-processing data, e.g. converting ISO strings into Date objects. */ - responseTransformer?: (data: unknown) => Promise + responseTransformer?: (data: unknown) => Promise; /** * A function validating response data. This is useful if you want to ensure * the response conforms to the desired shape, so it can be safely passed to * the transformers and returned to the user. */ - responseValidator?: (data: unknown) => Promise + responseValidator?: (data: unknown) => Promise; } type IsExactlyNeverOrNeverUndefined = [T] extends [never] @@ -79,8 +101,8 @@ type IsExactlyNeverOrNeverUndefined = [T] extends [never] ? [undefined] extends [T] ? false : true - : false + : false; export type OmitNever> = { - [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K] -} + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; +}; diff --git a/apps/web/server/opencode/v2/gen/core/utils.gen.ts b/apps/web/server/opencode/v2/gen/core/utils.gen.ts index 8a45f726..06fb6226 100644 --- a/apps/web/server/opencode/v2/gen/core/utils.gen.ts +++ b/apps/web/server/opencode/v2/gen/core/utils.gen.ts @@ -1,54 +1,54 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen.js" +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen.js'; import { type ArraySeparatorStyle, serializeArrayParam, serializeObjectParam, serializePrimitiveParam, -} from "./pathSerializer.gen.js" +} from './pathSerializer.gen.js'; export interface PathSerializer { - path: Record - url: string + path: Record; + url: string; } -export const PATH_PARAM_RE = /\{[^{}]+\}/g +export const PATH_PARAM_RE = /\{[^{}]+\}/g; export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { - let url = _url - const matches = _url.match(PATH_PARAM_RE) + let url = _url; + const matches = _url.match(PATH_PARAM_RE); if (matches) { for (const match of matches) { - let explode = false - let name = match.substring(1, match.length - 1) - let style: ArraySeparatorStyle = "simple" + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; - if (name.endsWith("*")) { - explode = true - name = name.substring(0, name.length - 1) + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); } - if (name.startsWith(".")) { - name = name.substring(1) - style = "label" - } else if (name.startsWith(";")) { - name = name.substring(1) - style = "matrix" + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; } - const value = path[name] + const value = path[name]; if (value === undefined || value === null) { - continue + continue; } if (Array.isArray(value)) { - url = url.replace(match, serializeArrayParam({ explode, name, style, value })) - continue + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; } - if (typeof value === "object") { + if (typeof value === 'object') { url = url.replace( match, serializeObjectParam({ @@ -58,27 +58,29 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { value: value as Record, valueOnly: true, }), - ) - continue + ); + continue; } - if (style === "matrix") { + if (style === 'matrix') { url = url.replace( match, `;${serializePrimitiveParam({ name, value: value as string, })}`, - ) - continue + ); + continue; } - const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string)) - url = url.replace(match, replaceValue) + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); } } - return url -} + return url; +}; export const getUrl = ({ baseUrl, @@ -87,51 +89,52 @@ export const getUrl = ({ querySerializer, url: _url, }: { - baseUrl?: string - path?: Record - query?: Record - querySerializer: QuerySerializer - url: string + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; }) => { - const pathUrl = _url.startsWith("/") ? _url : `/${_url}` - let url = (baseUrl ?? "") + pathUrl + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; if (path) { - url = defaultPathSerializer({ path, url }) + url = defaultPathSerializer({ path, url }); } - let search = query ? querySerializer(query) : "" - if (search.startsWith("?")) { - search = search.substring(1) + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); } if (search) { - url += `?${search}` + url += `?${search}`; } - return url -} + return url; +}; export function getValidRequestBody(options: { - body?: unknown - bodySerializer?: BodySerializer | null - serializedBody?: unknown + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; }) { - const hasBody = options.body !== undefined - const isSerializedBody = hasBody && options.bodySerializer + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; if (isSerializedBody) { - if ("serializedBody" in options) { - const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== "" + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; - return hasSerializedBody ? options.serializedBody : null + return hasSerializedBody ? options.serializedBody : null; } // not all clients implement a serializedBody property (i.e. client-axios) - return options.body !== "" ? options.body : null + return options.body !== '' ? options.body : null; } // plain/text body if (hasBody) { - return options.body + return options.body; } // no body was provided - return undefined + return undefined; } diff --git a/apps/web/server/opencode/v2/gen/sdk.gen.ts b/apps/web/server/opencode/v2/gen/sdk.gen.ts index b6821322..48a3773b 100644 --- a/apps/web/server/opencode/v2/gen/sdk.gen.ts +++ b/apps/web/server/opencode/v2/gen/sdk.gen.ts @@ -1,7 +1,12 @@ // This file is auto-generated by @hey-api/openapi-ts -import { client } from "./client.gen.js" -import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js" +import { client } from './client.gen.js'; +import { + buildClientParams, + type Client, + type Options as Options2, + type TDataShape, +} from './client/index.js'; import type { AgentPartInput, AppAgentsResponses, @@ -183,48 +188,50 @@ import type { WorktreeResetErrors, WorktreeResetInput, WorktreeResetResponses, -} from "./types.gen.js" +} from './types.gen.js'; -export type Options = Options2< - TData, - ThrowOnError -> & { +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, +> = Options2 & { /** * You can provide a client instance returned by `createClient()` instead of * individual options. This might be also useful if you want to implement a * custom client. */ - client?: Client + client?: Client; /** * You can pass arbitrary values through the `meta` object. This can be * used to access values that aren't defined as part of the SDK function. */ - meta?: Record -} + meta?: Record; +}; class HeyApiClient { - protected client: Client + protected client: Client; constructor(args?: { client?: Client }) { - this.client = args?.client ?? client + this.client = args?.client ?? client; } } class HeyApiRegistry { - private readonly defaultKey = "default" + private readonly defaultKey = 'default'; - private readonly instances: Map = new Map() + private readonly instances: Map = new Map(); get(key?: string): T { - const instance = this.instances.get(key ?? this.defaultKey) + const instance = this.instances.get(key ?? this.defaultKey); if (!instance) { - throw new Error(`No SDK client found. Create one with "new OpencodeClient()" to fix this error.`) + throw new Error( + `No SDK client found. Create one with "new OpencodeClient()" to fix this error.`, + ); } - return instance + return instance; } set(value: T, key?: string): void { - this.instances.set(key ?? this.defaultKey, value) + this.instances.set(key ?? this.defaultKey, value); } } @@ -236,9 +243,9 @@ export class Config extends HeyApiClient { */ public get(options?: Options) { return (options?.client ?? this.client).get({ - url: "/global/config", + url: '/global/config', ...options, - }) + }); } /** @@ -248,21 +255,25 @@ export class Config extends HeyApiClient { */ public update( parameters?: { - config?: Config3 + config?: Config3; }, options?: Options, ) { - const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) - return (options?.client ?? this.client).patch({ - url: "/global/config", + const params = buildClientParams([parameters], [{ args: [{ key: 'config', map: 'body' }] }]); + return (options?.client ?? this.client).patch< + GlobalConfigUpdateResponses, + GlobalConfigUpdateErrors, + ThrowOnError + >({ + url: '/global/config', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -274,9 +285,9 @@ export class Global extends HeyApiClient { */ public health(options?: Options) { return (options?.client ?? this.client).get({ - url: "/global/health", + url: '/global/health', ...options, - }) + }); } /** @@ -286,9 +297,9 @@ export class Global extends HeyApiClient { */ public event(options?: Options) { return (options?.client ?? this.client).sse.get({ - url: "/global/event", + url: '/global/event', ...options, - }) + }); } /** @@ -298,14 +309,14 @@ export class Global extends HeyApiClient { */ public dispose(options?: Options) { return (options?.client ?? this.client).post({ - url: "/global/dispose", + url: '/global/dispose', ...options, - }) + }); } - private _config?: Config + private _config?: Config; get config(): Config { - return (this._config ??= new Config({ client: this.client })) + return (this._config ??= new Config({ client: this.client })); } } @@ -317,16 +328,20 @@ export class Auth extends HeyApiClient { */ public remove( parameters: { - providerID: string + providerID: string; }, options?: Options, ) { - const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }]) - return (options?.client ?? this.client).delete({ - url: "/auth/{providerID}", + const params = buildClientParams([parameters], [{ args: [{ in: 'path', key: 'providerID' }] }]); + return (options?.client ?? this.client).delete< + AuthRemoveResponses, + AuthRemoveErrors, + ThrowOnError + >({ + url: '/auth/{providerID}', ...options, ...params, - }) + }); } /** @@ -336,8 +351,8 @@ export class Auth extends HeyApiClient { */ public set( parameters: { - providerID: string - auth?: Auth3 + providerID: string; + auth?: Auth3; }, options?: Options, ) { @@ -346,22 +361,22 @@ export class Auth extends HeyApiClient { [ { args: [ - { in: "path", key: "providerID" }, - { key: "auth", map: "body" }, + { in: 'path', key: 'providerID' }, + { key: 'auth', map: 'body' }, ], }, ], - ) + ); return (options?.client ?? this.client).put({ - url: "/auth/{providerID}", + url: '/auth/{providerID}', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -373,8 +388,8 @@ export class Project extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -383,17 +398,17 @@ export class Project extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/project", + url: '/project', ...options, ...params, - }) + }); } /** @@ -403,8 +418,8 @@ export class Project extends HeyApiClient { */ public current( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -413,17 +428,17 @@ export class Project extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/project/current", + url: '/project/current', ...options, ...params, - }) + }); } /** @@ -433,8 +448,8 @@ export class Project extends HeyApiClient { */ public initGit( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -443,17 +458,17 @@ export class Project extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/project/git/init", + url: '/project/git/init', ...options, ...params, - }) + }); } /** @@ -463,21 +478,21 @@ export class Project extends HeyApiClient { */ public update( parameters: { - projectID: string - directory?: string - workspace?: string - name?: string + projectID: string; + directory?: string; + workspace?: string; + name?: string; icon?: { - url?: string - override?: string - color?: string - } + url?: string; + override?: string; + color?: string; + }; commands?: { /** * Startup script to run when creating a new workspace (worktree) */ - start?: string - } + start?: string; + }; }, options?: Options, ) { @@ -486,26 +501,30 @@ export class Project extends HeyApiClient { [ { args: [ - { in: "path", key: "projectID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "icon" }, - { in: "body", key: "commands" }, + { in: 'path', key: 'projectID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'name' }, + { in: 'body', key: 'icon' }, + { in: 'body', key: 'commands' }, ], }, ], - ) - return (options?.client ?? this.client).patch({ - url: "/project/{projectID}", + ); + return (options?.client ?? this.client).patch< + ProjectUpdateResponses, + ProjectUpdateErrors, + ThrowOnError + >({ + url: '/project/{projectID}', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -517,8 +536,8 @@ export class Pty extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -527,17 +546,17 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/pty", + url: '/pty', ...options, ...params, - }) + }); } /** @@ -547,15 +566,15 @@ export class Pty extends HeyApiClient { */ public create( parameters?: { - directory?: string - workspace?: string - command?: string - args?: Array - cwd?: string - title?: string + directory?: string; + workspace?: string; + command?: string; + args?: Array; + cwd?: string; + title?: string; env?: { - [key: string]: string - } + [key: string]: string; + }; }, options?: Options, ) { @@ -564,27 +583,29 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "command" }, - { in: "body", key: "args" }, - { in: "body", key: "cwd" }, - { in: "body", key: "title" }, - { in: "body", key: "env" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'command' }, + { in: 'body', key: 'args' }, + { in: 'body', key: 'cwd' }, + { in: 'body', key: 'title' }, + { in: 'body', key: 'env' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/pty", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, + ); + return (options?.client ?? this.client).post( + { + url: '/pty', + ...options, + ...params, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + ...params.headers, + }, }, - }) + ); } /** @@ -594,9 +615,9 @@ export class Pty extends HeyApiClient { */ public remove( parameters: { - ptyID: string - directory?: string - workspace?: string + ptyID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -605,18 +626,22 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'ptyID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).delete({ - url: "/pty/{ptyID}", + ); + return (options?.client ?? this.client).delete< + PtyRemoveResponses, + PtyRemoveErrors, + ThrowOnError + >({ + url: '/pty/{ptyID}', ...options, ...params, - }) + }); } /** @@ -626,9 +651,9 @@ export class Pty extends HeyApiClient { */ public get( parameters: { - ptyID: string - directory?: string - workspace?: string + ptyID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -637,18 +662,18 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'ptyID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/pty/{ptyID}", + url: '/pty/{ptyID}', ...options, ...params, - }) + }); } /** @@ -658,14 +683,14 @@ export class Pty extends HeyApiClient { */ public update( parameters: { - ptyID: string - directory?: string - workspace?: string - title?: string + ptyID: string; + directory?: string; + workspace?: string; + title?: string; size?: { - rows: number - cols: number - } + rows: number; + cols: number; + }; }, options?: Options, ) { @@ -674,25 +699,25 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "title" }, - { in: "body", key: "size" }, + { in: 'path', key: 'ptyID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'title' }, + { in: 'body', key: 'size' }, ], }, ], - ) + ); return (options?.client ?? this.client).put({ - url: "/pty/{ptyID}", + url: '/pty/{ptyID}', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -702,9 +727,9 @@ export class Pty extends HeyApiClient { */ public connect( parameters: { - ptyID: string - directory?: string - workspace?: string + ptyID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -713,18 +738,22 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'ptyID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/pty/{ptyID}/connect", + ); + return (options?.client ?? this.client).get< + PtyConnectResponses, + PtyConnectErrors, + ThrowOnError + >({ + url: '/pty/{ptyID}/connect', ...options, ...params, - }) + }); } } @@ -736,8 +765,8 @@ export class Config2 extends HeyApiClient { */ public get( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -746,17 +775,17 @@ export class Config2 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/config", + url: '/config', ...options, ...params, - }) + }); } /** @@ -766,9 +795,9 @@ export class Config2 extends HeyApiClient { */ public update( parameters?: { - directory?: string - workspace?: string - config?: Config3 + directory?: string; + workspace?: string; + config?: Config3; }, options?: Options, ) { @@ -777,23 +806,27 @@ export class Config2 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "config", map: "body" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'config', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).patch({ - url: "/config", + ); + return (options?.client ?? this.client).patch< + ConfigUpdateResponses, + ConfigUpdateErrors, + ThrowOnError + >({ + url: '/config', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -803,8 +836,8 @@ export class Config2 extends HeyApiClient { */ public providers( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -813,17 +846,17 @@ export class Config2 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/config/providers", + url: '/config/providers', ...options, ...params, - }) + }); } } @@ -835,8 +868,8 @@ export class Tool extends HeyApiClient { */ public ids( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -845,17 +878,17 @@ export class Tool extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/experimental/tool/ids", + url: '/experimental/tool/ids', ...options, ...params, - }) + }); } /** @@ -865,10 +898,10 @@ export class Tool extends HeyApiClient { */ public list( parameters: { - directory?: string - workspace?: string - provider: string - model: string + directory?: string; + workspace?: string; + provider: string; + model: string; }, options?: Options, ) { @@ -877,19 +910,19 @@ export class Tool extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "provider" }, - { in: "query", key: "model" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'provider' }, + { in: 'query', key: 'model' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/experimental/tool", + url: '/experimental/tool', ...options, ...params, - }) + }); } } @@ -901,8 +934,8 @@ export class Workspace extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -911,17 +944,21 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace", + ); + return (options?.client ?? this.client).get< + ExperimentalWorkspaceListResponses, + unknown, + ThrowOnError + >({ + url: '/experimental/workspace', ...options, ...params, - }) + }); } /** @@ -931,12 +968,12 @@ export class Workspace extends HeyApiClient { */ public create( parameters?: { - directory?: string - workspace?: string - id?: string - type?: string - branch?: string | null - extra?: unknown | null + directory?: string; + workspace?: string; + id?: string; + type?: string; + branch?: string | null; + extra?: unknown | null; }, options?: Options, ) { @@ -945,30 +982,30 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "id" }, - { in: "body", key: "type" }, - { in: "body", key: "branch" }, - { in: "body", key: "extra" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'id' }, + { in: 'body', key: 'type' }, + { in: 'body', key: 'branch' }, + { in: 'body', key: 'extra' }, ], }, ], - ) + ); return (options?.client ?? this.client).post< ExperimentalWorkspaceCreateResponses, ExperimentalWorkspaceCreateErrors, ThrowOnError >({ - url: "/experimental/workspace", + url: '/experimental/workspace', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -978,9 +1015,9 @@ export class Workspace extends HeyApiClient { */ public remove( parameters: { - id: string - directory?: string - workspace?: string + id: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -989,22 +1026,22 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "path", key: "id" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'id' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).delete< ExperimentalWorkspaceRemoveResponses, ExperimentalWorkspaceRemoveErrors, ThrowOnError >({ - url: "/experimental/workspace/{id}", + url: '/experimental/workspace/{id}', ...options, ...params, - }) + }); } } @@ -1016,14 +1053,14 @@ export class Session extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string - roots?: boolean - start?: number - cursor?: number - search?: string - limit?: number - archived?: boolean + directory?: string; + workspace?: string; + roots?: boolean; + start?: number; + cursor?: number; + search?: string; + limit?: number; + archived?: boolean; }, options?: Options, ) { @@ -1032,23 +1069,27 @@ export class Session extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "roots" }, - { in: "query", key: "start" }, - { in: "query", key: "cursor" }, - { in: "query", key: "search" }, - { in: "query", key: "limit" }, - { in: "query", key: "archived" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'roots' }, + { in: 'query', key: 'start' }, + { in: 'query', key: 'cursor' }, + { in: 'query', key: 'search' }, + { in: 'query', key: 'limit' }, + { in: 'query', key: 'archived' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/session", + ); + return (options?.client ?? this.client).get< + ExperimentalSessionListResponses, + unknown, + ThrowOnError + >({ + url: '/experimental/session', ...options, ...params, - }) + }); } } @@ -1060,8 +1101,8 @@ export class Resource extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1070,34 +1111,38 @@ export class Resource extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/resource", + ); + return (options?.client ?? this.client).get< + ExperimentalResourceListResponses, + unknown, + ThrowOnError + >({ + url: '/experimental/resource', ...options, ...params, - }) + }); } } export class Experimental extends HeyApiClient { - private _workspace?: Workspace + private _workspace?: Workspace; get workspace(): Workspace { - return (this._workspace ??= new Workspace({ client: this.client })) + return (this._workspace ??= new Workspace({ client: this.client })); } - private _session?: Session + private _session?: Session; get session(): Session { - return (this._session ??= new Session({ client: this.client })) + return (this._session ??= new Session({ client: this.client })); } - private _resource?: Resource + private _resource?: Resource; get resource(): Resource { - return (this._resource ??= new Resource({ client: this.client })) + return (this._resource ??= new Resource({ client: this.client })); } } @@ -1109,9 +1154,9 @@ export class Worktree extends HeyApiClient { */ public remove( parameters?: { - directory?: string - workspace?: string - worktreeRemoveInput?: WorktreeRemoveInput + directory?: string; + workspace?: string; + worktreeRemoveInput?: WorktreeRemoveInput; }, options?: Options, ) { @@ -1120,23 +1165,27 @@ export class Worktree extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "worktreeRemoveInput", map: "body" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'worktreeRemoveInput', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).delete({ - url: "/experimental/worktree", + ); + return (options?.client ?? this.client).delete< + WorktreeRemoveResponses, + WorktreeRemoveErrors, + ThrowOnError + >({ + url: '/experimental/worktree', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1146,8 +1195,8 @@ export class Worktree extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1156,17 +1205,17 @@ export class Worktree extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/experimental/worktree", + url: '/experimental/worktree', ...options, ...params, - }) + }); } /** @@ -1176,9 +1225,9 @@ export class Worktree extends HeyApiClient { */ public create( parameters?: { - directory?: string - workspace?: string - worktreeCreateInput?: WorktreeCreateInput + directory?: string; + workspace?: string; + worktreeCreateInput?: WorktreeCreateInput; }, options?: Options, ) { @@ -1187,23 +1236,27 @@ export class Worktree extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "worktreeCreateInput", map: "body" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'worktreeCreateInput', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree", + ); + return (options?.client ?? this.client).post< + WorktreeCreateResponses, + WorktreeCreateErrors, + ThrowOnError + >({ + url: '/experimental/worktree', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1213,9 +1266,9 @@ export class Worktree extends HeyApiClient { */ public reset( parameters?: { - directory?: string - workspace?: string - worktreeResetInput?: WorktreeResetInput + directory?: string; + workspace?: string; + worktreeResetInput?: WorktreeResetInput; }, options?: Options, ) { @@ -1224,23 +1277,27 @@ export class Worktree extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "worktreeResetInput", map: "body" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'worktreeResetInput', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree/reset", + ); + return (options?.client ?? this.client).post< + WorktreeResetResponses, + WorktreeResetErrors, + ThrowOnError + >({ + url: '/experimental/worktree/reset', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -1252,12 +1309,12 @@ export class Session2 extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string - roots?: boolean - start?: number - search?: string - limit?: number + directory?: string; + workspace?: string; + roots?: boolean; + start?: number; + search?: string; + limit?: number; }, options?: Options, ) { @@ -1266,21 +1323,21 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "roots" }, - { in: "query", key: "start" }, - { in: "query", key: "search" }, - { in: "query", key: "limit" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'roots' }, + { in: 'query', key: 'start' }, + { in: 'query', key: 'search' }, + { in: 'query', key: 'limit' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/session", + url: '/session', ...options, ...params, - }) + }); } /** @@ -1290,12 +1347,12 @@ export class Session2 extends HeyApiClient { */ public create( parameters?: { - directory?: string - workspace?: string - parentID?: string - title?: string - permission?: PermissionRuleset - workspaceID?: string + directory?: string; + workspace?: string; + parentID?: string; + title?: string; + permission?: PermissionRuleset; + workspaceID?: string; }, options?: Options, ) { @@ -1304,26 +1361,30 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "parentID" }, - { in: "body", key: "title" }, - { in: "body", key: "permission" }, - { in: "body", key: "workspaceID" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'parentID' }, + { in: 'body', key: 'title' }, + { in: 'body', key: 'permission' }, + { in: 'body', key: 'workspaceID' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session", + ); + return (options?.client ?? this.client).post< + SessionCreateResponses, + SessionCreateErrors, + ThrowOnError + >({ + url: '/session', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1333,8 +1394,8 @@ export class Session2 extends HeyApiClient { */ public status( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1343,17 +1404,21 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/session/status", + ); + return (options?.client ?? this.client).get< + SessionStatusResponses, + SessionStatusErrors, + ThrowOnError + >({ + url: '/session/status', ...options, ...params, - }) + }); } /** @@ -1363,9 +1428,9 @@ export class Session2 extends HeyApiClient { */ public delete( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1374,18 +1439,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}", + ); + return (options?.client ?? this.client).delete< + SessionDeleteResponses, + SessionDeleteErrors, + ThrowOnError + >({ + url: '/session/{sessionID}', ...options, ...params, - }) + }); } /** @@ -1395,9 +1464,9 @@ export class Session2 extends HeyApiClient { */ public get( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1406,18 +1475,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}", + ); + return (options?.client ?? this.client).get< + SessionGetResponses, + SessionGetErrors, + ThrowOnError + >({ + url: '/session/{sessionID}', ...options, ...params, - }) + }); } /** @@ -1427,13 +1500,13 @@ export class Session2 extends HeyApiClient { */ public update( parameters: { - sessionID: string - directory?: string - workspace?: string - title?: string + sessionID: string; + directory?: string; + workspace?: string; + title?: string; time?: { - archived?: number - } + archived?: number; + }; }, options?: Options, ) { @@ -1442,25 +1515,29 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "title" }, - { in: "body", key: "time" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'title' }, + { in: 'body', key: 'time' }, ], }, ], - ) - return (options?.client ?? this.client).patch({ - url: "/session/{sessionID}", + ); + return (options?.client ?? this.client).patch< + SessionUpdateResponses, + SessionUpdateErrors, + ThrowOnError + >({ + url: '/session/{sessionID}', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1470,9 +1547,9 @@ export class Session2 extends HeyApiClient { */ public children( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1481,18 +1558,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/children", + ); + return (options?.client ?? this.client).get< + SessionChildrenResponses, + SessionChildrenErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/children', ...options, ...params, - }) + }); } /** @@ -1502,9 +1583,9 @@ export class Session2 extends HeyApiClient { */ public todo( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1513,18 +1594,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/todo", + ); + return (options?.client ?? this.client).get< + SessionTodoResponses, + SessionTodoErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/todo', ...options, ...params, - }) + }); } /** @@ -1534,12 +1619,12 @@ export class Session2 extends HeyApiClient { */ public init( parameters: { - sessionID: string - directory?: string - workspace?: string - modelID?: string - providerID?: string - messageID?: string + sessionID: string; + directory?: string; + workspace?: string; + modelID?: string; + providerID?: string; + messageID?: string; }, options?: Options, ) { @@ -1548,26 +1633,30 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "modelID" }, - { in: "body", key: "providerID" }, - { in: "body", key: "messageID" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'modelID' }, + { in: 'body', key: 'providerID' }, + { in: 'body', key: 'messageID' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/init", + ); + return (options?.client ?? this.client).post< + SessionInitResponses, + SessionInitErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/init', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1577,10 +1666,10 @@ export class Session2 extends HeyApiClient { */ public fork( parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string + sessionID: string; + directory?: string; + workspace?: string; + messageID?: string; }, options?: Options, ) { @@ -1589,24 +1678,24 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'messageID' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/fork", + url: '/session/{sessionID}/fork', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1616,9 +1705,9 @@ export class Session2 extends HeyApiClient { */ public abort( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1627,18 +1716,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/abort", + ); + return (options?.client ?? this.client).post< + SessionAbortResponses, + SessionAbortErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/abort', ...options, ...params, - }) + }); } /** @@ -1648,9 +1741,9 @@ export class Session2 extends HeyApiClient { */ public unshare( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1659,18 +1752,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}/share", + ); + return (options?.client ?? this.client).delete< + SessionUnshareResponses, + SessionUnshareErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/share', ...options, ...params, - }) + }); } /** @@ -1680,9 +1777,9 @@ export class Session2 extends HeyApiClient { */ public share( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1691,18 +1788,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/share", + ); + return (options?.client ?? this.client).post< + SessionShareResponses, + SessionShareErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/share', ...options, ...params, - }) + }); } /** @@ -1712,10 +1813,10 @@ export class Session2 extends HeyApiClient { */ public diff( parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string + sessionID: string; + directory?: string; + workspace?: string; + messageID?: string; }, options?: Options, ) { @@ -1724,19 +1825,19 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "messageID" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'messageID' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/diff", + url: '/session/{sessionID}/diff', ...options, ...params, - }) + }); } /** @@ -1746,12 +1847,12 @@ export class Session2 extends HeyApiClient { */ public summarize( parameters: { - sessionID: string - directory?: string - workspace?: string - providerID?: string - modelID?: string - auto?: boolean + sessionID: string; + directory?: string; + workspace?: string; + providerID?: string; + modelID?: string; + auto?: boolean; }, options?: Options, ) { @@ -1760,26 +1861,30 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "providerID" }, - { in: "body", key: "modelID" }, - { in: "body", key: "auto" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'providerID' }, + { in: 'body', key: 'modelID' }, + { in: 'body', key: 'auto' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/summarize", + ); + return (options?.client ?? this.client).post< + SessionSummarizeResponses, + SessionSummarizeErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/summarize', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1789,11 +1894,11 @@ export class Session2 extends HeyApiClient { */ public messages( parameters: { - sessionID: string - directory?: string - workspace?: string - limit?: number - before?: string + sessionID: string; + directory?: string; + workspace?: string; + limit?: number; + before?: string; }, options?: Options, ) { @@ -1802,20 +1907,24 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "limit" }, - { in: "query", key: "before" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'limit' }, + { in: 'query', key: 'before' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/message", + ); + return (options?.client ?? this.client).get< + SessionMessagesResponses, + SessionMessagesErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/message', ...options, ...params, - }) + }); } /** @@ -1825,23 +1934,23 @@ export class Session2 extends HeyApiClient { */ public prompt( parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string + sessionID: string; + directory?: string; + workspace?: string; + messageID?: string; model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean + providerID: string; + modelID: string; + }; + agent?: string; + noReply?: boolean; tools?: { - [key: string]: boolean - } - format?: OutputFormat - system?: string - variant?: string - parts?: Array + [key: string]: boolean; + }; + format?: OutputFormat; + system?: string; + variant?: string; + parts?: Array; }, options?: Options, ) { @@ -1850,32 +1959,36 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "model" }, - { in: "body", key: "agent" }, - { in: "body", key: "noReply" }, - { in: "body", key: "tools" }, - { in: "body", key: "format" }, - { in: "body", key: "system" }, - { in: "body", key: "variant" }, - { in: "body", key: "parts" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'messageID' }, + { in: 'body', key: 'model' }, + { in: 'body', key: 'agent' }, + { in: 'body', key: 'noReply' }, + { in: 'body', key: 'tools' }, + { in: 'body', key: 'format' }, + { in: 'body', key: 'system' }, + { in: 'body', key: 'variant' }, + { in: 'body', key: 'parts' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/message", + ); + return (options?.client ?? this.client).post< + SessionPromptResponses, + SessionPromptErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/message', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -1885,10 +1998,10 @@ export class Session2 extends HeyApiClient { */ public deleteMessage( parameters: { - sessionID: string - messageID: string - directory?: string - workspace?: string + sessionID: string; + messageID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1897,23 +2010,23 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "messageID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'path', key: 'messageID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).delete< SessionDeleteMessageResponses, SessionDeleteMessageErrors, ThrowOnError >({ - url: "/session/{sessionID}/message/{messageID}", + url: '/session/{sessionID}/message/{messageID}', ...options, ...params, - }) + }); } /** @@ -1923,10 +2036,10 @@ export class Session2 extends HeyApiClient { */ public message( parameters: { - sessionID: string - messageID: string - directory?: string - workspace?: string + sessionID: string; + messageID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -1935,19 +2048,23 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "messageID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'path', key: 'messageID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/message/{messageID}", + ); + return (options?.client ?? this.client).get< + SessionMessageResponses, + SessionMessageErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/message/{messageID}', ...options, ...params, - }) + }); } /** @@ -1957,23 +2074,23 @@ export class Session2 extends HeyApiClient { */ public promptAsync( parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string + sessionID: string; + directory?: string; + workspace?: string; + messageID?: string; model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean + providerID: string; + modelID: string; + }; + agent?: string; + noReply?: boolean; tools?: { - [key: string]: boolean - } - format?: OutputFormat - system?: string - variant?: string - parts?: Array + [key: string]: boolean; + }; + format?: OutputFormat; + system?: string; + variant?: string; + parts?: Array; }, options?: Options, ) { @@ -1982,32 +2099,36 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "model" }, - { in: "body", key: "agent" }, - { in: "body", key: "noReply" }, - { in: "body", key: "tools" }, - { in: "body", key: "format" }, - { in: "body", key: "system" }, - { in: "body", key: "variant" }, - { in: "body", key: "parts" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'messageID' }, + { in: 'body', key: 'model' }, + { in: 'body', key: 'agent' }, + { in: 'body', key: 'noReply' }, + { in: 'body', key: 'tools' }, + { in: 'body', key: 'format' }, + { in: 'body', key: 'system' }, + { in: 'body', key: 'variant' }, + { in: 'body', key: 'parts' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/prompt_async", + ); + return (options?.client ?? this.client).post< + SessionPromptAsyncResponses, + SessionPromptAsyncErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/prompt_async', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2017,23 +2138,23 @@ export class Session2 extends HeyApiClient { */ public command( parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string - agent?: string - model?: string - arguments?: string - command?: string - variant?: string + sessionID: string; + directory?: string; + workspace?: string; + messageID?: string; + agent?: string; + model?: string; + arguments?: string; + command?: string; + variant?: string; parts?: Array<{ - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource - }> + id?: string; + type: 'file'; + mime: string; + filename?: string; + url: string; + source?: FilePartSource; + }>; }, options?: Options, ) { @@ -2042,30 +2163,34 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "agent" }, - { in: "body", key: "model" }, - { in: "body", key: "arguments" }, - { in: "body", key: "command" }, - { in: "body", key: "variant" }, - { in: "body", key: "parts" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'messageID' }, + { in: 'body', key: 'agent' }, + { in: 'body', key: 'model' }, + { in: 'body', key: 'arguments' }, + { in: 'body', key: 'command' }, + { in: 'body', key: 'variant' }, + { in: 'body', key: 'parts' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/command", + ); + return (options?.client ?? this.client).post< + SessionCommandResponses, + SessionCommandErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/command', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2075,15 +2200,15 @@ export class Session2 extends HeyApiClient { */ public shell( parameters: { - sessionID: string - directory?: string - workspace?: string - agent?: string + sessionID: string; + directory?: string; + workspace?: string; + agent?: string; model?: { - providerID: string - modelID: string - } - command?: string + providerID: string; + modelID: string; + }; + command?: string; }, options?: Options, ) { @@ -2092,26 +2217,30 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "agent" }, - { in: "body", key: "model" }, - { in: "body", key: "command" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'agent' }, + { in: 'body', key: 'model' }, + { in: 'body', key: 'command' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/shell", + ); + return (options?.client ?? this.client).post< + SessionShellResponses, + SessionShellErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/shell', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2121,11 +2250,11 @@ export class Session2 extends HeyApiClient { */ public revert( parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string - partID?: string + sessionID: string; + directory?: string; + workspace?: string; + messageID?: string; + partID?: string; }, options?: Options, ) { @@ -2134,25 +2263,29 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "partID" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'messageID' }, + { in: 'body', key: 'partID' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/revert", + ); + return (options?.client ?? this.client).post< + SessionRevertResponses, + SessionRevertErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/revert', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2162,9 +2295,9 @@ export class Session2 extends HeyApiClient { */ public unrevert( parameters: { - sessionID: string - directory?: string - workspace?: string + sessionID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2173,18 +2306,22 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/unrevert", + ); + return (options?.client ?? this.client).post< + SessionUnrevertResponses, + SessionUnrevertErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/unrevert', ...options, ...params, - }) + }); } } @@ -2194,11 +2331,11 @@ export class Part extends HeyApiClient { */ public delete( parameters: { - sessionID: string - messageID: string - partID: string - directory?: string - workspace?: string + sessionID: string; + messageID: string; + partID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2207,20 +2344,24 @@ export class Part extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "messageID" }, - { in: "path", key: "partID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'sessionID' }, + { in: 'path', key: 'messageID' }, + { in: 'path', key: 'partID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}/message/{messageID}/part/{partID}", + ); + return (options?.client ?? this.client).delete< + PartDeleteResponses, + PartDeleteErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/message/{messageID}/part/{partID}', ...options, ...params, - }) + }); } /** @@ -2228,12 +2369,12 @@ export class Part extends HeyApiClient { */ public update( parameters: { - sessionID: string - messageID: string - partID: string - directory?: string - workspace?: string - part?: Part2 + sessionID: string; + messageID: string; + partID: string; + directory?: string; + workspace?: string; + part?: Part2; }, options?: Options, ) { @@ -2242,26 +2383,30 @@ export class Part extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "messageID" }, - { in: "path", key: "partID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "part", map: "body" }, + { in: 'path', key: 'sessionID' }, + { in: 'path', key: 'messageID' }, + { in: 'path', key: 'partID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'part', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).patch({ - url: "/session/{sessionID}/message/{messageID}/part/{partID}", + ); + return (options?.client ?? this.client).patch< + PartUpdateResponses, + PartUpdateErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/message/{messageID}/part/{partID}', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -2275,11 +2420,11 @@ export class Permission extends HeyApiClient { */ public respond( parameters: { - sessionID: string - permissionID: string - directory?: string - workspace?: string - response?: "once" | "always" | "reject" + sessionID: string; + permissionID: string; + directory?: string; + workspace?: string; + response?: 'once' | 'always' | 'reject'; }, options?: Options, ) { @@ -2288,25 +2433,29 @@ export class Permission extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "permissionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "response" }, + { in: 'path', key: 'sessionID' }, + { in: 'path', key: 'permissionID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'response' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/permissions/{permissionID}", + ); + return (options?.client ?? this.client).post< + PermissionRespondResponses, + PermissionRespondErrors, + ThrowOnError + >({ + url: '/session/{sessionID}/permissions/{permissionID}', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2316,11 +2465,11 @@ export class Permission extends HeyApiClient { */ public reply( parameters: { - requestID: string - directory?: string - workspace?: string - reply?: "once" | "always" | "reject" - message?: string + requestID: string; + directory?: string; + workspace?: string; + reply?: 'once' | 'always' | 'reject'; + message?: string; }, options?: Options, ) { @@ -2329,25 +2478,29 @@ export class Permission extends HeyApiClient { [ { args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "reply" }, - { in: "body", key: "message" }, + { in: 'path', key: 'requestID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'reply' }, + { in: 'body', key: 'message' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/permission/{requestID}/reply", + ); + return (options?.client ?? this.client).post< + PermissionReplyResponses, + PermissionReplyErrors, + ThrowOnError + >({ + url: '/permission/{requestID}/reply', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2357,8 +2510,8 @@ export class Permission extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2367,17 +2520,17 @@ export class Permission extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/permission", + url: '/permission', ...options, ...params, - }) + }); } } @@ -2389,8 +2542,8 @@ export class Question extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2399,17 +2552,17 @@ export class Question extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/question", + url: '/question', ...options, ...params, - }) + }); } /** @@ -2419,10 +2572,10 @@ export class Question extends HeyApiClient { */ public reply( parameters: { - requestID: string - directory?: string - workspace?: string - answers?: Array + requestID: string; + directory?: string; + workspace?: string; + answers?: Array; }, options?: Options, ) { @@ -2431,24 +2584,28 @@ export class Question extends HeyApiClient { [ { args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "answers" }, + { in: 'path', key: 'requestID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'answers' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reply", + ); + return (options?.client ?? this.client).post< + QuestionReplyResponses, + QuestionReplyErrors, + ThrowOnError + >({ + url: '/question/{requestID}/reply', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2458,9 +2615,9 @@ export class Question extends HeyApiClient { */ public reject( parameters: { - requestID: string - directory?: string - workspace?: string + requestID: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2469,18 +2626,22 @@ export class Question extends HeyApiClient { [ { args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'requestID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reject", + ); + return (options?.client ?? this.client).post< + QuestionRejectResponses, + QuestionRejectErrors, + ThrowOnError + >({ + url: '/question/{requestID}/reject', ...options, ...params, - }) + }); } } @@ -2492,13 +2653,13 @@ export class Oauth extends HeyApiClient { */ public authorize( parameters: { - providerID: string - directory?: string - workspace?: string - method?: number + providerID: string; + directory?: string; + workspace?: string; + method?: number; inputs?: { - [key: string]: string - } + [key: string]: string; + }; }, options?: Options, ) { @@ -2507,29 +2668,29 @@ export class Oauth extends HeyApiClient { [ { args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "inputs" }, + { in: 'path', key: 'providerID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'method' }, + { in: 'body', key: 'inputs' }, ], }, ], - ) + ); return (options?.client ?? this.client).post< ProviderOauthAuthorizeResponses, ProviderOauthAuthorizeErrors, ThrowOnError >({ - url: "/provider/{providerID}/oauth/authorize", + url: '/provider/{providerID}/oauth/authorize', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2539,11 +2700,11 @@ export class Oauth extends HeyApiClient { */ public callback( parameters: { - providerID: string - directory?: string - workspace?: string - method?: number - code?: string + providerID: string; + directory?: string; + workspace?: string; + method?: number; + code?: string; }, options?: Options, ) { @@ -2552,29 +2713,29 @@ export class Oauth extends HeyApiClient { [ { args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "code" }, + { in: 'path', key: 'providerID' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'method' }, + { in: 'body', key: 'code' }, ], }, ], - ) + ); return (options?.client ?? this.client).post< ProviderOauthCallbackResponses, ProviderOauthCallbackErrors, ThrowOnError >({ - url: "/provider/{providerID}/oauth/callback", + url: '/provider/{providerID}/oauth/callback', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -2586,8 +2747,8 @@ export class Provider extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2596,17 +2757,17 @@ export class Provider extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/provider", + url: '/provider', ...options, ...params, - }) + }); } /** @@ -2616,8 +2777,8 @@ export class Provider extends HeyApiClient { */ public auth( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2626,22 +2787,22 @@ export class Provider extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/provider/auth", + url: '/provider/auth', ...options, ...params, - }) + }); } - private _oauth?: Oauth + private _oauth?: Oauth; get oauth(): Oauth { - return (this._oauth ??= new Oauth({ client: this.client })) + return (this._oauth ??= new Oauth({ client: this.client })); } } @@ -2653,9 +2814,9 @@ export class Find extends HeyApiClient { */ public text( parameters: { - directory?: string - workspace?: string - pattern: string + directory?: string; + workspace?: string; + pattern: string; }, options?: Options, ) { @@ -2664,18 +2825,18 @@ export class Find extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "pattern" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'pattern' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/find", + url: '/find', ...options, ...params, - }) + }); } /** @@ -2685,12 +2846,12 @@ export class Find extends HeyApiClient { */ public files( parameters: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number + directory?: string; + workspace?: string; + query: string; + dirs?: 'true' | 'false'; + type?: 'file' | 'directory'; + limit?: number; }, options?: Options, ) { @@ -2699,21 +2860,21 @@ export class Find extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "query" }, - { in: "query", key: "dirs" }, - { in: "query", key: "type" }, - { in: "query", key: "limit" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'query' }, + { in: 'query', key: 'dirs' }, + { in: 'query', key: 'type' }, + { in: 'query', key: 'limit' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/find/file", + url: '/find/file', ...options, ...params, - }) + }); } /** @@ -2723,9 +2884,9 @@ export class Find extends HeyApiClient { */ public symbols( parameters: { - directory?: string - workspace?: string - query: string + directory?: string; + workspace?: string; + query: string; }, options?: Options, ) { @@ -2734,18 +2895,18 @@ export class Find extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "query" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'query' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/find/symbol", + url: '/find/symbol', ...options, ...params, - }) + }); } } @@ -2757,9 +2918,9 @@ export class File extends HeyApiClient { */ public list( parameters: { - directory?: string - workspace?: string - path: string + directory?: string; + workspace?: string; + path: string; }, options?: Options, ) { @@ -2768,18 +2929,18 @@ export class File extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "path" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'path' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/file", + url: '/file', ...options, ...params, - }) + }); } /** @@ -2789,9 +2950,9 @@ export class File extends HeyApiClient { */ public read( parameters: { - directory?: string - workspace?: string - path: string + directory?: string; + workspace?: string; + path: string; }, options?: Options, ) { @@ -2800,18 +2961,18 @@ export class File extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "path" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'query', key: 'path' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/file/content", + url: '/file/content', ...options, ...params, - }) + }); } /** @@ -2821,8 +2982,8 @@ export class File extends HeyApiClient { */ public status( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2831,17 +2992,17 @@ export class File extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/file/status", + url: '/file/status', ...options, ...params, - }) + }); } } @@ -2853,8 +3014,8 @@ export class Event extends HeyApiClient { */ public subscribe( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2863,17 +3024,19 @@ export class Event extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).sse.get({ - url: "/event", - ...options, - ...params, - }) + ); + return (options?.client ?? this.client).sse.get( + { + url: '/event', + ...options, + ...params, + }, + ); } } @@ -2885,9 +3048,9 @@ export class Auth2 extends HeyApiClient { */ public remove( parameters: { - name: string - directory?: string - workspace?: string + name: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2896,18 +3059,22 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'name' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).delete({ - url: "/mcp/{name}/auth", + ); + return (options?.client ?? this.client).delete< + McpAuthRemoveResponses, + McpAuthRemoveErrors, + ThrowOnError + >({ + url: '/mcp/{name}/auth', ...options, ...params, - }) + }); } /** @@ -2917,9 +3084,9 @@ export class Auth2 extends HeyApiClient { */ public start( parameters: { - name: string - directory?: string - workspace?: string + name: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2928,18 +3095,22 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'name' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth", + ); + return (options?.client ?? this.client).post< + McpAuthStartResponses, + McpAuthStartErrors, + ThrowOnError + >({ + url: '/mcp/{name}/auth', ...options, ...params, - }) + }); } /** @@ -2949,10 +3120,10 @@ export class Auth2 extends HeyApiClient { */ public callback( parameters: { - name: string - directory?: string - workspace?: string - code?: string + name: string; + directory?: string; + workspace?: string; + code?: string; }, options?: Options, ) { @@ -2961,24 +3132,28 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "code" }, + { in: 'path', key: 'name' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'code' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth/callback", + ); + return (options?.client ?? this.client).post< + McpAuthCallbackResponses, + McpAuthCallbackErrors, + ThrowOnError + >({ + url: '/mcp/{name}/auth/callback', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -2988,9 +3163,9 @@ export class Auth2 extends HeyApiClient { */ public authenticate( parameters: { - name: string - directory?: string - workspace?: string + name: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -2999,20 +3174,22 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'name' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) - return (options?.client ?? this.client).post( - { - url: "/mcp/{name}/auth/authenticate", - ...options, - ...params, - }, - ) + ); + return (options?.client ?? this.client).post< + McpAuthAuthenticateResponses, + McpAuthAuthenticateErrors, + ThrowOnError + >({ + url: '/mcp/{name}/auth/authenticate', + ...options, + ...params, + }); } } @@ -3024,8 +3201,8 @@ export class Mcp extends HeyApiClient { */ public status( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3034,17 +3211,17 @@ export class Mcp extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/mcp", + url: '/mcp', ...options, ...params, - }) + }); } /** @@ -3054,10 +3231,10 @@ export class Mcp extends HeyApiClient { */ public add( parameters?: { - directory?: string - workspace?: string - name?: string - config?: McpLocalConfig | McpRemoteConfig + directory?: string; + workspace?: string; + name?: string; + config?: McpLocalConfig | McpRemoteConfig; }, options?: Options, ) { @@ -3066,24 +3243,24 @@ export class Mcp extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "config" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'name' }, + { in: 'body', key: 'config' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/mcp", + url: '/mcp', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -3091,9 +3268,9 @@ export class Mcp extends HeyApiClient { */ public connect( parameters: { - name: string - directory?: string - workspace?: string + name: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3102,18 +3279,18 @@ export class Mcp extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'name' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/mcp/{name}/connect", + url: '/mcp/{name}/connect', ...options, ...params, - }) + }); } /** @@ -3121,9 +3298,9 @@ export class Mcp extends HeyApiClient { */ public disconnect( parameters: { - name: string - directory?: string - workspace?: string + name: string; + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3132,23 +3309,23 @@ export class Mcp extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'path', key: 'name' }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/mcp/{name}/disconnect", + url: '/mcp/{name}/disconnect', ...options, ...params, - }) + }); } - private _auth?: Auth2 + private _auth?: Auth2; get auth(): Auth2 { - return (this._auth ??= new Auth2({ client: this.client })) + return (this._auth ??= new Auth2({ client: this.client })); } } @@ -3160,8 +3337,8 @@ export class Control extends HeyApiClient { */ public next( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3170,17 +3347,17 @@ export class Control extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/tui/control/next", + url: '/tui/control/next', ...options, ...params, - }) + }); } /** @@ -3190,9 +3367,9 @@ export class Control extends HeyApiClient { */ public response( parameters?: { - directory?: string - workspace?: string - body?: unknown + directory?: string; + workspace?: string; + body?: unknown; }, options?: Options, ) { @@ -3201,23 +3378,27 @@ export class Control extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "body", map: "body" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'body', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/tui/control/response", + ); + return (options?.client ?? this.client).post< + TuiControlResponseResponses, + unknown, + ThrowOnError + >({ + url: '/tui/control/response', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } } @@ -3229,9 +3410,9 @@ export class Tui extends HeyApiClient { */ public appendPrompt( parameters?: { - directory?: string - workspace?: string - text?: string + directory?: string; + workspace?: string; + text?: string; }, options?: Options, ) { @@ -3240,23 +3421,27 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "text" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'text' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/tui/append-prompt", + ); + return (options?.client ?? this.client).post< + TuiAppendPromptResponses, + TuiAppendPromptErrors, + ThrowOnError + >({ + url: '/tui/append-prompt', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -3266,8 +3451,8 @@ export class Tui extends HeyApiClient { */ public openHelp( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3276,17 +3461,17 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/open-help", + url: '/tui/open-help', ...options, ...params, - }) + }); } /** @@ -3296,8 +3481,8 @@ export class Tui extends HeyApiClient { */ public openSessions( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3306,17 +3491,17 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/open-sessions", + url: '/tui/open-sessions', ...options, ...params, - }) + }); } /** @@ -3326,8 +3511,8 @@ export class Tui extends HeyApiClient { */ public openThemes( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3336,17 +3521,17 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/open-themes", + url: '/tui/open-themes', ...options, ...params, - }) + }); } /** @@ -3356,8 +3541,8 @@ export class Tui extends HeyApiClient { */ public openModels( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3366,17 +3551,17 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/open-models", + url: '/tui/open-models', ...options, ...params, - }) + }); } /** @@ -3386,8 +3571,8 @@ export class Tui extends HeyApiClient { */ public submitPrompt( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3396,17 +3581,17 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/submit-prompt", + url: '/tui/submit-prompt', ...options, ...params, - }) + }); } /** @@ -3416,8 +3601,8 @@ export class Tui extends HeyApiClient { */ public clearPrompt( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3426,17 +3611,17 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/clear-prompt", + url: '/tui/clear-prompt', ...options, ...params, - }) + }); } /** @@ -3446,9 +3631,9 @@ export class Tui extends HeyApiClient { */ public executeCommand( parameters?: { - directory?: string - workspace?: string - command?: string + directory?: string; + workspace?: string; + command?: string; }, options?: Options, ) { @@ -3457,23 +3642,27 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "command" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'command' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/tui/execute-command", + ); + return (options?.client ?? this.client).post< + TuiExecuteCommandResponses, + TuiExecuteCommandErrors, + ThrowOnError + >({ + url: '/tui/execute-command', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -3483,12 +3672,12 @@ export class Tui extends HeyApiClient { */ public showToast( parameters?: { - directory?: string - workspace?: string - title?: string - message?: string - variant?: "info" | "success" | "warning" | "error" - duration?: number + directory?: string; + workspace?: string; + title?: string; + message?: string; + variant?: 'info' | 'success' | 'warning' | 'error'; + duration?: number; }, options?: Options, ) { @@ -3497,26 +3686,26 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "title" }, - { in: "body", key: "message" }, - { in: "body", key: "variant" }, - { in: "body", key: "duration" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'title' }, + { in: 'body', key: 'message' }, + { in: 'body', key: 'variant' }, + { in: 'body', key: 'duration' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/tui/show-toast", + url: '/tui/show-toast', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -3526,9 +3715,13 @@ export class Tui extends HeyApiClient { */ public publish( parameters?: { - directory?: string - workspace?: string - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + directory?: string; + workspace?: string; + body?: + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect; }, options?: Options, ) { @@ -3537,23 +3730,27 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "body", map: "body" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { key: 'body', map: 'body' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/tui/publish", + ); + return (options?.client ?? this.client).post< + TuiPublishResponses, + TuiPublishErrors, + ThrowOnError + >({ + url: '/tui/publish', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -3563,9 +3760,9 @@ export class Tui extends HeyApiClient { */ public selectSession( parameters?: { - directory?: string - workspace?: string - sessionID?: string + directory?: string; + workspace?: string; + sessionID?: string; }, options?: Options, ) { @@ -3574,28 +3771,32 @@ export class Tui extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "sessionID" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'sessionID' }, ], }, ], - ) - return (options?.client ?? this.client).post({ - url: "/tui/select-session", + ); + return (options?.client ?? this.client).post< + TuiSelectSessionResponses, + TuiSelectSessionErrors, + ThrowOnError + >({ + url: '/tui/select-session', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } - private _control?: Control + private _control?: Control; get control(): Control { - return (this._control ??= new Control({ client: this.client })) + return (this._control ??= new Control({ client: this.client })); } } @@ -3607,8 +3808,8 @@ export class Instance extends HeyApiClient { */ public dispose( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3617,17 +3818,17 @@ export class Instance extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/instance/dispose", + url: '/instance/dispose', ...options, ...params, - }) + }); } } @@ -3639,8 +3840,8 @@ export class Path extends HeyApiClient { */ public get( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3649,17 +3850,17 @@ export class Path extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/path", + url: '/path', ...options, ...params, - }) + }); } } @@ -3671,8 +3872,8 @@ export class Vcs extends HeyApiClient { */ public get( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3681,17 +3882,17 @@ export class Vcs extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/vcs", + url: '/vcs', ...options, ...params, - }) + }); } } @@ -3703,8 +3904,8 @@ export class Command extends HeyApiClient { */ public list( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3713,17 +3914,17 @@ export class Command extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/command", + url: '/command', ...options, ...params, - }) + }); } } @@ -3735,14 +3936,14 @@ export class App extends HeyApiClient { */ public log( parameters?: { - directory?: string - workspace?: string - service?: string - level?: "debug" | "info" | "error" | "warn" - message?: string + directory?: string; + workspace?: string; + service?: string; + level?: 'debug' | 'info' | 'error' | 'warn'; + message?: string; extra?: { - [key: string]: unknown - } + [key: string]: unknown; + }; }, options?: Options, ) { @@ -3751,26 +3952,26 @@ export class App extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "service" }, - { in: "body", key: "level" }, - { in: "body", key: "message" }, - { in: "body", key: "extra" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, + { in: 'body', key: 'service' }, + { in: 'body', key: 'level' }, + { in: 'body', key: 'message' }, + { in: 'body', key: 'extra' }, ], }, ], - ) + ); return (options?.client ?? this.client).post({ - url: "/log", + url: '/log', ...options, ...params, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...options?.headers, ...params.headers, }, - }) + }); } /** @@ -3780,8 +3981,8 @@ export class App extends HeyApiClient { */ public agents( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3790,17 +3991,17 @@ export class App extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/agent", + url: '/agent', ...options, ...params, - }) + }); } /** @@ -3810,8 +4011,8 @@ export class App extends HeyApiClient { */ public skills( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3820,17 +4021,17 @@ export class App extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/skill", + url: '/skill', ...options, ...params, - }) + }); } } @@ -3842,8 +4043,8 @@ export class Lsp extends HeyApiClient { */ public status( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3852,17 +4053,17 @@ export class Lsp extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/lsp", + url: '/lsp', ...options, ...params, - }) + }); } } @@ -3874,8 +4075,8 @@ export class Formatter extends HeyApiClient { */ public status( parameters?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; }, options?: Options, ) { @@ -3884,150 +4085,150 @@ export class Formatter extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, + { in: 'query', key: 'directory' }, + { in: 'query', key: 'workspace' }, ], }, ], - ) + ); return (options?.client ?? this.client).get({ - url: "/formatter", + url: '/formatter', ...options, ...params, - }) + }); } } export class OpencodeClient extends HeyApiClient { - public static readonly __registry = new HeyApiRegistry() + public static readonly __registry = new HeyApiRegistry(); constructor(args?: { client?: Client; key?: string }) { - super(args) - OpencodeClient.__registry.set(this, args?.key) + super(args); + OpencodeClient.__registry.set(this, args?.key); } - private _global?: Global + private _global?: Global; get global(): Global { - return (this._global ??= new Global({ client: this.client })) + return (this._global ??= new Global({ client: this.client })); } - private _auth?: Auth + private _auth?: Auth; get auth(): Auth { - return (this._auth ??= new Auth({ client: this.client })) + return (this._auth ??= new Auth({ client: this.client })); } - private _project?: Project + private _project?: Project; get project(): Project { - return (this._project ??= new Project({ client: this.client })) + return (this._project ??= new Project({ client: this.client })); } - private _pty?: Pty + private _pty?: Pty; get pty(): Pty { - return (this._pty ??= new Pty({ client: this.client })) + return (this._pty ??= new Pty({ client: this.client })); } - private _config?: Config2 + private _config?: Config2; get config(): Config2 { - return (this._config ??= new Config2({ client: this.client })) + return (this._config ??= new Config2({ client: this.client })); } - private _tool?: Tool + private _tool?: Tool; get tool(): Tool { - return (this._tool ??= new Tool({ client: this.client })) + return (this._tool ??= new Tool({ client: this.client })); } - private _experimental?: Experimental + private _experimental?: Experimental; get experimental(): Experimental { - return (this._experimental ??= new Experimental({ client: this.client })) + return (this._experimental ??= new Experimental({ client: this.client })); } - private _worktree?: Worktree + private _worktree?: Worktree; get worktree(): Worktree { - return (this._worktree ??= new Worktree({ client: this.client })) + return (this._worktree ??= new Worktree({ client: this.client })); } - private _session?: Session2 + private _session?: Session2; get session(): Session2 { - return (this._session ??= new Session2({ client: this.client })) + return (this._session ??= new Session2({ client: this.client })); } - private _part?: Part + private _part?: Part; get part(): Part { - return (this._part ??= new Part({ client: this.client })) + return (this._part ??= new Part({ client: this.client })); } - private _permission?: Permission + private _permission?: Permission; get permission(): Permission { - return (this._permission ??= new Permission({ client: this.client })) + return (this._permission ??= new Permission({ client: this.client })); } - private _question?: Question + private _question?: Question; get question(): Question { - return (this._question ??= new Question({ client: this.client })) + return (this._question ??= new Question({ client: this.client })); } - private _provider?: Provider + private _provider?: Provider; get provider(): Provider { - return (this._provider ??= new Provider({ client: this.client })) + return (this._provider ??= new Provider({ client: this.client })); } - private _find?: Find + private _find?: Find; get find(): Find { - return (this._find ??= new Find({ client: this.client })) + return (this._find ??= new Find({ client: this.client })); } - private _file?: File + private _file?: File; get file(): File { - return (this._file ??= new File({ client: this.client })) + return (this._file ??= new File({ client: this.client })); } - private _event?: Event + private _event?: Event; get event(): Event { - return (this._event ??= new Event({ client: this.client })) + return (this._event ??= new Event({ client: this.client })); } - private _mcp?: Mcp + private _mcp?: Mcp; get mcp(): Mcp { - return (this._mcp ??= new Mcp({ client: this.client })) + return (this._mcp ??= new Mcp({ client: this.client })); } - private _tui?: Tui + private _tui?: Tui; get tui(): Tui { - return (this._tui ??= new Tui({ client: this.client })) + return (this._tui ??= new Tui({ client: this.client })); } - private _instance?: Instance + private _instance?: Instance; get instance(): Instance { - return (this._instance ??= new Instance({ client: this.client })) + return (this._instance ??= new Instance({ client: this.client })); } - private _path?: Path + private _path?: Path; get path(): Path { - return (this._path ??= new Path({ client: this.client })) + return (this._path ??= new Path({ client: this.client })); } - private _vcs?: Vcs + private _vcs?: Vcs; get vcs(): Vcs { - return (this._vcs ??= new Vcs({ client: this.client })) + return (this._vcs ??= new Vcs({ client: this.client })); } - private _command?: Command + private _command?: Command; get command(): Command { - return (this._command ??= new Command({ client: this.client })) + return (this._command ??= new Command({ client: this.client })); } - private _app?: App + private _app?: App; get app(): App { - return (this._app ??= new App({ client: this.client })) + return (this._app ??= new App({ client: this.client })); } - private _lsp?: Lsp + private _lsp?: Lsp; get lsp(): Lsp { - return (this._lsp ??= new Lsp({ client: this.client })) + return (this._lsp ??= new Lsp({ client: this.client })); } - private _formatter?: Formatter + private _formatter?: Formatter; get formatter(): Formatter { - return (this._formatter ??= new Formatter({ client: this.client })) + return (this._formatter ??= new Formatter({ client: this.client })); } } diff --git a/apps/web/server/opencode/v2/gen/types.gen.ts b/apps/web/server/opencode/v2/gen/types.gen.ts index ec797f2b..3e5f4dbb 100644 --- a/apps/web/server/opencode/v2/gen/types.gen.ts +++ b/apps/web/server/opencode/v2/gen/types.gen.ts @@ -1,329 +1,329 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: `${string}://${string}` | (string & {}) -} + baseUrl: `${string}://${string}` | (string & {}); +}; export type EventInstallationUpdated = { - type: "installation.updated" + type: 'installation.updated'; properties: { - version: string - } -} + version: string; + }; +}; export type EventInstallationUpdateAvailable = { - type: "installation.update-available" + type: 'installation.update-available'; properties: { - version: string - } -} + version: string; + }; +}; export type Project = { - id: string - worktree: string - vcs?: "git" - name?: string + id: string; + worktree: string; + vcs?: 'git'; + name?: string; icon?: { - url?: string - override?: string - color?: string - } + url?: string; + override?: string; + color?: string; + }; commands?: { /** * Startup script to run when creating a new workspace (worktree) */ - start?: string - } + start?: string; + }; time: { - created: number - updated: number - initialized?: number - } - sandboxes: Array -} + created: number; + updated: number; + initialized?: number; + }; + sandboxes: Array; +}; export type EventProjectUpdated = { - type: "project.updated" - properties: Project -} + type: 'project.updated'; + properties: Project; +}; export type EventFileEdited = { - type: "file.edited" + type: 'file.edited'; properties: { - file: string - } -} + file: string; + }; +}; export type EventServerInstanceDisposed = { - type: "server.instance.disposed" + type: 'server.instance.disposed'; properties: { - directory: string - } -} + directory: string; + }; +}; export type EventFileWatcherUpdated = { - type: "file.watcher.updated" + type: 'file.watcher.updated'; properties: { - file: string - event: "add" | "change" | "unlink" - } -} + file: string; + event: 'add' | 'change' | 'unlink'; + }; +}; export type PermissionRequest = { - id: string - sessionID: string - permission: string - patterns: Array + id: string; + sessionID: string; + permission: string; + patterns: Array; metadata: { - [key: string]: unknown - } - always: Array + [key: string]: unknown; + }; + always: Array; tool?: { - messageID: string - callID: string - } -} + messageID: string; + callID: string; + }; +}; export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} + type: 'permission.asked'; + properties: PermissionRequest; +}; export type EventPermissionReplied = { - type: "permission.replied" + type: 'permission.replied'; properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} + sessionID: string; + requestID: string; + reply: 'once' | 'always' | 'reject'; + }; +}; export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" + type: 'vcs.branch.updated'; properties: { - branch?: string - } -} + branch?: string; + }; +}; export type QuestionOption = { /** * Display text (1-5 words, concise) */ - label: string + label: string; /** * Explanation of choice */ - description: string -} + description: string; +}; export type QuestionInfo = { /** * Complete question */ - question: string + question: string; /** * Very short label (max 30 chars) */ - header: string + header: string; /** * Available choices */ - options: Array + options: Array; /** * Allow selecting multiple choices */ - multiple?: boolean + multiple?: boolean; /** * Allow typing a custom answer (default: true) */ - custom?: boolean -} + custom?: boolean; +}; export type QuestionRequest = { - id: string - sessionID: string + id: string; + sessionID: string; /** * Questions to ask */ - questions: Array + questions: Array; tool?: { - messageID: string - callID: string - } -} + messageID: string; + callID: string; + }; +}; export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} + type: 'question.asked'; + properties: QuestionRequest; +}; -export type QuestionAnswer = Array +export type QuestionAnswer = Array; export type EventQuestionReplied = { - type: "question.replied" + type: 'question.replied'; properties: { - sessionID: string - requestID: string - answers: Array - } -} + sessionID: string; + requestID: string; + answers: Array; + }; +}; export type EventQuestionRejected = { - type: "question.rejected" + type: 'question.rejected'; properties: { - sessionID: string - requestID: string - } -} + sessionID: string; + requestID: string; + }; +}; export type EventServerConnected = { - type: "server.connected" + type: 'server.connected'; properties: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type EventGlobalDisposed = { - type: "global.disposed" + type: 'global.disposed'; properties: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type EventLspClientDiagnostics = { - type: "lsp.client.diagnostics" + type: 'lsp.client.diagnostics'; properties: { - serverID: string - path: string - } -} + serverID: string; + path: string; + }; +}; export type EventLspUpdated = { - type: "lsp.updated" + type: 'lsp.updated'; properties: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type OutputFormatText = { - type: "text" -} + type: 'text'; +}; export type JsonSchema = { - [key: string]: unknown -} + [key: string]: unknown; +}; export type OutputFormatJsonSchema = { - type: "json_schema" - schema: JsonSchema - retryCount?: number -} + type: 'json_schema'; + schema: JsonSchema; + retryCount?: number; +}; -export type OutputFormat = OutputFormatText | OutputFormatJsonSchema +export type OutputFormat = OutputFormatText | OutputFormatJsonSchema; export type FileDiff = { - file: string - before: string - after: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} + file: string; + before: string; + after: string; + additions: number; + deletions: number; + status?: 'added' | 'deleted' | 'modified'; +}; export type UserMessage = { - id: string - sessionID: string - role: "user" + id: string; + sessionID: string; + role: 'user'; time: { - created: number - } - format?: OutputFormat + created: number; + }; + format?: OutputFormat; summary?: { - title?: string - body?: string - diffs: Array - } - agent: string + title?: string; + body?: string; + diffs: Array; + }; + agent: string; model: { - providerID: string - modelID: string - } - system?: string + providerID: string; + modelID: string; + }; + system?: string; tools?: { - [key: string]: boolean - } - variant?: string -} + [key: string]: boolean; + }; + variant?: string; +}; export type ProviderAuthError = { - name: "ProviderAuthError" + name: 'ProviderAuthError'; data: { - providerID: string - message: string - } -} + providerID: string; + message: string; + }; +}; export type UnknownError = { - name: "UnknownError" + name: 'UnknownError'; data: { - message: string - } -} + message: string; + }; +}; export type MessageOutputLengthError = { - name: "MessageOutputLengthError" + name: 'MessageOutputLengthError'; data: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type MessageAbortedError = { - name: "MessageAbortedError" + name: 'MessageAbortedError'; data: { - message: string - } -} + message: string; + }; +}; export type StructuredOutputError = { - name: "StructuredOutputError" + name: 'StructuredOutputError'; data: { - message: string - retries: number - } -} + message: string; + retries: number; + }; +}; export type ContextOverflowError = { - name: "ContextOverflowError" + name: 'ContextOverflowError'; data: { - message: string - responseBody?: string - } -} + message: string; + responseBody?: string; + }; +}; export type ApiError = { - name: "APIError" + name: 'APIError'; data: { - message: string - statusCode?: number - isRetryable: boolean + message: string; + statusCode?: number; + isRetryable: boolean; responseHeaders?: { - [key: string]: string - } - responseBody?: string + [key: string]: string; + }; + responseBody?: string; metadata?: { - [key: string]: string - } - } -} + [key: string]: string; + }; + }; +}; export type AssistantMessage = { - id: string - sessionID: string - role: "assistant" + id: string; + sessionID: string; + role: 'assistant'; time: { - created: number - completed?: number - } + created: number; + completed?: number; + }; error?: | ProviderAuthError | UnknownError @@ -331,297 +331,297 @@ export type AssistantMessage = { | MessageAbortedError | StructuredOutputError | ContextOverflowError - | ApiError - parentID: string - modelID: string - providerID: string - mode: string - agent: string + | ApiError; + parentID: string; + modelID: string; + providerID: string; + mode: string; + agent: string; path: { - cwd: string - root: string - } - summary?: boolean - cost: number + cwd: string; + root: string; + }; + summary?: boolean; + cost: number; tokens: { - total?: number - input: number - output: number - reasoning: number + total?: number; + input: number; + output: number; + reasoning: number; cache: { - read: number - write: number - } - } - structured?: unknown - variant?: string - finish?: string -} + read: number; + write: number; + }; + }; + structured?: unknown; + variant?: string; + finish?: string; +}; -export type Message = UserMessage | AssistantMessage +export type Message = UserMessage | AssistantMessage; export type EventMessageUpdated = { - type: "message.updated" + type: 'message.updated'; properties: { - info: Message - } -} + info: Message; + }; +}; export type EventMessageRemoved = { - type: "message.removed" + type: 'message.removed'; properties: { - sessionID: string - messageID: string - } -} + sessionID: string; + messageID: string; + }; +}; export type TextPart = { - id: string - sessionID: string - messageID: string - type: "text" - text: string - synthetic?: boolean - ignored?: boolean + id: string; + sessionID: string; + messageID: string; + type: 'text'; + text: string; + synthetic?: boolean; + ignored?: boolean; time?: { - start: number - end?: number - } + start: number; + end?: number; + }; metadata?: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type SubtaskPart = { - id: string - sessionID: string - messageID: string - type: "subtask" - prompt: string - description: string - agent: string + id: string; + sessionID: string; + messageID: string; + type: 'subtask'; + prompt: string; + description: string; + agent: string; model?: { - providerID: string - modelID: string - } - command?: string -} + providerID: string; + modelID: string; + }; + command?: string; +}; export type ReasoningPart = { - id: string - sessionID: string - messageID: string - type: "reasoning" - text: string + id: string; + sessionID: string; + messageID: string; + type: 'reasoning'; + text: string; metadata?: { - [key: string]: unknown - } + [key: string]: unknown; + }; time: { - start: number - end?: number - } -} + start: number; + end?: number; + }; +}; export type FilePartSourceText = { - value: string - start: number - end: number -} + value: string; + start: number; + end: number; +}; export type FileSource = { - text: FilePartSourceText - type: "file" - path: string -} + text: FilePartSourceText; + type: 'file'; + path: string; +}; export type Range = { start: { - line: number - character: number - } + line: number; + character: number; + }; end: { - line: number - character: number - } -} + line: number; + character: number; + }; +}; export type SymbolSource = { - text: FilePartSourceText - type: "symbol" - path: string - range: Range - name: string - kind: number -} + text: FilePartSourceText; + type: 'symbol'; + path: string; + range: Range; + name: string; + kind: number; +}; export type ResourceSource = { - text: FilePartSourceText - type: "resource" - clientName: string - uri: string -} + text: FilePartSourceText; + type: 'resource'; + clientName: string; + uri: string; +}; -export type FilePartSource = FileSource | SymbolSource | ResourceSource +export type FilePartSource = FileSource | SymbolSource | ResourceSource; export type FilePart = { - id: string - sessionID: string - messageID: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource -} + id: string; + sessionID: string; + messageID: string; + type: 'file'; + mime: string; + filename?: string; + url: string; + source?: FilePartSource; +}; export type ToolStatePending = { - status: "pending" + status: 'pending'; input: { - [key: string]: unknown - } - raw: string -} + [key: string]: unknown; + }; + raw: string; +}; export type ToolStateRunning = { - status: "running" + status: 'running'; input: { - [key: string]: unknown - } - title?: string + [key: string]: unknown; + }; + title?: string; metadata?: { - [key: string]: unknown - } + [key: string]: unknown; + }; time: { - start: number - } -} + start: number; + }; +}; export type ToolStateCompleted = { - status: "completed" + status: 'completed'; input: { - [key: string]: unknown - } - output: string - title: string + [key: string]: unknown; + }; + output: string; + title: string; metadata: { - [key: string]: unknown - } + [key: string]: unknown; + }; time: { - start: number - end: number - compacted?: number - } - attachments?: Array -} + start: number; + end: number; + compacted?: number; + }; + attachments?: Array; +}; export type ToolStateError = { - status: "error" + status: 'error'; input: { - [key: string]: unknown - } - error: string + [key: string]: unknown; + }; + error: string; metadata?: { - [key: string]: unknown - } + [key: string]: unknown; + }; time: { - start: number - end: number - } -} + start: number; + end: number; + }; +}; -export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError; export type ToolPart = { - id: string - sessionID: string - messageID: string - type: "tool" - callID: string - tool: string - state: ToolState + id: string; + sessionID: string; + messageID: string; + type: 'tool'; + callID: string; + tool: string; + state: ToolState; metadata?: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type StepStartPart = { - id: string - sessionID: string - messageID: string - type: "step-start" - snapshot?: string -} + id: string; + sessionID: string; + messageID: string; + type: 'step-start'; + snapshot?: string; +}; export type StepFinishPart = { - id: string - sessionID: string - messageID: string - type: "step-finish" - reason: string - snapshot?: string - cost: number + id: string; + sessionID: string; + messageID: string; + type: 'step-finish'; + reason: string; + snapshot?: string; + cost: number; tokens: { - total?: number - input: number - output: number - reasoning: number + total?: number; + input: number; + output: number; + reasoning: number; cache: { - read: number - write: number - } - } -} + read: number; + write: number; + }; + }; +}; export type SnapshotPart = { - id: string - sessionID: string - messageID: string - type: "snapshot" - snapshot: string -} + id: string; + sessionID: string; + messageID: string; + type: 'snapshot'; + snapshot: string; +}; export type PatchPart = { - id: string - sessionID: string - messageID: string - type: "patch" - hash: string - files: Array -} + id: string; + sessionID: string; + messageID: string; + type: 'patch'; + hash: string; + files: Array; +}; export type AgentPart = { - id: string - sessionID: string - messageID: string - type: "agent" - name: string + id: string; + sessionID: string; + messageID: string; + type: 'agent'; + name: string; source?: { - value: string - start: number - end: number - } -} + value: string; + start: number; + end: number; + }; +}; export type RetryPart = { - id: string - sessionID: string - messageID: string - type: "retry" - attempt: number - error: ApiError + id: string; + sessionID: string; + messageID: string; + type: 'retry'; + attempt: number; + error: ApiError; time: { - created: number - } -} + created: number; + }; +}; export type CompactionPart = { - id: string - sessionID: string - messageID: string - type: "compaction" - auto: boolean - overflow?: boolean -} + id: string; + sessionID: string; + messageID: string; + type: 'compaction'; + auto: boolean; + overflow?: boolean; +}; export type Part = | TextPart @@ -635,249 +635,249 @@ export type Part = | PatchPart | AgentPart | RetryPart - | CompactionPart + | CompactionPart; export type EventMessagePartUpdated = { - type: "message.part.updated" + type: 'message.part.updated'; properties: { - part: Part - } -} + part: Part; + }; +}; export type EventMessagePartDelta = { - type: "message.part.delta" + type: 'message.part.delta'; properties: { - sessionID: string - messageID: string - partID: string - field: string - delta: string - } -} + sessionID: string; + messageID: string; + partID: string; + field: string; + delta: string; + }; +}; export type EventMessagePartRemoved = { - type: "message.part.removed" + type: 'message.part.removed'; properties: { - sessionID: string - messageID: string - partID: string - } -} + sessionID: string; + messageID: string; + partID: string; + }; +}; export type SessionStatus = | { - type: "idle" + type: 'idle'; } | { - type: "retry" - attempt: number - message: string - next: number + type: 'retry'; + attempt: number; + message: string; + next: number; } | { - type: "busy" - } + type: 'busy'; + }; export type EventSessionStatus = { - type: "session.status" + type: 'session.status'; properties: { - sessionID: string - status: SessionStatus - } -} + sessionID: string; + status: SessionStatus; + }; +}; export type EventSessionIdle = { - type: "session.idle" + type: 'session.idle'; properties: { - sessionID: string - } -} + sessionID: string; + }; +}; export type EventSessionCompacted = { - type: "session.compacted" + type: 'session.compacted'; properties: { - sessionID: string - } -} + sessionID: string; + }; +}; export type Todo = { /** * Brief description of the task */ - content: string + content: string; /** * Current status of the task: pending, in_progress, completed, cancelled */ - status: string + status: string; /** * Priority level of the task: high, medium, low */ - priority: string -} + priority: string; +}; export type EventTodoUpdated = { - type: "todo.updated" + type: 'todo.updated'; properties: { - sessionID: string - todos: Array - } -} + sessionID: string; + todos: Array; + }; +}; export type EventTuiPromptAppend = { - type: "tui.prompt.append" + type: 'tui.prompt.append'; properties: { - text: string - } -} + text: string; + }; +}; export type EventTuiCommandExecute = { - type: "tui.command.execute" + type: 'tui.command.execute'; properties: { command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} + | 'session.list' + | 'session.new' + | 'session.share' + | 'session.interrupt' + | 'session.compact' + | 'session.page.up' + | 'session.page.down' + | 'session.line.up' + | 'session.line.down' + | 'session.half.page.up' + | 'session.half.page.down' + | 'session.first' + | 'session.last' + | 'prompt.clear' + | 'prompt.submit' + | 'agent.cycle' + | string; + }; +}; export type EventTuiToastShow = { - type: "tui.toast.show" + type: 'tui.toast.show'; properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" + title?: string; + message: string; + variant: 'info' | 'success' | 'warning' | 'error'; /** * Duration in milliseconds */ - duration?: number - } -} + duration?: number; + }; +}; export type EventTuiSessionSelect = { - type: "tui.session.select" + type: 'tui.session.select'; properties: { /** * Session ID to navigate to */ - sessionID: string - } -} + sessionID: string; + }; +}; export type EventMcpToolsChanged = { - type: "mcp.tools.changed" + type: 'mcp.tools.changed'; properties: { - server: string - } -} + server: string; + }; +}; export type EventMcpBrowserOpenFailed = { - type: "mcp.browser.open.failed" + type: 'mcp.browser.open.failed'; properties: { - mcpName: string - url: string - } -} + mcpName: string; + url: string; + }; +}; export type EventCommandExecuted = { - type: "command.executed" + type: 'command.executed'; properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} + name: string; + sessionID: string; + arguments: string; + messageID: string; + }; +}; -export type PermissionAction = "allow" | "deny" | "ask" +export type PermissionAction = 'allow' | 'deny' | 'ask'; export type PermissionRule = { - permission: string - pattern: string - action: PermissionAction -} + permission: string; + pattern: string; + action: PermissionAction; +}; -export type PermissionRuleset = Array +export type PermissionRuleset = Array; export type Session = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - parentID?: string + id: string; + slug: string; + projectID: string; + workspaceID?: string; + directory: string; + parentID?: string; summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } + additions: number; + deletions: number; + files: number; + diffs?: Array; + }; share?: { - url: string - } - title: string - version: string + url: string; + }; + title: string; + version: string; time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset + created: number; + updated: number; + compacting?: number; + archived?: number; + }; + permission?: PermissionRuleset; revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} + messageID: string; + partID?: string; + snapshot?: string; + diff?: string; + }; +}; export type EventSessionCreated = { - type: "session.created" + type: 'session.created'; properties: { - info: Session - } -} + info: Session; + }; +}; export type EventSessionUpdated = { - type: "session.updated" + type: 'session.updated'; properties: { - info: Session - } -} + info: Session; + }; +}; export type EventSessionDeleted = { - type: "session.deleted" + type: 'session.deleted'; properties: { - info: Session - } -} + info: Session; + }; +}; export type EventSessionDiff = { - type: "session.diff" + type: 'session.diff'; properties: { - sessionID: string - diff: Array - } -} + sessionID: string; + diff: Array; + }; +}; export type EventSessionError = { - type: "session.error" + type: 'session.error'; properties: { - sessionID?: string + sessionID?: string; error?: | ProviderAuthError | UnknownError @@ -885,77 +885,77 @@ export type EventSessionError = { | MessageAbortedError | StructuredOutputError | ContextOverflowError - | ApiError - } -} + | ApiError; + }; +}; export type EventWorkspaceReady = { - type: "workspace.ready" + type: 'workspace.ready'; properties: { - name: string - } -} + name: string; + }; +}; export type EventWorkspaceFailed = { - type: "workspace.failed" + type: 'workspace.failed'; properties: { - message: string - } -} + message: string; + }; +}; export type Pty = { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number -} + id: string; + title: string; + command: string; + args: Array; + cwd: string; + status: 'running' | 'exited'; + pid: number; +}; export type EventPtyCreated = { - type: "pty.created" + type: 'pty.created'; properties: { - info: Pty - } -} + info: Pty; + }; +}; export type EventPtyUpdated = { - type: "pty.updated" + type: 'pty.updated'; properties: { - info: Pty - } -} + info: Pty; + }; +}; export type EventPtyExited = { - type: "pty.exited" + type: 'pty.exited'; properties: { - id: string - exitCode: number - } -} + id: string; + exitCode: number; + }; +}; export type EventPtyDeleted = { - type: "pty.deleted" + type: 'pty.deleted'; properties: { - id: string - } -} + id: string; + }; +}; export type EventWorktreeReady = { - type: "worktree.ready" + type: 'worktree.ready'; properties: { - name: string - branch: string - } -} + name: string; + branch: string; + }; +}; export type EventWorktreeFailed = { - type: "worktree.failed" + type: 'worktree.failed'; properties: { - message: string - } -} + message: string; + }; +}; export type Event = | EventInstallationUpdated @@ -1002,17 +1002,17 @@ export type Event = | EventPtyExited | EventPtyDeleted | EventWorktreeReady - | EventWorktreeFailed + | EventWorktreeFailed; export type GlobalEvent = { - directory: string - payload: Event -} + directory: string; + payload: Event; +}; /** * Log level */ -export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" +export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; /** * Server configuration for opencode serve and web commands @@ -1021,179 +1021,179 @@ export type ServerConfig = { /** * Port to listen on */ - port?: number + port?: number; /** * Hostname to listen on */ - hostname?: string + hostname?: string; /** * Enable mDNS service discovery */ - mdns?: boolean + mdns?: boolean; /** * Custom domain name for mDNS service (default: opencode.local) */ - mdnsDomain?: string + mdnsDomain?: string; /** * Additional domains to allow for CORS */ - cors?: Array -} + cors?: Array; +}; -export type PermissionActionConfig = "ask" | "allow" | "deny" +export type PermissionActionConfig = 'ask' | 'allow' | 'deny'; export type PermissionObjectConfig = { - [key: string]: PermissionActionConfig -} + [key: string]: PermissionActionConfig; +}; -export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig +export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig; export type PermissionConfig = | { - __originalKeys?: Array - read?: PermissionRuleConfig - edit?: PermissionRuleConfig - glob?: PermissionRuleConfig - grep?: PermissionRuleConfig - list?: PermissionRuleConfig - bash?: PermissionRuleConfig - task?: PermissionRuleConfig - external_directory?: PermissionRuleConfig - todowrite?: PermissionActionConfig - todoread?: PermissionActionConfig - question?: PermissionActionConfig - webfetch?: PermissionActionConfig - websearch?: PermissionActionConfig - codesearch?: PermissionActionConfig - lsp?: PermissionRuleConfig - doom_loop?: PermissionActionConfig - skill?: PermissionRuleConfig - [key: string]: PermissionRuleConfig | Array | PermissionActionConfig | undefined + __originalKeys?: Array; + read?: PermissionRuleConfig; + edit?: PermissionRuleConfig; + glob?: PermissionRuleConfig; + grep?: PermissionRuleConfig; + list?: PermissionRuleConfig; + bash?: PermissionRuleConfig; + task?: PermissionRuleConfig; + external_directory?: PermissionRuleConfig; + todowrite?: PermissionActionConfig; + todoread?: PermissionActionConfig; + question?: PermissionActionConfig; + webfetch?: PermissionActionConfig; + websearch?: PermissionActionConfig; + codesearch?: PermissionActionConfig; + lsp?: PermissionRuleConfig; + doom_loop?: PermissionActionConfig; + skill?: PermissionRuleConfig; + [key: string]: PermissionRuleConfig | Array | PermissionActionConfig | undefined; } - | PermissionActionConfig + | PermissionActionConfig; export type AgentConfig = { - model?: string + model?: string; /** * Default model variant for this agent (applies only when using the agent's configured model). */ - variant?: string - temperature?: number - top_p?: number - prompt?: string + variant?: string; + temperature?: number; + top_p?: number; + prompt?: string; /** * @deprecated Use 'permission' field instead */ tools?: { - [key: string]: boolean - } - disable?: boolean + [key: string]: boolean; + }; + disable?: boolean; /** * Description of when to use the agent */ - description?: string - mode?: "subagent" | "primary" | "all" + description?: string; + mode?: 'subagent' | 'primary' | 'all'; /** * Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent) */ - hidden?: boolean + hidden?: boolean; options?: { - [key: string]: unknown - } + [key: string]: unknown; + }; /** * Hex color code (e.g., #FF5733) or theme color (e.g., primary) */ - color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + color?: string | 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'error' | 'info'; /** * Maximum number of agentic iterations before forcing text-only response */ - steps?: number + steps?: number; /** * @deprecated Use 'steps' field instead. */ - maxSteps?: number - permission?: PermissionConfig + maxSteps?: number; + permission?: PermissionConfig; [key: string]: | unknown | string | number | { - [key: string]: boolean + [key: string]: boolean; } | boolean - | "subagent" - | "primary" - | "all" + | 'subagent' + | 'primary' + | 'all' | { - [key: string]: unknown + [key: string]: unknown; } | string - | "primary" - | "secondary" - | "accent" - | "success" - | "warning" - | "error" - | "info" + | 'primary' + | 'secondary' + | 'accent' + | 'success' + | 'warning' + | 'error' + | 'info' | number | PermissionConfig - | undefined -} + | undefined; +}; export type ProviderConfig = { - api?: string - name?: string - env?: Array - id?: string - npm?: string + api?: string; + name?: string; + env?: Array; + id?: string; + npm?: string; models?: { [key: string]: { - id?: string - name?: string - family?: string - release_date?: string - attachment?: boolean - reasoning?: boolean - temperature?: boolean - tool_call?: boolean + id?: string; + name?: string; + family?: string; + release_date?: string; + attachment?: boolean; + reasoning?: boolean; + temperature?: boolean; + tool_call?: boolean; interleaved?: | true | { - field: "reasoning_content" | "reasoning_details" - } + field: 'reasoning_content' | 'reasoning_details'; + }; cost?: { - input: number - output: number - cache_read?: number - cache_write?: number + input: number; + output: number; + cache_read?: number; + cache_write?: number; context_over_200k?: { - input: number - output: number - cache_read?: number - cache_write?: number - } - } + input: number; + output: number; + cache_read?: number; + cache_write?: number; + }; + }; limit?: { - context: number - input?: number - output: number - } + context: number; + input?: number; + output: number; + }; modalities?: { - input: Array<"text" | "audio" | "image" | "video" | "pdf"> - output: Array<"text" | "audio" | "image" | "video" | "pdf"> - } - experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + input: Array<'text' | 'audio' | 'image' | 'video' | 'pdf'>; + output: Array<'text' | 'audio' | 'image' | 'video' | 'pdf'>; + }; + experimental?: boolean; + status?: 'alpha' | 'beta' | 'deprecated'; options?: { - [key: string]: unknown - } + [key: string]: unknown; + }; headers?: { - [key: string]: string - } + [key: string]: string; + }; provider?: { - npm?: string - api?: string - } + npm?: string; + api?: string; + }; /** * Variant-specific configuration */ @@ -1202,130 +1202,130 @@ export type ProviderConfig = { /** * Disable this variant for the model */ - disabled?: boolean - [key: string]: unknown | boolean | undefined - } - } - } - } - whitelist?: Array - blacklist?: Array + disabled?: boolean; + [key: string]: unknown | boolean | undefined; + }; + }; + }; + }; + whitelist?: Array; + blacklist?: Array; options?: { - apiKey?: string - baseURL?: string + apiKey?: string; + baseURL?: string; /** * GitHub Enterprise URL for copilot authentication */ - enterpriseUrl?: string + enterpriseUrl?: string; /** * Enable promptCacheKey for this provider (default false) */ - setCacheKey?: boolean + setCacheKey?: boolean; /** * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. */ - timeout?: number | false + timeout?: number | false; /** * Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. */ - chunkTimeout?: number - [key: string]: unknown | string | boolean | number | false | number | undefined - } -} + chunkTimeout?: number; + [key: string]: unknown | string | boolean | number | false | number | undefined; + }; +}; export type McpLocalConfig = { /** * Type of MCP server connection */ - type: "local" + type: 'local'; /** * Command and arguments to run the MCP server */ - command: Array + command: Array; /** * Environment variables to set when running the MCP server */ environment?: { - [key: string]: string - } + [key: string]: string; + }; /** * Enable or disable the MCP server on startup */ - enabled?: boolean + enabled?: boolean; /** * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. */ - timeout?: number -} + timeout?: number; +}; export type McpOAuthConfig = { /** * OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted. */ - clientId?: string + clientId?: string; /** * OAuth client secret (if required by the authorization server) */ - clientSecret?: string + clientSecret?: string; /** * OAuth scopes to request during authorization */ - scope?: string -} + scope?: string; +}; export type McpRemoteConfig = { /** * Type of MCP server connection */ - type: "remote" + type: 'remote'; /** * URL of the remote MCP server */ - url: string + url: string; /** * Enable or disable the MCP server on startup */ - enabled?: boolean + enabled?: boolean; /** * Headers to send with the request */ headers?: { - [key: string]: string - } + [key: string]: string; + }; /** * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. */ - oauth?: McpOAuthConfig | false + oauth?: McpOAuthConfig | false; /** * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. */ - timeout?: number -} + timeout?: number; +}; /** * @deprecated Always uses stretch layout. */ -export type LayoutConfig = "auto" | "stretch" +export type LayoutConfig = 'auto' | 'stretch'; export type Config = { /** * JSON schema reference for configuration validation */ - $schema?: string - logLevel?: LogLevel - server?: ServerConfig + $schema?: string; + logLevel?: LogLevel; + server?: ServerConfig; /** * Command configuration, see https://opencode.ai/docs/commands */ command?: { [key: string]: { - template: string - description?: string - agent?: string - model?: string - subtask?: boolean - } - } + template: string; + description?: string; + agent?: string; + model?: string; + subtask?: boolean; + }; + }; /** * Additional skill folder paths */ @@ -1333,83 +1333,83 @@ export type Config = { /** * Additional paths to skill folders */ - paths?: Array + paths?: Array; /** * URLs to fetch skills from (e.g., https://example.com/.well-known/skills/) */ - urls?: Array - } + urls?: Array; + }; watcher?: { - ignore?: Array - } - plugin?: Array + ignore?: Array; + }; + plugin?: Array; /** * Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true. */ - snapshot?: boolean + snapshot?: boolean; /** * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing */ - share?: "manual" | "auto" | "disabled" + share?: 'manual' | 'auto' | 'disabled'; /** * @deprecated Use 'share' field instead. Share newly created sessions automatically */ - autoshare?: boolean + autoshare?: boolean; /** * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications */ - autoupdate?: boolean | "notify" + autoupdate?: boolean | 'notify'; /** * Disable providers that are loaded automatically */ - disabled_providers?: Array + disabled_providers?: Array; /** * When set, ONLY these providers will be enabled. All other providers will be ignored */ - enabled_providers?: Array + enabled_providers?: Array; /** * Model to use in the format of provider/model, eg anthropic/claude-2 */ - model?: string + model?: string; /** * Small model to use for tasks like title generation in the format of provider/model */ - small_model?: string + small_model?: string; /** * Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid. */ - default_agent?: string + default_agent?: string; /** * Custom username to display in conversations instead of system username */ - username?: string + username?: string; /** * @deprecated Use `agent` field instead. */ mode?: { - build?: AgentConfig - plan?: AgentConfig - [key: string]: AgentConfig | undefined - } + build?: AgentConfig; + plan?: AgentConfig; + [key: string]: AgentConfig | undefined; + }; /** * Agent configuration, see https://opencode.ai/docs/agents */ agent?: { - plan?: AgentConfig - build?: AgentConfig - general?: AgentConfig - explore?: AgentConfig - title?: AgentConfig - summary?: AgentConfig - compaction?: AgentConfig - [key: string]: AgentConfig | undefined - } + plan?: AgentConfig; + build?: AgentConfig; + general?: AgentConfig; + explore?: AgentConfig; + title?: AgentConfig; + summary?: AgentConfig; + compaction?: AgentConfig; + [key: string]: AgentConfig | undefined; + }; /** * Custom provider configurations and model overrides */ provider?: { - [key: string]: ProviderConfig - } + [key: string]: ProviderConfig; + }; /** * MCP (Model Context Protocol) server configurations */ @@ -1418,2495 +1418,2501 @@ export type Config = { | McpLocalConfig | McpRemoteConfig | { - enabled: boolean - } - } + enabled: boolean; + }; + }; formatter?: | false | { [key: string]: { - disabled?: boolean - command?: Array + disabled?: boolean; + command?: Array; environment?: { - [key: string]: string - } - extensions?: Array - } - } + [key: string]: string; + }; + extensions?: Array; + }; + }; lsp?: | false | { [key: string]: | { - disabled: true + disabled: true; } | { - command: Array - extensions?: Array - disabled?: boolean + command: Array; + extensions?: Array; + disabled?: boolean; env?: { - [key: string]: string - } + [key: string]: string; + }; initialization?: { - [key: string]: unknown - } - } - } + [key: string]: unknown; + }; + }; + }; /** * Additional instruction files or patterns to include */ - instructions?: Array - layout?: LayoutConfig - permission?: PermissionConfig + instructions?: Array; + layout?: LayoutConfig; + permission?: PermissionConfig; tools?: { - [key: string]: boolean - } + [key: string]: boolean; + }; enterprise?: { /** * Enterprise URL */ - url?: string - } + url?: string; + }; compaction?: { /** * Enable automatic compaction when context is full (default: true) */ - auto?: boolean + auto?: boolean; /** * Enable pruning of old tool outputs (default: true) */ - prune?: boolean + prune?: boolean; /** * Token buffer for compaction. Leaves enough window to avoid overflow during compaction. */ - reserved?: number - } + reserved?: number; + }; experimental?: { - disable_paste_summary?: boolean + disable_paste_summary?: boolean; /** * Enable the batch tool */ - batch_tool?: boolean + batch_tool?: boolean; /** * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) */ - openTelemetry?: boolean + openTelemetry?: boolean; /** * Tools that should only be available to primary agents. */ - primary_tools?: Array + primary_tools?: Array; /** * Continue the agent loop when a tool call is denied */ - continue_loop_on_deny?: boolean + continue_loop_on_deny?: boolean; /** * Timeout in milliseconds for model context protocol (MCP) requests */ - mcp_timeout?: number - } -} + mcp_timeout?: number; + }; +}; export type BadRequestError = { - data: unknown + data: unknown; errors: Array<{ - [key: string]: unknown - }> - success: false -} + [key: string]: unknown; + }>; + success: false; +}; export type OAuth = { - type: "oauth" - refresh: string - access: string - expires: number - accountId?: string - enterpriseUrl?: string -} + type: 'oauth'; + refresh: string; + access: string; + expires: number; + accountId?: string; + enterpriseUrl?: string; +}; export type ApiAuth = { - type: "api" - key: string -} + type: 'api'; + key: string; +}; export type WellKnownAuth = { - type: "wellknown" - key: string - token: string -} + type: 'wellknown'; + key: string; + token: string; +}; -export type Auth = OAuth | ApiAuth | WellKnownAuth +export type Auth = OAuth | ApiAuth | WellKnownAuth; export type NotFoundError = { - name: "NotFoundError" + name: 'NotFoundError'; data: { - message: string - } -} + message: string; + }; +}; export type Model = { - id: string - providerID: string + id: string; + providerID: string; api: { - id: string - url: string - npm: string - } - name: string - family?: string + id: string; + url: string; + npm: string; + }; + name: string; + family?: string; capabilities: { - temperature: boolean - reasoning: boolean - attachment: boolean - toolcall: boolean + temperature: boolean; + reasoning: boolean; + attachment: boolean; + toolcall: boolean; input: { - text: boolean - audio: boolean - image: boolean - video: boolean - pdf: boolean - } + text: boolean; + audio: boolean; + image: boolean; + video: boolean; + pdf: boolean; + }; output: { - text: boolean - audio: boolean - image: boolean - video: boolean - pdf: boolean - } + text: boolean; + audio: boolean; + image: boolean; + video: boolean; + pdf: boolean; + }; interleaved: | boolean | { - field: "reasoning_content" | "reasoning_details" - } - } + field: 'reasoning_content' | 'reasoning_details'; + }; + }; cost: { - input: number - output: number + input: number; + output: number; cache: { - read: number - write: number - } + read: number; + write: number; + }; experimentalOver200K?: { - input: number - output: number + input: number; + output: number; cache: { - read: number - write: number - } - } - } + read: number; + write: number; + }; + }; + }; limit: { - context: number - input?: number - output: number - } - status: "alpha" | "beta" | "deprecated" | "active" + context: number; + input?: number; + output: number; + }; + status: 'alpha' | 'beta' | 'deprecated' | 'active'; options: { - [key: string]: unknown - } + [key: string]: unknown; + }; headers: { - [key: string]: string - } - release_date: string + [key: string]: string; + }; + release_date: string; variants?: { [key: string]: { - [key: string]: unknown - } - } -} + [key: string]: unknown; + }; + }; +}; export type Provider = { - id: string - name: string - source: "env" | "config" | "custom" | "api" - env: Array - key?: string + id: string; + name: string; + source: 'env' | 'config' | 'custom' | 'api'; + env: Array; + key?: string; options: { - [key: string]: unknown - } + [key: string]: unknown; + }; models: { - [key: string]: Model - } -} + [key: string]: Model; + }; +}; -export type ToolIds = Array +export type ToolIds = Array; export type ToolListItem = { - id: string - description: string - parameters: unknown -} + id: string; + description: string; + parameters: unknown; +}; -export type ToolList = Array +export type ToolList = Array; export type Workspace = { - id: string - type: string - branch: string | null - name: string | null - directory: string | null - extra: unknown | null - projectID: string -} + id: string; + type: string; + branch: string | null; + name: string | null; + directory: string | null; + extra: unknown | null; + projectID: string; +}; export type Worktree = { - name: string - branch: string - directory: string -} + name: string; + branch: string; + directory: string; +}; export type WorktreeCreateInput = { - name?: string + name?: string; /** * Additional startup script to run after the project's start command */ - startCommand?: string -} + startCommand?: string; +}; export type WorktreeRemoveInput = { - directory: string -} + directory: string; +}; export type WorktreeResetInput = { - directory: string -} + directory: string; +}; export type ProjectSummary = { - id: string - name?: string - worktree: string -} + id: string; + name?: string; + worktree: string; +}; export type GlobalSession = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - parentID?: string + id: string; + slug: string; + projectID: string; + workspaceID?: string; + directory: string; + parentID?: string; summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } + additions: number; + deletions: number; + files: number; + diffs?: Array; + }; share?: { - url: string - } - title: string - version: string + url: string; + }; + title: string; + version: string; time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset + created: number; + updated: number; + compacting?: number; + archived?: number; + }; + permission?: PermissionRuleset; revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } - project: ProjectSummary | null -} + messageID: string; + partID?: string; + snapshot?: string; + diff?: string; + }; + project: ProjectSummary | null; +}; export type McpResource = { - name: string - uri: string - description?: string - mimeType?: string - client: string -} + name: string; + uri: string; + description?: string; + mimeType?: string; + client: string; +}; export type TextPartInput = { - id?: string - type: "text" - text: string - synthetic?: boolean - ignored?: boolean + id?: string; + type: 'text'; + text: string; + synthetic?: boolean; + ignored?: boolean; time?: { - start: number - end?: number - } + start: number; + end?: number; + }; metadata?: { - [key: string]: unknown - } -} + [key: string]: unknown; + }; +}; export type FilePartInput = { - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource -} + id?: string; + type: 'file'; + mime: string; + filename?: string; + url: string; + source?: FilePartSource; +}; export type AgentPartInput = { - id?: string - type: "agent" - name: string + id?: string; + type: 'agent'; + name: string; source?: { - value: string - start: number - end: number - } -} + value: string; + start: number; + end: number; + }; +}; export type SubtaskPartInput = { - id?: string - type: "subtask" - prompt: string - description: string - agent: string + id?: string; + type: 'subtask'; + prompt: string; + description: string; + agent: string; model?: { - providerID: string - modelID: string - } - command?: string -} + providerID: string; + modelID: string; + }; + command?: string; +}; export type ProviderAuthMethod = { - type: "oauth" | "api" - label: string + type: 'oauth' | 'api'; + label: string; prompts?: Array< | { - type: "text" - key: string - message: string - placeholder?: string + type: 'text'; + key: string; + message: string; + placeholder?: string; when?: { - key: string - op: "eq" | "neq" - value: string - } + key: string; + op: 'eq' | 'neq'; + value: string; + }; } | { - type: "select" - key: string - message: string + type: 'select'; + key: string; + message: string; options: Array<{ - label: string - value: string - hint?: string - }> + label: string; + value: string; + hint?: string; + }>; when?: { - key: string - op: "eq" | "neq" - value: string - } + key: string; + op: 'eq' | 'neq'; + value: string; + }; } - > -} + >; +}; export type ProviderAuthAuthorization = { - url: string - method: "auto" | "code" - instructions: string -} + url: string; + method: 'auto' | 'code'; + instructions: string; +}; export type Symbol = { - name: string - kind: number + name: string; + kind: number; location: { - uri: string - range: Range - } -} + uri: string; + range: Range; + }; +}; export type FileNode = { - name: string - path: string - absolute: string - type: "file" | "directory" - ignored: boolean -} + name: string; + path: string; + absolute: string; + type: 'file' | 'directory'; + ignored: boolean; +}; export type FileContent = { - type: "text" | "binary" - content: string - diff?: string + type: 'text' | 'binary'; + content: string; + diff?: string; patch?: { - oldFileName: string - newFileName: string - oldHeader?: string - newHeader?: string + oldFileName: string; + newFileName: string; + oldHeader?: string; + newHeader?: string; hunks: Array<{ - oldStart: number - oldLines: number - newStart: number - newLines: number - lines: Array - }> - index?: string - } - encoding?: "base64" - mimeType?: string -} + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: Array; + }>; + index?: string; + }; + encoding?: 'base64'; + mimeType?: string; +}; export type File = { - path: string - added: number - removed: number - status: "added" | "deleted" | "modified" -} + path: string; + added: number; + removed: number; + status: 'added' | 'deleted' | 'modified'; +}; export type McpStatusConnected = { - status: "connected" -} + status: 'connected'; +}; export type McpStatusDisabled = { - status: "disabled" -} + status: 'disabled'; +}; export type McpStatusFailed = { - status: "failed" - error: string -} + status: 'failed'; + error: string; +}; export type McpStatusNeedsAuth = { - status: "needs_auth" -} + status: 'needs_auth'; +}; export type McpStatusNeedsClientRegistration = { - status: "needs_client_registration" - error: string -} + status: 'needs_client_registration'; + error: string; +}; export type McpStatus = | McpStatusConnected | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth - | McpStatusNeedsClientRegistration + | McpStatusNeedsClientRegistration; export type Path = { - home: string - state: string - config: string - worktree: string - directory: string -} + home: string; + state: string; + config: string; + worktree: string; + directory: string; +}; export type VcsInfo = { - branch: string -} + branch: string; +}; export type Command = { - name: string - description?: string - agent?: string - model?: string - source?: "command" | "mcp" | "skill" - template: string - subtask?: boolean - hints: Array -} + name: string; + description?: string; + agent?: string; + model?: string; + source?: 'command' | 'mcp' | 'skill'; + template: string; + subtask?: boolean; + hints: Array; +}; export type Agent = { - name: string - description?: string - mode: "subagent" | "primary" | "all" - native?: boolean - hidden?: boolean - topP?: number - temperature?: number - color?: string - permission: PermissionRuleset + name: string; + description?: string; + mode: 'subagent' | 'primary' | 'all'; + native?: boolean; + hidden?: boolean; + topP?: number; + temperature?: number; + color?: string; + permission: PermissionRuleset; model?: { - modelID: string - providerID: string - } - variant?: string - prompt?: string + modelID: string; + providerID: string; + }; + variant?: string; + prompt?: string; options: { - [key: string]: unknown - } - steps?: number -} + [key: string]: unknown; + }; + steps?: number; +}; export type LspStatus = { - id: string - name: string - root: string - status: "connected" | "error" -} + id: string; + name: string; + root: string; + status: 'connected' | 'error'; +}; export type FormatterStatus = { - name: string - extensions: Array - enabled: boolean -} + name: string; + extensions: Array; + enabled: boolean; +}; export type GlobalHealthData = { - body?: never - path?: never - query?: never - url: "/global/health" -} + body?: never; + path?: never; + query?: never; + url: '/global/health'; +}; export type GlobalHealthResponses = { /** * Health information */ 200: { - healthy: true - version: string - } -} + healthy: true; + version: string; + }; +}; -export type GlobalHealthResponse = GlobalHealthResponses[keyof GlobalHealthResponses] +export type GlobalHealthResponse = GlobalHealthResponses[keyof GlobalHealthResponses]; export type GlobalEventData = { - body?: never - path?: never - query?: never - url: "/global/event" -} + body?: never; + path?: never; + query?: never; + url: '/global/event'; +}; export type GlobalEventResponses = { /** * Event stream */ - 200: GlobalEvent -} + 200: GlobalEvent; +}; -export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses] +export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses]; export type GlobalConfigGetData = { - body?: never - path?: never - query?: never - url: "/global/config" -} + body?: never; + path?: never; + query?: never; + url: '/global/config'; +}; export type GlobalConfigGetResponses = { /** * Get global config info */ - 200: Config -} + 200: Config; +}; -export type GlobalConfigGetResponse = GlobalConfigGetResponses[keyof GlobalConfigGetResponses] +export type GlobalConfigGetResponse = GlobalConfigGetResponses[keyof GlobalConfigGetResponses]; export type GlobalConfigUpdateData = { - body?: Config - path?: never - query?: never - url: "/global/config" -} + body?: Config; + path?: never; + query?: never; + url: '/global/config'; +}; export type GlobalConfigUpdateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type GlobalConfigUpdateError = GlobalConfigUpdateErrors[keyof GlobalConfigUpdateErrors] +export type GlobalConfigUpdateError = GlobalConfigUpdateErrors[keyof GlobalConfigUpdateErrors]; export type GlobalConfigUpdateResponses = { /** * Successfully updated global config */ - 200: Config -} + 200: Config; +}; -export type GlobalConfigUpdateResponse = GlobalConfigUpdateResponses[keyof GlobalConfigUpdateResponses] +export type GlobalConfigUpdateResponse = + GlobalConfigUpdateResponses[keyof GlobalConfigUpdateResponses]; export type GlobalDisposeData = { - body?: never - path?: never - query?: never - url: "/global/dispose" -} + body?: never; + path?: never; + query?: never; + url: '/global/dispose'; +}; export type GlobalDisposeResponses = { /** * Global disposed */ - 200: boolean -} + 200: boolean; +}; -export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses] +export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses]; export type AuthRemoveData = { - body?: never + body?: never; path: { - providerID: string - } - query?: never - url: "/auth/{providerID}" -} + providerID: string; + }; + query?: never; + url: '/auth/{providerID}'; +}; export type AuthRemoveErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] +export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors]; export type AuthRemoveResponses = { /** * Successfully removed authentication credentials */ - 200: boolean -} + 200: boolean; +}; -export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] +export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses]; export type AuthSetData = { - body?: Auth + body?: Auth; path: { - providerID: string - } - query?: never - url: "/auth/{providerID}" -} + providerID: string; + }; + query?: never; + url: '/auth/{providerID}'; +}; export type AuthSetErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] +export type AuthSetError = AuthSetErrors[keyof AuthSetErrors]; export type AuthSetResponses = { /** * Successfully set authentication credentials */ - 200: boolean -} + 200: boolean; +}; -export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] +export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]; export type ProjectListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/project" -} + directory?: string; + workspace?: string; + }; + url: '/project'; +}; export type ProjectListResponses = { /** * List of projects */ - 200: Array -} + 200: Array; +}; -export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses] +export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses]; export type ProjectCurrentData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/project/current" -} + directory?: string; + workspace?: string; + }; + url: '/project/current'; +}; export type ProjectCurrentResponses = { /** * Current project information */ - 200: Project -} + 200: Project; +}; -export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] +export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses]; export type ProjectInitGitData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/project/git/init" -} + directory?: string; + workspace?: string; + }; + url: '/project/git/init'; +}; export type ProjectInitGitResponses = { /** * Project information after git initialization */ - 200: Project -} + 200: Project; +}; -export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses] +export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses]; export type ProjectUpdateData = { body?: { - name?: string + name?: string; icon?: { - url?: string - override?: string - color?: string - } + url?: string; + override?: string; + color?: string; + }; commands?: { /** * Startup script to run when creating a new workspace (worktree) */ - start?: string - } - } + start?: string; + }; + }; path: { - projectID: string - } + projectID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/project/{projectID}" -} + directory?: string; + workspace?: string; + }; + url: '/project/{projectID}'; +}; export type ProjectUpdateErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors] +export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors]; export type ProjectUpdateResponses = { /** * Updated project information */ - 200: Project -} + 200: Project; +}; -export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses] +export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses]; export type PtyListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/pty" -} + directory?: string; + workspace?: string; + }; + url: '/pty'; +}; export type PtyListResponses = { /** * List of sessions */ - 200: Array -} + 200: Array; +}; -export type PtyListResponse = PtyListResponses[keyof PtyListResponses] +export type PtyListResponse = PtyListResponses[keyof PtyListResponses]; export type PtyCreateData = { body?: { - command?: string - args?: Array - cwd?: string - title?: string + command?: string; + args?: Array; + cwd?: string; + title?: string; env?: { - [key: string]: string - } - } - path?: never + [key: string]: string; + }; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/pty" -} + directory?: string; + workspace?: string; + }; + url: '/pty'; +}; export type PtyCreateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors] +export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors]; export type PtyCreateResponses = { /** * Created session */ - 200: Pty -} + 200: Pty; +}; -export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses] +export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses]; export type PtyRemoveData = { - body?: never + body?: never; path: { - ptyID: string - } + ptyID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/pty/{ptyID}" -} + directory?: string; + workspace?: string; + }; + url: '/pty/{ptyID}'; +}; export type PtyRemoveErrors = { /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors] +export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors]; export type PtyRemoveResponses = { /** * Session removed */ - 200: boolean -} + 200: boolean; +}; -export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses] +export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses]; export type PtyGetData = { - body?: never + body?: never; path: { - ptyID: string - } + ptyID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/pty/{ptyID}" -} + directory?: string; + workspace?: string; + }; + url: '/pty/{ptyID}'; +}; export type PtyGetErrors = { /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PtyGetError = PtyGetErrors[keyof PtyGetErrors] +export type PtyGetError = PtyGetErrors[keyof PtyGetErrors]; export type PtyGetResponses = { /** * Session info */ - 200: Pty -} + 200: Pty; +}; -export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses] +export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses]; export type PtyUpdateData = { body?: { - title?: string + title?: string; size?: { - rows: number - cols: number - } - } + rows: number; + cols: number; + }; + }; path: { - ptyID: string - } + ptyID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/pty/{ptyID}" -} + directory?: string; + workspace?: string; + }; + url: '/pty/{ptyID}'; +}; export type PtyUpdateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors] +export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors]; export type PtyUpdateResponses = { /** * Updated session */ - 200: Pty -} + 200: Pty; +}; -export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] +export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses]; export type PtyConnectData = { - body?: never + body?: never; path: { - ptyID: string - } + ptyID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/pty/{ptyID}/connect" -} + directory?: string; + workspace?: string; + }; + url: '/pty/{ptyID}/connect'; +}; export type PtyConnectErrors = { /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] +export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors]; export type PtyConnectResponses = { /** * Connected session */ - 200: boolean -} + 200: boolean; +}; -export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] +export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses]; export type ConfigGetData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/config" -} + directory?: string; + workspace?: string; + }; + url: '/config'; +}; export type ConfigGetResponses = { /** * Get config info */ - 200: Config -} + 200: Config; +}; -export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] +export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses]; export type ConfigUpdateData = { - body?: Config - path?: never + body?: Config; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/config" -} + directory?: string; + workspace?: string; + }; + url: '/config'; +}; export type ConfigUpdateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] +export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors]; export type ConfigUpdateResponses = { /** * Successfully updated config */ - 200: Config -} + 200: Config; +}; -export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] +export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses]; export type ConfigProvidersData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/config/providers" -} + directory?: string; + workspace?: string; + }; + url: '/config/providers'; +}; export type ConfigProvidersResponses = { /** * List of providers */ 200: { - providers: Array + providers: Array; default: { - [key: string]: string - } - } -} + [key: string]: string; + }; + }; +}; -export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses]; export type ToolIdsData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/tool/ids" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/tool/ids'; +}; export type ToolIdsErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] +export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors]; export type ToolIdsResponses = { /** * Tool IDs */ - 200: ToolIds -} + 200: ToolIds; +}; -export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] +export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses]; export type ToolListData = { - body?: never - path?: never + body?: never; + path?: never; query: { - directory?: string - workspace?: string - provider: string - model: string - } - url: "/experimental/tool" -} + directory?: string; + workspace?: string; + provider: string; + model: string; + }; + url: '/experimental/tool'; +}; export type ToolListErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type ToolListError = ToolListErrors[keyof ToolListErrors] +export type ToolListError = ToolListErrors[keyof ToolListErrors]; export type ToolListResponses = { /** * Tools */ - 200: ToolList -} + 200: ToolList; +}; -export type ToolListResponse = ToolListResponses[keyof ToolListResponses] +export type ToolListResponse = ToolListResponses[keyof ToolListResponses]; export type ExperimentalWorkspaceListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/workspace'; +}; export type ExperimentalWorkspaceListResponses = { /** * Workspaces */ - 200: Array -} + 200: Array; +}; export type ExperimentalWorkspaceListResponse = - ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] + ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses]; export type ExperimentalWorkspaceCreateData = { body?: { - id?: string - type: string - branch: string | null - extra: unknown | null - } - path?: never + id?: string; + type: string; + branch: string | null; + extra: unknown | null; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/workspace'; +}; export type ExperimentalWorkspaceCreateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; export type ExperimentalWorkspaceCreateError = - ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] + ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors]; export type ExperimentalWorkspaceCreateResponses = { /** * Workspace created */ - 200: Workspace -} + 200: Workspace; +}; export type ExperimentalWorkspaceCreateResponse = - ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] + ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]; export type ExperimentalWorkspaceRemoveData = { - body?: never + body?: never; path: { - id: string - } + id: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/workspace/{id}'; +}; export type ExperimentalWorkspaceRemoveErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; export type ExperimentalWorkspaceRemoveError = - ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] + ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors]; export type ExperimentalWorkspaceRemoveResponses = { /** * Workspace removed */ - 200: Workspace -} + 200: Workspace; +}; export type ExperimentalWorkspaceRemoveResponse = - ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] + ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]; export type WorktreeRemoveData = { - body?: WorktreeRemoveInput - path?: never + body?: WorktreeRemoveInput; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/worktree'; +}; export type WorktreeRemoveErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] +export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors]; export type WorktreeRemoveResponses = { /** * Worktree removed */ - 200: boolean -} + 200: boolean; +}; -export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] +export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses]; export type WorktreeListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/worktree'; +}; export type WorktreeListResponses = { /** * List of worktree directories */ - 200: Array -} + 200: Array; +}; -export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] +export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses]; export type WorktreeCreateData = { - body?: WorktreeCreateInput - path?: never + body?: WorktreeCreateInput; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/worktree'; +}; export type WorktreeCreateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] +export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors]; export type WorktreeCreateResponses = { /** * Worktree created */ - 200: Worktree -} + 200: Worktree; +}; -export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] +export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses]; export type WorktreeResetData = { - body?: WorktreeResetInput - path?: never + body?: WorktreeResetInput; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree/reset" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/worktree/reset'; +}; export type WorktreeResetErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] +export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors]; export type WorktreeResetResponses = { /** * Worktree reset */ - 200: boolean -} + 200: boolean; +}; -export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] +export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses]; export type ExperimentalSessionListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { /** * Filter sessions by project directory */ - directory?: string - workspace?: string + directory?: string; + workspace?: string; /** * Only return root sessions (no parentID) */ - roots?: boolean + roots?: boolean; /** * Filter sessions updated on or after this timestamp (milliseconds since epoch) */ - start?: number + start?: number; /** * Return sessions updated before this timestamp (milliseconds since epoch) */ - cursor?: number + cursor?: number; /** * Filter sessions by title (case-insensitive) */ - search?: string + search?: string; /** * Maximum number of sessions to return */ - limit?: number + limit?: number; /** * Include archived sessions (default false) */ - archived?: boolean - } - url: "/experimental/session" -} + archived?: boolean; + }; + url: '/experimental/session'; +}; export type ExperimentalSessionListResponses = { /** * List of sessions */ - 200: Array -} + 200: Array; +}; -export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] +export type ExperimentalSessionListResponse = + ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses]; export type ExperimentalResourceListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/experimental/resource" -} + directory?: string; + workspace?: string; + }; + url: '/experimental/resource'; +}; export type ExperimentalResourceListResponses = { /** * MCP resources */ 200: { - [key: string]: McpResource - } -} + [key: string]: McpResource; + }; +}; export type ExperimentalResourceListResponse = - ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] + ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses]; export type SessionListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { /** * Filter sessions by project directory */ - directory?: string - workspace?: string + directory?: string; + workspace?: string; /** * Only return root sessions (no parentID) */ - roots?: boolean + roots?: boolean; /** * Filter sessions updated on or after this timestamp (milliseconds since epoch) */ - start?: number + start?: number; /** * Filter sessions by title (case-insensitive) */ - search?: string + search?: string; /** * Maximum number of sessions to return */ - limit?: number - } - url: "/session" -} + limit?: number; + }; + url: '/session'; +}; export type SessionListResponses = { /** * List of sessions */ - 200: Array -} + 200: Array; +}; -export type SessionListResponse = SessionListResponses[keyof SessionListResponses] +export type SessionListResponse = SessionListResponses[keyof SessionListResponses]; export type SessionCreateData = { body?: { - parentID?: string - title?: string - permission?: PermissionRuleset - workspaceID?: string - } - path?: never + parentID?: string; + title?: string; + permission?: PermissionRuleset; + workspaceID?: string; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/session" -} + directory?: string; + workspace?: string; + }; + url: '/session'; +}; export type SessionCreateErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type SessionCreateError = SessionCreateErrors[keyof SessionCreateErrors] +export type SessionCreateError = SessionCreateErrors[keyof SessionCreateErrors]; export type SessionCreateResponses = { /** * Successfully created session */ - 200: Session -} + 200: Session; +}; -export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses] +export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses]; export type SessionStatusData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/session/status" -} + directory?: string; + workspace?: string; + }; + url: '/session/status'; +}; export type SessionStatusErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type SessionStatusError = SessionStatusErrors[keyof SessionStatusErrors] +export type SessionStatusError = SessionStatusErrors[keyof SessionStatusErrors]; export type SessionStatusResponses = { /** * Get session status */ 200: { - [key: string]: SessionStatus - } -} + [key: string]: SessionStatus; + }; +}; -export type SessionStatusResponse = SessionStatusResponses[keyof SessionStatusResponses] +export type SessionStatusResponse = SessionStatusResponses[keyof SessionStatusResponses]; export type SessionDeleteData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}'; +}; export type SessionDeleteErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionDeleteError = SessionDeleteErrors[keyof SessionDeleteErrors] +export type SessionDeleteError = SessionDeleteErrors[keyof SessionDeleteErrors]; export type SessionDeleteResponses = { /** * Successfully deleted session */ - 200: boolean -} + 200: boolean; +}; -export type SessionDeleteResponse = SessionDeleteResponses[keyof SessionDeleteResponses] +export type SessionDeleteResponse = SessionDeleteResponses[keyof SessionDeleteResponses]; export type SessionGetData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}'; +}; export type SessionGetErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionGetError = SessionGetErrors[keyof SessionGetErrors] +export type SessionGetError = SessionGetErrors[keyof SessionGetErrors]; export type SessionGetResponses = { /** * Get session */ - 200: Session -} + 200: Session; +}; -export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] +export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses]; export type SessionUpdateData = { body?: { - title?: string + title?: string; time?: { - archived?: number - } - } + archived?: number; + }; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}'; +}; export type SessionUpdateErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionUpdateError = SessionUpdateErrors[keyof SessionUpdateErrors] +export type SessionUpdateError = SessionUpdateErrors[keyof SessionUpdateErrors]; export type SessionUpdateResponses = { /** * Successfully updated session */ - 200: Session -} + 200: Session; +}; -export type SessionUpdateResponse = SessionUpdateResponses[keyof SessionUpdateResponses] +export type SessionUpdateResponse = SessionUpdateResponses[keyof SessionUpdateResponses]; export type SessionChildrenData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/children" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/children'; +}; export type SessionChildrenErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionChildrenError = SessionChildrenErrors[keyof SessionChildrenErrors] +export type SessionChildrenError = SessionChildrenErrors[keyof SessionChildrenErrors]; export type SessionChildrenResponses = { /** * List of children */ - 200: Array -} + 200: Array; +}; -export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses] +export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses]; export type SessionTodoData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/todo" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/todo'; +}; export type SessionTodoErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionTodoError = SessionTodoErrors[keyof SessionTodoErrors] +export type SessionTodoError = SessionTodoErrors[keyof SessionTodoErrors]; export type SessionTodoResponses = { /** * Todo list */ - 200: Array -} + 200: Array; +}; -export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses] +export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses]; export type SessionInitData = { body?: { - modelID: string - providerID: string - messageID: string - } + modelID: string; + providerID: string; + messageID: string; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/init" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/init'; +}; export type SessionInitErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] +export type SessionInitError = SessionInitErrors[keyof SessionInitErrors]; export type SessionInitResponses = { /** * 200 */ - 200: boolean -} + 200: boolean; +}; -export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] +export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses]; export type SessionForkData = { body?: { - messageID?: string - } + messageID?: string; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/fork" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/fork'; +}; export type SessionForkResponses = { /** * 200 */ - 200: Session -} + 200: Session; +}; -export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] +export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses]; export type SessionAbortData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/abort" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/abort'; +}; export type SessionAbortErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] +export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors]; export type SessionAbortResponses = { /** * Aborted session */ - 200: boolean -} + 200: boolean; +}; -export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] +export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses]; export type SessionUnshareData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/share" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/share'; +}; export type SessionUnshareErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] +export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors]; export type SessionUnshareResponses = { /** * Successfully unshared session */ - 200: Session -} + 200: Session; +}; -export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] +export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses]; export type SessionShareData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/share" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/share'; +}; export type SessionShareErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] +export type SessionShareError = SessionShareErrors[keyof SessionShareErrors]; export type SessionShareResponses = { /** * Successfully shared session */ - 200: Session -} + 200: Session; +}; -export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] +export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses]; export type SessionDiffData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - messageID?: string - } - url: "/session/{sessionID}/diff" -} + directory?: string; + workspace?: string; + messageID?: string; + }; + url: '/session/{sessionID}/diff'; +}; export type SessionDiffResponses = { /** * Successfully retrieved diff */ - 200: Array -} + 200: Array; +}; -export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] +export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses]; export type SessionSummarizeData = { body?: { - providerID: string - modelID: string - auto?: boolean - } + providerID: string; + modelID: string; + auto?: boolean; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/summarize" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/summarize'; +}; export type SessionSummarizeErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] +export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors]; export type SessionSummarizeResponses = { /** * Summarized session */ - 200: boolean -} + 200: boolean; +}; -export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] +export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses]; export type SessionMessagesData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string + directory?: string; + workspace?: string; /** * Maximum number of messages to return */ - limit?: number - before?: string - } - url: "/session/{sessionID}/message" -} + limit?: number; + before?: string; + }; + url: '/session/{sessionID}/message'; +}; export type SessionMessagesErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionMessagesError = SessionMessagesErrors[keyof SessionMessagesErrors] +export type SessionMessagesError = SessionMessagesErrors[keyof SessionMessagesErrors]; export type SessionMessagesResponses = { /** * List of messages */ 200: Array<{ - info: Message - parts: Array - }> -} + info: Message; + parts: Array; + }>; +}; -export type SessionMessagesResponse = SessionMessagesResponses[keyof SessionMessagesResponses] +export type SessionMessagesResponse = SessionMessagesResponses[keyof SessionMessagesResponses]; export type SessionPromptData = { body?: { - messageID?: string + messageID?: string; model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean + providerID: string; + modelID: string; + }; + agent?: string; + noReply?: boolean; /** * @deprecated tools and permissions have been merged, you can set permissions on the session itself now */ tools?: { - [key: string]: boolean - } - format?: OutputFormat - system?: string - variant?: string - parts: Array - } + [key: string]: boolean; + }; + format?: OutputFormat; + system?: string; + variant?: string; + parts: Array; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/message" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/message'; +}; export type SessionPromptErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionPromptError = SessionPromptErrors[keyof SessionPromptErrors] +export type SessionPromptError = SessionPromptErrors[keyof SessionPromptErrors]; export type SessionPromptResponses = { /** * Created message */ 200: { - info: AssistantMessage - parts: Array - } -} + info: AssistantMessage; + parts: Array; + }; +}; -export type SessionPromptResponse = SessionPromptResponses[keyof SessionPromptResponses] +export type SessionPromptResponse = SessionPromptResponses[keyof SessionPromptResponses]; export type SessionDeleteMessageData = { - body?: never + body?: never; path: { - sessionID: string - messageID: string - } + sessionID: string; + messageID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/message/{messageID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/message/{messageID}'; +}; export type SessionDeleteMessageErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionDeleteMessageError = SessionDeleteMessageErrors[keyof SessionDeleteMessageErrors] +export type SessionDeleteMessageError = + SessionDeleteMessageErrors[keyof SessionDeleteMessageErrors]; export type SessionDeleteMessageResponses = { /** * Successfully deleted message */ - 200: boolean -} + 200: boolean; +}; -export type SessionDeleteMessageResponse = SessionDeleteMessageResponses[keyof SessionDeleteMessageResponses] +export type SessionDeleteMessageResponse = + SessionDeleteMessageResponses[keyof SessionDeleteMessageResponses]; export type SessionMessageData = { - body?: never + body?: never; path: { - sessionID: string - messageID: string - } + sessionID: string; + messageID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/message/{messageID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/message/{messageID}'; +}; export type SessionMessageErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionMessageError = SessionMessageErrors[keyof SessionMessageErrors] +export type SessionMessageError = SessionMessageErrors[keyof SessionMessageErrors]; export type SessionMessageResponses = { /** * Message */ 200: { - info: Message - parts: Array - } -} + info: Message; + parts: Array; + }; +}; -export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses] +export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses]; export type PartDeleteData = { - body?: never + body?: never; path: { - sessionID: string - messageID: string - partID: string - } + sessionID: string; + messageID: string; + partID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/message/{messageID}/part/{partID}'; +}; export type PartDeleteErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] +export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors]; export type PartDeleteResponses = { /** * Successfully deleted part */ - 200: boolean -} + 200: boolean; +}; -export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] +export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses]; export type PartUpdateData = { - body?: Part + body?: Part; path: { - sessionID: string - messageID: string - partID: string - } + sessionID: string; + messageID: string; + partID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/message/{messageID}/part/{partID}'; +}; export type PartUpdateErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] +export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors]; export type PartUpdateResponses = { /** * Successfully updated part */ - 200: Part -} + 200: Part; +}; -export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] +export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses]; export type SessionPromptAsyncData = { body?: { - messageID?: string + messageID?: string; model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean + providerID: string; + modelID: string; + }; + agent?: string; + noReply?: boolean; /** * @deprecated tools and permissions have been merged, you can set permissions on the session itself now */ tools?: { - [key: string]: boolean - } - format?: OutputFormat - system?: string - variant?: string - parts: Array - } + [key: string]: boolean; + }; + format?: OutputFormat; + system?: string; + variant?: string; + parts: Array; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/prompt_async" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/prompt_async'; +}; export type SessionPromptAsyncErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionPromptAsyncError = SessionPromptAsyncErrors[keyof SessionPromptAsyncErrors] +export type SessionPromptAsyncError = SessionPromptAsyncErrors[keyof SessionPromptAsyncErrors]; export type SessionPromptAsyncResponses = { /** * Prompt accepted */ - 204: void -} + 204: void; +}; -export type SessionPromptAsyncResponse = SessionPromptAsyncResponses[keyof SessionPromptAsyncResponses] +export type SessionPromptAsyncResponse = + SessionPromptAsyncResponses[keyof SessionPromptAsyncResponses]; export type SessionCommandData = { body?: { - messageID?: string - agent?: string - model?: string - arguments: string - command: string - variant?: string + messageID?: string; + agent?: string; + model?: string; + arguments: string; + command: string; + variant?: string; parts?: Array<{ - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource - }> - } + id?: string; + type: 'file'; + mime: string; + filename?: string; + url: string; + source?: FilePartSource; + }>; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/command" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/command'; +}; export type SessionCommandErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionCommandError = SessionCommandErrors[keyof SessionCommandErrors] +export type SessionCommandError = SessionCommandErrors[keyof SessionCommandErrors]; export type SessionCommandResponses = { /** * Created message */ 200: { - info: AssistantMessage - parts: Array - } -} + info: AssistantMessage; + parts: Array; + }; +}; -export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses] +export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses]; export type SessionShellData = { body?: { - agent: string + agent: string; model?: { - providerID: string - modelID: string - } - command: string - } + providerID: string; + modelID: string; + }; + command: string; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/shell" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/shell'; +}; export type SessionShellErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionShellError = SessionShellErrors[keyof SessionShellErrors] +export type SessionShellError = SessionShellErrors[keyof SessionShellErrors]; export type SessionShellResponses = { /** * Created message */ - 200: AssistantMessage -} + 200: AssistantMessage; +}; -export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses] +export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses]; export type SessionRevertData = { body?: { - messageID: string - partID?: string - } + messageID: string; + partID?: string; + }; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/revert" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/revert'; +}; export type SessionRevertErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionRevertError = SessionRevertErrors[keyof SessionRevertErrors] +export type SessionRevertError = SessionRevertErrors[keyof SessionRevertErrors]; export type SessionRevertResponses = { /** * Updated session */ - 200: Session -} + 200: Session; +}; -export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses] +export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses]; export type SessionUnrevertData = { - body?: never + body?: never; path: { - sessionID: string - } + sessionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/unrevert" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/unrevert'; +}; export type SessionUnrevertErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type SessionUnrevertError = SessionUnrevertErrors[keyof SessionUnrevertErrors] +export type SessionUnrevertError = SessionUnrevertErrors[keyof SessionUnrevertErrors]; export type SessionUnrevertResponses = { /** * Updated session */ - 200: Session -} + 200: Session; +}; -export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses] +export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses]; export type PermissionRespondData = { body?: { - response: "once" | "always" | "reject" - } + response: 'once' | 'always' | 'reject'; + }; path: { - sessionID: string - permissionID: string - } + sessionID: string; + permissionID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/permissions/{permissionID}" -} + directory?: string; + workspace?: string; + }; + url: '/session/{sessionID}/permissions/{permissionID}'; +}; export type PermissionRespondErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PermissionRespondError = PermissionRespondErrors[keyof PermissionRespondErrors] +export type PermissionRespondError = PermissionRespondErrors[keyof PermissionRespondErrors]; export type PermissionRespondResponses = { /** * Permission processed successfully */ - 200: boolean -} + 200: boolean; +}; -export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] +export type PermissionRespondResponse = + PermissionRespondResponses[keyof PermissionRespondResponses]; export type PermissionReplyData = { body?: { - reply: "once" | "always" | "reject" - message?: string - } + reply: 'once' | 'always' | 'reject'; + message?: string; + }; path: { - requestID: string - } + requestID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/permission/{requestID}/reply" -} + directory?: string; + workspace?: string; + }; + url: '/permission/{requestID}/reply'; +}; export type PermissionReplyErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] +export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors]; export type PermissionReplyResponses = { /** * Permission processed successfully */ - 200: boolean -} + 200: boolean; +}; -export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] +export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses]; export type PermissionListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/permission" -} + directory?: string; + workspace?: string; + }; + url: '/permission'; +}; export type PermissionListResponses = { /** * List of pending permissions */ - 200: Array -} + 200: Array; +}; -export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] +export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses]; export type QuestionListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/question" -} + directory?: string; + workspace?: string; + }; + url: '/question'; +}; export type QuestionListResponses = { /** * List of pending questions */ - 200: Array -} + 200: Array; +}; -export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] +export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses]; export type QuestionReplyData = { body?: { /** * User answers in order of questions (each answer is an array of selected labels) */ - answers: Array - } + answers: Array; + }; path: { - requestID: string - } + requestID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/question/{requestID}/reply" -} + directory?: string; + workspace?: string; + }; + url: '/question/{requestID}/reply'; +}; export type QuestionReplyErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] +export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors]; export type QuestionReplyResponses = { /** * Question answered successfully */ - 200: boolean -} + 200: boolean; +}; -export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] +export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses]; export type QuestionRejectData = { - body?: never + body?: never; path: { - requestID: string - } + requestID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/question/{requestID}/reject" -} + directory?: string; + workspace?: string; + }; + url: '/question/{requestID}/reject'; +}; export type QuestionRejectErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] +export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors]; export type QuestionRejectResponses = { /** * Question rejected successfully */ - 200: boolean -} + 200: boolean; +}; -export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] +export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses]; export type ProviderListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/provider" -} + directory?: string; + workspace?: string; + }; + url: '/provider'; +}; export type ProviderListResponses = { /** @@ -3914,193 +3920,197 @@ export type ProviderListResponses = { */ 200: { all: Array<{ - api?: string - name: string - env: Array - id: string - npm?: string + api?: string; + name: string; + env: Array; + id: string; + npm?: string; models: { [key: string]: { - id: string - name: string - family?: string - release_date: string - attachment: boolean - reasoning: boolean - temperature: boolean - tool_call: boolean + id: string; + name: string; + family?: string; + release_date: string; + attachment: boolean; + reasoning: boolean; + temperature: boolean; + tool_call: boolean; interleaved?: | true | { - field: "reasoning_content" | "reasoning_details" - } + field: 'reasoning_content' | 'reasoning_details'; + }; cost?: { - input: number - output: number - cache_read?: number - cache_write?: number + input: number; + output: number; + cache_read?: number; + cache_write?: number; context_over_200k?: { - input: number - output: number - cache_read?: number - cache_write?: number - } - } + input: number; + output: number; + cache_read?: number; + cache_write?: number; + }; + }; limit: { - context: number - input?: number - output: number - } + context: number; + input?: number; + output: number; + }; modalities?: { - input: Array<"text" | "audio" | "image" | "video" | "pdf"> - output: Array<"text" | "audio" | "image" | "video" | "pdf"> - } - experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + input: Array<'text' | 'audio' | 'image' | 'video' | 'pdf'>; + output: Array<'text' | 'audio' | 'image' | 'video' | 'pdf'>; + }; + experimental?: boolean; + status?: 'alpha' | 'beta' | 'deprecated'; options: { - [key: string]: unknown - } + [key: string]: unknown; + }; headers?: { - [key: string]: string - } + [key: string]: string; + }; provider?: { - npm?: string - api?: string - } + npm?: string; + api?: string; + }; variants?: { [key: string]: { - [key: string]: unknown - } - } - } - } - }> + [key: string]: unknown; + }; + }; + }; + }; + }>; default: { - [key: string]: string - } - connected: Array - } -} + [key: string]: string; + }; + connected: Array; + }; +}; -export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] +export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses]; export type ProviderAuthData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/provider/auth" -} + directory?: string; + workspace?: string; + }; + url: '/provider/auth'; +}; export type ProviderAuthResponses = { /** * Provider auth methods */ 200: { - [key: string]: Array - } -} + [key: string]: Array; + }; +}; -export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] +export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses]; export type ProviderOauthAuthorizeData = { body?: { /** * Auth method index */ - method: number + method: number; /** * Prompt inputs */ inputs?: { - [key: string]: string - } - } + [key: string]: string; + }; + }; path: { /** * Provider ID */ - providerID: string - } + providerID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/provider/{providerID}/oauth/authorize" -} + directory?: string; + workspace?: string; + }; + url: '/provider/{providerID}/oauth/authorize'; +}; export type ProviderOauthAuthorizeErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] +export type ProviderOauthAuthorizeError = + ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors]; export type ProviderOauthAuthorizeResponses = { /** * Authorization URL and method */ - 200: ProviderAuthAuthorization -} + 200: ProviderAuthAuthorization; +}; -export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] +export type ProviderOauthAuthorizeResponse = + ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses]; export type ProviderOauthCallbackData = { body?: { /** * Auth method index */ - method: number + method: number; /** * OAuth authorization code */ - code?: string - } + code?: string; + }; path: { /** * Provider ID */ - providerID: string - } + providerID: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/provider/{providerID}/oauth/callback" -} + directory?: string; + workspace?: string; + }; + url: '/provider/{providerID}/oauth/callback'; +}; export type ProviderOauthCallbackErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] +export type ProviderOauthCallbackError = + ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors]; export type ProviderOauthCallbackResponses = { /** * OAuth callback processed successfully */ - 200: boolean -} + 200: boolean; +}; -export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] +export type ProviderOauthCallbackResponse = + ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]; export type FindTextData = { - body?: never - path?: never + body?: never; + path?: never; query: { - directory?: string - workspace?: string - pattern: string - } - url: "/find" -} + directory?: string; + workspace?: string; + pattern: string; + }; + url: '/find'; +}; export type FindTextResponses = { /** @@ -4108,256 +4118,256 @@ export type FindTextResponses = { */ 200: Array<{ path: { - text: string - } + text: string; + }; lines: { - text: string - } - line_number: number - absolute_offset: number + text: string; + }; + line_number: number; + absolute_offset: number; submatches: Array<{ match: { - text: string - } - start: number - end: number - }> - }> -} + text: string; + }; + start: number; + end: number; + }>; + }>; +}; -export type FindTextResponse = FindTextResponses[keyof FindTextResponses] +export type FindTextResponse = FindTextResponses[keyof FindTextResponses]; export type FindFilesData = { - body?: never - path?: never + body?: never; + path?: never; query: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number - } - url: "/find/file" -} + directory?: string; + workspace?: string; + query: string; + dirs?: 'true' | 'false'; + type?: 'file' | 'directory'; + limit?: number; + }; + url: '/find/file'; +}; export type FindFilesResponses = { /** * File paths */ - 200: Array -} + 200: Array; +}; -export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] +export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses]; export type FindSymbolsData = { - body?: never - path?: never + body?: never; + path?: never; query: { - directory?: string - workspace?: string - query: string - } - url: "/find/symbol" -} + directory?: string; + workspace?: string; + query: string; + }; + url: '/find/symbol'; +}; export type FindSymbolsResponses = { /** * Symbols */ - 200: Array -} + 200: Array; +}; -export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] +export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses]; export type FileListData = { - body?: never - path?: never + body?: never; + path?: never; query: { - directory?: string - workspace?: string - path: string - } - url: "/file" -} + directory?: string; + workspace?: string; + path: string; + }; + url: '/file'; +}; export type FileListResponses = { /** * Files and directories */ - 200: Array -} + 200: Array; +}; -export type FileListResponse = FileListResponses[keyof FileListResponses] +export type FileListResponse = FileListResponses[keyof FileListResponses]; export type FileReadData = { - body?: never - path?: never + body?: never; + path?: never; query: { - directory?: string - workspace?: string - path: string - } - url: "/file/content" -} + directory?: string; + workspace?: string; + path: string; + }; + url: '/file/content'; +}; export type FileReadResponses = { /** * File content */ - 200: FileContent -} + 200: FileContent; +}; -export type FileReadResponse = FileReadResponses[keyof FileReadResponses] +export type FileReadResponse = FileReadResponses[keyof FileReadResponses]; export type FileStatusData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/file/status" -} + directory?: string; + workspace?: string; + }; + url: '/file/status'; +}; export type FileStatusResponses = { /** * File status */ - 200: Array -} + 200: Array; +}; -export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] +export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses]; export type EventSubscribeData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/event" -} + directory?: string; + workspace?: string; + }; + url: '/event'; +}; export type EventSubscribeResponses = { /** * Event stream */ - 200: Event -} + 200: Event; +}; -export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]; export type McpStatusData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/mcp" -} + directory?: string; + workspace?: string; + }; + url: '/mcp'; +}; export type McpStatusResponses = { /** * MCP server status */ 200: { - [key: string]: McpStatus - } -} + [key: string]: McpStatus; + }; +}; -export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] +export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses]; export type McpAddData = { body?: { - name: string - config: McpLocalConfig | McpRemoteConfig - } - path?: never + name: string; + config: McpLocalConfig | McpRemoteConfig; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/mcp" -} + directory?: string; + workspace?: string; + }; + url: '/mcp'; +}; export type McpAddErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type McpAddError = McpAddErrors[keyof McpAddErrors] +export type McpAddError = McpAddErrors[keyof McpAddErrors]; export type McpAddResponses = { /** * MCP server added successfully */ 200: { - [key: string]: McpStatus - } -} + [key: string]: McpStatus; + }; +}; -export type McpAddResponse = McpAddResponses[keyof McpAddResponses] +export type McpAddResponse = McpAddResponses[keyof McpAddResponses]; export type McpAuthRemoveData = { - body?: never + body?: never; path: { - name: string - } + name: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth" -} + directory?: string; + workspace?: string; + }; + url: '/mcp/{name}/auth'; +}; export type McpAuthRemoveErrors = { /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] +export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors]; export type McpAuthRemoveResponses = { /** * OAuth credentials removed */ 200: { - success: true - } -} + success: true; + }; +}; -export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] +export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses]; export type McpAuthStartData = { - body?: never + body?: never; path: { - name: string - } + name: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth" -} + directory?: string; + workspace?: string; + }; + url: '/mcp/{name}/auth'; +}; export type McpAuthStartErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] +export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors]; export type McpAuthStartResponses = { /** @@ -4367,634 +4377,637 @@ export type McpAuthStartResponses = { /** * URL to open in browser for authorization */ - authorizationUrl: string - } -} + authorizationUrl: string; + }; +}; -export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] +export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses]; export type McpAuthCallbackData = { body?: { /** * Authorization code from OAuth callback */ - code: string - } + code: string; + }; path: { - name: string - } + name: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth/callback" -} + directory?: string; + workspace?: string; + }; + url: '/mcp/{name}/auth/callback'; +}; export type McpAuthCallbackErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] +export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors]; export type McpAuthCallbackResponses = { /** * OAuth authentication completed */ - 200: McpStatus -} + 200: McpStatus; +}; -export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] +export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses]; export type McpAuthAuthenticateData = { - body?: never + body?: never; path: { - name: string - } + name: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth/authenticate" -} + directory?: string; + workspace?: string; + }; + url: '/mcp/{name}/auth/authenticate'; +}; export type McpAuthAuthenticateErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] +export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors]; export type McpAuthAuthenticateResponses = { /** * OAuth authentication completed */ - 200: McpStatus -} + 200: McpStatus; +}; -export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] +export type McpAuthAuthenticateResponse = + McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses]; export type McpConnectData = { - body?: never + body?: never; path: { - name: string - } + name: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/connect" -} + directory?: string; + workspace?: string; + }; + url: '/mcp/{name}/connect'; +}; export type McpConnectResponses = { /** * MCP server connected successfully */ - 200: boolean -} + 200: boolean; +}; -export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] +export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses]; export type McpDisconnectData = { - body?: never + body?: never; path: { - name: string - } + name: string; + }; query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/disconnect" -} + directory?: string; + workspace?: string; + }; + url: '/mcp/{name}/disconnect'; +}; export type McpDisconnectResponses = { /** * MCP server disconnected successfully */ - 200: boolean -} + 200: boolean; +}; -export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] +export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses]; export type TuiAppendPromptData = { body?: { - text: string - } - path?: never + text: string; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/append-prompt" -} + directory?: string; + workspace?: string; + }; + url: '/tui/append-prompt'; +}; export type TuiAppendPromptErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type TuiAppendPromptError = TuiAppendPromptErrors[keyof TuiAppendPromptErrors] +export type TuiAppendPromptError = TuiAppendPromptErrors[keyof TuiAppendPromptErrors]; export type TuiAppendPromptResponses = { /** * Prompt processed successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiAppendPromptResponse = TuiAppendPromptResponses[keyof TuiAppendPromptResponses] +export type TuiAppendPromptResponse = TuiAppendPromptResponses[keyof TuiAppendPromptResponses]; export type TuiOpenHelpData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/open-help" -} + directory?: string; + workspace?: string; + }; + url: '/tui/open-help'; +}; export type TuiOpenHelpResponses = { /** * Help dialog opened successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiOpenHelpResponse = TuiOpenHelpResponses[keyof TuiOpenHelpResponses] +export type TuiOpenHelpResponse = TuiOpenHelpResponses[keyof TuiOpenHelpResponses]; export type TuiOpenSessionsData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/open-sessions" -} + directory?: string; + workspace?: string; + }; + url: '/tui/open-sessions'; +}; export type TuiOpenSessionsResponses = { /** * Session dialog opened successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiOpenSessionsResponse = TuiOpenSessionsResponses[keyof TuiOpenSessionsResponses] +export type TuiOpenSessionsResponse = TuiOpenSessionsResponses[keyof TuiOpenSessionsResponses]; export type TuiOpenThemesData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/open-themes" -} + directory?: string; + workspace?: string; + }; + url: '/tui/open-themes'; +}; export type TuiOpenThemesResponses = { /** * Theme dialog opened successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiOpenThemesResponse = TuiOpenThemesResponses[keyof TuiOpenThemesResponses] +export type TuiOpenThemesResponse = TuiOpenThemesResponses[keyof TuiOpenThemesResponses]; export type TuiOpenModelsData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/open-models" -} + directory?: string; + workspace?: string; + }; + url: '/tui/open-models'; +}; export type TuiOpenModelsResponses = { /** * Model dialog opened successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiOpenModelsResponse = TuiOpenModelsResponses[keyof TuiOpenModelsResponses] +export type TuiOpenModelsResponse = TuiOpenModelsResponses[keyof TuiOpenModelsResponses]; export type TuiSubmitPromptData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/submit-prompt" -} + directory?: string; + workspace?: string; + }; + url: '/tui/submit-prompt'; +}; export type TuiSubmitPromptResponses = { /** * Prompt submitted successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiSubmitPromptResponse = TuiSubmitPromptResponses[keyof TuiSubmitPromptResponses] +export type TuiSubmitPromptResponse = TuiSubmitPromptResponses[keyof TuiSubmitPromptResponses]; export type TuiClearPromptData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/clear-prompt" -} + directory?: string; + workspace?: string; + }; + url: '/tui/clear-prompt'; +}; export type TuiClearPromptResponses = { /** * Prompt cleared successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiClearPromptResponse = TuiClearPromptResponses[keyof TuiClearPromptResponses] +export type TuiClearPromptResponse = TuiClearPromptResponses[keyof TuiClearPromptResponses]; export type TuiExecuteCommandData = { body?: { - command: string - } - path?: never + command: string; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/execute-command" -} + directory?: string; + workspace?: string; + }; + url: '/tui/execute-command'; +}; export type TuiExecuteCommandErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type TuiExecuteCommandError = TuiExecuteCommandErrors[keyof TuiExecuteCommandErrors] +export type TuiExecuteCommandError = TuiExecuteCommandErrors[keyof TuiExecuteCommandErrors]; export type TuiExecuteCommandResponses = { /** * Command executed successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiExecuteCommandResponse = TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses] +export type TuiExecuteCommandResponse = + TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses]; export type TuiShowToastData = { body?: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" + title?: string; + message: string; + variant: 'info' | 'success' | 'warning' | 'error'; /** * Duration in milliseconds */ - duration?: number - } - path?: never + duration?: number; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/show-toast" -} + directory?: string; + workspace?: string; + }; + url: '/tui/show-toast'; +}; export type TuiShowToastResponses = { /** * Toast notification shown successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses] +export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses]; export type TuiPublishData = { - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect - path?: never + body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/publish" -} + directory?: string; + workspace?: string; + }; + url: '/tui/publish'; +}; export type TuiPublishErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type TuiPublishError = TuiPublishErrors[keyof TuiPublishErrors] +export type TuiPublishError = TuiPublishErrors[keyof TuiPublishErrors]; export type TuiPublishResponses = { /** * Event published successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiPublishResponse = TuiPublishResponses[keyof TuiPublishResponses] +export type TuiPublishResponse = TuiPublishResponses[keyof TuiPublishResponses]; export type TuiSelectSessionData = { body?: { /** * Session ID to navigate to */ - sessionID: string - } - path?: never + sessionID: string; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/select-session" -} + directory?: string; + workspace?: string; + }; + url: '/tui/select-session'; +}; export type TuiSelectSessionErrors = { /** * Bad request */ - 400: BadRequestError + 400: BadRequestError; /** * Not found */ - 404: NotFoundError -} + 404: NotFoundError; +}; -export type TuiSelectSessionError = TuiSelectSessionErrors[keyof TuiSelectSessionErrors] +export type TuiSelectSessionError = TuiSelectSessionErrors[keyof TuiSelectSessionErrors]; export type TuiSelectSessionResponses = { /** * Session selected successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiSelectSessionResponse = TuiSelectSessionResponses[keyof TuiSelectSessionResponses] +export type TuiSelectSessionResponse = TuiSelectSessionResponses[keyof TuiSelectSessionResponses]; export type TuiControlNextData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/control/next" -} + directory?: string; + workspace?: string; + }; + url: '/tui/control/next'; +}; export type TuiControlNextResponses = { /** * Next TUI request */ 200: { - path: string - body: unknown - } -} + path: string; + body: unknown; + }; +}; -export type TuiControlNextResponse = TuiControlNextResponses[keyof TuiControlNextResponses] +export type TuiControlNextResponse = TuiControlNextResponses[keyof TuiControlNextResponses]; export type TuiControlResponseData = { - body?: unknown - path?: never + body?: unknown; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/tui/control/response" -} + directory?: string; + workspace?: string; + }; + url: '/tui/control/response'; +}; export type TuiControlResponseResponses = { /** * Response submitted successfully */ - 200: boolean -} + 200: boolean; +}; -export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses] +export type TuiControlResponseResponse = + TuiControlResponseResponses[keyof TuiControlResponseResponses]; export type InstanceDisposeData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/instance/dispose" -} + directory?: string; + workspace?: string; + }; + url: '/instance/dispose'; +}; export type InstanceDisposeResponses = { /** * Instance disposed */ - 200: boolean -} + 200: boolean; +}; -export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] +export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses]; export type PathGetData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/path" -} + directory?: string; + workspace?: string; + }; + url: '/path'; +}; export type PathGetResponses = { /** * Path */ - 200: Path -} + 200: Path; +}; -export type PathGetResponse = PathGetResponses[keyof PathGetResponses] +export type PathGetResponse = PathGetResponses[keyof PathGetResponses]; export type VcsGetData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/vcs" -} + directory?: string; + workspace?: string; + }; + url: '/vcs'; +}; export type VcsGetResponses = { /** * VCS info */ - 200: VcsInfo -} + 200: VcsInfo; +}; -export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] +export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]; export type CommandListData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/command" -} + directory?: string; + workspace?: string; + }; + url: '/command'; +}; export type CommandListResponses = { /** * List of commands */ - 200: Array -} + 200: Array; +}; -export type CommandListResponse = CommandListResponses[keyof CommandListResponses] +export type CommandListResponse = CommandListResponses[keyof CommandListResponses]; export type AppLogData = { body?: { /** * Service name for the log entry */ - service: string + service: string; /** * Log level */ - level: "debug" | "info" | "error" | "warn" + level: 'debug' | 'info' | 'error' | 'warn'; /** * Log message */ - message: string + message: string; /** * Additional metadata for the log entry */ extra?: { - [key: string]: unknown - } - } - path?: never + [key: string]: unknown; + }; + }; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/log" -} + directory?: string; + workspace?: string; + }; + url: '/log'; +}; export type AppLogErrors = { /** * Bad request */ - 400: BadRequestError -} + 400: BadRequestError; +}; -export type AppLogError = AppLogErrors[keyof AppLogErrors] +export type AppLogError = AppLogErrors[keyof AppLogErrors]; export type AppLogResponses = { /** * Log entry written successfully */ - 200: boolean -} + 200: boolean; +}; -export type AppLogResponse = AppLogResponses[keyof AppLogResponses] +export type AppLogResponse = AppLogResponses[keyof AppLogResponses]; export type AppAgentsData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/agent" -} + directory?: string; + workspace?: string; + }; + url: '/agent'; +}; export type AppAgentsResponses = { /** * List of agents */ - 200: Array -} + 200: Array; +}; -export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] +export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses]; export type AppSkillsData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/skill" -} + directory?: string; + workspace?: string; + }; + url: '/skill'; +}; export type AppSkillsResponses = { /** * List of skills */ 200: Array<{ - name: string - description: string - location: string - content: string - }> -} + name: string; + description: string; + location: string; + content: string; + }>; +}; -export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] +export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses]; export type LspStatusData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/lsp" -} + directory?: string; + workspace?: string; + }; + url: '/lsp'; +}; export type LspStatusResponses = { /** * LSP server status */ - 200: Array -} + 200: Array; +}; -export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] +export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses]; export type FormatterStatusData = { - body?: never - path?: never + body?: never; + path?: never; query?: { - directory?: string - workspace?: string - } - url: "/formatter" -} + directory?: string; + workspace?: string; + }; + url: '/formatter'; +}; export type FormatterStatusResponses = { /** * Formatter status */ - 200: Array -} + 200: Array; +}; -export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] +export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]; diff --git a/apps/web/server/opencode/v2/index.ts b/apps/web/server/opencode/v2/index.ts index d044f5ad..1173c1b5 100644 --- a/apps/web/server/opencode/v2/index.ts +++ b/apps/web/server/opencode/v2/index.ts @@ -1,21 +1,21 @@ -export * from "./client.js" -export * from "./server.js" +export * from './client.js'; +export * from './server.js'; -import { createOpencodeClient } from "./client.js" -import { createOpencodeServer } from "./server.js" -import type { ServerOptions } from "./server.js" +import { createOpencodeClient } from './client.js'; +import { createOpencodeServer } from './server.js'; +import type { ServerOptions } from './server.js'; export async function createOpencode(options?: ServerOptions) { const server = await createOpencodeServer({ ...options, - }) + }); const client = createOpencodeClient({ baseUrl: server.url, - }) + }); return { client, server, - } + }; } diff --git a/apps/web/server/opencode/v2/server.ts b/apps/web/server/opencode/v2/server.ts index 6d54f511..57b8ca60 100644 --- a/apps/web/server/opencode/v2/server.ts +++ b/apps/web/server/opencode/v2/server.ts @@ -1,39 +1,39 @@ -import { spawn } from "node:child_process" -import { type Config } from "./gen/types.gen.js" +import { spawn } from 'node:child_process'; +import { type Config } from './gen/types.gen.js'; export type ServerOptions = { - hostname?: string - port?: number - signal?: AbortSignal - timeout?: number - config?: Config + hostname?: string; + port?: number; + signal?: AbortSignal; + timeout?: number; + config?: Config; /** Absolute path to the opencode binary (avoids PATH lookup issues in Electron). */ - binaryPath?: string -} + binaryPath?: string; +}; export type TuiOptions = { - project?: string - model?: string - session?: string - agent?: string - signal?: AbortSignal - config?: Config -} + project?: string; + model?: string; + session?: string; + agent?: string; + signal?: AbortSignal; + config?: Config; +}; export async function createOpencodeServer(options?: ServerOptions) { options = Object.assign( { - hostname: "127.0.0.1", + hostname: '127.0.0.1', port: 4096, timeout: 5000, }, options ?? {}, - ) + ); - const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`] - if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`) + const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]; + if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`); - const cmd = options.binaryPath ?? 'opencode' + const cmd = options.binaryPath ?? 'opencode'; const proc = spawn(cmd, args, { signal: options.signal, shell: process.platform === 'win32', @@ -41,88 +41,88 @@ export async function createOpencodeServer(options?: ServerOptions) { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}), }, - }) + }); const url = await new Promise((resolve, reject) => { const id = setTimeout(() => { - reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`)) - }, options.timeout) - let output = "" - proc.stdout?.on("data", (chunk) => { - output += chunk.toString() - const lines = output.split("\n") + reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`)); + }, options.timeout); + let output = ''; + proc.stdout?.on('data', (chunk) => { + output += chunk.toString(); + const lines = output.split('\n'); for (const line of lines) { - if (line.startsWith("opencode server listening")) { - const match = line.match(/on\s+(https?:\/\/[^\s]+)/) + if (line.startsWith('opencode server listening')) { + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); if (!match) { - throw new Error(`Failed to parse server url from output: ${line}`) + throw new Error(`Failed to parse server url from output: ${line}`); } - clearTimeout(id) - resolve(match[1]!) - return + clearTimeout(id); + resolve(match[1]!); + return; } } - }) - proc.stderr?.on("data", (chunk) => { - output += chunk.toString() - }) - proc.on("exit", (code) => { - clearTimeout(id) - let msg = `Server exited with code ${code}` + }); + proc.stderr?.on('data', (chunk) => { + output += chunk.toString(); + }); + proc.on('exit', (code) => { + clearTimeout(id); + let msg = `Server exited with code ${code}`; if (output.trim()) { - msg += `\nServer output: ${output}` + msg += `\nServer output: ${output}`; } - reject(new Error(msg)) - }) - proc.on("error", (error) => { - clearTimeout(id) - reject(error) - }) + reject(new Error(msg)); + }); + proc.on('error', (error) => { + clearTimeout(id); + reject(error); + }); if (options.signal) { - options.signal.addEventListener("abort", () => { - clearTimeout(id) - reject(new Error("Aborted")) - }) + options.signal.addEventListener('abort', () => { + clearTimeout(id); + reject(new Error('Aborted')); + }); } - }) + }); return { url, close() { - proc.kill() + proc.kill(); }, - } + }; } export function createOpencodeTui(options?: TuiOptions) { - const args = [] + const args = []; if (options?.project) { - args.push(`--project=${options.project}`) + args.push(`--project=${options.project}`); } if (options?.model) { - args.push(`--model=${options.model}`) + args.push(`--model=${options.model}`); } if (options?.session) { - args.push(`--session=${options.session}`) + args.push(`--session=${options.session}`); } if (options?.agent) { - args.push(`--agent=${options.agent}`) + args.push(`--agent=${options.agent}`); } const proc = spawn(`opencode`, args, { signal: options?.signal, - stdio: "inherit", + stdio: 'inherit', shell: process.platform === 'win32', env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}), }, - }) + }); return { close() { - proc.kill() + proc.kill(); }, - } + }; } diff --git a/apps/web/server/plugins/port-file.ts b/apps/web/server/plugins/port-file.ts index c6a62472..362e820d 100644 --- a/apps/web/server/plugins/port-file.ts +++ b/apps/web/server/plugins/port-file.ts @@ -7,21 +7,21 @@ * discoverable too. */ -import { writeFile, mkdir, unlink, readFile } from 'node:fs/promises' -import { randomUUID } from 'node:crypto' -import { join } from 'node:path' -import { homedir } from 'node:os' -const PORT_FILE_DIR = join(homedir(), '.openpencil') -const PORT_FILE_PATH = join(PORT_FILE_DIR, '.port') -const PORT_FILE_TOKEN = randomUUID() +import { writeFile, mkdir, unlink, readFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +const PORT_FILE_DIR = join(homedir(), '.openpencil'); +const PORT_FILE_PATH = join(PORT_FILE_DIR, '.port'); +const PORT_FILE_TOKEN = randomUUID(); function getOwnerPid(): number { - return process.ppid > 1 ? process.ppid : process.pid + return process.ppid > 1 ? process.ppid : process.pid; } async function writePortFile(port: number): Promise { try { - await mkdir(PORT_FILE_DIR, { recursive: true }) + await mkdir(PORT_FILE_DIR, { recursive: true }); await writeFile( PORT_FILE_PATH, JSON.stringify({ @@ -32,7 +32,7 @@ async function writePortFile(port: number): Promise { timestamp: Date.now(), }), 'utf-8', - ) + ); } catch { // Non-critical — MCP sync will fall back to file I/O } @@ -40,22 +40,22 @@ async function writePortFile(port: number): Promise { async function cleanupPortFile(): Promise { try { - const raw = await readFile(PORT_FILE_PATH, 'utf-8') - const current = JSON.parse(raw) as { token?: string } - if (current.token !== PORT_FILE_TOKEN) return - await unlink(PORT_FILE_PATH) + const raw = await readFile(PORT_FILE_PATH, 'utf-8'); + const current = JSON.parse(raw) as { token?: string }; + if (current.token !== PORT_FILE_TOKEN) return; + await unlink(PORT_FILE_PATH); } catch { // Ignore if already removed } } export default () => { - const port = parseInt(process.env.PORT || '3000', 10) - writePortFile(port) + const port = parseInt(process.env.PORT || '3000', 10); + writePortFile(port); const cleanup = () => { - cleanupPortFile() - } - process.on('SIGINT', cleanup) - process.on('SIGTERM', cleanup) -} + cleanupPortFile(); + }; + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); +}; diff --git a/apps/web/server/utils/__tests__/codegen-plan-store.test.ts b/apps/web/server/utils/__tests__/codegen-plan-store.test.ts new file mode 100644 index 00000000..53f44fca --- /dev/null +++ b/apps/web/server/utils/__tests__/codegen-plan-store.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from 'vitest'; +import { + createPlan, + getPlan, + submitChunkResult, + assemblePlan, + cleanPlan, +} from '../codegen-plan-store'; +import type { CodePlanFromAI, ChunkResult, PenNode } from '@zseven-w/pen-types'; + +const mockNodes: PenNode[] = [ + { id: 'n1', type: 'frame', name: 'Hero', x: 0, y: 0, width: 800, height: 600 } as PenNode, + { id: 'n2', type: 'rectangle', name: 'Card', x: 0, y: 0, width: 200, height: 150 } as PenNode, +]; + +const validPlan: CodePlanFromAI = { + chunks: [ + { + id: 'c1', + name: 'Hero', + nodeIds: ['n1'], + role: 'section', + suggestedComponentName: 'Hero', + dependencies: [], + exposedSlots: [], + }, + { + id: 'c2', + name: 'Card', + nodeIds: ['n2'], + role: 'component', + suggestedComponentName: 'Card', + dependencies: ['c1'], + }, + ], + sharedStyles: [], + rootLayout: { direction: 'vertical', gap: 16, responsive: true }, +}; + +describe('codegen-plan-store', () => { + it('createPlan returns planId and sorted executionPlan', () => { + const result = createPlan(validPlan, mockNodes); + expect(result.planId).toBeTruthy(); + expect(result.executionPlan).toHaveLength(2); + expect(result.executionPlan[0].id).toBe('c1'); + expect(result.executionPlan[1].id).toBe('c2'); + expect(result.warnings).toHaveLength(0); + cleanPlan(result.planId); + }); + + it('createPlan rejects duplicate chunkIds', () => { + const badPlan = { + ...validPlan, + chunks: [{ ...validPlan.chunks[0] }, { ...validPlan.chunks[1], id: 'c1' }], + }; + expect(() => createPlan(badPlan, mockNodes)).toThrow('Duplicate chunkId: c1'); + }); + + it('createPlan rejects empty nodeIds', () => { + const badPlan = { + ...validPlan, + chunks: [{ ...validPlan.chunks[0], nodeIds: [] }], + }; + expect(() => createPlan(badPlan, mockNodes)).toThrow('has no nodeIds'); + }); + + it('createPlan rejects unknown dependency', () => { + const badPlan = { + ...validPlan, + chunks: [{ ...validPlan.chunks[0], dependencies: ['unknown'] }], + }; + expect(() => createPlan(badPlan, mockNodes)).toThrow('depends on unknown chunk'); + }); + + it('createPlan rejects circular dependency', () => { + const badPlan: CodePlanFromAI = { + ...validPlan, + chunks: [ + { ...validPlan.chunks[0], dependencies: ['c2'] }, + { ...validPlan.chunks[1], dependencies: ['c1'] }, + ], + }; + expect(() => createPlan(badPlan, mockNodes)).toThrow('Circular dependency'); + }); + + it('createPlan rejects missing nodeId in document', () => { + const badPlan: CodePlanFromAI = { + ...validPlan, + chunks: [{ ...validPlan.chunks[0], nodeIds: ['missing'] }], + }; + expect(() => createPlan(badPlan, [])).toThrow('not found in document'); + }); + + it('createPlan warns on shared nodeIds', () => { + const sharedPlan: CodePlanFromAI = { + ...validPlan, + chunks: [ + { ...validPlan.chunks[0], nodeIds: ['n1'] }, + { ...validPlan.chunks[1], nodeIds: ['n1'], dependencies: [] }, + ], + }; + const result = createPlan(sharedPlan, mockNodes); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toContain('claimed by chunks'); + cleanPlan(result.planId); + }); + + it('submitChunkResult stores result and returns next chunk', () => { + const { planId } = createPlan(validPlan, mockNodes); + const chunkResult: ChunkResult = { + chunkId: 'c1', + code: 'function Hero() {}', + contract: { + chunkId: 'c1', + componentName: 'Hero', + exportedProps: [], + slots: [], + cssClasses: [], + cssVariables: [], + imports: [], + }, + }; + const submitResult = submitChunkResult(planId, chunkResult); + expect(submitResult.validation.valid).toBe(true); + expect(submitResult.nextChunk).toBeDefined(); + expect(submitResult.nextChunk!.id).toBe('c2'); + cleanPlan(planId); + }); + + it('assemblePlan returns all results and clears cache', () => { + const { planId } = createPlan(validPlan, mockNodes); + const cr1: ChunkResult = { + chunkId: 'c1', + code: 'function Hero() {}', + contract: { + chunkId: 'c1', + componentName: 'Hero', + exportedProps: [], + slots: [], + cssClasses: [], + cssVariables: [], + imports: [], + }, + }; + const cr2: ChunkResult = { + chunkId: 'c2', + code: 'function Card() {}', + contract: { + chunkId: 'c2', + componentName: 'Card', + exportedProps: [], + slots: [], + cssClasses: [], + cssVariables: [], + imports: [], + }, + }; + submitChunkResult(planId, cr1); + submitChunkResult(planId, cr2); + + const assembled = assemblePlan(planId, 'react'); + expect(assembled.chunks).toHaveLength(2); + expect(assembled.degraded).toBe(false); + + // Terminal operation — plan should be cleared + expect(getPlan(planId)).toBeUndefined(); + }); + + it('cleanPlan deletes existing plan', () => { + const { planId } = createPlan(validPlan, mockNodes); + expect(cleanPlan(planId)).toEqual({ ok: true, deleted: true }); + expect(getPlan(planId)).toBeUndefined(); + }); + + it('cleanPlan returns deleted=false for unknown plan', () => { + expect(cleanPlan('nonexistent')).toEqual({ ok: true, deleted: false }); + }); +}); diff --git a/apps/web/server/utils/agent-sessions.ts b/apps/web/server/utils/agent-sessions.ts index 363a332d..035b2358 100644 --- a/apps/web/server/utils/agent-sessions.ts +++ b/apps/web/server/utils/agent-sessions.ts @@ -1,23 +1,121 @@ -import type { Agent } from '@zseven-w/agent' +import type { + QueryEngineHandle, + IteratorHandle, + ProviderHandle, + ToolRegistryHandle, + TeamHandle, +} from '@zseven-w/agent-native'; +import type { LayoutPhase } from './agent-tool-guard'; +import { + abortEngine, + destroyIterator, + destroyQueryEngine, + destroyToolRegistry, + destroyProvider, + abortTeam, + destroyTeam, +} from '@zseven-w/agent-native'; export interface AgentSession { - agent: Agent - abortController: AbortController - createdAt: number - lastActivity: number + engine?: QueryEngineHandle; + team?: TeamHandle; + iter?: IteratorHandle; + provider: ProviderHandle; + tools?: ToolRegistryHandle; + memberHandles?: Array<{ provider: ProviderHandle; tools: ToolRegistryHandle }>; + createdAt: number; + lastActivity: number; + /** toolCallId → memberId — routes async tool results to the correct member engine. */ + toolOwners: Map; + /** toolCallId → tool name — used for session-level tool guards and state updates. */ + toolNames: Map; + /** memberId → role — used for delegation-time skill resolution. */ + memberRoles: Map; + /** Session-local layout progress for builtin single-agent guardrails. */ + layoutPhase: LayoutPhase; + layoutRootId: string | null; } -export const agentSessions = new Map() +/** Create a session with required defaults. */ +export function createSession( + fields: Omit< + AgentSession, + 'toolOwners' | 'toolNames' | 'memberRoles' | 'layoutPhase' | 'layoutRootId' + > & + Partial< + Pick< + AgentSession, + 'toolOwners' | 'toolNames' | 'memberRoles' | 'layoutPhase' | 'layoutRootId' + > + >, +): AgentSession { + return { + ...fields, + toolOwners: fields.toolOwners ?? new Map(), + toolNames: fields.toolNames ?? new Map(), + memberRoles: fields.memberRoles ?? new Map(), + layoutPhase: fields.layoutPhase ?? 'idle', + layoutRootId: fields.layoutRootId ?? null, + }; +} + +export const agentSessions = new Map(); + +/** Mark a session as active so long-running external tool callbacks are not expired. */ +export function touchSession(session: Pick, now = Date.now()): void { + session.lastActivity = now; +} + +/** Idempotent cleanup — nullifies handles after destroying to prevent double-free. */ +export function cleanup(session: AgentSession): void { + if (session.iter) { + destroyIterator(session.iter); + session.iter = undefined; + } + if (session.team) { + abortTeam(session.team); + destroyTeam(session.team); + session.team = undefined; + } + if (session.engine) { + destroyQueryEngine(session.engine); + session.engine = undefined; + } + if (session.memberHandles) { + for (const mh of session.memberHandles) { + destroyToolRegistry(mh.tools); + destroyProvider(mh.provider); + } + session.memberHandles = undefined; + } + if (session.tools) { + destroyToolRegistry(session.tools); + session.tools = undefined; + } + if (session.provider) { + destroyProvider(session.provider); + (session as any).provider = undefined; + } +} + +/** Abort a session — makes pending nextEvent resolve null. */ +export function abortSession(session: AgentSession): void { + if (session.team) abortTeam(session.team); + else if (session.engine) abortEngine(session.engine); +} // Cleanup stale sessions every 60s (5-minute TTL from last activity) setInterval(() => { try { - const now = Date.now() + const now = Date.now(); for (const [id, session] of agentSessions) { - if (now - session.lastActivity > 5 * 60 * 1000) { - try { session.abortController.abort() } catch { /* ignore */ } - agentSessions.delete(id) + if (now - session.lastActivity > 5 * 60_000) { + abortSession(session); + cleanup(session); + agentSessions.delete(id); } } - } catch { /* ignore cleanup errors */ } -}, 60_000) + } catch { + /* ignore cleanup errors */ + } +}, 60_000); diff --git a/apps/web/server/utils/agent-tool-guard.ts b/apps/web/server/utils/agent-tool-guard.ts new file mode 100644 index 00000000..10f73fc0 --- /dev/null +++ b/apps/web/server/utils/agent-tool-guard.ts @@ -0,0 +1,62 @@ +import type { ToolResult } from '../../src/types/agent'; + +export type LayoutPhase = 'idle' | 'layout_done' | 'content_started'; + +export interface LayoutSessionState { + layoutPhase: LayoutPhase; + layoutRootId: string | null; +} + +function parseToolInput(input: unknown): Record | null { + if (!input) return null; + if (typeof input === 'string') { + try { + const parsed = JSON.parse(input); + return parsed && typeof parsed === 'object' ? (parsed as Record) : null; + } catch { + return null; + } + } + return typeof input === 'object' ? (input as Record) : null; +} + +export function shouldShortCircuitPlanLayout( + session: LayoutSessionState, + toolName: string, + input: unknown, +): ToolResult | null { + if (toolName !== 'plan_layout') return null; + if (session.layoutPhase === 'idle') return null; + + const parsed = parseToolInput(input); + const newRoot = parsed?.newRoot === true; + if (newRoot) return null; + + return { + success: false, + ...(session.layoutRootId ? { data: { rootFrameId: session.layoutRootId } } : {}), + error: + `Layout already exists for this session${session.layoutRootId ? ` (rootFrameId: ${session.layoutRootId})` : ''}. ` + + 'Use batch_insert or insert_node to add content to the existing frame. ' + + 'Only call plan_layout again with {"prompt": "...", "newRoot": true} if you intentionally want another root frame or artboard.', + }; +} + +export function updateLayoutSessionState( + session: LayoutSessionState, + toolName: string | undefined, + result: ToolResult, +): void { + if (!toolName || !result?.success) return; + + if (toolName === 'plan_layout') { + session.layoutPhase = 'layout_done'; + const rootFrameId = (result.data as { rootFrameId?: string } | undefined)?.rootFrameId; + if (rootFrameId) session.layoutRootId = rootFrameId; + return; + } + + if (toolName === 'batch_insert' || toolName === 'insert_node') { + session.layoutPhase = 'content_started'; + } +} diff --git a/apps/web/server/utils/codegen-plan-store.ts b/apps/web/server/utils/codegen-plan-store.ts new file mode 100644 index 00000000..7a02bd9d --- /dev/null +++ b/apps/web/server/utils/codegen-plan-store.ts @@ -0,0 +1,353 @@ +import type { + CodePlanFromAI, + PlannedChunk, + ChunkResult, + ChunkContract, + ChunkStatus, + ContractValidationResult, + Framework, + ExecutableChunkPayload, + ResolvedDepContract, + NodeSnapshot, + CodeGenProgress, + PenNode, +} from '@zseven-w/pen-types'; +import { randomUUID } from 'node:crypto'; +import { validateContract } from '@zseven-w/pen-mcp'; + +// --- Internal state --- + +export interface PlanState { + plan: CodePlanFromAI; + nodes: Map; + order: Map; + results: Map; + statuses: Map; + lastActivity: number; +} + +const plans = new Map(); + +const TTL_MS = 30 * 60 * 1000; // 30 minutes + +function cleanExpired(): void { + const now = Date.now(); + for (const [id, state] of plans) { + if (now - state.lastActivity > TTL_MS) plans.delete(id); + } +} + +function touch(planId: string): void { + const state = plans.get(planId); + if (state) state.lastActivity = Date.now(); +} + +// --- Validation --- + +function validatePlan(plan: CodePlanFromAI, nodeIndex: Map): string[] { + const errors: string[] = []; + const ids = new Set(); + + // Duplicate chunkId + for (const chunk of plan.chunks) { + if (ids.has(chunk.id)) errors.push(`Duplicate chunkId: ${chunk.id}`); + ids.add(chunk.id); + } + if (errors.length > 0) return errors; + + for (const chunk of plan.chunks) { + if (!chunk.nodeIds || chunk.nodeIds.length === 0) { + errors.push(`Chunk ${chunk.id} has no nodeIds`); + } + for (const depId of chunk.dependencies) { + if (!ids.has(depId)) { + errors.push(`Chunk ${chunk.id} depends on unknown chunk ${depId}`); + } + } + for (const nodeId of chunk.nodeIds) { + if (!nodeIndex.has(nodeId)) { + errors.push(`Chunk ${chunk.id}: node ${nodeId} not found in document`); + } + } + } + + // Circular dependency detection via topological sort + if (errors.length === 0) { + const inDegree = new Map(); + const adj = new Map(); + for (const chunk of plan.chunks) { + inDegree.set(chunk.id, chunk.dependencies.length); + for (const dep of chunk.dependencies) { + const list = adj.get(dep) ?? []; + list.push(chunk.id); + adj.set(dep, list); + } + } + const queue = plan.chunks.filter((c) => c.dependencies.length === 0).map((c) => c.id); + let processed = 0; + while (queue.length > 0) { + const id = queue.shift()!; + processed++; + for (const next of adj.get(id) ?? []) { + const deg = (inDegree.get(next) ?? 1) - 1; + inDegree.set(next, deg); + if (deg === 0) queue.push(next); + } + } + if (processed < plan.chunks.length) { + const cycleIds = plan.chunks.filter((c) => (inDegree.get(c.id) ?? 0) > 0).map((c) => c.id); + errors.push(`Circular dependency: ${cycleIds.join(' → ')}`); + } + } + + return errors; +} + +function detectWarnings(plan: CodePlanFromAI): string[] { + const warnings: string[] = []; + const nodeToChunks = new Map(); + for (const chunk of plan.chunks) { + for (const nodeId of chunk.nodeIds) { + const list = nodeToChunks.get(nodeId) ?? []; + list.push(chunk.id); + nodeToChunks.set(nodeId, list); + } + } + for (const [nodeId, chunkIds] of nodeToChunks) { + if (chunkIds.length > 1) { + warnings.push(`Node ${nodeId} claimed by chunks: ${chunkIds.join(', ')}`); + } + } + return warnings; +} + +// --- Node indexing --- + +function indexNodes(nodes: PenNode[]): Map { + const map = new Map(); + function walk(list: PenNode[]) { + for (const n of list) { + map.set(n.id, n); + const children = (n as { children?: PenNode[] }).children; + if (children) walk(children); + } + } + walk(nodes); + return map; +} + +// --- Topological sort --- + +function topoSort(chunks: PlannedChunk[]): PlannedChunk[] { + const inDegree = new Map(); + const adj = new Map(); + const byId = new Map(); + + for (const c of chunks) { + byId.set(c.id, c); + inDegree.set(c.id, c.dependencies.length); + for (const dep of c.dependencies) { + const list = adj.get(dep) ?? []; + list.push(c.id); + adj.set(dep, list); + } + } + + const result: PlannedChunk[] = []; + const queue = chunks.filter((c) => c.dependencies.length === 0).map((c) => c.id); + + while (queue.length > 0) { + const id = queue.shift()!; + result.push(byId.get(id)!); + for (const next of adj.get(id) ?? []) { + const deg = (inDegree.get(next) ?? 1) - 1; + inDegree.set(next, deg); + if (deg === 0) queue.push(next); + } + } + return result; +} + +// --- Chunk hydration --- + +function hydrateChunk( + chunk: PlannedChunk, + order: number, + nodeIndex: Map, + depContracts: ResolvedDepContract[], + fullHydrate: boolean, +): ExecutableChunkPayload { + const nodes: NodeSnapshot[] = chunk.nodeIds + .map((id) => nodeIndex.get(id)) + .filter((n): n is PenNode => n !== undefined) + .map((n) => + fullHydrate + ? (n as unknown as NodeSnapshot) + : ({ ...n, children: '...' } as unknown as NodeSnapshot), + ); + + return { + ...chunk, + nodes, + order, + depContracts, + }; +} + +// --- Public API --- + +export interface CreatePlanResult { + planId: string; + executionPlan: ExecutableChunkPayload[]; + warnings: string[]; +} + +export function createPlan(plan: CodePlanFromAI, allNodes: PenNode[]): CreatePlanResult { + cleanExpired(); + + const nodeIndex = indexNodes(allNodes); + const errors = validatePlan(plan, nodeIndex); + if (errors.length > 0) { + throw new Error(errors.join('; ')); + } + + const warnings = detectWarnings(plan); + const sorted = topoSort(plan.chunks); + const orderMap = new Map(); + sorted.forEach((c, i) => orderMap.set(c.id, i)); + + const planId = randomUUID(); + const state: PlanState = { + plan, + nodes: nodeIndex, + order: orderMap, + results: new Map(), + statuses: new Map(), + lastActivity: Date.now(), + }; + for (const c of plan.chunks) { + state.statuses.set(c.id, 'pending'); + } + plans.set(planId, state); + + const executionPlan = sorted.map((chunk, i) => hydrateChunk(chunk, i, nodeIndex, [], i === 0)); + + return { planId, executionPlan, warnings }; +} + +export function getPlan(planId: string): PlanState | undefined { + return plans.get(planId); +} + +export interface SubmitChunkResult { + validation: ContractValidationResult; + progress: CodeGenProgress[]; + nextChunk?: ExecutableChunkPayload; +} + +export function submitChunkResult( + planId: string, + result: ChunkResult, + statusOverride?: 'failed' | 'skipped', +): SubmitChunkResult { + const state = plans.get(planId); + if (!state) throw new Error(`Plan ${planId} not found`); + touch(planId); + + const validation = validateContract(result); + + let status: ChunkStatus; + if (statusOverride === 'failed' || statusOverride === 'skipped') { + status = statusOverride; + } else if (validation.valid) { + status = 'done'; + } else { + status = 'degraded'; + } + + state.results.set(result.chunkId, result); + state.statuses.set(result.chunkId, status); + + const progress: CodeGenProgress[] = state.plan.chunks.map((c) => ({ + step: 'chunk' as const, + chunkId: c.id, + name: c.name, + status: state.statuses.get(c.id) ?? 'pending', + result: state.results.get(c.id), + })); + + let nextChunk: ExecutableChunkPayload | undefined; + const sorted = topoSort(state.plan.chunks); + for (const chunk of sorted) { + const chunkStatus = state.statuses.get(chunk.id); + if (chunkStatus !== 'pending') continue; + + const depsReady = chunk.dependencies.every((depId) => { + const depStatus = state.statuses.get(depId); + return ( + depStatus === 'done' || + depStatus === 'degraded' || + depStatus === 'failed' || + depStatus === 'skipped' + ); + }); + + if (depsReady) { + const depContracts: ResolvedDepContract[] = chunk.dependencies.map((depId) => { + const depStatus = state.statuses.get(depId); + if (depStatus === 'failed' || depStatus === 'skipped') return null; + return state.results.get(depId)?.contract ?? null; + }); + nextChunk = hydrateChunk( + chunk, + state.order.get(chunk.id) ?? 0, + state.nodes, + depContracts, + true, + ); + break; + } + } + + return { validation, progress, nextChunk }; +} + +export interface AssemblePlanResult { + chunks: ChunkResult[]; + contracts: ChunkContract[]; + dependencyGraph: Record; + degraded: boolean; +} + +export function assemblePlan(planId: string, _framework: Framework): AssemblePlanResult { + const state = plans.get(planId); + if (!state) throw new Error(`Plan ${planId} not found`); + + const sorted = topoSort(state.plan.chunks); + const chunks: ChunkResult[] = []; + const contracts: ChunkContract[] = []; + const dependencyGraph: Record = {}; + let degraded = false; + + for (const chunk of sorted) { + const result = state.results.get(chunk.id); + if (result) { + chunks.push(result); + contracts.push(result.contract); + } + dependencyGraph[chunk.id] = chunk.dependencies; + const status = state.statuses.get(chunk.id); + if (status !== 'done') degraded = true; + } + + // Terminal operation — clear cache + plans.delete(planId); + + return { chunks, contracts, dependencyGraph, degraded }; +} + +export function cleanPlan(planId: string): { ok: boolean; deleted: boolean } { + const existed = plans.has(planId); + if (existed) plans.delete(planId); + return { ok: true, deleted: existed }; +} diff --git a/apps/web/server/utils/codex-client.ts b/apps/web/server/utils/codex-client.ts index 82b6c175..05fad869 100644 --- a/apps/web/server/utils/codex-client.ts +++ b/apps/web/server/utils/codex-client.ts @@ -1,28 +1,28 @@ -import { spawn } from 'node:child_process' -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; -type ThinkingMode = 'adaptive' | 'disabled' | 'enabled' -type ThinkingEffort = 'low' | 'medium' | 'high' | 'max' +type ThinkingMode = 'adaptive' | 'disabled' | 'enabled'; +type ThinkingEffort = 'low' | 'medium' | 'high' | 'max'; interface CodexExecOptions { - model?: string - systemPrompt?: string - thinkingMode?: ThinkingMode - thinkingBudgetTokens?: number - effort?: ThinkingEffort - timeoutMs?: number + model?: string; + systemPrompt?: string; + thinkingMode?: ThinkingMode; + thinkingBudgetTokens?: number; + effort?: ThinkingEffort; + timeoutMs?: number; /** Paths to temporary image files to reference in the prompt */ - imageFiles?: string[] + imageFiles?: string[]; } interface CodexCliResult { - text?: string - error?: string + text?: string; + error?: string; } -const DEFAULT_CODEX_TIMEOUT_MS = 15 * 60 * 1000 +const DEFAULT_CODEX_TIMEOUT_MS = 15 * 60 * 1000; /** * Allowlist-based env filter for Codex CLI subprocess. @@ -30,32 +30,46 @@ const DEFAULT_CODEX_TIMEOUT_MS = 15 * 60 * 1000 * Prevents leaking secrets like ANTHROPIC_API_KEY, AWS_SECRET_KEY, GITHUB_TOKEN, etc. */ const CODEX_ENV_ALLOWLIST = new Set([ - 'PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR', + 'PATH', + 'HOME', + 'TERM', + 'LANG', + 'SHELL', + 'TMPDIR', // Windows-essential vars - 'SYSTEMROOT', 'COMSPEC', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', - 'PATHEXT', 'SYSTEMDRIVE', 'TEMP', 'TMP', 'HOMEDRIVE', 'HOMEPATH', -]) + 'SYSTEMROOT', + 'COMSPEC', + 'USERPROFILE', + 'APPDATA', + 'LOCALAPPDATA', + 'PATHEXT', + 'SYSTEMDRIVE', + 'TEMP', + 'TMP', + 'HOMEDRIVE', + 'HOMEPATH', +]); export function filterCodexEnv( env: Record, ): Record { - const result: Record = {} + const result: Record = {}; for (const [k, v] of Object.entries(env)) { if (CODEX_ENV_ALLOWLIST.has(k) || k.startsWith('OPENAI_') || k.startsWith('CODEX_')) { - result[k] = v + result[k] = v; } } - return result + return result; } export async function runCodexExec( userPrompt: string, options: CodexExecOptions = {}, ): Promise { - const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-codex-')) - const outputPath = join(tempDir, 'last-message.txt') - const prompt = buildPrompt(options.systemPrompt, userPrompt, options.imageFiles) - const codexEffort = resolveCodexEffort(options.thinkingMode, options.effort) + const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-codex-')); + const outputPath = join(tempDir, 'last-message.txt'); + const prompt = buildPrompt(options.systemPrompt, userPrompt, options.imageFiles); + const codexEffort = resolveCodexEffort(options.thinkingMode, options.effort); const args = [ 'exec', @@ -65,55 +79,117 @@ export async function runCodexExec( 'read-only', '--output-last-message', outputPath, - ] + ]; if (options.model) { - args.push('--model', options.model) + args.push('--model', options.model); } if (codexEffort) { - args.push('--config', `model_reasoning_effort=${codexEffort}`) + args.push('--config', `model_reasoning_effort=${codexEffort}`); } // On Windows, passing long prompts as command-line arguments causes // shell escaping issues (PowerShell MissingExpression, special chars). // Use codex's stdin mode (`-` as prompt arg) on all platforms — simpler // and avoids command-line length limits. - args.push('-') + args.push('-'); try { const runResult = await executeCodexCommand( args, options.timeoutMs ?? DEFAULT_CODEX_TIMEOUT_MS, prompt, - ) - const finalText = await readFile(outputPath, 'utf-8').catch(() => '') - const normalizedText = finalText.trim() || runResult.text.trim() + ); + const finalText = await readFile(outputPath, 'utf-8').catch(() => ''); + const normalizedText = finalText.trim() || runResult.text.trim(); if (normalizedText) { - return { text: normalizedText } + return { text: normalizedText }; } if (runResult.errors.length > 0) { - return { error: runResult.errors.join('; ') } + return { error: runResult.errors.join('; ') }; } - return { error: 'Codex returned no output.' } + return { error: 'Codex returned no output.' }; } catch (error) { - return { error: error instanceof Error ? error.message : 'Codex execution failed' } + return { error: error instanceof Error ? error.message : 'Codex execution failed' }; } finally { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + await rm(tempDir, { recursive: true, force: true }).catch(() => {}); } } -function buildPrompt(systemPrompt: string | undefined, userPrompt: string, imageFiles?: string[]): string { - const userText = userPrompt.trim() - const imageSection = imageFiles && imageFiles.length > 0 - ? '\n' + imageFiles.map((f) => `[Attached image: ${f} — read this file to see the image]`).join('\n') - : '' +export async function* streamCodexExec( + userPrompt: string, + options: CodexExecOptions = {}, +): AsyncGenerator<{ type: 'text'; content: string } | { type: 'error'; content: string }> { + const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-codex-')); + const prompt = buildPrompt(options.systemPrompt, userPrompt, options.imageFiles); + const codexEffort = resolveCodexEffort(options.thinkingMode, options.effort); + + const args = [ + 'exec', + '--json', + '--skip-git-repo-check', + '--sandbox', + 'read-only', + '-', + ...(options.model ? ['--model', options.model] : []), + ...(codexEffort ? ['--config', `model_reasoning_effort=${codexEffort}`] : []), + ]; + + const child = spawn('codex', args, { + env: filterCodexEnv(process.env as Record), + stdio: ['pipe', 'pipe', 'pipe'], + ...(process.platform === 'win32' && { shell: true }), + }); + + if (child.stdin) { + child.stdin.write(prompt); + child.stdin.end(); + } + + try { + let stdoutBuffer = ''; + for await (const chunk of child.stdout!) { + stdoutBuffer += chunk.toString('utf-8'); + let idx = stdoutBuffer.indexOf('\n'); + while (idx >= 0) { + const line = stdoutBuffer.slice(0, idx).trim(); + stdoutBuffer = stdoutBuffer.slice(idx + 1); + if (line) { + const event = parseCodexJsonLine(line); + if (event?.text) yield { type: 'text' as const, content: event.text }; + if (event?.error) yield { type: 'error' as const, content: event.error }; + } + idx = stdoutBuffer.indexOf('\n'); + } + } + if (stdoutBuffer.trim()) { + const event = parseCodexJsonLine(stdoutBuffer.trim()); + if (event?.text) yield { type: 'text' as const, content: event.text }; + if (event?.error) yield { type: 'error' as const, content: event.error }; + } + } finally { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +} + +function buildPrompt( + systemPrompt: string | undefined, + userPrompt: string, + imageFiles?: string[], +): string { + const userText = userPrompt.trim(); + const imageSection = + imageFiles && imageFiles.length > 0 + ? '\n' + + imageFiles.map((f) => `[Attached image: ${f} — read this file to see the image]`).join('\n') + : ''; if (!systemPrompt?.trim()) { - return userText + imageSection + return userText + imageSection; } return [ @@ -124,7 +200,7 @@ function buildPrompt(systemPrompt: string | undefined, userPrompt: string, image '', '--- TASK ---', userText + imageSection, - ].join('\n') + ].join('\n'); } function resolveCodexEffort( @@ -132,22 +208,22 @@ function resolveCodexEffort( effort: ThinkingEffort | undefined, ): 'low' | 'medium' | 'high' | undefined { if (thinkingMode === 'disabled') { - return 'low' + return 'low'; } if (effort === 'max') { - return 'high' + return 'high'; } if (effort === 'low' || effort === 'medium' || effort === 'high') { - return effort + return effort; } if (thinkingMode === 'enabled') { - return 'medium' + return 'medium'; } - return undefined + return undefined; } async function executeCodexCommand( @@ -161,153 +237,145 @@ async function executeCodexCommand( stdio: [stdinText ? 'pipe' : 'ignore', 'pipe', 'pipe'], // On Windows, npm-installed CLIs are .cmd scripts — need shell to resolve. ...(process.platform === 'win32' && { shell: true }), - }) + }); // Pipe prompt via stdin (codex reads from stdin when `-` is the prompt arg) if (stdinText && child.stdin) { - child.stdin.write(stdinText) - child.stdin.end() + child.stdin.write(stdinText); + child.stdin.end(); } - let stdoutBuffer = '' - let stderrBuffer = '' - let textAccumulator = '' - const errors: string[] = [] + let stdoutBuffer = ''; + let stderrBuffer = ''; + let textAccumulator = ''; + const errors: string[] = []; const flushStdoutLine = (line: string) => { - const event = parseCodexJsonLine(line) - if (!event) return + const event = parseCodexJsonLine(line); + if (!event) return; if (event.text) { - textAccumulator += event.text + textAccumulator += event.text; } if (event.error) { - errors.push(event.error) + errors.push(event.error); } - } + }; const timer = setTimeout(() => { - child.kill('SIGTERM') - reject(new Error(`Codex request timed out after ${Math.round(timeoutMs / 1000)}s.`)) - }, timeoutMs) + child.kill('SIGTERM'); + reject(new Error(`Codex request timed out after ${Math.round(timeoutMs / 1000)}s.`)); + }, timeoutMs); child.stdout!.on('data', (chunk: Buffer) => { - stdoutBuffer += chunk.toString('utf-8') - let idx = stdoutBuffer.indexOf('\n') + stdoutBuffer += chunk.toString('utf-8'); + let idx = stdoutBuffer.indexOf('\n'); while (idx >= 0) { - const line = stdoutBuffer.slice(0, idx).trim() - stdoutBuffer = stdoutBuffer.slice(idx + 1) - if (line) flushStdoutLine(line) - idx = stdoutBuffer.indexOf('\n') + const line = stdoutBuffer.slice(0, idx).trim(); + stdoutBuffer = stdoutBuffer.slice(idx + 1); + if (line) flushStdoutLine(line); + idx = stdoutBuffer.indexOf('\n'); } - }) + }); child.stderr!.on('data', (chunk: Buffer) => { - stderrBuffer += chunk.toString('utf-8') - }) + stderrBuffer += chunk.toString('utf-8'); + }); child.on('error', (err) => { - clearTimeout(timer) - reject(err) - }) + clearTimeout(timer); + reject(err); + }); child.on('close', (code) => { - clearTimeout(timer) + clearTimeout(timer); - const tail = stdoutBuffer.trim() + const tail = stdoutBuffer.trim(); if (tail) { - flushStdoutLine(tail) + flushStdoutLine(tail); } if (code === 0) { - resolve({ text: textAccumulator, errors }) - return + resolve({ text: textAccumulator, errors }); + return; } - const stderrError = extractCodexCliError(stderrBuffer) - const fallback = errors[errors.length - 1] - reject( - new Error( - stderrError - || fallback - || `Codex exited with code ${code ?? 'unknown'}.`, - ), - ) - }) - }) + const stderrError = extractCodexCliError(stderrBuffer); + const fallback = errors[errors.length - 1]; + reject(new Error(stderrError || fallback || `Codex exited with code ${code ?? 'unknown'}.`)); + }); + }); } -function parseCodexJsonLine( - line: string, -): { text?: string; error?: string } | null { - let parsed: Record +function parseCodexJsonLine(line: string): { text?: string; error?: string } | null { + let parsed: Record; try { - parsed = JSON.parse(line) as Record + parsed = JSON.parse(line) as Record; } catch { - return null + return null; } - const type = typeof parsed.type === 'string' ? parsed.type : '' + const type = typeof parsed.type === 'string' ? parsed.type : ''; if (type === 'error') { - const message = getStringField(parsed, ['message']) - return { error: message || 'Codex returned an unknown error.' } + const message = getStringField(parsed, ['message']); + return { error: message || 'Codex returned an unknown error.' }; } // Common Codex JSONL stream events include deltas in "delta" or "text". const text = - getStringField(parsed, ['delta']) - || getStringField(parsed, ['text']) - || getStringField(parsed, ['content']) + getStringField(parsed, ['delta']) || + getStringField(parsed, ['text']) || + getStringField(parsed, ['content']); - if (!text) return null - return { text } + if (!text) return null; + return { text }; } -function getStringField( - obj: Record, - keys: string[], -): string | null { +function getStringField(obj: Record, keys: string[]): string | null { for (const key of keys) { - const val = obj[key] + const val = obj[key]; if (typeof val === 'string' && val.length > 0) { - return val + return val; } } - return null + return null; } function extractCodexCliError(stderr: string): string | null { - const trimmed = stderr.trim() - if (!trimmed) return null + const trimmed = stderr.trim(); + if (!trimmed) return null; - const lines = trimmed.split('\n').map((line) => line.trim()).filter(Boolean) + const lines = trimmed + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); // 1. Look for "error: ..." lines (simple CLI errors) for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i] + const line = lines[i]; if (line.toLowerCase().startsWith('error:')) { - return line.replace(/^error:\s*/i, '').trim() + return line.replace(/^error:\s*/i, '').trim(); } } // 2. Look for Codex structured log errors: " ERROR : " // These contain the real error (auth failures, API errors, etc.) for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].match(/\bERROR\s+\S+:\s*(.+)/) + const match = lines[i].match(/\bERROR\s+\S+:\s*(.+)/); if (match) { - const msg = match[1].trim() + const msg = match[1].trim(); // For auth errors, provide actionable guidance if (/refresh token|sign in again|token.*expired|401 Unauthorized/i.test(msg)) { - return 'Codex authentication expired. Run "codex logout && codex login" to re-authenticate.' + return 'Codex authentication expired. Run "codex logout && codex login" to re-authenticate.'; } - return msg + return msg; } } // 3. Skip unhelpful "Warning: no last agent message" — surface it only as fallback - const lastLine = lines[lines.length - 1] ?? null + const lastLine = lines[lines.length - 1] ?? null; if (lastLine && /^warning:\s*no last agent message/i.test(lastLine)) { - return 'Codex returned no output. Check "codex login" status or try a different model.' + return 'Codex returned no output. Check "codex login" status or try a different model.'; } - return lastLine + return lastLine; } diff --git a/apps/web/server/utils/copilot-client.ts b/apps/web/server/utils/copilot-client.ts index b4101a4a..743e7eb5 100644 --- a/apps/web/server/utils/copilot-client.ts +++ b/apps/web/server/utils/copilot-client.ts @@ -1,59 +1,65 @@ -import { execSync } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { serverLog } from './server-logger' +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { serverLog } from './server-logger'; -const isWindows = process.platform === 'win32' +const isWindows = process.platform === 'win32'; /** Windows npm global installs may create .cmd or .ps1 wrappers — try both */ function winNpmCandidates(dir: string, name: string): string[] { - return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)] + return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)]; } /** On Windows, `where` may return an extensionless shell script — prefer .cmd/.ps1 */ function resolveWinExtension(binPath: string): string { - if (!isWindows) return binPath - if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath + if (!isWindows) return binPath; + if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath; for (const ext of ['.cmd', '.ps1']) { - if (existsSync(binPath + ext)) return binPath + ext + if (existsSync(binPath + ext)) return binPath + ext; } - return binPath + return binPath; } /** Resolve the standalone copilot CLI binary path to avoid Bun's node:sqlite issue */ export function resolveCopilotCli(): string | undefined { - serverLog.info(`[resolve-copilot] platform=${process.platform}, isWindows=${isWindows}`) + serverLog.info(`[resolve-copilot] platform=${process.platform}, isWindows=${isWindows}`); // 1. Try PATH lookup try { - const cmd = isWindows ? 'where copilot 2>nul' : 'which copilot 2>/dev/null' - serverLog.info(`[resolve-copilot] PATH lookup: ${cmd}`) - const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim() + const cmd = isWindows ? 'where copilot 2>nul' : 'which copilot 2>/dev/null'; + serverLog.info(`[resolve-copilot] PATH lookup: ${cmd}`); + const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim(); // `where` on Windows may return multiple lines - const path = result.split(/\r?\n/)[0]?.trim() - serverLog.info(`[resolve-copilot] PATH result: "${path}" (exists=${path ? existsSync(path) : false})`) - if (path && existsSync(path)) return resolveWinExtension(path) + const path = result.split(/\r?\n/)[0]?.trim(); + serverLog.info( + `[resolve-copilot] PATH result: "${path}" (exists=${path ? existsSync(path) : false})`, + ); + if (path && existsSync(path)) return resolveWinExtension(path); } catch (err) { - serverLog.info(`[resolve-copilot] PATH lookup failed: ${err instanceof Error ? err.message : err}`) + serverLog.info( + `[resolve-copilot] PATH lookup failed: ${err instanceof Error ? err.message : err}`, + ); } // 2. Try `npm prefix -g` on Windows (npm install -g creates .cmd wrappers) if (isWindows) { try { - serverLog.info('[resolve-copilot] trying npm.cmd prefix -g') + serverLog.info('[resolve-copilot] trying npm.cmd prefix -g'); const prefix = execSync('npm.cmd prefix -g', { encoding: 'utf-8', timeout: 5000, - }).trim() - serverLog.info(`[resolve-copilot] npm global prefix: "${prefix}"`) + }).trim(); + serverLog.info(`[resolve-copilot] npm global prefix: "${prefix}"`); if (prefix) { for (const bin of winNpmCandidates(prefix, 'copilot')) { - serverLog.info(`[resolve-copilot] npm global bin: "${bin}" (exists=${existsSync(bin)})`) - if (existsSync(bin)) return bin + serverLog.info(`[resolve-copilot] npm global bin: "${bin}" (exists=${existsSync(bin)})`); + if (existsSync(bin)) return bin; } } } catch (err) { - serverLog.info(`[resolve-copilot] npm prefix -g failed: ${err instanceof Error ? err.message : err}`) + serverLog.info( + `[resolve-copilot] npm prefix -g failed: ${err instanceof Error ? err.message : err}`, + ); } } @@ -67,16 +73,16 @@ export function resolveCopilotCli(): string | undefined { ...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'copilot'), // winget / native join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'copilot.exe'), - ] + ]; for (const c of candidates) { - const exists = c ? existsSync(c) : false - serverLog.info(`[resolve-copilot] candidate: "${c}" (exists=${exists})`) - if (c && exists) return c + const exists = c ? existsSync(c) : false; + serverLog.info(`[resolve-copilot] candidate: "${c}" (exists=${exists})`); + if (c && exists) return c; } } - serverLog.warn('[resolve-copilot] no copilot binary found') - return undefined + serverLog.warn('[resolve-copilot] no copilot binary found'); + return undefined; } /** @@ -85,38 +91,38 @@ export function resolveCopilotCli(): string | undefined { * Resolve the `.cmd` to the actual `.js` entry point so the SDK can run it via node. */ export function resolveCliPathForSdk(cliPath: string): string { - if (!isWindows || !cliPath.endsWith('.cmd')) return cliPath + if (!isWindows || !cliPath.endsWith('.cmd')) return cliPath; // npm .cmd wrappers live alongside node_modules — the JS entry is at: // /node_modules/@github/copilot/index.js - const dir = dirname(cliPath) - const jsEntry = join(dir, 'node_modules', '@github', 'copilot', 'index.js') + const dir = dirname(cliPath); + const jsEntry = join(dir, 'node_modules', '@github', 'copilot', 'index.js'); if (existsSync(jsEntry)) { - serverLog.info(`[resolve-copilot] resolved .cmd → .js: ${jsEntry}`) - return jsEntry + serverLog.info(`[resolve-copilot] resolved .cmd → .js: ${jsEntry}`); + return jsEntry; } // Fallback: parse the .cmd file to extract the JS path // npm .cmd wrappers use %dp0% or %~dp0 to reference their own directory try { - const content = readFileSync(cliPath, 'utf-8') - const match = content.match(/"([^"]+\.js)"/g) + const content = readFileSync(cliPath, 'utf-8'); + const match = content.match(/"([^"]+\.js)"/g); if (match) { for (const m of match) { - const jsPath = m - .replace(/"/g, '') - .replace(/%~?dp0%?\\/g, dir + '\\') + const jsPath = m.replace(/"/g, '').replace(/%~?dp0%?\\/g, dir + '\\'); if (jsPath.includes('copilot') && existsSync(jsPath)) { - serverLog.info(`[resolve-copilot] parsed .cmd → .js: ${jsPath}`) - return jsPath + serverLog.info(`[resolve-copilot] parsed .cmd → .js: ${jsPath}`); + return jsPath; } } } } catch (err) { - serverLog.warn(`[resolve-copilot] failed to parse .cmd: ${err instanceof Error ? err.message : err}`) + serverLog.warn( + `[resolve-copilot] failed to parse .cmd: ${err instanceof Error ? err.message : err}`, + ); } // Last resort: return as-is (will likely fail with EINVAL) - serverLog.warn(`[resolve-copilot] could not resolve .cmd to .js, using as-is: ${cliPath}`) - return cliPath + serverLog.warn(`[resolve-copilot] could not resolve .cmd to .js, using as-is: ${cliPath}`); + return cliPath; } diff --git a/apps/web/server/utils/gemini-client.ts b/apps/web/server/utils/gemini-client.ts index 4c9538d2..4d2feaf1 100644 --- a/apps/web/server/utils/gemini-client.ts +++ b/apps/web/server/utils/gemini-client.ts @@ -1,51 +1,65 @@ -import { spawn } from 'node:child_process' -import { resolveGeminiCli } from './resolve-gemini-cli' +import { spawn } from 'node:child_process'; +import { resolveGeminiCli } from './resolve-gemini-cli'; -type ThinkingMode = 'adaptive' | 'disabled' | 'enabled' -type ThinkingEffort = 'low' | 'medium' | 'high' | 'max' +type ThinkingMode = 'adaptive' | 'disabled' | 'enabled'; +type ThinkingEffort = 'low' | 'medium' | 'high' | 'max'; export interface GeminiExecOptions { - model?: string - systemPrompt?: string - thinkingMode?: ThinkingMode - thinkingBudgetTokens?: number - effort?: ThinkingEffort - timeoutMs?: number + model?: string; + systemPrompt?: string; + thinkingMode?: ThinkingMode; + thinkingBudgetTokens?: number; + effort?: ThinkingEffort; + timeoutMs?: number; } interface GeminiCliResult { - text?: string - error?: string + text?: string; + error?: string; } -const DEFAULT_GEMINI_TIMEOUT_MS = 15 * 60 * 1000 +const DEFAULT_GEMINI_TIMEOUT_MS = 15 * 60 * 1000; /** * Allowlist-based env filter for Gemini CLI subprocess. * Passes through safe system vars and Google/Gemini-specific prefixes. */ const GEMINI_ENV_ALLOWLIST = new Set([ - 'PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR', + 'PATH', + 'HOME', + 'TERM', + 'LANG', + 'SHELL', + 'TMPDIR', // Windows-essential - 'SYSTEMROOT', 'COMSPEC', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', - 'PATHEXT', 'SYSTEMDRIVE', 'TEMP', 'TMP', 'HOMEDRIVE', 'HOMEPATH', -]) + 'SYSTEMROOT', + 'COMSPEC', + 'USERPROFILE', + 'APPDATA', + 'LOCALAPPDATA', + 'PATHEXT', + 'SYSTEMDRIVE', + 'TEMP', + 'TMP', + 'HOMEDRIVE', + 'HOMEPATH', +]); function filterGeminiEnv( env: Record, ): Record { - const result: Record = {} + const result: Record = {}; for (const [k, v] of Object.entries(env)) { if ( - GEMINI_ENV_ALLOWLIST.has(k) - || k.startsWith('GOOGLE_') - || k.startsWith('GEMINI_') - || k.startsWith('GCLOUD_') + GEMINI_ENV_ALLOWLIST.has(k) || + k.startsWith('GOOGLE_') || + k.startsWith('GEMINI_') || + k.startsWith('GCLOUD_') ) { - result[k] = v + result[k] = v; } } - return result + return result; } /** @@ -57,25 +71,22 @@ export async function runGeminiExec( userPrompt: string, options: GeminiExecOptions = {}, ): Promise { - const binPath = resolveGeminiCli() + const binPath = resolveGeminiCli(); if (!binPath) { - return { error: 'Gemini CLI not found. Install it first.' } + return { error: 'Gemini CLI not found. Install it first.' }; } - const prompt = buildPrompt(options.systemPrompt, userPrompt) + const prompt = buildPrompt(options.systemPrompt, userPrompt); - const args = [ - '-o', 'json', - '--approval-mode', 'plan', - ] + const args = ['-o', 'json', '--approval-mode', 'plan']; if (options.model) { - args.push('-m', options.model) + args.push('-m', options.model); } // Use -p with a minimal marker; full prompt piped via stdin. // Gemini CLI appends -p value after stdin content. - args.push('-p', ' ') + args.push('-p', ' '); try { const result = await executeGeminiCommand( @@ -83,10 +94,10 @@ export async function runGeminiExec( args, options.timeoutMs ?? DEFAULT_GEMINI_TIMEOUT_MS, prompt, - ) - return result + ); + return result; } catch (error) { - return { error: error instanceof Error ? error.message : 'Gemini execution failed' } + return { error: error instanceof Error ? error.message : 'Gemini execution failed' }; } } @@ -98,98 +109,100 @@ export function streamGeminiExec( userPrompt: string, options: GeminiExecOptions = {}, ): { - stream: AsyncGenerator<{ type: 'text' | 'error' | 'done'; content: string }> - kill: () => void + stream: AsyncGenerator<{ type: 'text' | 'error' | 'done'; content: string }>; + kill: () => void; } { - const binPath = resolveGeminiCli() + const binPath = resolveGeminiCli(); if (!binPath) { return { stream: (async function* () { - yield { type: 'error' as const, content: 'Gemini CLI not found.' } + yield { type: 'error' as const, content: 'Gemini CLI not found.' }; })(), kill: () => {}, - } + }; } - const prompt = buildPrompt(options.systemPrompt, userPrompt) + const prompt = buildPrompt(options.systemPrompt, userPrompt); - const args = [ - '-o', 'stream-json', - '--approval-mode', 'plan', - ] + const args = ['-o', 'stream-json', '--approval-mode', 'plan']; if (options.model) { - args.push('-m', options.model) + args.push('-m', options.model); } // Use -p with minimal marker; full prompt piped via stdin. - args.push('-p', ' ') + args.push('-p', ' '); const child = spawn(binPath, args, { env: filterGeminiEnv(process.env as Record), stdio: ['pipe', 'pipe', 'pipe'], ...(process.platform === 'win32' && { shell: true }), - }) + }); // Pipe prompt via stdin if (child.stdin) { - child.stdin.write(prompt) - child.stdin.end() + child.stdin.write(prompt); + child.stdin.end(); } - const timeoutMs = options.timeoutMs ?? DEFAULT_GEMINI_TIMEOUT_MS + const timeoutMs = options.timeoutMs ?? DEFAULT_GEMINI_TIMEOUT_MS; const timer = setTimeout(() => { - child.kill('SIGTERM') - }, timeoutMs) + child.kill('SIGTERM'); + }, timeoutMs); - async function* generateStream(): AsyncGenerator<{ type: 'text' | 'error' | 'done'; content: string }> { - let buffer = '' + async function* generateStream(): AsyncGenerator<{ + type: 'text' | 'error' | 'done'; + content: string; + }> { + let buffer = ''; - child.stderr?.on('data', () => { /* discard stderr */ }) + child.stderr?.on('data', () => { + /* discard stderr */ + }); try { for await (const chunk of child.stdout!) { - buffer += chunk.toString('utf-8') - let idx = buffer.indexOf('\n') + buffer += chunk.toString('utf-8'); + let idx = buffer.indexOf('\n'); while (idx >= 0) { - const line = buffer.slice(0, idx).trim() - buffer = buffer.slice(idx + 1) + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); if (line) { - const event = parseStreamJsonLine(line) - if (event) yield event + const event = parseStreamJsonLine(line); + if (event) yield event; } - idx = buffer.indexOf('\n') + idx = buffer.indexOf('\n'); } } // Flush remaining buffer - const tail = buffer.trim() + const tail = buffer.trim(); if (tail) { - const event = parseStreamJsonLine(tail) - if (event) yield event + const event = parseStreamJsonLine(tail); + if (event) yield event; } - yield { type: 'done', content: '' } + yield { type: 'done', content: '' }; } catch (error) { - const msg = error instanceof Error ? error.message : 'Stream error' - yield { type: 'error', content: msg } + const msg = error instanceof Error ? error.message : 'Stream error'; + yield { type: 'error', content: msg }; } finally { - clearTimeout(timer) + clearTimeout(timer); } } return { stream: generateStream(), kill: () => { - clearTimeout(timer) - child.kill('SIGTERM') + clearTimeout(timer); + child.kill('SIGTERM'); }, - } + }; } function buildPrompt(systemPrompt: string | undefined, userPrompt: string): string { - const userText = userPrompt.trim() - if (!systemPrompt?.trim()) return userText + const userText = userPrompt.trim(); + if (!systemPrompt?.trim()) return userText; return [ 'You are a design generation assistant. Follow the guidelines below to produce the requested output.', @@ -199,7 +212,7 @@ function buildPrompt(systemPrompt: string | undefined, userPrompt: string): stri '', '--- TASK ---', userText, - ].join('\n') + ].join('\n'); } async function executeGeminiCommand( @@ -213,64 +226,64 @@ async function executeGeminiCommand( env: filterGeminiEnv(process.env as Record), stdio: [stdinText ? 'pipe' : 'ignore', 'pipe', 'pipe'], ...(process.platform === 'win32' && { shell: true }), - }) + }); // Pipe prompt via stdin if (stdinText && child.stdin) { - child.stdin.write(stdinText) - child.stdin.end() + child.stdin.write(stdinText); + child.stdin.end(); } - let stdoutBuffer = '' - let stderrBuffer = '' + let stdoutBuffer = ''; + let stderrBuffer = ''; const timer = setTimeout(() => { - child.kill('SIGTERM') - reject(new Error(`Gemini request timed out after ${Math.round(timeoutMs / 1000)}s.`)) - }, timeoutMs) + child.kill('SIGTERM'); + reject(new Error(`Gemini request timed out after ${Math.round(timeoutMs / 1000)}s.`)); + }, timeoutMs); child.stdout!.on('data', (chunk: Buffer) => { - stdoutBuffer += chunk.toString('utf-8') - }) + stdoutBuffer += chunk.toString('utf-8'); + }); child.stderr!.on('data', (chunk: Buffer) => { - stderrBuffer += chunk.toString('utf-8') - }) + stderrBuffer += chunk.toString('utf-8'); + }); child.on('error', (err) => { - clearTimeout(timer) - reject(err) - }) + clearTimeout(timer); + reject(err); + }); child.on('close', (code) => { - clearTimeout(timer) + clearTimeout(timer); // Parse JSON output — Gemini CLI always outputs a JSON object at the end of stdout. // Error text / stack traces may appear before it. - const parsed = parseGeminiJsonOutput(stdoutBuffer) + const parsed = parseGeminiJsonOutput(stdoutBuffer); if (parsed) { if (parsed.response) { - resolve({ text: parsed.response }) - return + resolve({ text: parsed.response }); + return; } if (parsed.errorMessage) { - resolve({ error: friendlyGeminiApiError(parsed.errorMessage) }) - return + resolve({ error: friendlyGeminiApiError(parsed.errorMessage) }); + return; } } if (code !== 0) { // Extract meaningful error from stderr or stdout - const errorMsg = extractGeminiError(stdoutBuffer, stderrBuffer) - resolve({ error: errorMsg || `Gemini exited with code ${code ?? 'unknown'}.` }) - return + const errorMsg = extractGeminiError(stdoutBuffer, stderrBuffer); + resolve({ error: errorMsg || `Gemini exited with code ${code ?? 'unknown'}.` }); + return; } - const raw = stdoutBuffer.trim() - resolve(raw ? { text: raw } : { error: 'Gemini returned no output.' }) - }) - }) + const raw = stdoutBuffer.trim(); + resolve(raw ? { text: raw } : { error: 'Gemini returned no output.' }); + }); + }); } /** @@ -279,115 +292,118 @@ async function executeGeminiCommand( * We search from the END of stdout for the last valid JSON block. */ function parseGeminiJsonOutput(raw: string): { response?: string; errorMessage?: string } | null { - const trimmed = raw.trim() - if (!trimmed) return null + const trimmed = raw.trim(); + if (!trimmed) return null; // Search backwards for the last top-level JSON object (starts with `{` at line beginning) - const lines = trimmed.split('\n') + const lines = trimmed.split('\n'); for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim() - if (!line.startsWith('{')) continue + const line = lines[i].trim(); + if (!line.startsWith('{')) continue; // Try to parse from this line to the end - const candidate = lines.slice(i).join('\n').trim() + const candidate = lines.slice(i).join('\n').trim(); try { - const data = JSON.parse(candidate) as Record + const data = JSON.parse(candidate) as Record; // Must have session_id to be a valid Gemini CLI response - if (!data.session_id && !data.response && !data.error) continue + if (!data.session_id && !data.response && !data.error) continue; - const response = typeof data.response === 'string' ? data.response : undefined + const response = typeof data.response === 'string' ? data.response : undefined; // error can be a string or an object { type, message, code } - let errorMessage: string | undefined + let errorMessage: string | undefined; if (data.error) { if (typeof data.error === 'string') { - errorMessage = data.error + errorMessage = data.error; } else if (typeof data.error === 'object' && data.error !== null) { - const errObj = data.error as Record - errorMessage = typeof errObj.message === 'string' ? errObj.message : JSON.stringify(data.error) + const errObj = data.error as Record; + errorMessage = + typeof errObj.message === 'string' ? errObj.message : JSON.stringify(data.error); } } - return { response, errorMessage } - } catch { /* not valid JSON from this point */ } + return { response, errorMessage }; + } catch { + /* not valid JSON from this point */ + } } - return null + return null; } /** Extract a human-readable error from Gemini CLI stdout/stderr */ function extractGeminiError(stdout: string, stderr: string): string | null { // Look for quota errors - const quotaMatch = stdout.match(/quota will reset after (\S+)/i) - || stderr.match(/quota will reset after (\S+)/i) + const quotaMatch = + stdout.match(/quota will reset after (\S+)/i) || stderr.match(/quota will reset after (\S+)/i); if (quotaMatch) { - return `Gemini quota exhausted. Resets after ${quotaMatch[1]}.` + return `Gemini quota exhausted. Resets after ${quotaMatch[1]}.`; } // Look for TerminalQuotaError or other named errors - const namedError = stdout.match(/(Terminal\w+Error|ApiError|AuthError):\s*(.+)/m) + const namedError = stdout.match(/(Terminal\w+Error|ApiError|AuthError):\s*(.+)/m); if (namedError) { - return namedError[2].trim() + return namedError[2].trim(); } // Stderr fallback - const stderrTrimmed = stderr.trim() - if (stderrTrimmed) return stderrTrimmed + const stderrTrimmed = stderr.trim(); + if (stderrTrimmed) return stderrTrimmed; - return null + return null; } /** Map raw Gemini API errors to user-friendly messages */ function friendlyGeminiApiError(raw: string): string { if (/quota|exhausted|429|capacity/i.test(raw)) { - const resetMatch = raw.match(/reset after (\S+)/i) + const resetMatch = raw.match(/reset after (\S+)/i); return resetMatch ? `Gemini quota exhausted. Resets after ${resetMatch[1]}.` - : 'Gemini quota exhausted. Please wait and try again.' + : 'Gemini quota exhausted. Please wait and try again.'; } if (/401|unauthenticated|auth/i.test(raw)) { - return 'Gemini auth expired. Run "gemini" in your terminal to re-authenticate.' + return 'Gemini auth expired. Run "gemini" in your terminal to re-authenticate.'; } if (/\[object Object\]/.test(raw)) { - return 'Gemini API error. Check your quota or try a different model.' + return 'Gemini API error. Check your quota or try a different model.'; } - return raw + return raw; } function parseStreamJsonLine( line: string, ): { type: 'text' | 'error' | 'done'; content: string } | null { // Skip non-JSON lines (e.g. "Loaded cached credentials.") - if (!line.startsWith('{')) return null + if (!line.startsWith('{')) return null; - let parsed: Record + let parsed: Record; try { - parsed = JSON.parse(line) as Record + parsed = JSON.parse(line) as Record; } catch { - return null + return null; } - const type = typeof parsed.type === 'string' ? parsed.type : '' + const type = typeof parsed.type === 'string' ? parsed.type : ''; if (type === 'message' && parsed.role === 'assistant') { - const content = typeof parsed.content === 'string' ? parsed.content : '' - if (content) return { type: 'text', content } + const content = typeof parsed.content === 'string' ? parsed.content : ''; + if (content) return { type: 'text', content }; } if (type === 'result') { // Check for error in result event if (parsed.status === 'error' && parsed.error) { - const errObj = parsed.error as Record - const msg = typeof errObj.message === 'string' ? errObj.message : 'Unknown error' - return { type: 'error', content: friendlyGeminiApiError(msg) } + const errObj = parsed.error as Record; + const msg = typeof errObj.message === 'string' ? errObj.message : 'Unknown error'; + return { type: 'error', content: friendlyGeminiApiError(msg) }; } - return null + return null; } if (type === 'error') { - const content = typeof parsed.message === 'string' ? parsed.message : 'Unknown error' - return { type: 'error', content: friendlyGeminiApiError(content) } + const content = typeof parsed.message === 'string' ? parsed.message : 'Unknown error'; + return { type: 'error', content: friendlyGeminiApiError(content) }; } - return null + return null; } diff --git a/apps/web/server/utils/local-asset.test.ts b/apps/web/server/utils/local-asset.test.ts new file mode 100644 index 00000000..a41dc87f --- /dev/null +++ b/apps/web/server/utils/local-asset.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { resolveServableLocalImagePath } from './local-asset'; + +describe('resolveServableLocalImagePath', () => { + it('resolves an extensionless png file by sniffing its bytes', async () => { + const dir = mkdtempSync(join(tmpdir(), 'openpencil-local-asset-')); + try { + const filePath = join(dir, 'hero'); + writeFileSync( + filePath, + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]), + ); + + await expect(resolveServableLocalImagePath(filePath)).resolves.toEqual({ + resolvedPath: filePath, + mimeType: 'image/png', + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resolves a sibling image file when the requested path is missing an extension', async () => { + const dir = mkdtempSync(join(tmpdir(), 'openpencil-local-asset-')); + try { + const filePath = join(dir, 'hero.jpg'); + writeFileSync(filePath, Buffer.from([0xff, 0xd8, 0xff, 0xe0])); + + await expect(resolveServableLocalImagePath(join(dir, 'hero'))).resolves.toEqual({ + resolvedPath: filePath, + mimeType: 'image/jpeg', + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns null for explicit unsupported extensions', async () => { + const dir = mkdtempSync(join(tmpdir(), 'openpencil-local-asset-')); + try { + const filePath = join(dir, 'hero.pdf'); + writeFileSync(filePath, Buffer.from('%PDF-1.7')); + + await expect(resolveServableLocalImagePath(filePath)).resolves.toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/web/server/utils/local-asset.ts b/apps/web/server/utils/local-asset.ts new file mode 100644 index 00000000..c9b9d7f4 --- /dev/null +++ b/apps/web/server/utils/local-asset.ts @@ -0,0 +1,120 @@ +import { readFile, stat } from 'node:fs/promises'; +import { extname, resolve } from 'node:path'; + +export const IMAGE_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.svg': 'image/svg+xml', + '.avif': 'image/avif', +}; + +const EXTENSIONLESS_IMAGE_CANDIDATES = Object.keys(IMAGE_MIME_TYPES); + +export async function resolveServableLocalImagePath(path: string): Promise<{ + resolvedPath: string; + mimeType: string; +} | null> { + const resolvedPath = resolve(path); + const extension = extname(resolvedPath).toLowerCase(); + + if (extension) { + const mimeType = IMAGE_MIME_TYPES[extension]; + if (!mimeType) return null; + + if (!(await isFile(resolvedPath))) return null; + return { resolvedPath, mimeType }; + } + + if (await isFile(resolvedPath)) { + const mimeType = await inferMimeTypeFromFile(resolvedPath); + if (!mimeType) return null; + return { resolvedPath, mimeType }; + } + + for (const candidateExt of EXTENSIONLESS_IMAGE_CANDIDATES) { + const candidatePath = `${resolvedPath}${candidateExt}`; + if (await isFile(candidatePath)) { + return { + resolvedPath: candidatePath, + mimeType: IMAGE_MIME_TYPES[candidateExt], + }; + } + } + + return null; +} + +async function isFile(path: string): Promise { + try { + const fileStat = await stat(path); + return fileStat.isFile(); + } catch { + return false; + } +} + +async function inferMimeTypeFromFile(path: string): Promise { + const content = await readFile(path); + return inferMimeTypeFromBuffer(content); +} + +function inferMimeTypeFromBuffer(content: Buffer): string | null { + if ( + content.length >= 8 && + content[0] === 0x89 && + content[1] === 0x50 && + content[2] === 0x4e && + content[3] === 0x47 && + content[4] === 0x0d && + content[5] === 0x0a && + content[6] === 0x1a && + content[7] === 0x0a + ) { + return 'image/png'; + } + + if (content.length >= 3 && content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff) { + return 'image/jpeg'; + } + + if (content.length >= 6) { + const header = content.subarray(0, 6).toString('ascii'); + if (header === 'GIF87a' || header === 'GIF89a') { + return 'image/gif'; + } + } + + if ( + content.length >= 12 && + content.subarray(0, 4).toString('ascii') === 'RIFF' && + content.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'image/webp'; + } + + if (content.length >= 2 && content[0] === 0x42 && content[1] === 0x4d) { + return 'image/bmp'; + } + + if ( + content.length >= 12 && + content.subarray(4, 8).toString('ascii') === 'ftyp' && + /^avif|avis$/i.test(content.subarray(8, 12).toString('ascii')) + ) { + return 'image/avif'; + } + + const textProbe = content.subarray(0, 512).toString('utf8').trimStart(); + if ( + textProbe.startsWith(' void; + reject: (err: Error) => void; + timer: ReturnType; +} + +const pendingRequests = new Map(); + +/** Allocate a new request id. */ +export function allocateRequestId(): string { + return randomUUID(); +} + +/** + * Register a pending screenshot request and return a promise that resolves + * when the renderer posts its response or rejects on timeout. + */ +export function registerPending(requestId: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingRequests.delete(requestId); + reject(new Error(`Screenshot request ${requestId} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + pendingRequests.set(requestId, { resolve, reject, timer }); + }); +} + +/** Called from the renderer-response endpoint. Returns true if matched. */ +export function resolvePending(response: ScreenshotResponse): boolean { + const entry = pendingRequests.get(response.requestId); + if (!entry) return false; + clearTimeout(entry.timer); + pendingRequests.delete(response.requestId); + entry.resolve(response); + return true; +} + +/** Reject a pending request without waiting for timeout. */ +export function rejectPending(requestId: string, err: Error): void { + const entry = pendingRequests.get(requestId); + if (!entry) return; + clearTimeout(entry.timer); + pendingRequests.delete(requestId); + entry.reject(err); +} diff --git a/apps/web/server/utils/mcp-server-manager.ts b/apps/web/server/utils/mcp-server-manager.ts index 83267991..b2d93162 100644 --- a/apps/web/server/utils/mcp-server-manager.ts +++ b/apps/web/server/utils/mcp-server-manager.ts @@ -1,25 +1,25 @@ -import { spawn } from 'node:child_process' -import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs' -import { networkInterfaces } from 'node:os' -import { join, resolve, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' -import { tmpdir } from 'node:os' +import { spawn } from 'node:child_process'; +import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs'; +import { networkInterfaces } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; // ESM-compatible __dirname polyfill -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); // PID/Port files for tracking the detached MCP server process across restarts -const MCP_PID_FILE = join(tmpdir(), 'openpencil-mcp-server.pid') -const MCP_PORT_FILE = join(tmpdir(), 'openpencil-mcp-server.port') +const MCP_PID_FILE = join(tmpdir(), 'openpencil-mcp-server.pid'); +const MCP_PORT_FILE = join(tmpdir(), 'openpencil-mcp-server.port'); /** Resolve the MCP server script path across dev, web build, and Electron production. */ function resolveMcpServerScript(): string { // Electron production: extraResources - const electronResources = process.env.ELECTRON_RESOURCES_PATH + const electronResources = process.env.ELECTRON_RESOURCES_PATH; if (electronResources) { - const p = join(electronResources, 'mcp-server.cjs') - if (existsSync(p)) return p + const p = join(electronResources, 'mcp-server.cjs'); + if (existsSync(p)) return p; } // dev + web build (from cwd) — monorepo outputs to out/ // In dev mode, CWD is apps/web/ (due to "cd apps/web && ..." in dev script), @@ -28,74 +28,91 @@ function resolveMcpServerScript(): string { resolve(process.cwd(), 'out', 'mcp-server.cjs'), resolve(process.cwd(), '..', '..', 'out', 'mcp-server.cjs'), ]) { - if (existsSync(base)) return base + if (existsSync(base)) return base; } // Fallback: relative to this file (Nitro bundled output) - const fromFile = resolve(__dirname, '..', '..', '..', 'out', 'mcp-server.cjs') - if (existsSync(fromFile)) return fromFile - return resolve(process.cwd(), 'out', 'mcp-server.cjs') + const fromFile = resolve(__dirname, '..', '..', '..', 'out', 'mcp-server.cjs'); + if (existsSync(fromFile)) return fromFile; + return resolve(process.cwd(), 'out', 'mcp-server.cjs'); } /** Get the first non-internal IPv4 address (LAN IP). */ export function getLocalIp(): string | null { - const nets = networkInterfaces() + const nets = networkInterfaces(); for (const name of Object.keys(nets)) { for (const net of nets[name] ?? []) { if (net.family === 'IPv4' && !net.internal) { - return net.address + return net.address; } } } - return null + return null; } /** Check if a process with the given PID is running. */ function isProcessRunning(pid: number): boolean { try { // Signal 0 checks existence without actually sending a signal - process.kill(pid, 0) - return true + process.kill(pid, 0); + return true; } catch { - return false + return false; } } /** Read PID from file if it exists and process is still running. */ function getRunningPid(): { pid: number; port: number } | null { try { - if (!existsSync(MCP_PID_FILE)) return null - const pid = parseInt(readFileSync(MCP_PID_FILE, 'utf-8').trim(), 10) + if (!existsSync(MCP_PID_FILE)) return null; + const pid = parseInt(readFileSync(MCP_PID_FILE, 'utf-8').trim(), 10); if (isNaN(pid) || !isProcessRunning(pid)) { // Stale PID file - clean up - try { unlinkSync(MCP_PID_FILE) } catch { /* ignore */ } - try { unlinkSync(MCP_PORT_FILE) } catch { /* ignore */ } - return null + try { + unlinkSync(MCP_PID_FILE); + } catch { + /* ignore */ + } + try { + unlinkSync(MCP_PORT_FILE); + } catch { + /* ignore */ + } + return null; } const port = existsSync(MCP_PORT_FILE) ? parseInt(readFileSync(MCP_PORT_FILE, 'utf-8').trim(), 10) - : 3100 - return { pid, port: isNaN(port) ? 3100 : port } + : 3100; + return { pid, port: isNaN(port) ? 3100 : port }; } catch { - return null + return null; } } -export function getMcpServerStatus(): { running: boolean; port: number | null; localIp: string | null } { - const info = getRunningPid() +export function getMcpServerStatus(): { + running: boolean; + port: number | null; + localIp: string | null; +} { + const info = getRunningPid(); if (!info) { - return { running: false, port: null, localIp: null } + return { running: false, port: null, localIp: null }; } - return { running: true, port: info.port, localIp: getLocalIp() } + return { running: true, port: info.port, localIp: getLocalIp() }; } -export function startMcpHttpServer(port: number): { running: boolean; port: number; localIp: string | null; error?: string } { +export function startMcpHttpServer(port: number): { + running: boolean; + port: number; + localIp: string | null; + error?: string; +} { // Check if already running - const existing = getRunningPid() + const existing = getRunningPid(); if (existing) { - return { running: true, port: existing.port, localIp: getLocalIp() } + return { running: true, port: existing.port, localIp: getLocalIp() }; } - const serverScript = resolveMcpServerScript() + const serverScript = resolveMcpServerScript(); try { // CRITICAL: Use detached mode with unref() so the MCP server survives @@ -118,40 +135,57 @@ export function startMcpHttpServer(port: number): { running: boolean; port: numb }, detached: true, windowsHide: true, - }) + }); // Allow parent to exit independently of child - child.unref() + child.unref(); // Write PID to file for later tracking (after brief delay to ensure startup) - const childPid = child.pid + const childPid = child.pid; if (childPid) { setTimeout(() => { try { if (isProcessRunning(childPid)) { - writeFileSync(MCP_PID_FILE, String(childPid), 'utf-8') - writeFileSync(MCP_PORT_FILE, String(port), 'utf-8') + writeFileSync(MCP_PID_FILE, String(childPid), 'utf-8'); + writeFileSync(MCP_PORT_FILE, String(port), 'utf-8'); } - } catch { /* ignore write errors */ } - }, 100) + } catch { + /* ignore write errors */ + } + }, 100); } - return { running: true, port, localIp: getLocalIp() } + return { running: true, port, localIp: getLocalIp() }; } catch (err) { - return { running: false, port, localIp: null, error: err instanceof Error ? err.message : String(err) } + return { + running: false, + port, + localIp: null, + error: err instanceof Error ? err.message : String(err), + }; } } export function stopMcpHttpServer(): { running: false } { - const info = getRunningPid() + const info = getRunningPid(); if (info) { try { // Use process.kill which is cross-platform and safe - process.kill(info.pid, 'SIGTERM') - } catch { /* process may have already exited */ } + process.kill(info.pid, 'SIGTERM'); + } catch { + /* process may have already exited */ + } // Clean up PID/Port files - try { unlinkSync(MCP_PID_FILE) } catch { /* ignore */ } - try { unlinkSync(MCP_PORT_FILE) } catch { /* ignore */ } + try { + unlinkSync(MCP_PID_FILE); + } catch { + /* ignore */ + } + try { + unlinkSync(MCP_PORT_FILE); + } catch { + /* ignore */ + } } - return { running: false } + return { running: false }; } diff --git a/apps/web/server/utils/mcp-sync-state.ts b/apps/web/server/utils/mcp-sync-state.ts index b27264a5..effb0c98 100644 --- a/apps/web/server/utils/mcp-sync-state.ts +++ b/apps/web/server/utils/mcp-sync-state.ts @@ -3,67 +3,101 @@ * Shared across Nitro API endpoints: GET/POST /api/mcp/document, GET /api/mcp/events. */ -import type { PenDocument } from '../../src/types/pen' +import type { PenDocument } from '../../src/types/pen'; -let currentDocument: PenDocument | null = null -let documentVersion = 0 -let currentSelection: string[] = [] -let currentActivePageId: string | null = null +let currentDocument: PenDocument | null = null; +let documentVersion = 0; +let currentSelection: string[] = []; +let currentActivePageId: string | null = null; +let lastActiveClientId: string | null = null; interface SSEWriter { - push(data: string): void + push(data: string): void; } interface SSEClient { - id: string - writer: SSEWriter + id: string; + writer: SSEWriter; } -const clients = new Map() +const clients = new Map(); export function getSyncDocument(): { doc: PenDocument | null; version: number } { - return { doc: currentDocument, version: documentVersion } + return { doc: currentDocument, version: documentVersion }; } export function setSyncDocument(doc: PenDocument, sourceClientId?: string): number { - currentDocument = doc - documentVersion++ - broadcast({ type: 'document:update', version: documentVersion, document: doc }, sourceClientId) - return documentVersion + currentDocument = doc; + documentVersion++; + if (sourceClientId) lastActiveClientId = sourceClientId; + broadcast({ type: 'document:update', version: documentVersion, document: doc }, sourceClientId); + return documentVersion; } export function getSyncSelection(): { selectedIds: string[]; activePageId: string | null } { - return { selectedIds: currentSelection, activePageId: currentActivePageId } + return { selectedIds: currentSelection, activePageId: currentActivePageId }; } export function clearSyncState(): void { - currentDocument = null - documentVersion = 0 - currentSelection = [] - currentActivePageId = null + currentDocument = null; + documentVersion = 0; + currentSelection = []; + currentActivePageId = null; + lastActiveClientId = null; } -export function setSyncSelection(selectedIds: string[], activePageId?: string | null): void { - currentSelection = selectedIds - if (activePageId !== undefined) currentActivePageId = activePageId +export function setSyncSelection( + selectedIds: string[], + activePageId?: string | null, + sourceClientId?: string, +): void { + currentSelection = selectedIds; + if (activePageId !== undefined) currentActivePageId = activePageId; + if (sourceClientId) lastActiveClientId = sourceClientId; } export function registerSSEClient(id: string, writer: SSEWriter): void { - clients.set(id, { id, writer }) + clients.set(id, { id, writer }); } export function unregisterSSEClient(id: string): void { - clients.delete(id) + clients.delete(id); } function broadcast(payload: Record, excludeClientId?: string): void { - const data = JSON.stringify(payload) + const data = JSON.stringify(payload); for (const [id, client] of clients) { - if (id === excludeClientId) continue + if (id === excludeClientId) continue; try { - client.writer.push(data) + client.writer.push(data); } catch { - clients.delete(id) + clients.delete(id); } } } + +export function markClientActive(clientId: string): void { + if (clients.has(clientId)) { + lastActiveClientId = clientId; + } +} + +export function getLastActiveClientId(): string | null { + return lastActiveClientId; +} + +export function isClientConnected(clientId: string): boolean { + return clients.has(clientId); +} + +export function sendToClient(clientId: string, payload: Record): boolean { + const client = clients.get(clientId); + if (!client) return false; + try { + client.writer.push(JSON.stringify(payload)); + return true; + } catch { + clients.delete(clientId); + return false; + } +} diff --git a/apps/web/server/utils/opencode-client.ts b/apps/web/server/utils/opencode-client.ts index 458f5d05..1f451bbd 100644 --- a/apps/web/server/utils/opencode-client.ts +++ b/apps/web/server/utils/opencode-client.ts @@ -3,46 +3,55 @@ * Reuses an existing server on port 4096; starts one on a random port as fallback. * Tracks spawned servers so they can be cleaned up on process exit. */ -import { execSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { homedir } from 'node:os' +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; -const activeServers = new Set<{ close(): void }>() +const activeServers = new Set<{ close(): void }>(); // Clean up spawned OpenCode servers on process exit function cleanup() { for (const server of activeServers) { - try { server.close() } catch { /* ignore */ } + try { + server.close(); + } catch { + /* ignore */ + } } - activeServers.clear() + activeServers.clear(); } -process.on('beforeExit', cleanup) -process.on('SIGTERM', cleanup) -process.on('SIGINT', cleanup) +process.on('beforeExit', cleanup); +process.on('SIGTERM', cleanup); +process.on('SIGINT', cleanup); -const isWindows = process.platform === 'win32' +const isWindows = process.platform === 'win32'; /** Cached resolved binary path */ -let _resolvedBinary: string | undefined | null = null +let _resolvedBinary: string | undefined | null = null; /** Resolve the opencode binary, with caching. */ function resolveOpencodeBinary(): string | undefined { - if (_resolvedBinary !== null) return _resolvedBinary ?? undefined + if (_resolvedBinary !== null) return _resolvedBinary ?? undefined; // PATH lookup try { - const cmd = isWindows ? 'where opencode 2>nul' : 'which opencode 2>/dev/null' - const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim().split(/\r?\n/)[0]?.trim() + const cmd = isWindows ? 'where opencode 2>nul' : 'which opencode 2>/dev/null'; + const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }) + .trim() + .split(/\r?\n/)[0] + ?.trim(); if (result && existsSync(result)) { - _resolvedBinary = result - return result + _resolvedBinary = result; + return result; } - } catch { /* not on PATH */ } + } catch { + /* not on PATH */ + } // Common install locations - const home = homedir() + const home = homedir(); const candidates = isWindows ? [ join(process.env.APPDATA || '', 'npm', 'opencode.cmd'), @@ -56,39 +65,43 @@ function resolveOpencodeBinary(): string | undefined { '/usr/local/bin/opencode', '/opt/homebrew/bin/opencode', join(home, '.local', 'bin', 'opencode'), - ] + ]; for (const c of candidates) { if (c && existsSync(c)) { - _resolvedBinary = c - return c + _resolvedBinary = c; + return c; } } - _resolvedBinary = undefined - return undefined + _resolvedBinary = undefined; + return undefined; } export async function getOpencodeClient(binaryPath?: string) { - const { createOpencodeClient, createOpencode } = await import('../opencode/index') + const { createOpencodeClient, createOpencode } = await import('../opencode/index'); // Try connecting to an existing server first try { - const client = createOpencodeClient() - await client.config.providers() // probe - return { client, server: undefined } + const client = createOpencodeClient(); + await client.config.providers(); // probe + return { client, server: undefined }; } catch { // No running server — start a temporary one on a random port - const resolvedPath = binaryPath ?? resolveOpencodeBinary() - const timeout = isWindows ? 15_000 : 5000 - const oc = await createOpencode({ port: 0, binaryPath: resolvedPath, timeout }) - activeServers.add(oc.server) - return { client: oc.client, server: oc.server } + const resolvedPath = binaryPath ?? resolveOpencodeBinary(); + const timeout = isWindows ? 15_000 : 5000; + const oc = await createOpencode({ port: 0, binaryPath: resolvedPath, timeout }); + activeServers.add(oc.server); + return { client: oc.client, server: oc.server }; } } export function releaseOpencodeServer(server: { close(): void } | undefined) { - if (!server) return - try { server.close() } catch { /* ignore */ } - activeServers.delete(server) + if (!server) return; + try { + server.close(); + } catch { + /* ignore */ + } + activeServers.delete(server); } diff --git a/apps/web/server/utils/resolve-claude-agent-env.ts b/apps/web/server/utils/resolve-claude-agent-env.ts index 59da0705..d23f3ee4 100644 --- a/apps/web/server/utils/resolve-claude-agent-env.ts +++ b/apps/web/server/utils/resolve-claude-agent-env.ts @@ -1,53 +1,57 @@ -import { spawn } from 'node:child_process' -import { mkdirSync, readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs' -import { homedir, tmpdir, platform } from 'node:os' -import { join } from 'node:path' +import { spawn } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs'; +import { homedir, tmpdir, platform } from 'node:os'; +import { join } from 'node:path'; -const IS_WIN = platform() === 'win32' +const IS_WIN = platform() === 'win32'; -type EnvLike = Record +type EnvLike = Record; interface ClaudeSettings { - env?: Record + env?: Record; } function normalizeEnvValue(key: string, value: unknown): string | undefined { - if (value == null) return undefined + if (value == null) return undefined; if (typeof value === 'string') { // Filter out empty strings - they cause issues - if (value.trim() === '') return undefined - return value + if (value.trim() === '') return undefined; + return value; } if (typeof value === 'number' || typeof value === 'boolean') { - return String(value) + return String(value); } // ANTHROPIC_CUSTOM_HEADERS can be an object in settings.json — serialize it. // Other object values are skipped to prevent "Invalid header name" errors. if (typeof value === 'object') { if (key === 'ANTHROPIC_CUSTOM_HEADERS') { - try { return JSON.stringify(value) } catch { return undefined } + try { + return JSON.stringify(value); + } catch { + return undefined; + } } - return undefined + return undefined; } - return undefined + return undefined; } function readSingleSettingsFile(filePath: string): EnvLike { try { - const raw = readFileSync(filePath, 'utf-8') - const parsed = JSON.parse(raw) as ClaudeSettings - if (!parsed.env || typeof parsed.env !== 'object') return {} + const raw = readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw) as ClaudeSettings; + if (!parsed.env || typeof parsed.env !== 'object') return {}; - const env: EnvLike = {} + const env: EnvLike = {}; for (const [key, value] of Object.entries(parsed.env)) { - const normalized = normalizeEnvValue(key, value) + const normalized = normalizeEnvValue(key, value); if (normalized !== undefined) { - env[key] = normalized + env[key] = normalized; } } - return env + return env; } catch { - return {} + return {}; } } @@ -56,10 +60,10 @@ function readSingleSettingsFile(filePath: string): EnvLike { * Local settings take priority (same as Claude Code's own precedence). */ function readClaudeSettingsEnv(): EnvLike { - const claudeDir = join(homedir(), '.claude') - const base = readSingleSettingsFile(join(claudeDir, 'settings.json')) - const local = readSingleSettingsFile(join(claudeDir, 'settings.local.json')) - return { ...base, ...local } + const claudeDir = join(homedir(), '.claude'); + const base = readSingleSettingsFile(join(claudeDir, 'settings.json')); + const local = readSingleSettingsFile(join(claudeDir, 'settings.local.json')); + return { ...base, ...local }; } /** @@ -67,10 +71,10 @@ function readClaudeSettingsEnv(): EnvLike { */ function isValidJson(str: string): boolean { try { - JSON.parse(str) - return true + JSON.parse(str); + return true; } catch { - return false + return false; } } @@ -79,23 +83,23 @@ function isValidJson(str: string): boolean { * or ~/.claude/ config files. Ensure the directory and config file exist and are writable. */ function ensureClaudeConfigWritable(): void { - if (!IS_WIN) return + if (!IS_WIN) return; try { - const claudeDir = join(homedir(), '.claude') - mkdirSync(claudeDir, { recursive: true }) + const claudeDir = join(homedir(), '.claude'); + mkdirSync(claudeDir, { recursive: true }); // Ensure .claude.json exists — Claude SDK crashes if it can't write/lock it - const configFile = join(homedir(), '.claude.json') + const configFile = join(homedir(), '.claude.json'); if (!existsSync(configFile)) { - writeFileSync(configFile, '{}', 'utf-8') + writeFileSync(configFile, '{}', 'utf-8'); } // Ensure credentials.json exists — SDK may crash trying to read/write it - const credFile = join(claudeDir, 'credentials.json') + const credFile = join(claudeDir, 'credentials.json'); if (!existsSync(credFile)) { - writeFileSync(credFile, '{}', 'utf-8') + writeFileSync(credFile, '{}', 'utf-8'); } // Ensure statsig/ cache dir exists — SDK crashes writing feature gate cache - const statsigDir = join(claudeDir, 'statsig') - mkdirSync(statsigDir, { recursive: true }) + const statsigDir = join(claudeDir, 'statsig'); + mkdirSync(statsigDir, { recursive: true }); } catch { // Best effort — if we can't fix it, the SDK error hint will guide the user } @@ -107,78 +111,80 @@ function ensureClaudeConfigWritable(): void { */ export function buildClaudeAgentEnv(): EnvLike { // On Windows, pre-create config files to avoid EPERM errors - ensureClaudeConfigWritable() + ensureClaudeConfigWritable(); - const fromSettings = readClaudeSettingsEnv() - const fromProcess = process.env as EnvLike + const fromSettings = readClaudeSettingsEnv(); + const fromProcess = process.env as EnvLike; const merged: EnvLike = { ...fromSettings, ...fromProcess, - } + }; // Validate ANTHROPIC_CUSTOM_HEADERS if it exists - must be valid JSON // If invalid, delete it to prevent "Invalid header name" errors if (merged.ANTHROPIC_CUSTOM_HEADERS) { if (!isValidJson(merged.ANTHROPIC_CUSTOM_HEADERS)) { - delete merged.ANTHROPIC_CUSTOM_HEADERS + delete merged.ANTHROPIC_CUSTOM_HEADERS; } } // Compatibility: use ANTHROPIC_AUTH_TOKEN as ANTHROPIC_API_KEY if no API key is set - const authToken = merged.ANTHROPIC_AUTH_TOKEN + const authToken = merged.ANTHROPIC_AUTH_TOKEN; if (authToken && !merged.ANTHROPIC_API_KEY) { - merged.ANTHROPIC_API_KEY = authToken + merged.ANTHROPIC_API_KEY = authToken; } // Running inside Claude terminal can break nested Claude invocations. - delete merged.CLAUDECODE + delete merged.CLAUDECODE; // Remove Electron-specific env vars that may confuse spawned CLI processes - delete merged.ELECTRON_RUN_AS_NODE - delete merged.ELECTRON_RESOURCES_PATH - delete merged.CHROME_CRASHPAD_PIPE_NAME + delete merged.ELECTRON_RUN_AS_NODE; + delete merged.ELECTRON_RESOURCES_PATH; + delete merged.CHROME_CRASHPAD_PIPE_NAME; // Enable Agent SDK debug stderr so we can capture CLI crash diagnostics. // Without this, the SDK sets stderr to "ignore" and crash output is lost. if (!merged.DEBUG_CLAUDE_AGENT_SDK) { - merged.DEBUG_CLAUDE_AGENT_SDK = '1' + merged.DEBUG_CLAUDE_AGENT_SDK = '1'; } // Apply NODE_TLS_REJECT_UNAUTHORIZED to the current process as well, // so Node.js HTTP/TLS in this process (used by the SDK internals) respects it. if (merged.NODE_TLS_REJECT_UNAUTHORIZED && !process.env.NODE_TLS_REJECT_UNAUTHORIZED) { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = merged.NODE_TLS_REJECT_UNAUTHORIZED + process.env.NODE_TLS_REJECT_UNAUTHORIZED = merged.NODE_TLS_REJECT_UNAUTHORIZED; } if (IS_WIN) { // Redirect Claude debug output to temp to avoid write permission issues if (!merged.CLAUDE_DEBUG_FILE) { - const debugPath = getClaudeAgentDebugFilePath() - if (debugPath) merged.CLAUDE_DEBUG_FILE = debugPath + const debugPath = getClaudeAgentDebugFilePath(); + if (debugPath) merged.CLAUDE_DEBUG_FILE = debugPath; } // Set CLAUDE_CONFIG_DIR to a writable temp location as fallback // if the default ~/.claude directory is not writable (common in Windows Electron) if (!merged.CLAUDE_CONFIG_DIR) { try { - const fallbackDir = join(tmpdir(), 'openpencil-claude-config') - mkdirSync(fallbackDir, { recursive: true }) + const fallbackDir = join(tmpdir(), 'openpencil-claude-config'); + mkdirSync(fallbackDir, { recursive: true }); // Only use fallback if we can't write to the default location - const defaultDir = join(homedir(), '.claude') - const testFile = join(defaultDir, '.write-test') + const defaultDir = join(homedir(), '.claude'); + const testFile = join(defaultDir, '.write-test'); try { - writeFileSync(testFile, '', 'utf-8') - const { unlinkSync } = require('node:fs') - unlinkSync(testFile) + writeFileSync(testFile, '', 'utf-8'); + const { unlinkSync } = require('node:fs'); + unlinkSync(testFile); } catch { // Default dir is not writable — use fallback - merged.CLAUDE_CONFIG_DIR = fallbackDir + merged.CLAUDE_CONFIG_DIR = fallbackDir; } - } catch { /* ignore */ } + } catch { + /* ignore */ + } } } - return merged + return merged; } /** @@ -192,18 +198,24 @@ export function buildClaudeAgentEnv(): EnvLike { * Example: user selects "Claude Sonnet 4.6" → detected as sonnet tier → * mapped to ANTHROPIC_DEFAULT_SONNET_MODEL (e.g. "gpt-5.3-codex") */ -export function resolveAgentModel(requestedModel: string | undefined, env: Record): string | undefined { - if (!requestedModel) return undefined - if (!env.ANTHROPIC_BASE_URL) return requestedModel +export function resolveAgentModel( + requestedModel: string | undefined, + env: Record, +): string | undefined { + if (!requestedModel) return undefined; + if (!env.ANTHROPIC_BASE_URL) return requestedModel; // Proxy mode: map model tier to the proxy's model via env vars - const lower = requestedModel.toLowerCase() - if (lower.includes('opus')) return env.ANTHROPIC_DEFAULT_OPUS_MODEL || env.ANTHROPIC_MODEL || undefined - if (lower.includes('haiku')) return env.ANTHROPIC_DEFAULT_HAIKU_MODEL || env.ANTHROPIC_MODEL || undefined - if (lower.includes('sonnet')) return env.ANTHROPIC_DEFAULT_SONNET_MODEL || env.ANTHROPIC_MODEL || undefined + const lower = requestedModel.toLowerCase(); + if (lower.includes('opus')) + return env.ANTHROPIC_DEFAULT_OPUS_MODEL || env.ANTHROPIC_MODEL || undefined; + if (lower.includes('haiku')) + return env.ANTHROPIC_DEFAULT_HAIKU_MODEL || env.ANTHROPIC_MODEL || undefined; + if (lower.includes('sonnet')) + return env.ANTHROPIC_DEFAULT_SONNET_MODEL || env.ANTHROPIC_MODEL || undefined; // Unknown tier: use the general default - return env.ANTHROPIC_MODEL || undefined + return env.ANTHROPIC_MODEL || undefined; } /** @@ -212,11 +224,11 @@ export function resolveAgentModel(requestedModel: string | undefined, env: Recor */ export function getClaudeAgentDebugFilePath(): string | undefined { try { - const dir = join(tmpdir(), 'openpencil-claude-debug') - mkdirSync(dir, { recursive: true }) - return join(dir, 'claude-agent.log') + const dir = join(tmpdir(), 'openpencil-claude-debug'); + mkdirSync(dir, { recursive: true }); + return join(dir, 'claude-agent.log'); } catch { - return undefined + return undefined; } } @@ -234,22 +246,28 @@ export function getClaudeAgentDebugFilePath(): string | undefined { * the debug file may be empty but stderr often contains the root cause. */ export function buildSpawnClaudeCodeProcess() { - if (process.platform !== 'win32') return undefined - return (options: { command: string; args: string[]; cwd?: string; env: Record; signal: AbortSignal }) => { - const cmd = options.command - const isPowerShell = cmd.endsWith('.ps1') + if (process.platform !== 'win32') return undefined; + return (options: { + command: string; + args: string[]; + cwd?: string; + env: Record; + signal: AbortSignal; + }) => { + const cmd = options.command; + const isPowerShell = cmd.endsWith('.ps1'); - let child + let child; if (isPowerShell) { // For .ps1 scripts, invoke via PowerShell - const psArgs = ['-ExecutionPolicy', 'Bypass', '-File', cmd, ...options.args] + const psArgs = ['-ExecutionPolicy', 'Bypass', '-File', cmd, ...options.args]; child = spawn('powershell.exe', psArgs, { cwd: options.cwd, env: options.env as NodeJS.ProcessEnv, signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, - }) + }); } else if (cmd.endsWith('.exe')) { // .exe files can be spawned directly without shell child = spawn(cmd, options.args, { @@ -258,21 +276,25 @@ export function buildSpawnClaudeCodeProcess() { signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, - }) + }); } else { // For .cmd or extensionless binaries, use shell. // When shell: true on Windows, empty string args get swallowed. // Filter out --setting-sources with empty value to prevent the next // flag (e.g. --permission-mode) from being consumed as its value. - const safeArgs: string[] = [] + const safeArgs: string[] = []; for (let i = 0; i < options.args.length; i++) { - const arg = options.args[i] + const arg = options.args[i]; // Skip --setting-sources followed by an empty string - if (arg === '--setting-sources' && i + 1 < options.args.length && options.args[i + 1] === '') { - i++ // skip the empty value too - continue + if ( + arg === '--setting-sources' && + i + 1 < options.args.length && + options.args[i + 1] === '' + ) { + i++; // skip the empty value too + continue; } - safeArgs.push(arg) + safeArgs.push(arg); } child = spawn(cmd, safeArgs, { cwd: options.cwd, @@ -281,27 +303,31 @@ export function buildSpawnClaudeCodeProcess() { stdio: ['pipe', 'pipe', 'pipe'], shell: true, windowsHide: true, - }) + }); } // Capture stderr to debug file — helps diagnose crashes where the process // exits before writing anything to the debug log - const stderrChunks: Buffer[] = [] - child.stderr?.on('data', (chunk: Buffer) => { stderrChunks.push(chunk) }) + const stderrChunks: Buffer[] = []; + child.stderr?.on('data', (chunk: Buffer) => { + stderrChunks.push(chunk); + }); child.on('exit', (code) => { if (code !== 0 && stderrChunks.length > 0) { - const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim() + const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim(); if (stderr) { - const debugPath = getClaudeAgentDebugFilePath() + const debugPath = getClaudeAgentDebugFilePath(); if (debugPath) { try { - appendFileSync(debugPath, `\n[stderr exit=${code}] ${stderr}\n`) - } catch { /* best effort */ } + appendFileSync(debugPath, `\n[stderr exit=${code}] ${stderr}\n`); + } catch { + /* best effort */ + } } } } - }) + }); - return child - } + return child; + }; } diff --git a/apps/web/server/utils/resolve-claude-cli.ts b/apps/web/server/utils/resolve-claude-cli.ts index 23a388b4..baeb5c64 100644 --- a/apps/web/server/utils/resolve-claude-cli.ts +++ b/apps/web/server/utils/resolve-claude-cli.ts @@ -1,24 +1,24 @@ -import { execSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import { homedir, platform } from 'node:os' -import { join } from 'node:path' -import { serverLog } from './server-logger' +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir, platform } from 'node:os'; +import { join } from 'node:path'; +import { serverLog } from './server-logger'; -const isWindows = platform() === 'win32' +const isWindows = platform() === 'win32'; /** Windows npm global installs may create .cmd or .ps1 wrappers — try both */ function winNpmCandidates(dir: string, name: string): string[] { - return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)] + return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)]; } /** On Windows, `where` may return an extensionless shell script — prefer .cmd/.ps1/.exe */ function resolveWinExtension(binPath: string): string { - if (!isWindows) return binPath - if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath + if (!isWindows) return binPath; + if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath; for (const ext of ['.cmd', '.ps1', '.exe']) { - if (existsSync(binPath + ext)) return binPath + ext + if (existsSync(binPath + ext)) return binPath + ext; } - return binPath + return binPath; } /** @@ -31,41 +31,49 @@ function resolveWinExtension(binPath: string): string { * binaries and spawns them directly (no `node` wrapper needed). */ export function resolveClaudeCli(): string | undefined { - serverLog.info(`[resolve-claude-cli] platform=${platform()}, isWindows=${isWindows}`) + serverLog.info(`[resolve-claude-cli] platform=${platform()}, isWindows=${isWindows}`); // 1. Try PATH lookup try { - const cmd = isWindows ? 'where claude 2>nul' : 'which claude 2>/dev/null' - serverLog.info(`[resolve-claude-cli] PATH lookup: ${cmd}`) + const cmd = isWindows ? 'where claude 2>nul' : 'which claude 2>/dev/null'; + serverLog.info(`[resolve-claude-cli] PATH lookup: ${cmd}`); const raw = execSync(cmd, { encoding: 'utf-8', timeout: 3000, - }).trim() - const p = raw.split(/\r?\n/)[0] // `where` on Windows may return multiple lines - serverLog.info(`[resolve-claude-cli] PATH lookup result: "${p}" (exists=${p ? existsSync(p) : false})`) - if (p && existsSync(p)) return resolveWinExtension(p) + }).trim(); + const p = raw.split(/\r?\n/)[0]; // `where` on Windows may return multiple lines + serverLog.info( + `[resolve-claude-cli] PATH lookup result: "${p}" (exists=${p ? existsSync(p) : false})`, + ); + if (p && existsSync(p)) return resolveWinExtension(p); } catch (err) { - serverLog.info(`[resolve-claude-cli] PATH lookup failed: ${err instanceof Error ? err.message : err}`) + serverLog.info( + `[resolve-claude-cli] PATH lookup failed: ${err instanceof Error ? err.message : err}`, + ); } // 2. Try `npm prefix -g` to find actual npm global bin directory // On Windows, must use `npm.cmd` since Electron spawns cmd.exe if (isWindows) { try { - serverLog.info('[resolve-claude-cli] trying npm.cmd prefix -g') + serverLog.info('[resolve-claude-cli] trying npm.cmd prefix -g'); const prefix = execSync('npm.cmd prefix -g', { encoding: 'utf-8', timeout: 5000, - }).trim() - serverLog.info(`[resolve-claude-cli] npm global prefix: "${prefix}"`) + }).trim(); + serverLog.info(`[resolve-claude-cli] npm global prefix: "${prefix}"`); if (prefix) { for (const bin of winNpmCandidates(prefix, 'claude')) { - serverLog.info(`[resolve-claude-cli] checking npm global bin: "${bin}" (exists=${existsSync(bin)})`) - if (existsSync(bin)) return bin + serverLog.info( + `[resolve-claude-cli] checking npm global bin: "${bin}" (exists=${existsSync(bin)})`, + ); + if (existsSync(bin)) return bin; } } } catch (err) { - serverLog.info(`[resolve-claude-cli] npm prefix -g failed: ${err instanceof Error ? err.message : err}`) + serverLog.info( + `[resolve-claude-cli] npm prefix -g failed: ${err instanceof Error ? err.message : err}`, + ); } } @@ -87,13 +95,13 @@ export function resolveClaudeCli(): string | undefined { join(homedir(), '.local', 'bin', 'claude'), '/usr/local/bin/claude', '/opt/homebrew/bin/claude', - ] + ]; for (const c of candidates) { - const exists = c ? existsSync(c) : false - serverLog.info(`[resolve-claude-cli] candidate: "${c}" (exists=${exists})`) - if (c && exists) return c + const exists = c ? existsSync(c) : false; + serverLog.info(`[resolve-claude-cli] candidate: "${c}" (exists=${exists})`); + if (c && exists) return c; } - serverLog.warn('[resolve-claude-cli] no claude binary found') - return undefined + serverLog.warn('[resolve-claude-cli] no claude binary found'); + return undefined; } diff --git a/apps/web/server/utils/resolve-gemini-cli.ts b/apps/web/server/utils/resolve-gemini-cli.ts index 44e3c286..b81ddc80 100644 --- a/apps/web/server/utils/resolve-gemini-cli.ts +++ b/apps/web/server/utils/resolve-gemini-cli.ts @@ -1,67 +1,73 @@ -import { execSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { homedir } from 'node:os' -import { serverLog } from './server-logger' +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { serverLog } from './server-logger'; -const isWindows = process.platform === 'win32' +const isWindows = process.platform === 'win32'; /** Windows npm global installs may create .cmd or .ps1 wrappers — try both */ function winNpmCandidates(dir: string, name: string): string[] { - return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)] + return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)]; } /** On Windows, `where` may return an extensionless shell script — prefer .cmd/.ps1 */ function resolveWinExtension(binPath: string): string { - if (!isWindows) return binPath - if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath + if (!isWindows) return binPath; + if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath; for (const ext of ['.cmd', '.ps1']) { - if (existsSync(binPath + ext)) return binPath + ext + if (existsSync(binPath + ext)) return binPath + ext; } - return binPath + return binPath; } /** Resolve the Gemini CLI binary path across macOS, Linux, and Windows. */ export function resolveGeminiCli(): string | undefined { - serverLog.info(`[resolve-gemini] platform=${process.platform}, isWindows=${isWindows}`) + serverLog.info(`[resolve-gemini] platform=${process.platform}, isWindows=${isWindows}`); // 1. Try PATH lookup try { - const cmd = isWindows ? 'where gemini 2>nul' : 'which gemini 2>/dev/null' - serverLog.info(`[resolve-gemini] PATH lookup: ${cmd}`) - const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim() + const cmd = isWindows ? 'where gemini 2>nul' : 'which gemini 2>/dev/null'; + serverLog.info(`[resolve-gemini] PATH lookup: ${cmd}`); + const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim(); // `where` on Windows may return multiple lines - const path = result.split(/\r?\n/)[0]?.trim() - serverLog.info(`[resolve-gemini] PATH result: "${path}" (exists=${path ? existsSync(path) : false})`) - if (path && existsSync(path)) return resolveWinExtension(path) + const path = result.split(/\r?\n/)[0]?.trim(); + serverLog.info( + `[resolve-gemini] PATH result: "${path}" (exists=${path ? existsSync(path) : false})`, + ); + if (path && existsSync(path)) return resolveWinExtension(path); } catch (err) { - serverLog.info(`[resolve-gemini] PATH lookup failed: ${err instanceof Error ? err.message : err}`) + serverLog.info( + `[resolve-gemini] PATH lookup failed: ${err instanceof Error ? err.message : err}`, + ); } // 2. Try `npm prefix -g` (Windows uses npm.cmd; Unix uses npm) try { - const npmCmd = isWindows ? 'npm.cmd prefix -g' : 'npm prefix -g' - serverLog.info(`[resolve-gemini] npm prefix lookup: ${npmCmd}`) - const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim() - serverLog.info(`[resolve-gemini] npm global prefix: "${prefix}"`) + const npmCmd = isWindows ? 'npm.cmd prefix -g' : 'npm prefix -g'; + serverLog.info(`[resolve-gemini] npm prefix lookup: ${npmCmd}`); + const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim(); + serverLog.info(`[resolve-gemini] npm global prefix: "${prefix}"`); if (prefix) { if (isWindows) { for (const bin of winNpmCandidates(prefix, 'gemini')) { - serverLog.info(`[resolve-gemini] npm global bin: "${bin}" (exists=${existsSync(bin)})`) - if (existsSync(bin)) return bin + serverLog.info(`[resolve-gemini] npm global bin: "${bin}" (exists=${existsSync(bin)})`); + if (existsSync(bin)) return bin; } } else { - const bin = join(prefix, 'bin', 'gemini') - serverLog.info(`[resolve-gemini] npm global bin: "${bin}" (exists=${existsSync(bin)})`) - if (existsSync(bin)) return bin + const bin = join(prefix, 'bin', 'gemini'); + serverLog.info(`[resolve-gemini] npm global bin: "${bin}" (exists=${existsSync(bin)})`); + if (existsSync(bin)) return bin; } } } catch (err) { - serverLog.info(`[resolve-gemini] npm prefix -g failed: ${err instanceof Error ? err.message : err}`) + serverLog.info( + `[resolve-gemini] npm prefix -g failed: ${err instanceof Error ? err.message : err}`, + ); } // 3. Common install locations - const home = homedir() + const home = homedir(); const candidates = isWindows ? [ // npm global (.cmd + .ps1) @@ -80,14 +86,14 @@ export function resolveGeminiCli(): string | undefined { // User-local join(home, '.local', 'bin', 'gemini'), join(home, '.npm-global', 'bin', 'gemini'), - ] + ]; for (const c of candidates) { - const exists = c ? existsSync(c) : false - serverLog.info(`[resolve-gemini] candidate: "${c}" (exists=${exists})`) - if (c && exists) return c + const exists = c ? existsSync(c) : false; + serverLog.info(`[resolve-gemini] candidate: "${c}" (exists=${exists})`); + if (c && exists) return c; } - serverLog.warn('[resolve-gemini] no gemini binary found') - return undefined + serverLog.warn('[resolve-gemini] no gemini binary found'); + return undefined; } diff --git a/apps/web/server/utils/server-logger.ts b/apps/web/server/utils/server-logger.ts index 664df0fe..e873b396 100644 --- a/apps/web/server/utils/server-logger.ts +++ b/apps/web/server/utils/server-logger.ts @@ -6,53 +6,53 @@ * Keeps the last 7 days of logs, auto-cleans on first write. */ -import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' +import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; -const MAX_LOG_DAYS = 7 +const MAX_LOG_DAYS = 7; -const logDir = join(homedir(), '.openpencil', 'logs') -let dirEnsured = false -let cleanedUp = false +const logDir = join(homedir(), '.openpencil', 'logs'); +let dirEnsured = false; +let cleanedUp = false; function ensureDir(): void { - if (dirEnsured) return + if (dirEnsured) return; try { if (!existsSync(logDir)) { - mkdirSync(logDir, { recursive: true }) + mkdirSync(logDir, { recursive: true }); } - dirEnsured = true + dirEnsured = true; } catch { // Silently fail — logging is best-effort } } function todayStamp(): string { - return new Date().toISOString().slice(0, 10) + return new Date().toISOString().slice(0, 10); } function timestamp(): string { - return new Date().toISOString() + return new Date().toISOString(); } function getLogFilePath(): string { - return join(logDir, `server-${todayStamp()}.log`) + return join(logDir, `server-${todayStamp()}.log`); } function cleanOldLogs(): void { - if (cleanedUp) return - cleanedUp = true + if (cleanedUp) return; + cleanedUp = true; try { - const files = readdirSync(logDir) - const cutoff = Date.now() - MAX_LOG_DAYS * 24 * 60 * 60 * 1000 + const files = readdirSync(logDir); + const cutoff = Date.now() - MAX_LOG_DAYS * 24 * 60 * 60 * 1000; for (const file of files) { - if (!file.endsWith('.log')) continue - const filePath = join(logDir, file) + if (!file.endsWith('.log')) continue; + const filePath = join(logDir, file); try { - const s = statSync(filePath) + const s = statSync(filePath); if (s.mtimeMs < cutoff) { - unlinkSync(filePath) + unlinkSync(filePath); } } catch { // ignore individual file errors @@ -64,18 +64,18 @@ function cleanOldLogs(): void { } function writeLine(level: string, msg: string): void { - const line = `${timestamp()} [${level}] ${msg}\n` + const line = `${timestamp()} [${level}] ${msg}\n`; // Forward to console if (level === 'ERROR') { - process.stderr.write(line) + process.stderr.write(line); } else { - process.stdout.write(line) + process.stdout.write(line); } // Write to file try { - ensureDir() - cleanOldLogs() - appendFileSync(getLogFilePath(), line, 'utf-8') + ensureDir(); + cleanOldLogs(); + appendFileSync(getLogFilePath(), line, 'utf-8'); } catch { // Disk full or permission error — silently drop } @@ -85,4 +85,4 @@ export const serverLog = { info: (msg: string) => writeLine('INFO', msg), warn: (msg: string) => writeLine('WARN', msg), error: (msg: string) => writeLine('ERROR', msg), -} +}; diff --git a/apps/web/server/utils/sse-keepalive.ts b/apps/web/server/utils/sse-keepalive.ts new file mode 100644 index 00000000..56e1401c --- /dev/null +++ b/apps/web/server/utils/sse-keepalive.ts @@ -0,0 +1,15 @@ +export function startSSEKeepAlive( + send: () => void, + intervalMs: number, +): ReturnType { + const tick = () => { + try { + send(); + } catch { + /* stream already closed */ + } + }; + + tick(); + return setInterval(tick, intervalMs); +} diff --git a/apps/web/server/utils/sse-stream.ts b/apps/web/server/utils/sse-stream.ts new file mode 100644 index 00000000..e78cec80 --- /dev/null +++ b/apps/web/server/utils/sse-stream.ts @@ -0,0 +1,59 @@ +export type SSEEvent = + | { type: 'text'; content: string } + | { type: 'thinking'; content: string } + | { type: 'error'; content: string } + | { type: 'done' }; + +export function createSSEResponse( + producer: (emit: (event: SSEEvent) => void, signal: AbortSignal) => Promise, +): Response { + const encoder = new TextEncoder(); + const abortController = new AbortController(); + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (raw: string) => { + try { + controller.enqueue(encoder.encode(raw)); + } catch { + /* closed */ + } + }; + + const emit = (event: SSEEvent) => { + enqueue(`data: ${JSON.stringify(event)}\n\n`); + }; + + const pingTimer = setInterval( + () => enqueue(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`), + 5000, + ); + + try { + await producer(emit, abortController.signal); + emit({ type: 'done' }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + emit({ type: 'error', content: msg }); + } finally { + clearInterval(pingTimer); + try { + controller.close(); + } catch { + /* already closed */ + } + } + }, + cancel() { + abortController.abort(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} diff --git a/apps/web/src/__tests__/setup-react.ts b/apps/web/src/__tests__/setup-react.ts new file mode 100644 index 00000000..cb52583c --- /dev/null +++ b/apps/web/src/__tests__/setup-react.ts @@ -0,0 +1,50 @@ +/** + * Vitest setup file to fix "multiple copies of React" error in bun monorepos. + * + * Problem: Vite's ESM module runner creates a separate React instance (via its + * transform pipeline) that has different ReactSharedInternals than the native + * CJS React used by react-dom. When react-dom renders and sets the hook dispatcher + * on CJS React's ReactSharedInternals.H, hooks in pen-react (which use the + * vite-transformed React) see a null dispatcher and throw "Invalid hook call". + * + * Root cause: `import 'react'` through vite's pipeline returns a module with its + * own `__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE` object, + * separate from the one that native CJS `require('react')` returns (which react-dom + * uses internally via its own require chain). + * + * Fix: In setupFiles (which run in the same vitest worker scope as each test file), + * install a proxy on the vite-transformed React's internals so all reads/writes + * delegate to the native CJS React's internals. After this, when react-dom sets H + * (the hook dispatcher), pen-react hooks see it immediately. + */ +import { createRequire } from 'node:module'; + +// Import 'react' via vite's transform pipeline — same instance that pen-react hooks use +import * as viteTranformedReact from 'react'; + +// Load react via native CJS require — same instance that react-dom uses internally. +// Resolve dynamically via node's own module lookup so this file is not tied to +// any single developer's machine (the previous hardcoded absolute path broke +// the entire apps/web test suite on every checkout outside that user's home). +const require = createRequire(import.meta.url); +const cjsReact = require('react') as Record; + +const viteInternals = (viteTranformedReact as any) + .__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE as Record; +const cjsInternals = + cjsReact.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE as Record; + +if (viteInternals && cjsInternals && viteInternals !== cjsInternals) { + // Make vite-transformed React's internals delegate all reads/writes to CJS internals. + // This bridges the two React instances so react-dom's dispatcher is visible to hooks. + for (const key of Object.keys(cjsInternals)) { + Object.defineProperty(viteInternals, key, { + get: () => (cjsInternals as any)[key], + set: (v) => { + (cjsInternals as any)[key] = v; + }, + configurable: true, + enumerable: true, + }); + } +} diff --git a/apps/web/src/canvas/__tests__/canvas-document-sync.test.ts b/apps/web/src/canvas/__tests__/canvas-document-sync.test.ts new file mode 100644 index 00000000..68057ca7 --- /dev/null +++ b/apps/web/src/canvas/__tests__/canvas-document-sync.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useDocumentStore } from '@/stores/document-store'; +import { subscribeToActivePageChildren } from '../canvas-document-sync'; + +describe('subscribeToActivePageChildren', () => { + beforeEach(() => { + useDocumentStore.getState().newDocument(); + }); + + it('should call onSync when active page children reference changes', () => { + const onSync = vi.fn(); + const unsub = subscribeToActivePageChildren(onSync); + + const testNode = { + id: 'test-sync-1', + type: 'rectangle' as const, + name: 'Test Rect', + x: 0, + y: 0, + width: 100, + height: 100, + }; + useDocumentStore.getState().addNode(null, testNode as any); + expect(onSync).toHaveBeenCalledTimes(1); + + unsub(); + }); + + it('should NOT call onSync when non-children state changes', () => { + const onSync = vi.fn(); + const unsub = subscribeToActivePageChildren(onSync); + + useDocumentStore.setState({ fileName: 'test.pen' }); + expect(onSync).not.toHaveBeenCalled(); + + useDocumentStore.setState({ isDirty: true }); + expect(onSync).not.toHaveBeenCalled(); + + unsub(); + }); + + it('should fire again on second mutation', () => { + const onSync = vi.fn(); + const unsub = subscribeToActivePageChildren(onSync); + + const node1 = { + id: 'n1', + type: 'rectangle' as const, + name: 'N1', + x: 0, + y: 0, + width: 50, + height: 50, + }; + const node2 = { + id: 'n2', + type: 'rectangle' as const, + name: 'N2', + x: 0, + y: 0, + width: 50, + height: 50, + }; + useDocumentStore.getState().addNode(null, node1 as any); + useDocumentStore.getState().addNode(null, node2 as any); + expect(onSync).toHaveBeenCalledTimes(2); + + unsub(); + }); + + it('should stop firing after unsubscribe', () => { + const onSync = vi.fn(); + const unsub = subscribeToActivePageChildren(onSync); + unsub(); + + const node = { + id: 'n3', + type: 'rectangle' as const, + name: 'N3', + x: 0, + y: 0, + width: 50, + height: 50, + }; + useDocumentStore.getState().addNode(null, node as any); + expect(onSync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/canvas/agent-indicator.ts b/apps/web/src/canvas/agent-indicator.ts index 2874346f..2ad76773 100644 --- a/apps/web/src/canvas/agent-indicator.ts +++ b/apps/web/src/canvas/agent-indicator.ts @@ -6,90 +6,90 @@ // --------------------------------------------------------------------------- export interface AgentIndicatorEntry { - nodeId: string - color: string - name: string + nodeId: string; + color: string; + name: string; } /** Tracks which root frame an agent is responsible for (for badge placement). */ export interface AgentFrameEntry { - frameId: string - color: string - name: string + frameId: string; + color: string; + name: string; } -const INDICATORS_KEY = '__openpencil_agent_indicators__' -const PREVIEWS_KEY = '__openpencil_agent_previews__' -const AGENT_FRAMES_KEY = '__openpencil_agent_frames__' +const INDICATORS_KEY = '__openpencil_agent_indicators__'; +const PREVIEWS_KEY = '__openpencil_agent_previews__'; +const AGENT_FRAMES_KEY = '__openpencil_agent_frames__'; function getIndicatorMap(): Map { - const g = globalThis as Record + const g = globalThis as Record; if (!g[INDICATORS_KEY]) { - g[INDICATORS_KEY] = new Map() + g[INDICATORS_KEY] = new Map(); } - return g[INDICATORS_KEY] as Map + return g[INDICATORS_KEY] as Map; } function getPreviewSet(): Set { - const g = globalThis as Record + const g = globalThis as Record; if (!g[PREVIEWS_KEY]) { - g[PREVIEWS_KEY] = new Set() + g[PREVIEWS_KEY] = new Set(); } - return g[PREVIEWS_KEY] as Set + return g[PREVIEWS_KEY] as Set; } function getAgentFrameMap(): Map { - const g = globalThis as Record + const g = globalThis as Record; if (!g[AGENT_FRAMES_KEY]) { - g[AGENT_FRAMES_KEY] = new Map() + g[AGENT_FRAMES_KEY] = new Map(); } - return g[AGENT_FRAMES_KEY] as Map + return g[AGENT_FRAMES_KEY] as Map; } export function getActiveAgentIndicators(): Map { - return getIndicatorMap() + return getIndicatorMap(); } export function getActiveAgentFrames(): Map { - return getAgentFrameMap() + return getAgentFrameMap(); } export function addAgentIndicator(nodeId: string, color: string, name: string): void { - getIndicatorMap().set(nodeId, { nodeId, color, name }) + getIndicatorMap().set(nodeId, { nodeId, color, name }); } /** Register a frame as being owned by an agent (for badge next to frame name). */ export function addAgentFrame(frameId: string, color: string, name: string): void { - getAgentFrameMap().set(frameId, { frameId, color, name }) + getAgentFrameMap().set(frameId, { frameId, color, name }); } export function removeAgentIndicator(nodeId: string): void { - getIndicatorMap().delete(nodeId) - getPreviewSet().delete(nodeId) + getIndicatorMap().delete(nodeId); + getPreviewSet().delete(nodeId); } export function addPreviewNode(nodeId: string): void { - getPreviewSet().add(nodeId) + getPreviewSet().add(nodeId); } export function removePreviewNode(nodeId: string): void { - getPreviewSet().delete(nodeId) + getPreviewSet().delete(nodeId); } export function isPreviewNode(nodeId: string): boolean { - return getPreviewSet().has(nodeId) + return getPreviewSet().has(nodeId); } /** Remove all node indicators whose nodeId starts with the given prefix. * Agent frame badges are NOT removed — they persist independently. */ export function removeAgentIndicatorsByPrefix(prefix: string): void { - const map = getIndicatorMap() - const set = getPreviewSet() - const prefixDash = `${prefix}-` + const map = getIndicatorMap(); + const set = getPreviewSet(); + const prefixDash = `${prefix}-`; for (const key of [...map.keys()]) { if (key.startsWith(prefixDash)) { - map.delete(key) - set.delete(key) + map.delete(key); + set.delete(key); } } } @@ -103,24 +103,24 @@ export function addAgentIndicatorRecursive( color: string, name: string, ): void { - const map = getIndicatorMap() - const set = getPreviewSet() + const map = getIndicatorMap(); + const set = getPreviewSet(); const walk = (n: { id: string; children?: unknown[] }) => { - map.set(n.id, { nodeId: n.id, color, name }) - set.add(n.id) + map.set(n.id, { nodeId: n.id, color, name }); + set.add(n.id); if (Array.isArray(n.children)) { for (const child of n.children) { - walk(child as { id: string; children?: unknown[] }) + walk(child as { id: string; children?: unknown[] }); } } - } - walk(node) + }; + walk(node); } /** Clear node indicators immediately; agent frame badges fade out after a delay. */ export function clearAgentIndicators(): void { - getIndicatorMap().clear() - getPreviewSet().clear() + getIndicatorMap().clear(); + getPreviewSet().clear(); // Agent frame badges linger briefly so the user sees which agent built what - setTimeout(() => getAgentFrameMap().clear(), 2000) + setTimeout(() => getAgentFrameMap().clear(), 2000); } diff --git a/apps/web/src/canvas/canvas-constants.ts b/apps/web/src/canvas/canvas-constants.ts index 0a264953..b23b79a6 100644 --- a/apps/web/src/canvas/canvas-constants.ts +++ b/apps/web/src/canvas/canvas-constants.ts @@ -36,14 +36,14 @@ export { GUIDE_COLOR, GUIDE_LINE_WIDTH, GUIDE_DASH, -} from '@zseven-w/pen-core' +} from '@zseven-w/pen-core'; -import { CANVAS_BACKGROUND_LIGHT, CANVAS_BACKGROUND_DARK } from '@zseven-w/pen-core' +import { CANVAS_BACKGROUND_LIGHT, CANVAS_BACKGROUND_DARK } from '@zseven-w/pen-core'; // Browser-only function — not in pen-core export function getCanvasBackground(): string { - if (typeof document === 'undefined') return CANVAS_BACKGROUND_DARK + if (typeof document === 'undefined') return CANVAS_BACKGROUND_DARK; return document.documentElement.classList.contains('light') ? CANVAS_BACKGROUND_LIGHT - : CANVAS_BACKGROUND_DARK + : CANVAS_BACKGROUND_DARK; } diff --git a/apps/web/src/canvas/canvas-document-sync.ts b/apps/web/src/canvas/canvas-document-sync.ts new file mode 100644 index 00000000..13d31ce8 --- /dev/null +++ b/apps/web/src/canvas/canvas-document-sync.ts @@ -0,0 +1,24 @@ +import { useDocumentStore } from '@/stores/document-store'; +import { useCanvasStore } from '@/stores/canvas-store'; +import { getActivePageChildren } from '@/stores/document-tree-utils'; + +/** + * Subscribe to the active page's children array reference. + * Calls `onSync` only when the children reference changes (not on + * unrelated store mutations like fileName or isDirty). + * + * Returns an unsubscribe function. + */ +export function subscribeToActivePageChildren(onSync: () => void): () => void { + let prevChildren = getActivePageChildren( + useDocumentStore.getState().document, + useCanvasStore.getState().activePageId, + ); + return useDocumentStore.subscribe((state) => { + const children = getActivePageChildren(state.document, useCanvasStore.getState().activePageId); + if (children !== prevChildren) { + prevChildren = children; + onSync(); + } + }); +} diff --git a/apps/web/src/canvas/canvas-layout-engine.ts b/apps/web/src/canvas/canvas-layout-engine.ts index 1c45ec9c..5e29cfc1 100644 --- a/apps/web/src/canvas/canvas-layout-engine.ts +++ b/apps/web/src/canvas/canvas-layout-engine.ts @@ -12,4 +12,8 @@ export { getNodeHeight, computeLayoutPositions, estimateLineWidth, -} from '@zseven-w/pen-core' + normalizeTreeLayout, + unwrapFakePhoneMockups, + stripRedundantSectionFills, + normalizeStrokeFillSchema, +} from '@zseven-w/pen-core'; diff --git a/apps/web/src/canvas/canvas-node-creator.ts b/apps/web/src/canvas/canvas-node-creator.ts index 14aac62e..235e05ba 100644 --- a/apps/web/src/canvas/canvas-node-creator.ts +++ b/apps/web/src/canvas/canvas-node-creator.ts @@ -1,114 +1,2 @@ -import { generateId } from '@/stores/document-store' -import type { PenNode } from '@/types/pen' -import type { ToolType } from '@/types/canvas' -import { - DEFAULT_FILL, - DEFAULT_STROKE, - DEFAULT_STROKE_WIDTH, - DEFAULT_FRAME_FILL, - DEFAULT_TEXT_FILL, -} from './canvas-constants' - -export function createNodeForTool( - tool: ToolType, - x: number, - y: number, - width: number, - height: number, -): PenNode | null { - const id = generateId() - - switch (tool) { - case 'rectangle': - return { - id, - type: 'rectangle', - name: 'Rectangle', - x, - y, - width: Math.abs(width), - height: Math.abs(height), - fill: [{ type: 'solid', color: DEFAULT_FILL }], - stroke: { - thickness: DEFAULT_STROKE_WIDTH, - fill: [{ type: 'solid', color: DEFAULT_STROKE }], - }, - } - case 'frame': - return { - id, - type: 'frame', - name: 'Frame', - x, - y, - width: Math.abs(width), - height: Math.abs(height), - fill: [{ type: 'solid', color: DEFAULT_FRAME_FILL }], - children: [], - } - case 'ellipse': - return { - id, - type: 'ellipse', - name: 'Ellipse', - x, - y, - width: Math.abs(width), - height: Math.abs(height), - fill: [{ type: 'solid', color: DEFAULT_FILL }], - stroke: { - thickness: DEFAULT_STROKE_WIDTH, - fill: [{ type: 'solid', color: DEFAULT_STROKE }], - }, - } - case 'polygon': - return { - id, - type: 'polygon', - name: 'Polygon', - x, - y, - width: Math.abs(width), - height: Math.abs(height), - polygonCount: 3, - fill: [{ type: 'solid', color: DEFAULT_FILL }], - stroke: { - thickness: DEFAULT_STROKE_WIDTH, - fill: [{ type: 'solid', color: DEFAULT_STROKE }], - }, - } - case 'line': - return { - id, - type: 'line', - name: 'Line', - x, - y, - x2: x + width, - y2: y + height, - stroke: { - thickness: DEFAULT_STROKE_WIDTH, - fill: [{ type: 'solid', color: DEFAULT_STROKE }], - }, - } - case 'text': - return { - id, - type: 'text', - name: 'Text', - x, - y, - content: 'Type here', - fontSize: 16, - fontFamily: 'Inter, sans-serif', - fill: [{ type: 'solid', color: DEFAULT_TEXT_FILL }], - } - default: - return null - } -} - -export function isDrawingTool(tool: ToolType): boolean { - return tool !== 'select' && tool !== 'hand' -} - +// Re-export from pen-engine (canonical source) +export { createNodeForTool, isDrawingTool } from '@zseven-w/pen-engine'; diff --git a/apps/web/src/canvas/canvas-sync-lock.ts b/apps/web/src/canvas/canvas-sync-lock.ts index f584423f..cbf195b2 100644 --- a/apps/web/src/canvas/canvas-sync-lock.ts +++ b/apps/web/src/canvas/canvas-sync-lock.ts @@ -1,2 +1,2 @@ // Re-export from @zseven-w/pen-core — the canonical source -export { isFabricSyncLocked, setFabricSyncLock } from '@zseven-w/pen-core' +export { isFabricSyncLocked, setFabricSyncLock } from '@zseven-w/pen-core'; diff --git a/apps/web/src/canvas/canvas-sync-utils.ts b/apps/web/src/canvas/canvas-sync-utils.ts index 364ee20b..a364fcf5 100644 --- a/apps/web/src/canvas/canvas-sync-utils.ts +++ b/apps/web/src/canvas/canvas-sync-utils.ts @@ -1,6 +1,6 @@ -import { useDocumentStore } from '@/stores/document-store' -import { useCanvasStore } from '@/stores/canvas-store' -import { getActivePageChildren, setActivePageChildren } from '@/stores/document-tree-utils' +import { useDocumentStore } from '@/stores/document-store'; +import { useCanvasStore } from '@/stores/canvas-store'; +import { getActivePageChildren, setActivePageChildren } from '@/stores/document-tree-utils'; /** * Force the canvas sync subscriber to re-run by creating a new page children @@ -8,10 +8,10 @@ import { getActivePageChildren, setActivePageChildren } from '@/stores/document- * touched root-level children which are empty under the pages architecture. */ export function forcePageResync() { - const doc = useDocumentStore.getState().document - const activePageId = useCanvasStore.getState().activePageId - const children = getActivePageChildren(doc, activePageId) + const doc = useDocumentStore.getState().document; + const activePageId = useCanvasStore.getState().activePageId; + const children = getActivePageChildren(doc, activePageId); useDocumentStore.setState({ document: setActivePageChildren(doc, activePageId, [...children]), - }) + }); } diff --git a/apps/web/src/canvas/canvas-text-measure.ts b/apps/web/src/canvas/canvas-text-measure.ts index f1de4fc0..aa8b51c1 100644 --- a/apps/web/src/canvas/canvas-text-measure.ts +++ b/apps/web/src/canvas/canvas-text-measure.ts @@ -14,29 +14,29 @@ export { getTextOpticalCenterYOffset, estimateTextHeight, setWrappedLineCounter, -} from '@zseven-w/pen-core' +} from '@zseven-w/pen-core'; import { isCjkCodePoint, estimateLineWidth, widthSafetyFactor, setWrappedLineCounter, -} from '@zseven-w/pen-core' -import { cssFontFamily } from './font-utils' +} from '@zseven-w/pen-core'; +import { cssFontFamily } from './font-utils'; // --------------------------------------------------------------------------- // Canvas 2D measurement context (lazy singleton, browser-only) // Wire up the browser-based wrapped line counter at module load time. // --------------------------------------------------------------------------- -let _textMeasureCtx: CanvasRenderingContext2D | null = null +let _textMeasureCtx: CanvasRenderingContext2D | null = null; function getTextMeasureCtx(): CanvasRenderingContext2D | null { - if (typeof document === 'undefined') return null + if (typeof document === 'undefined') return null; if (!_textMeasureCtx) { - const c = document.createElement('canvas') - _textMeasureCtx = c.getContext('2d') + const c = document.createElement('canvas'); + _textMeasureCtx = c.getContext('2d'); } - return _textMeasureCtx + return _textMeasureCtx; } function countWrappedLinesCanvas2D( @@ -47,64 +47,75 @@ function countWrappedLinesCanvas2D( fontFamily: string, letterSpacing: number, ): number { - const ctx = getTextMeasureCtx() + const ctx = getTextMeasureCtx(); if (!ctx) { return rawLines.reduce((sum, line) => { - const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight) * widthSafetyFactor(line) - return sum + Math.max(1, Math.ceil(lineWidth / wrapWidth)) - }, 0) + const lineWidth = + estimateLineWidth(line, fontSize, letterSpacing, fontWeight) * widthSafetyFactor(line); + return sum + Math.max(1, Math.ceil(lineWidth / wrapWidth)); + }, 0); } - const fw = typeof fontWeight === 'number' ? String(fontWeight) : (fontWeight ?? '400') - ctx.font = `${fw} ${fontSize}px ${cssFontFamily(fontFamily)}` + const fw = typeof fontWeight === 'number' ? String(fontWeight) : (fontWeight ?? '400'); + ctx.font = `${fw} ${fontSize}px ${cssFontFamily(fontFamily)}`; - let total = 0 + let total = 0; for (const rawLine of rawLines) { - if (!rawLine) { total += 1; continue } - if (ctx.measureText(rawLine).width <= wrapWidth) { total += 1; continue } - let lineCount = 0 - let current = '' - let i = 0 + if (!rawLine) { + total += 1; + continue; + } + if (ctx.measureText(rawLine).width <= wrapWidth) { + total += 1; + continue; + } + let lineCount = 0; + let current = ''; + let i = 0; while (i < rawLine.length) { - const ch = rawLine[i] + const ch = rawLine[i]; if (isCjkCodePoint(ch.codePointAt(0) ?? 0)) { - const test = current + ch + const test = current + ch; if (ctx.measureText(test).width > wrapWidth && current) { - lineCount++ - current = ch + lineCount++; + current = ch; } else { - current = test + current = test; } - i++ + i++; } else if (ch === ' ') { - const test = current + ch + const test = current + ch; if (ctx.measureText(test).width > wrapWidth && current) { - lineCount++ - current = '' + lineCount++; + current = ''; } else { - current = test + current = test; } - i++ + i++; } else { - let word = '' - while (i < rawLine.length && rawLine[i] !== ' ' && !isCjkCodePoint(rawLine[i].codePointAt(0) ?? 0)) { - word += rawLine[i] - i++ + let word = ''; + while ( + i < rawLine.length && + rawLine[i] !== ' ' && + !isCjkCodePoint(rawLine[i].codePointAt(0) ?? 0) + ) { + word += rawLine[i]; + i++; } - const test = current + word + const test = current + word; if (ctx.measureText(test).width > wrapWidth && current) { - lineCount++ - current = word + lineCount++; + current = word; } else { - current = test + current = test; } } } - if (current) lineCount++ - total += Math.max(1, lineCount) + if (current) lineCount++; + total += Math.max(1, lineCount); } - return total + return total; } // Register the Canvas 2D counter so pen-core's estimateTextHeight uses it -setWrappedLineCounter(countWrappedLinesCanvas2D) +setWrappedLineCounter(countWrappedLinesCanvas2D); diff --git a/apps/web/src/canvas/font-utils.ts b/apps/web/src/canvas/font-utils.ts index d9f6c33f..2bb76b4d 100644 --- a/apps/web/src/canvas/font-utils.ts +++ b/apps/web/src/canvas/font-utils.ts @@ -1,2 +1,2 @@ // Re-export from @zseven-w/pen-core — the canonical source -export { cssFontFamily } from '@zseven-w/pen-core' +export { cssFontFamily } from '@zseven-w/pen-core'; diff --git a/apps/web/src/canvas/insertion-indicator.ts b/apps/web/src/canvas/insertion-indicator.ts index fa9b0e93..9d990e88 100644 --- a/apps/web/src/canvas/insertion-indicator.ts +++ b/apps/web/src/canvas/insertion-indicator.ts @@ -5,26 +5,26 @@ // --------------------------------------------------------------------------- export interface InsertionIndicator { - x: number - y: number - length: number - orientation: 'vertical' | 'horizontal' + x: number; + y: number; + length: number; + orientation: 'vertical' | 'horizontal'; } export interface ContainerHighlight { - x: number - y: number - w: number - h: number + x: number; + y: number; + w: number; + h: number; } -export let activeInsertionIndicator: InsertionIndicator | null = null -export let activeContainerHighlight: ContainerHighlight | null = null +export let activeInsertionIndicator: InsertionIndicator | null = null; +export let activeContainerHighlight: ContainerHighlight | null = null; export function setInsertionIndicator(v: InsertionIndicator | null) { - activeInsertionIndicator = v + activeInsertionIndicator = v; } export function setContainerHighlight(v: ContainerHighlight | null) { - activeContainerHighlight = v + activeContainerHighlight = v; } diff --git a/apps/web/src/canvas/node-helpers.ts b/apps/web/src/canvas/node-helpers.ts index f6f80e24..202b3c60 100644 --- a/apps/web/src/canvas/node-helpers.ts +++ b/apps/web/src/canvas/node-helpers.ts @@ -1,2 +1,2 @@ // Re-export from @zseven-w/pen-core — the canonical source -export { isBadgeOverlayNode } from '@zseven-w/pen-core' +export { isBadgeOverlayNode } from '@zseven-w/pen-core'; diff --git a/apps/web/src/canvas/selection-context.ts b/apps/web/src/canvas/selection-context.ts index 72c199d9..ad5b8f97 100644 --- a/apps/web/src/canvas/selection-context.ts +++ b/apps/web/src/canvas/selection-context.ts @@ -1,6 +1,6 @@ -import { useCanvasStore } from '@/stores/canvas-store' -import { useDocumentStore, getActivePageChildren } from '@/stores/document-store' -import type { PenNode } from '@/types/pen' +import { useCanvasStore } from '@/stores/canvas-store'; +import { useDocumentStore, getActivePageChildren } from '@/stores/document-store'; +import type { PenNode } from '@/types/pen'; /** * Pure utility module for depth-aware selection. @@ -9,21 +9,21 @@ import type { PenNode } from '@/types/pen' /** Returns the set of node IDs that are selectable at the current depth. */ export function getSelectableNodeIds(): Set { - const { enteredFrameId } = useCanvasStore.getState().selection - const doc = useDocumentStore.getState().document + const { enteredFrameId } = useCanvasStore.getState().selection; + const doc = useDocumentStore.getState().document; if (!enteredFrameId) { // Root level: only top-level children of the active page are selectable - const activePageId = useCanvasStore.getState().activePageId - const children = getActivePageChildren(doc, activePageId) - return new Set(children.map((n) => n.id)) + const activePageId = useCanvasStore.getState().activePageId; + const children = getActivePageChildren(doc, activePageId); + return new Set(children.map((n) => n.id)); } - const frame = useDocumentStore.getState().getNodeById(enteredFrameId) + const frame = useDocumentStore.getState().getNodeById(enteredFrameId); if (!frame || !('children' in frame) || !frame.children) { - return new Set() + return new Set(); } - return new Set(frame.children.map((n) => n.id)) + return new Set(frame.children.map((n) => n.id)); } /** @@ -35,49 +35,49 @@ export function getSelectableNodeIds(): Set { * (e.g. belongs to a different root frame when inside an entered frame). */ export function resolveTargetAtDepth(nodeId: string): string | null { - const selectableIds = getSelectableNodeIds() + const selectableIds = getSelectableNodeIds(); // Direct match - if (selectableIds.has(nodeId)) return nodeId + if (selectableIds.has(nodeId)) return nodeId; // Handle virtual instance child IDs (refId__childId) if (nodeId.includes('__')) { - const refId = nodeId.substring(0, nodeId.indexOf('__')) - if (selectableIds.has(refId)) return refId + const refId = nodeId.substring(0, nodeId.indexOf('__')); + if (selectableIds.has(refId)) return refId; // Walk up from the RefNode - let cur: string | undefined = refId + let cur: string | undefined = refId; while (cur) { - const parent = useDocumentStore.getState().getParentOf(cur) - if (!parent) break - if (selectableIds.has(parent.id)) return parent.id - cur = parent.id + const parent = useDocumentStore.getState().getParentOf(cur); + if (!parent) break; + if (selectableIds.has(parent.id)) return parent.id; + cur = parent.id; } } // Walk up parent chain - let currentId: string | undefined = nodeId + let currentId: string | undefined = nodeId; while (currentId) { - const parent = useDocumentStore.getState().getParentOf(currentId) - if (!parent) break - if (selectableIds.has(parent.id)) return parent.id - currentId = parent.id + const parent = useDocumentStore.getState().getParentOf(currentId); + if (!parent) break; + if (selectableIds.has(parent.id)) return parent.id; + currentId = parent.id; } - return null + return null; } /** Check whether a node is a container that can be "entered" via double-click. */ export function isEnterableContainer(nodeId: string): boolean { - const node = useDocumentStore.getState().getNodeById(nodeId) - if (!node) return false - if (node.type !== 'frame' && node.type !== 'group') return false - if (!('children' in node) || !node.children || node.children.length === 0) return false - return true + const node = useDocumentStore.getState().getNodeById(nodeId); + if (!node) return false; + if (node.type !== 'frame' && node.type !== 'group') return false; + if (!('children' in node) || !node.children || node.children.length === 0) return false; + return true; } /** Return the direct children IDs of a container node. */ export function getChildIds(nodeId: string): Set { - const node = useDocumentStore.getState().getNodeById(nodeId) - if (!node || !('children' in node) || !node.children) return new Set() - return new Set(node.children.map((n: PenNode) => n.id)) + const node = useDocumentStore.getState().getNodeById(nodeId); + if (!node || !('children' in node) || !node.children) return new Set(); + return new Set(node.children.map((n: PenNode) => n.id)); } diff --git a/apps/web/src/canvas/skia-engine-ref.ts b/apps/web/src/canvas/skia-engine-ref.ts index 001cdd1b..bd75813c 100644 --- a/apps/web/src/canvas/skia-engine-ref.ts +++ b/apps/web/src/canvas/skia-engine-ref.ts @@ -5,16 +5,16 @@ * engine methods like zoomToFitContent() without prop-drilling. */ -import type { SkiaEngine } from './skia/skia-engine' +import type { SkiaEngine } from './skia/skia-engine'; -let _engine: SkiaEngine | null = null +let _engine: SkiaEngine | null = null; export function setSkiaEngineRef(engine: SkiaEngine | null) { - _engine = engine + _engine = engine; } export function getSkiaEngineRef(): SkiaEngine | null { - return _engine + return _engine; } /** @@ -22,7 +22,7 @@ export function getSkiaEngineRef(): SkiaEngine | null { * Delegates to the active SkiaEngine instance. */ export function zoomToFitContent() { - _engine?.zoomToFitContent() + _engine?.zoomToFitContent(); } /** @@ -30,7 +30,7 @@ export function zoomToFitContent() { * Falls back to 800x600 if no engine is mounted. */ export function getCanvasSize(): { width: number; height: number } { - return _engine?.getCanvasSize() ?? { width: 800, height: 600 } + return _engine?.getCanvasSize() ?? { width: 800, height: 600 }; } /** @@ -47,12 +47,12 @@ export function syncCanvasPositionsToStore() { * Used by layer panel to programmatically select children without * auto-resolving them to their parent group. */ -let _skipNextDepthResolve = false +let _skipNextDepthResolve = false; export function setSkipNextDepthResolve() { - _skipNextDepthResolve = true + _skipNextDepthResolve = true; } export function consumeSkipNextDepthResolve(): boolean { - const v = _skipNextDepthResolve - _skipNextDepthResolve = false - return v + const v = _skipNextDepthResolve; + _skipNextDepthResolve = false; + return v; } diff --git a/apps/web/src/canvas/skia/__tests__/document-sync-scheduler.test.ts b/apps/web/src/canvas/skia/__tests__/document-sync-scheduler.test.ts new file mode 100644 index 00000000..db0134de --- /dev/null +++ b/apps/web/src/canvas/skia/__tests__/document-sync-scheduler.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { createDocumentSyncScheduler } from '../document-sync-scheduler'; + +describe('document sync scheduler', () => { + it('coalesces repeated schedule calls into one sync per frame', () => { + const rafQueue: Array<(time: number) => void> = []; + const engine = { + dragSyncSuppressed: false, + syncFromDocumentCalls: 0, + syncFromDocument() { + this.syncFromDocumentCalls += 1; + }, + }; + + const scheduler = createDocumentSyncScheduler( + () => engine, + (cb) => { + rafQueue.push(cb); + return rafQueue.length; + }, + () => {}, + ); + + scheduler.schedule(); + scheduler.schedule(); + scheduler.schedule(); + + expect(rafQueue).toHaveLength(1); + expect(engine.syncFromDocumentCalls).toBe(0); + + const cb = rafQueue.shift(); + cb?.(0); + + expect(engine.syncFromDocumentCalls).toBe(1); + }); + + it('retries on the next frame while drag sync is suppressed', () => { + const rafQueue: Array<(time: number) => void> = []; + const engine = { + dragSyncSuppressed: true, + syncFromDocumentCalls: 0, + syncFromDocument() { + this.syncFromDocumentCalls += 1; + }, + }; + + const scheduler = createDocumentSyncScheduler( + () => engine, + (cb) => { + rafQueue.push(cb); + return rafQueue.length; + }, + () => {}, + ); + + scheduler.schedule(); + expect(rafQueue).toHaveLength(1); + + rafQueue.shift()?.(0); + expect(engine.syncFromDocumentCalls).toBe(0); + expect(rafQueue).toHaveLength(1); + + engine.dragSyncSuppressed = false; + rafQueue.shift()?.(16); + expect(engine.syncFromDocumentCalls).toBe(1); + }); +}); diff --git a/apps/web/src/canvas/skia/__tests__/focus-fit.test.ts b/apps/web/src/canvas/skia/__tests__/focus-fit.test.ts new file mode 100644 index 00000000..df1b30ee --- /dev/null +++ b/apps/web/src/canvas/skia/__tests__/focus-fit.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import type { RenderNode } from '@zseven-w/pen-renderer'; + +import { fitSceneBoundsToViewport, getFocusBounds } from '../focus-fit'; + +function renderNode( + partial: Partial & { node: { id: string; type: string } }, +): RenderNode { + return { + absX: 0, + absY: 0, + absW: 0, + absH: 0, + ...partial, + } as RenderNode; +} + +describe('focus fit helpers', () => { + it('uses selected render-node bounds when selection exists', () => { + const renderNodes = [ + renderNode({ + node: { id: 'page', type: 'frame' }, + absX: 0, + absY: 0, + absW: 1000, + absH: 800, + }), + renderNode({ + node: { id: 'shape-1', type: 'rectangle' }, + absX: 120, + absY: 80, + absW: 240, + absH: 100, + }), + renderNode({ + node: { id: 'shape-2', type: 'rectangle' }, + absX: 420, + absY: 200, + absW: 180, + absH: 160, + }), + ]; + + expect(getFocusBounds(renderNodes, ['shape-1', 'shape-2'])).toEqual({ + minX: 120, + minY: 80, + maxX: 600, + maxY: 360, + }); + }); + + it('falls back to top-level content bounds when nothing is selected', () => { + const renderNodes = [ + renderNode({ + node: { id: 'page', type: 'frame' }, + absX: 0, + absY: 0, + absW: 1000, + absH: 800, + }), + renderNode({ + node: { id: 'child', type: 'rectangle' }, + absX: 80, + absY: 60, + absW: 120, + absH: 90, + clipRect: { x: 0, y: 0, w: 1000, h: 800, rx: 0 }, + }), + ]; + + expect(getFocusBounds(renderNodes, [])).toEqual({ + minX: 0, + minY: 0, + maxX: 1000, + maxY: 800, + }); + }); + + it('computes a centered fit viewport and honors max zoom', () => { + const viewport = fitSceneBoundsToViewport( + { + minX: 100, + minY: 50, + maxX: 300, + maxY: 150, + }, + 1000, + 800, + { padding: 100, maxZoom: 2 }, + ); + + expect(viewport).toEqual({ + zoom: 2, + panX: 100, + panY: 200, + }); + }); +}); diff --git a/apps/web/src/canvas/skia/__tests__/path-editing.test.ts b/apps/web/src/canvas/skia/__tests__/path-editing.test.ts new file mode 100644 index 00000000..1bde467d --- /dev/null +++ b/apps/web/src/canvas/skia/__tests__/path-editing.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; + +import type { PenPathAnchor } from '@/types/pen'; + +import { + bakeSceneAnchorsToPathNode, + mapAnchorsToScene, + movePathControl, + resetPathPointHandles, + setPathPointType, +} from '../path-editing'; + +describe('path editing helpers', () => { + it('maps local path anchors into scene coordinates with node scaling', () => { + const anchors: PenPathAnchor[] = [ + { + x: 10, + y: 20, + handleIn: null, + handleOut: { x: 5, y: 10 }, + }, + { + x: 60, + y: 120, + handleIn: { x: -10, y: -20 }, + handleOut: null, + }, + ]; + + const result = mapAnchorsToScene( + anchors, + { x: 10, y: 20, width: 50, height: 100 }, + { x: 200, y: 300, width: 100, height: 200 }, + ); + + expect(result).toEqual([ + { + x: 200, + y: 300, + handleIn: null, + handleOut: { x: 10, y: 20 }, + }, + { + x: 300, + y: 500, + handleIn: { x: -20, y: -40 }, + handleOut: null, + }, + ]); + }); + + it('moves anchors and handles without losing relative handle vectors', () => { + const anchors: PenPathAnchor[] = [ + { + x: 20, + y: 30, + handleIn: null, + handleOut: { x: 10, y: 0 }, + }, + ]; + + const movedAnchor = movePathControl(anchors, 0, 'anchor', 15, -5); + expect(movedAnchor[0]).toEqual({ + x: 35, + y: 25, + handleIn: null, + handleOut: { x: 10, y: 0 }, + }); + + const movedHandle = movePathControl(movedAnchor, 0, 'handleOut', -3, 7); + expect(movedHandle[0]).toEqual({ + x: 35, + y: 25, + handleIn: null, + handleOut: { x: 7, y: 7 }, + }); + }); + + it('keeps mirrored handles locked together when dragging one side', () => { + const anchors: PenPathAnchor[] = [ + { + x: 100, + y: 100, + handleIn: { x: -20, y: 0 }, + handleOut: { x: 20, y: 0 }, + pointType: 'mirrored', + }, + ]; + + const movedHandle = movePathControl(anchors, 0, 'handleOut', 10, 5); + expect(movedHandle[0]).toEqual({ + x: 100, + y: 100, + handleIn: { x: -30, y: -5 }, + handleOut: { x: 30, y: 5 }, + pointType: 'mirrored', + }); + }); + + it('can convert a corner point into curve modes and reset its default handles', () => { + const anchors: PenPathAnchor[] = [ + { + x: 0, + y: 0, + handleIn: null, + handleOut: null, + }, + { + x: 100, + y: 0, + handleIn: null, + handleOut: null, + }, + { + x: 200, + y: 0, + handleIn: null, + handleOut: null, + }, + ]; + + const mirrored = setPathPointType(anchors, 1, 'mirrored', false); + expect(mirrored[1].x).toBe(100); + expect(mirrored[1].y).toBe(0); + expect(mirrored[1].pointType).toBe('mirrored'); + expect(mirrored[1].handleIn?.x).toBeCloseTo(-100 / 3, 5); + expect(mirrored[1].handleIn?.y).toBeCloseTo(0, 5); + expect(mirrored[1].handleOut?.x).toBeCloseTo(100 / 3, 5); + expect(mirrored[1].handleOut?.y).toBeCloseTo(0, 5); + + const corner = setPathPointType(mirrored, 1, 'corner', false); + expect(corner[1]).toEqual({ + x: 100, + y: 0, + handleIn: null, + handleOut: null, + pointType: 'corner', + }); + + const reset = resetPathPointHandles(corner, 1, false); + expect(reset[1].x).toBe(100); + expect(reset[1].y).toBe(0); + expect(reset[1].pointType).toBe('mirrored'); + expect(reset[1].handleIn?.x).toBeCloseTo(-100 / 3, 5); + expect(reset[1].handleIn?.y).toBeCloseTo(0, 5); + expect(reset[1].handleOut?.x).toBeCloseTo(100 / 3, 5); + expect(reset[1].handleOut?.y).toBeCloseTo(0, 5); + }); + + it('bakes edited scene anchors back into a normalized path node patch', () => { + const anchors: PenPathAnchor[] = [ + { + x: 210, + y: 305, + handleIn: null, + handleOut: { x: 20, y: 10 }, + }, + { + x: 290, + y: 355, + handleIn: { x: -15, y: -20 }, + handleOut: null, + }, + ]; + + const result = bakeSceneAnchorsToPathNode(anchors, false, { x: 100, y: 200 }); + + expect(result).toEqual({ + x: 110, + y: 105, + width: 80, + height: 50, + closed: false, + d: 'M 0 0 C 20 10 65 30 80 50', + anchors: [ + { + x: 0, + y: 0, + handleIn: null, + handleOut: { x: 20, y: 10 }, + }, + { + x: 80, + y: 50, + handleIn: { x: -15, y: -20 }, + handleOut: null, + }, + ], + }); + }); +}); diff --git a/apps/web/src/canvas/skia/__tests__/skia-engine-capture.test.ts b/apps/web/src/canvas/skia/__tests__/skia-engine-capture.test.ts new file mode 100644 index 00000000..3fef667b --- /dev/null +++ b/apps/web/src/canvas/skia/__tests__/skia-engine-capture.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Must mock before importing SkiaEngine to prevent CanvasKit WASM initialization. +// SkiaEngine → SkiaRenderer → SkiaNodeRenderer → CanvasKit TypefaceFontProvider.Make() +vi.mock('@zseven-w/pen-renderer', async () => { + // Minimal stub of SkiaNodeRenderer and associated exports + const SkiaNodeRenderer = vi.fn().mockImplementation(() => ({ + setIconLookup: vi.fn(), + setRedrawCallback: vi.fn(), + init: vi.fn(), + dispose: vi.fn(), + fontManager: { + ensureFont: vi.fn().mockResolvedValue(undefined), + pendingCount: () => 0, + flushPending: async () => {}, + }, + imageLoader: { + pendingCount: () => 0, + flushPending: async () => {}, + }, + })); + // Keep named exports used by skia-engine.ts + return { + SkiaNodeRenderer, + SpatialIndex: vi.fn().mockImplementation(() => ({ + rebuild: vi.fn(), + get: vi.fn(), + })), + parseColor: vi.fn(), + viewportMatrix: vi.fn(), + zoomToPoint: vi.fn(), + flattenToRenderNodes: vi.fn().mockReturnValue([]), + resolveRefs: vi.fn().mockReturnValue([]), + premeasureTextHeights: vi.fn().mockReturnValue([]), + collectReusableIds: vi.fn(), + collectInstanceIds: vi.fn(), + getViewportBounds: vi.fn().mockReturnValue({}), + isRectInViewport: vi.fn().mockReturnValue(false), + screenToScene: vi.fn(), + }; +}); + +vi.mock('@/stores/document-store', () => ({ + useDocumentStore: { + getState: () => ({ document: { children: [], variables: {}, themes: undefined } }), + }, + getActivePageChildren: () => [], + getAllChildren: () => [], +})); + +vi.mock('@/stores/canvas-store', () => ({ + useCanvasStore: { getState: () => ({ activePageId: null, selection: { selectedIds: [] } }) }, +})); + +vi.mock('@/canvas/canvas-layout-engine', () => ({ + setRootChildrenProvider: vi.fn(), +})); + +vi.mock('@/variables/resolve-variables', () => ({ + resolveNodeForCanvas: vi.fn((n: unknown) => n), + getDefaultTheme: vi.fn().mockReturnValue(null), +})); + +vi.mock('@/services/ai/icon-resolver', () => ({ + lookupIconByName: vi.fn(), +})); + +vi.mock('@/services/ai/design-animation', () => ({ + isNodeBorderReady: vi.fn().mockReturnValue(false), + getNodeRevealTime: vi.fn().mockReturnValue(undefined), +})); + +vi.mock('@/canvas/agent-indicator', () => ({ + getActiveAgentIndicators: vi.fn().mockReturnValue(new Map()), + getActiveAgentFrames: vi.fn().mockReturnValue(new Map()), + isPreviewNode: vi.fn().mockReturnValue(false), +})); + +import { SkiaEngine } from '../skia-engine'; + +// Provide requestAnimationFrame for Node.js test environment. +// waitForSettled() uses it to yield one render cycle. We use setImmediate +// so the callback fires on the next tick without a real animation frame. +let _rafCounter = 0; +const _rafMap = new Map(); +if (typeof globalThis.requestAnimationFrame === 'undefined') { + globalThis.requestAnimationFrame = (cb: FrameRequestCallback): number => { + const id = ++_rafCounter; + _rafMap.set( + id, + setImmediate(() => { + _rafMap.delete(id); + cb(Date.now()); + }), + ); + return id; + }; + globalThis.cancelAnimationFrame = (id: number) => { + const handle = _rafMap.get(id); + if (handle) { + clearImmediate(handle); + _rafMap.delete(id); + } + }; +} + +// Minimal stub renderer with the two manager APIs we need. +function makeStubRenderer(opts: { fontPending?: number; imagePending?: number }): unknown { + let fontCount = opts.fontPending ?? 0; + let imageCount = opts.imagePending ?? 0; + return { + fontManager: { + pendingCount: () => fontCount, + flushPending: async () => { + // Drain pending counts on flush — simulates real flush behavior + fontCount = 0; + }, + }, + imageLoader: { + pendingCount: () => imageCount, + flushPending: async () => { + imageCount = 0; + }, + }, + }; +} + +describe('SkiaEngine.waitForSettled', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns quickly when nothing is pending and dirty is false', async () => { + const engine = new SkiaEngine({} as never); + // Inject minimal renderer stub + clean state + (engine as unknown as { renderer: unknown }).renderer = makeStubRenderer({}); + (engine as unknown as { dirty: boolean }).dirty = false; + + const start = Date.now(); + await engine.waitForSettled(1000); + expect(Date.now() - start).toBeLessThan(500); + }); + + it('drains font/image pending and returns when stable', async () => { + const engine = new SkiaEngine({} as never); + (engine as unknown as { renderer: unknown }).renderer = makeStubRenderer({ + fontPending: 2, + imagePending: 1, + }); + (engine as unknown as { dirty: boolean }).dirty = false; + + await engine.waitForSettled(2000); + // After two stable frames, both should be drained + const r = ( + engine as unknown as { + renderer: { + fontManager: { pendingCount(): number }; + imageLoader: { pendingCount(): number }; + }; + } + ).renderer; + expect(r.fontManager.pendingCount()).toBe(0); + expect(r.imageLoader.pendingCount()).toBe(0); + }); + + it('logs warning on timeout when state cannot stabilize', async () => { + const engine = new SkiaEngine({} as never); + // Renderer that always reports new pending (never stable) + (engine as unknown as { renderer: unknown }).renderer = { + fontManager: { + pendingCount: () => 1, // Never zero + flushPending: async () => {}, + }, + imageLoader: { + pendingCount: () => 0, + flushPending: async () => {}, + }, + }; + (engine as unknown as { dirty: boolean }).dirty = false; + + const warnSpy = (() => { + const original = console.warn; + const calls: unknown[][] = []; + console.warn = (...args: unknown[]) => calls.push(args); + return { calls, restore: () => (console.warn = original) }; + })(); + + try { + await engine.waitForSettled(150); + expect(warnSpy.calls.length).toBeGreaterThan(0); + expect(String(warnSpy.calls[0][0])).toContain('Timed out'); + } finally { + warnSpy.restore(); + } + }); +}); + +describe('SkiaEngine.captureRegion', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects with a clear error when bounds contain non-numeric fields', async () => { + const engine = new SkiaEngine({} as never); + (engine as unknown as { renderer: unknown }).renderer = makeStubRenderer({}); + + await expect( + engine.captureRegion( + { x: 0, y: 0, w: 'fill_container' as unknown as number, h: 80 }, + { waitForSettled: false }, + ), + ).rejects.toThrow('bounds must have numeric'); + }); +}); diff --git a/apps/web/src/canvas/skia/__tests__/skia-engine.test.ts b/apps/web/src/canvas/skia/__tests__/skia-engine.test.ts new file mode 100644 index 00000000..a4b6b0d2 --- /dev/null +++ b/apps/web/src/canvas/skia/__tests__/skia-engine.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { SkiaEngine } from '../skia-engine'; + +function createCanvasStub() { + return { + clientWidth: 1200, + clientHeight: 800, + width: 1200, + height: 800, + } as HTMLCanvasElement; +} + +function createCanvasOps() { + return { + clear() {}, + save() {}, + scale() {}, + concat() {}, + restore() {}, + }; +} + +describe('SkiaEngine surface recovery', () => { + it('recreates the surface instead of throwing when the current surface is invalid', () => { + const healthyCanvas = createCanvasOps(); + const healthySurface = { + getCanvas() { + return healthyCanvas; + }, + flush() {}, + delete() {}, + }; + + let recreatedSurfaces = 0; + const ck = { + Color4f(r: number, g: number, b: number, a: number) { + return Float32Array.of(r, g, b, a); + }, + TypefaceFontProvider: { + Make() { + return { + registerFont() {}, + delete() {}, + }; + }, + }, + MakeWebGLCanvasSurface() { + recreatedSurfaces += 1; + return healthySurface; + }, + MakeSWCanvasSurface() { + return null; + }, + }; + + const engine = new SkiaEngine(ck as any); + (engine as any).canvasEl = createCanvasStub(); + (engine as any).renderNodes = []; + engine.surface = { + getCanvas() { + throw new Error('Surface instance already deleted'); + }, + flush() {}, + delete() {}, + } as any; + + expect(() => { + (engine as any).render(); + }).not.toThrow(); + + expect(recreatedSurfaces).toBe(1); + expect(engine.surface).toBe(healthySurface as any); + }); +}); diff --git a/apps/web/src/canvas/skia/__tests__/skia-interaction.test.ts b/apps/web/src/canvas/skia/__tests__/skia-interaction.test.ts new file mode 100644 index 00000000..38fb3936 --- /dev/null +++ b/apps/web/src/canvas/skia/__tests__/skia-interaction.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest'; + +import { createEmptyDocument } from '@/stores/document-tree-utils'; +import { useCanvasStore } from '@/stores/canvas-store'; +import { useDocumentStore } from '@/stores/document-store'; +import type { PenNode } from '@/types/pen'; + +import { SkiaInteractionManager } from '../skia-interaction'; + +function createCanvasStub() { + return { + style: { cursor: 'default' }, + } as unknown as HTMLCanvasElement; +} + +function createEngineStub(renderNodes: Array) { + let rebuildCount = 0; + let dirtyCount = 0; + const spatialIndex = { + get: (id: string) => renderNodes.find((rn) => rn.node.id === id) ?? null, + rebuild: () => { + rebuildCount += 1; + }, + hitTest: () => [], + searchRect: () => [], + }; + + return { + zoom: 1, + panX: 0, + panY: 0, + renderNodes, + spatialIndex, + dragSyncSuppressed: false, + getCanvasRect: () => + ({ + left: 0, + top: 0, + width: 1000, + height: 1000, + }) as DOMRect, + markDirty: () => { + dirtyCount += 1; + }, + get rebuildCount() { + return rebuildCount; + }, + get dirtyCount() { + return dirtyCount; + }, + }; +} + +function resetStores() { + useCanvasStore.setState({ + activeTool: 'select', + selection: { + ...useCanvasStore.getState().selection, + selectedIds: [], + activeId: null, + }, + }); + useDocumentStore.setState({ + document: createEmptyDocument(), + isDirty: false, + fileHandle: null, + fileName: null, + filePath: null, + } as any); +} + +describe('SkiaInteractionManager continuous interaction commits', () => { + it('defers resize store writes until mouseup', () => { + resetStores(); + let node: any = { + id: 'path-1', + type: 'path', + x: 10, + y: 20, + width: 100, + height: 50, + d: 'M 0 0 L 100 50', + stroke: { thickness: 1, fill: [{ type: 'solid', color: '#000000' }] }, + } as PenNode; + const updateNodeCalls: Array<[string, Partial]> = []; + const scaleCalls: Array<[string, number, number]> = []; + const updateNode = (id: string, updates: Partial) => { + updateNodeCalls.push([id, updates]); + expect(id).toBe('path-1'); + node = { ...node, ...updates }; + }; + const scaleDescendantsInStore = (id: string, scaleX: number, scaleY: number) => { + scaleCalls.push([id, scaleX, scaleY]); + }; + + useDocumentStore.setState({ + getNodeById: (id: string) => (id === 'path-1' ? node : undefined), + updateNode, + scaleDescendantsInStore, + } as any); + + const renderNode = { + node: { ...node }, + absX: 110, + absY: 220, + absW: 100, + absH: 50, + }; + const engine = createEngineStub([renderNode]); + const manager = new SkiaInteractionManager( + { current: engine as any }, + createCanvasStub(), + () => {}, + ) as any; + + manager.isResizing = true; + manager.resizeHandle = 'se'; + manager.resizeNodeId = 'path-1'; + manager.resizeOrigX = 10; + manager.resizeOrigY = 20; + manager.resizeOrigW = 100; + manager.resizeOrigH = 50; + manager.resizeStartSceneX = 110; + manager.resizeStartSceneY = 220; + + manager.handleResizeMove({ x: 150, y: 250 }, engine as any); + + expect(updateNodeCalls).toHaveLength(0); + expect(scaleCalls).toHaveLength(0); + expect(renderNode.absW).toBe(140); + expect(renderNode.absH).toBe(80); + expect(engine.dirtyCount).toBeGreaterThan(0); + + manager.onMouseUp(); + + expect(updateNodeCalls).toHaveLength(1); + expect(updateNodeCalls[0]?.[1]).toMatchObject({ + x: 10, + y: 20, + width: 140, + height: 80, + }); + }); + + it('defers rotate store writes until mouseup', () => { + resetStores(); + let node: any = { + id: 'rect-1', + type: 'rectangle', + x: 100, + y: 120, + width: 80, + height: 40, + rotation: 0, + fill: [{ type: 'solid', color: '#ffffff' }], + } as PenNode; + const updateNodeCalls: Array<[string, Partial]> = []; + const updateNode = (id: string, updates: Partial) => { + updateNodeCalls.push([id, updates]); + expect(id).toBe('rect-1'); + node = { ...node, ...updates }; + }; + + useDocumentStore.setState({ + getNodeById: (id: string) => (id === 'rect-1' ? node : undefined), + updateNode, + } as any); + + const renderNode = { + node: { ...node }, + absX: 100, + absY: 120, + absW: 80, + absH: 40, + }; + const engine = createEngineStub([renderNode]); + const manager = new SkiaInteractionManager( + { current: engine as any }, + createCanvasStub(), + () => {}, + ) as any; + + manager.isRotating = true; + manager.rotateNodeId = 'rect-1'; + manager.rotateOrigAngle = 0; + manager.rotateCenterX = 140; + manager.rotateCenterY = 140; + manager.rotateStartAngle = 0; + + manager.handleRotateMove({ x: 140, y: 200 }, false); + + expect(updateNodeCalls).toHaveLength(0); + expect(renderNode.node.rotation).not.toBe(0); + + manager.onMouseUp(); + + expect(updateNodeCalls).toHaveLength(1); + expect(updateNodeCalls[0]?.[1]).toHaveProperty('rotation'); + }); + + it('defers arc handle store writes until mouseup', () => { + resetStores(); + let node: any = { + id: 'ellipse-1', + type: 'ellipse', + x: 200, + y: 200, + width: 100, + height: 100, + startAngle: 0, + sweepAngle: 360, + innerRadius: 0, + fill: [{ type: 'solid', color: '#ffffff' }], + stroke: { thickness: 1, fill: [{ type: 'solid', color: '#000000' }] }, + } as PenNode; + const updateNodeCalls: Array<[string, Partial]> = []; + const updateNode = (id: string, updates: Partial) => { + updateNodeCalls.push([id, updates]); + expect(id).toBe('ellipse-1'); + node = { ...node, ...updates }; + }; + + useDocumentStore.setState({ + getNodeById: (id: string) => (id === 'ellipse-1' ? node : undefined), + updateNode, + } as any); + + const renderNode = { + node: { ...node }, + absX: 200, + absY: 200, + absW: 100, + absH: 100, + }; + const engine = createEngineStub([renderNode]); + const manager = new SkiaInteractionManager( + { current: engine as any }, + createCanvasStub(), + () => {}, + ) as any; + + manager.isDraggingArc = true; + manager.arcNodeId = 'ellipse-1'; + manager.arcHandleType = 'inner'; + + manager.handleArcMove({ x: 225, y: 250 }, engine as any); + + expect(updateNodeCalls).toHaveLength(0); + expect(renderNode.node.innerRadius).not.toBe(0); + + manager.onMouseUp(); + + expect(updateNodeCalls).toHaveLength(1); + expect(updateNodeCalls[0]?.[1]).toHaveProperty('innerRadius'); + }); +}); diff --git a/apps/web/src/canvas/skia/__tests__/spatial-index-hit-test.test.ts b/apps/web/src/canvas/skia/__tests__/spatial-index-hit-test.test.ts new file mode 100644 index 00000000..c63b7277 --- /dev/null +++ b/apps/web/src/canvas/skia/__tests__/spatial-index-hit-test.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { SpatialIndex } from '@zseven-w/pen-renderer'; +import type { PenNode } from '@/types/pen'; +import type { RenderNode } from '@zseven-w/pen-renderer'; + +function renderNode(node: PenNode): RenderNode { + return { + node, + absX: node.x ?? 0, + absY: node.y ?? 0, + absW: + typeof (node as { width?: unknown }).width === 'number' + ? ((node as { width?: number }).width ?? 0) + : 0, + absH: + typeof (node as { height?: unknown }).height === 'number' + ? ((node as { height?: number }).height ?? 0) + : 0, + }; +} + +describe('SpatialIndex hitTest', () => { + it('skips transparent QA-style container overlays without self paint', () => { + const index = new SpatialIndex(); + const content = renderNode({ + id: 'content', + type: 'rectangle', + x: 0, + y: 0, + width: 200, + height: 200, + fill: [{ type: 'solid', color: '#88A750' }], + } as PenNode); + const qa = renderNode({ + id: 'qa', + type: 'frame', + name: 'QA', + x: 0, + y: 0, + width: 200, + height: 200, + } as PenNode); + + index.rebuild([content, qa]); + + expect(index.hitTest(100, 100).map((rn) => rn.node.id)).toEqual(['content']); + }); + + it('keeps visibly painted containers hittable', () => { + const index = new SpatialIndex(); + const content = renderNode({ + id: 'content', + type: 'rectangle', + x: 0, + y: 0, + width: 200, + height: 200, + fill: [{ type: 'solid', color: '#88A750' }], + } as PenNode); + const overlay = renderNode({ + id: 'overlay', + type: 'frame', + x: 0, + y: 0, + width: 200, + height: 200, + fill: [{ type: 'solid', color: '#ffffff10' }], + } as PenNode); + + index.rebuild([content, overlay]); + + expect(index.hitTest(100, 100).map((rn) => rn.node.id)).toEqual(['overlay', 'content']); + }); + + it('keeps stroke-only containers hittable', () => { + const index = new SpatialIndex(); + const content = renderNode({ + id: 'content', + type: 'rectangle', + x: 0, + y: 0, + width: 200, + height: 200, + fill: [{ type: 'solid', color: '#88A750' }], + } as PenNode); + const outlinedFrame = renderNode({ + id: 'outlined', + type: 'frame', + x: 0, + y: 0, + width: 200, + height: 200, + stroke: { + thickness: 1, + fill: [{ type: 'solid', color: '#4969A8' }], + }, + } as PenNode); + + index.rebuild([content, outlinedFrame]); + + expect(index.hitTest(100, 100).map((rn) => rn.node.id)).toEqual(['outlined', 'content']); + }); +}); diff --git a/apps/web/src/canvas/skia/__tests__/text-edit-overlay.test.ts b/apps/web/src/canvas/skia/__tests__/text-edit-overlay.test.ts new file mode 100644 index 00000000..73e91839 --- /dev/null +++ b/apps/web/src/canvas/skia/__tests__/text-edit-overlay.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { projectTextEditStateToViewport } from '../text-edit-overlay'; + +describe('projectTextEditStateToViewport', () => { + it('projects scene-space text editing bounds into the current viewport', () => { + expect( + projectTextEditStateToViewport( + { + nodeId: 'text-1', + x: 120, + y: 80, + w: 240, + h: 64, + content: 'Hello', + fontSize: 18, + fontFamily: 'Inter', + fontWeight: '400', + textAlign: 'left', + color: '#111111', + lineHeight: 1.5, + }, + { + zoom: 1.5, + panX: -30, + panY: 45, + }, + ), + ).toEqual({ + left: 150, + top: 165, + width: 360, + minHeight: 96, + fontSize: 27, + }); + }); + + it('keeps overlay dimensions positive even when zoomed far out', () => { + expect( + projectTextEditStateToViewport( + { + nodeId: 'text-1', + x: 10, + y: 20, + w: 0.1, + h: 0.2, + content: 'Hello', + fontSize: 12, + fontFamily: 'Inter', + fontWeight: '400', + textAlign: 'left', + color: '#111111', + lineHeight: 1.4, + }, + { + zoom: 0.01, + panX: 0, + panY: 0, + }, + ), + ).toEqual({ + left: 0.1, + top: 0.2, + width: 1, + minHeight: 1, + fontSize: 0.12, + }); + }); +}); diff --git a/apps/web/src/canvas/skia/document-sync-scheduler.ts b/apps/web/src/canvas/skia/document-sync-scheduler.ts new file mode 100644 index 00000000..6d9023fe --- /dev/null +++ b/apps/web/src/canvas/skia/document-sync-scheduler.ts @@ -0,0 +1,47 @@ +interface SyncableEngine { + dragSyncSuppressed: boolean; + syncFromDocument: () => void; +} + +export function createDocumentSyncScheduler( + getEngine: () => SyncableEngine | null, + requestFrame: (cb: (time: number) => void) => number = (cb) => requestAnimationFrame(cb), + cancelFrame: (id: number) => void = (id) => cancelAnimationFrame(id), +) { + let frameId = 0; + let pending = false; + let disposed = false; + + const flush = () => { + frameId = 0; + pending = false; + if (disposed) return; + + const engine = getEngine(); + if (!engine) return; + + if (engine.dragSyncSuppressed) { + schedule(); + return; + } + + engine.syncFromDocument(); + }; + + const schedule = () => { + if (disposed || pending) return; + pending = true; + frameId = requestFrame(flush); + }; + + const dispose = () => { + disposed = true; + pending = false; + if (frameId) { + cancelFrame(frameId); + frameId = 0; + } + }; + + return { schedule, dispose }; +} diff --git a/apps/web/src/canvas/skia/focus-fit.ts b/apps/web/src/canvas/skia/focus-fit.ts new file mode 100644 index 00000000..5142088c --- /dev/null +++ b/apps/web/src/canvas/skia/focus-fit.ts @@ -0,0 +1,82 @@ +import type { RenderNode, ViewportState } from '@zseven-w/pen-renderer'; + +import { MAX_ZOOM, MIN_ZOOM } from '../canvas-constants'; + +export interface SceneBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +interface FitOptions { + padding?: number; + maxZoom?: number; +} + +export function getFocusBounds( + renderNodes: RenderNode[], + selectedIds: Iterable, +): SceneBounds | null { + if (renderNodes.length === 0) return null; + + const selectedSet = new Set(selectedIds); + const selectedNodes = renderNodes.filter((rn) => selectedSet.has(rn.node.id)); + const targetNodes = + selectedNodes.length > 0 ? selectedNodes : renderNodes.filter((rn) => !rn.clipRect); + + if (targetNodes.length === 0) return null; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const rn of targetNodes) { + minX = Math.min(minX, rn.absX); + minY = Math.min(minY, rn.absY); + maxX = Math.max(maxX, rn.absX + rn.absW); + maxY = Math.max(maxY, rn.absY + rn.absH); + } + + if ( + !Number.isFinite(minX) || + !Number.isFinite(minY) || + !Number.isFinite(maxX) || + !Number.isFinite(maxY) + ) { + return null; + } + + return { minX, minY, maxX, maxY }; +} + +export function fitSceneBoundsToViewport( + bounds: SceneBounds, + canvasWidth: number, + canvasHeight: number, + options: FitOptions = {}, +): ViewportState | null { + if (canvasWidth <= 0 || canvasHeight <= 0) return null; + + const padding = options.padding ?? 64; + const fitWidth = Math.max(canvasWidth - padding * 2, 1); + const fitHeight = Math.max(canvasHeight - padding * 2, 1); + const boundsWidth = Math.max(bounds.maxX - bounds.minX, 1); + const boundsHeight = Math.max(bounds.maxY - bounds.minY, 1); + + let zoom = Math.min(fitWidth / boundsWidth, fitHeight / boundsHeight); + if (typeof options.maxZoom === 'number') { + zoom = Math.min(zoom, options.maxZoom); + } + zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)); + + const centerX = (bounds.minX + bounds.maxX) / 2; + const centerY = (bounds.minY + bounds.maxY) / 2; + + return { + zoom, + panX: canvasWidth / 2 - centerX * zoom, + panY: canvasHeight / 2 - centerY * zoom, + }; +} diff --git a/apps/web/src/canvas/skia/path-editing.ts b/apps/web/src/canvas/skia/path-editing.ts new file mode 100644 index 00000000..f5de3c12 --- /dev/null +++ b/apps/web/src/canvas/skia/path-editing.ts @@ -0,0 +1,337 @@ +import { + anchorsToPathData, + getPathBoundsFromAnchors, + inferPathAnchorPointType, + pathDataToAnchors, + type PathBounds, +} from '@zseven-w/pen-core'; + +import type { PathNode, PenPathAnchor, PenPathPointType } from '@/types/pen'; + +export type PathControlKind = 'anchor' | 'handleIn' | 'handleOut'; + +export interface PathFrame { + x: number; + y: number; + width: number; + height: number; +} + +export interface EditablePathState { + anchors: PenPathAnchor[]; + sceneAnchors: PenPathAnchor[]; + closed: boolean; + localBounds: PathBounds; +} + +const DEFAULT_HANDLE_RATIO = 1 / 3; +const HANDLE_EPSILON = 1e-6; + +export function getEditablePathState(node: PathNode, frame: PathFrame): EditablePathState | null { + const parsed = node.anchors + ? { + anchors: cloneAnchors(node.anchors), + closed: node.closed ?? /[Zz]\s*$/.test(node.d), + } + : pathDataToAnchors(node.d); + + if (!parsed) return null; + + const localBounds = getPathBoundsFromAnchors(parsed.anchors, parsed.closed); + return { + anchors: cloneAnchors(parsed.anchors), + sceneAnchors: mapAnchorsToScene(parsed.anchors, localBounds, frame), + closed: parsed.closed, + localBounds, + }; +} + +export function mapAnchorsToScene( + anchors: PenPathAnchor[], + localBounds: PathBounds, + frame: PathFrame, +): PenPathAnchor[] { + const sx = localBounds.width > 0 ? frame.width / localBounds.width : 1; + const sy = localBounds.height > 0 ? frame.height / localBounds.height : 1; + + return anchors.map((anchor) => ({ + x: frame.x + (anchor.x - localBounds.x) * sx, + y: frame.y + (anchor.y - localBounds.y) * sy, + handleIn: anchor.handleIn ? { x: anchor.handleIn.x * sx, y: anchor.handleIn.y * sy } : null, + handleOut: anchor.handleOut ? { x: anchor.handleOut.x * sx, y: anchor.handleOut.y * sy } : null, + ...(anchor.pointType ? { pointType: anchor.pointType } : {}), + })); +} + +export function movePathControl( + anchors: PenPathAnchor[], + anchorIndex: number, + control: PathControlKind, + dx: number, + dy: number, +): PenPathAnchor[] { + return anchors.map((anchor, index) => { + if (index !== anchorIndex) return cloneAnchor(anchor); + + if (control === 'anchor') { + return { + ...cloneAnchor(anchor), + x: anchor.x + dx, + y: anchor.y + dy, + }; + } + + const nextHandle = anchor[control] + ? { + x: anchor[control]!.x + dx, + y: anchor[control]!.y + dy, + } + : { x: dx, y: dy }; + + const resolvedPointType = getPathPointType(anchor); + const nextAnchor: PenPathAnchor = { + ...cloneAnchor(anchor), + [control]: nextHandle, + }; + + if (resolvedPointType === 'mirrored') { + const oppositeControl = control === 'handleIn' ? 'handleOut' : 'handleIn'; + if (nextAnchor[oppositeControl]) { + nextAnchor[oppositeControl] = { + x: -nextHandle.x, + y: -nextHandle.y, + }; + } + nextAnchor.pointType = 'mirrored'; + return nextAnchor; + } + + if (anchor.pointType === 'independent') { + nextAnchor.pointType = 'independent'; + } + return nextAnchor; + }); +} + +export function setPathPointType( + anchors: PenPathAnchor[], + anchorIndex: number, + pointType: PenPathPointType, + closed: boolean, +): PenPathAnchor[] { + return anchors.map((anchor, index) => { + if (index !== anchorIndex) return cloneAnchor(anchor); + + if (pointType === 'corner') { + return { + ...cloneAnchor(anchor), + handleIn: null, + handleOut: null, + pointType: 'corner', + }; + } + + const defaults = buildDefaultHandles(anchors, anchorIndex, closed); + if (pointType === 'mirrored') { + return applyMirroredPointType(anchor, defaults); + } + + return { + ...cloneAnchor(anchor), + handleIn: anchor.handleIn ?? defaults.handleIn, + handleOut: anchor.handleOut ?? defaults.handleOut, + pointType: 'independent', + }; + }); +} + +export function resetPathPointHandles( + anchors: PenPathAnchor[], + anchorIndex: number, + closed: boolean, +): PenPathAnchor[] { + return anchors.map((anchor, index) => { + if (index !== anchorIndex) return cloneAnchor(anchor); + + const defaults = buildDefaultHandles(anchors, anchorIndex, closed); + const pointType = inferPathAnchorPointType({ + ...anchor, + handleIn: defaults.handleIn, + handleOut: defaults.handleOut, + }); + + return { + ...cloneAnchor(anchor), + handleIn: defaults.handleIn, + handleOut: defaults.handleOut, + pointType, + }; + }); +} + +export function bakeSceneAnchorsToPathNode( + sceneAnchors: PenPathAnchor[], + closed: boolean, + parentSceneOrigin: { x: number; y: number }, +): Pick | null { + if (sceneAnchors.length < 2) return null; + + const sceneBounds = getPathBoundsFromAnchors(sceneAnchors, closed); + if (sceneBounds.width < 0.001 && sceneBounds.height < 0.001) return null; + + const anchors = sceneAnchors.map((anchor) => ({ + ...cloneAnchor(anchor), + x: anchor.x - sceneBounds.x, + y: anchor.y - sceneBounds.y, + })); + + return { + x: sceneBounds.x - parentSceneOrigin.x, + y: sceneBounds.y - parentSceneOrigin.y, + width: sceneBounds.width, + height: sceneBounds.height, + closed, + d: anchorsToPathData(anchors, closed), + anchors, + }; +} + +function buildDefaultHandles( + anchors: PenPathAnchor[], + anchorIndex: number, + closed: boolean, +): Pick { + const current = anchors[anchorIndex]; + const prev = getAdjacentAnchor(anchors, anchorIndex, -1, closed); + const next = getAdjacentAnchor(anchors, anchorIndex, 1, closed); + + if (!prev && !next) { + return { handleIn: null, handleOut: null }; + } + + if (prev && next) { + const tangent = normalizeVector({ + x: next.x - prev.x, + y: next.y - prev.y, + }); + if (tangent) { + const inLen = distance(current, prev) * DEFAULT_HANDLE_RATIO; + const outLen = distance(current, next) * DEFAULT_HANDLE_RATIO; + return { + handleIn: { x: -tangent.x * inLen, y: -tangent.y * inLen }, + handleOut: { x: tangent.x * outLen, y: tangent.y * outLen }, + }; + } + } + + if (prev) { + return { + handleIn: { + x: (prev.x - current.x) * DEFAULT_HANDLE_RATIO, + y: (prev.y - current.y) * DEFAULT_HANDLE_RATIO, + }, + handleOut: null, + }; + } + + return { + handleIn: null, + handleOut: { + x: (next!.x - current.x) * DEFAULT_HANDLE_RATIO, + y: (next!.y - current.y) * DEFAULT_HANDLE_RATIO, + }, + }; +} + +function applyMirroredPointType( + anchor: PenPathAnchor, + defaults: Pick, +): PenPathAnchor { + const preferredDirection = normalizeVector(anchor.handleOut) ?? + invertVector(normalizeVector(anchor.handleIn)) ?? + normalizeVector(defaults.handleOut) ?? + invertVector(normalizeVector(defaults.handleIn)) ?? { x: 1, y: 0 }; + + const mirrorLength = + average( + [ + handleLength(anchor.handleIn), + handleLength(anchor.handleOut), + handleLength(defaults.handleIn), + handleLength(defaults.handleOut), + ].filter((value) => value > HANDLE_EPSILON), + ) || 40; + + const hasIn = !!anchor.handleIn || !!defaults.handleIn; + const hasOut = !!anchor.handleOut || !!defaults.handleOut; + + return { + ...cloneAnchor(anchor), + handleIn: hasIn + ? { x: -preferredDirection.x * mirrorLength, y: -preferredDirection.y * mirrorLength } + : null, + handleOut: hasOut + ? { x: preferredDirection.x * mirrorLength, y: preferredDirection.y * mirrorLength } + : null, + pointType: 'mirrored', + }; +} + +function getAdjacentAnchor( + anchors: PenPathAnchor[], + anchorIndex: number, + delta: -1 | 1, + closed: boolean, +): PenPathAnchor | null { + const nextIndex = anchorIndex + delta; + if (nextIndex >= 0 && nextIndex < anchors.length) { + return anchors[nextIndex]; + } + if (!closed || anchors.length === 0) return null; + return anchors[(nextIndex + anchors.length) % anchors.length]; +} + +function getPathPointType(anchor: PenPathAnchor): PenPathPointType { + return anchor.pointType ?? inferPathAnchorPointType(anchor); +} + +function cloneAnchors(anchors: PenPathAnchor[]): PenPathAnchor[] { + return anchors.map((anchor) => cloneAnchor(anchor)); +} + +function cloneAnchor(anchor: PenPathAnchor): PenPathAnchor { + return { + x: anchor.x, + y: anchor.y, + handleIn: anchor.handleIn ? { ...anchor.handleIn } : null, + handleOut: anchor.handleOut ? { ...anchor.handleOut } : null, + ...(anchor.pointType ? { pointType: anchor.pointType } : {}), + }; +} + +function normalizeVector( + handle: { x: number; y: number } | null | undefined, +): { x: number; y: number } | null { + if (!handle) return null; + const length = Math.hypot(handle.x, handle.y); + if (length <= HANDLE_EPSILON) return null; + return { x: handle.x / length, y: handle.y / length }; +} + +function invertVector(handle: { x: number; y: number } | null): { x: number; y: number } | null { + if (!handle) return null; + return { x: -handle.x, y: -handle.y }; +} + +function handleLength(handle: { x: number; y: number } | null | undefined): number { + return handle ? Math.hypot(handle.x, handle.y) : 0; +} + +function average(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function distance(a: { x: number; y: number }, b: { x: number; y: number }): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} diff --git a/apps/web/src/canvas/skia/skia-canvas.tsx b/apps/web/src/canvas/skia/skia-canvas.tsx index 79dd1655..9187bd4b 100644 --- a/apps/web/src/canvas/skia/skia-canvas.tsx +++ b/apps/web/src/canvas/skia/skia-canvas.tsx @@ -1,162 +1,223 @@ -import { useRef, useEffect, useState } from 'react' -import { loadCanvasKit } from './skia-init' -import { SkiaEngine } from './skia-engine' -import { useCanvasStore } from '@/stores/canvas-store' -import { useDocumentStore } from '@/stores/document-store' -import { setSkiaEngineRef } from '../skia-engine-ref' -import type { PenNode } from '@/types/pen' -import { SkiaInteractionManager, type TextEditState } from './skia-interaction' +import { useCallback, useEffect, useRef, useState } from 'react'; +import { loadCanvasKit } from './skia-init'; +import { SkiaEngine } from './skia-engine'; +import { useCanvasStore } from '@/stores/canvas-store'; +import { useDocumentStore } from '@/stores/document-store'; +import { setSkiaEngineRef } from '../skia-engine-ref'; +import type { PathNode, PenNode, PenPathPointType } from '@/types/pen'; +import { + bakeSceneAnchorsToPathNode, + getEditablePathState, + resetPathPointHandles, + setPathPointType, +} from './path-editing'; +import { + SkiaInteractionManager, + type PathAnchorContextMenuState, + type TextEditState, +} from './skia-interaction'; +import { createDocumentSyncScheduler } from './document-sync-scheduler'; +import { projectTextEditStateToViewport } from './text-edit-overlay'; export default function SkiaCanvas() { - const canvasRef = useRef(null) - const containerRef = useRef(null) - const engineRef = useRef(null) - const [error, setError] = useState(null) - const [editingText, setEditingText] = useState(null) + const canvasRef = useRef(null); + const containerRef = useRef(null); + const engineRef = useRef(null); + const [error, setError] = useState(null); + const [editingText, setEditingText] = useState(null); + const [pathContextMenu, setPathContextMenu] = useState(null); + const viewport = useCanvasStore((state) => state.viewport); + const editingTextOverlay = editingText + ? projectTextEditStateToViewport(editingText, viewport) + : null; + + const closePathContextMenu = useCallback(() => { + setPathContextMenu(null); + }, []); + + const updatePathAnchor = useCallback( + (menuState: PathAnchorContextMenuState, action: PenPathPointType | 'reset') => { + const engine = engineRef.current; + if (!engine) return; + + const rn = engine.spatialIndex.get(menuState.nodeId); + if (!rn || rn.node.type !== 'path') return; + + const node = rn.node as PathNode; + const state = getEditablePathState(node, { + x: rn.absX, + y: rn.absY, + width: rn.absW, + height: rn.absH, + }); + if (!state) return; + + const nextSceneAnchors = + action === 'reset' + ? resetPathPointHandles(state.sceneAnchors, menuState.anchorIndex, state.closed) + : setPathPointType(state.sceneAnchors, menuState.anchorIndex, action, state.closed); + + const parentSceneOrigin = { + x: rn.absX - (node.x ?? 0), + y: rn.absY - (node.y ?? 0), + }; + const patch = bakeSceneAnchorsToPathNode(nextSceneAnchors, state.closed, parentSceneOrigin); + if (!patch) return; + + useDocumentStore.getState().updateNode(menuState.nodeId, patch as Partial); + useCanvasStore.getState().setSelection([menuState.nodeId], menuState.nodeId); + }, + [], + ); // Initialize CanvasKit + engine useEffect(() => { - let disposed = false + let disposed = false; async function init() { try { - const ck = await loadCanvasKit() - if (disposed) return + const ck = await loadCanvasKit(); + if (disposed) return; - const canvasEl = canvasRef.current - if (!canvasEl) return + const canvasEl = canvasRef.current; + if (!canvasEl) return; - const engine = new SkiaEngine(ck) - engine.init(canvasEl) - engineRef.current = engine - setSkiaEngineRef(engine) + const engine = new SkiaEngine(ck); + engine.init(canvasEl); + engineRef.current = engine; + setSkiaEngineRef(engine); // Initial sync - engine.syncFromDocument() - requestAnimationFrame(() => engine.zoomToFitContent()) - + engine.syncFromDocument(); + requestAnimationFrame(() => engine.zoomToFitContent()); } catch (err) { - console.error('SkiaCanvas init failed:', err) - setError(String(err)) + console.error('SkiaCanvas init failed:', err); + setError(String(err)); } } - init() + init(); return () => { - disposed = true - setSkiaEngineRef(null) - engineRef.current?.dispose() - engineRef.current = null - } - }, []) + disposed = true; + setSkiaEngineRef(null); + engineRef.current?.dispose(); + engineRef.current = null; + }; + }, []); // Resize observer useEffect(() => { - const container = containerRef.current - if (!container) return + const container = containerRef.current; + if (!container) return; const observer = new ResizeObserver((entries) => { - const engine = engineRef.current - if (!engine) return + const engine = engineRef.current; + if (!engine) return; for (const entry of entries) { - const { width, height } = entry.contentRect - engine.resize(width, height) + const { width, height } = entry.contentRect; + engine.resize(width, height); } - }) - observer.observe(container) - return () => observer.disconnect() - }, []) + }); + observer.observe(container); + return () => observer.disconnect(); + }, []); // Document sync: re-render when document changes useEffect(() => { + const scheduler = createDocumentSyncScheduler(() => engineRef.current); const unsub = useDocumentStore.subscribe(() => { - engineRef.current?.syncFromDocument() - }) - return unsub - }, []) + scheduler.schedule(); + }); + return () => { + unsub(); + scheduler.dispose(); + }; + }, []); // Page sync: re-render when active page changes useEffect(() => { - let prevPageId = useCanvasStore.getState().activePageId + let prevPageId = useCanvasStore.getState().activePageId; const unsub = useCanvasStore.subscribe((state) => { if (state.activePageId !== prevPageId) { - prevPageId = state.activePageId - engineRef.current?.syncFromDocument() + prevPageId = state.activePageId; + engineRef.current?.syncFromDocument(); } - }) - return unsub - }, []) + }); + return unsub; + }, []); // Selection sync: re-render when selection changes useEffect(() => { - let prevIds = useCanvasStore.getState().selection.selectedIds + let prevIds = useCanvasStore.getState().selection.selectedIds; const unsub = useCanvasStore.subscribe((state) => { if (state.selection.selectedIds !== prevIds) { - prevIds = state.selection.selectedIds - engineRef.current?.markDirty() + prevIds = state.selection.selectedIds; + engineRef.current?.markDirty(); } - }) - return unsub - }, []) + }); + return unsub; + }, []); // Wheel: zoom + pan useEffect(() => { - const canvasEl = canvasRef.current - if (!canvasEl) return + const canvasEl = canvasRef.current; + if (!canvasEl) return; const handleWheel = (e: WheelEvent) => { - e.preventDefault() - e.stopPropagation() - const engine = engineRef.current - if (!engine) return + e.preventDefault(); + e.stopPropagation(); + const engine = engineRef.current; + if (!engine) return; if (e.ctrlKey || e.metaKey) { - let delta = -e.deltaY - if (e.deltaMode === 1) delta *= 40 - const factor = Math.pow(1.005, delta) - const newZoom = engine.zoom * factor - engine.zoomToPoint(e.clientX, e.clientY, newZoom) + let delta = -e.deltaY; + if (e.deltaMode === 1) delta *= 40; + const factor = Math.pow(1.005, delta); + const newZoom = engine.zoom * factor; + engine.zoomToPoint(e.clientX, e.clientY, newZoom); } else { - let dx = -e.deltaX - let dy = -e.deltaY - if (e.deltaMode === 1) { dx *= 40; dy *= 40 } - engine.pan(dx, dy) + let dx = -e.deltaX; + let dy = -e.deltaY; + if (e.deltaMode === 1) { + dx *= 40; + dy *= 40; + } + engine.pan(dx, dy); } - } + }; - canvasEl.addEventListener('wheel', handleWheel, { passive: false }) - return () => canvasEl.removeEventListener('wheel', handleWheel) - }, []) + canvasEl.addEventListener('wheel', handleWheel, { passive: false }); + return () => canvasEl.removeEventListener('wheel', handleWheel); + }, []); // Mouse/keyboard interactions (select, move, resize, draw, hover, etc.) useEffect(() => { - const canvasEl = canvasRef.current - if (!canvasEl) return + const canvasEl = canvasRef.current; + if (!canvasEl) return; - const manager = new SkiaInteractionManager(engineRef, canvasEl, setEditingText) - return manager.attach() - }, []) + const manager = new SkiaInteractionManager( + engineRef, + canvasEl, + setEditingText, + setPathContextMenu, + ); + return manager.attach(); + }, []); return ( -
- +
+ {editingText && (