diff --git a/.gitignore b/.gitignore index 7baccfcf0..3f90c9692 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ out/ .tmp/ .DS_Store *.log -*.exe +/OpenDesign.exe .vite .astro/ .vscode diff --git a/AGENTS.md b/AGENTS.md index 6ff00dcb9..b1536bc1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,13 +7,13 @@ This file is the single source of truth for agents entering this repository. Rea - Product and onboarding: `README.md`, `README.zh-CN.md`, `QUICKSTART.md`. - Contribution and environment: `CONTRIBUTING.md`, `CONTRIBUTING.zh-CN.md`. - Architecture and protocols: `docs/spec.md`, `docs/architecture.md`, `docs/skills-protocol.md`, `docs/agent-adapters.md`, `docs/modes.md`. -- Roadmap and references: `docs/roadmap.md`, `docs/references.md`, `specs/current/maintainability-roadmap.md`. +- Roadmap and references: `docs/roadmap.md`, `docs/references.md`, `docs/code-review-guidelines.md`, `specs/current/maintainability-roadmap.md`. - Directory-level agent guidance: `apps/AGENTS.md`, `packages/AGENTS.md`, `tools/AGENTS.md`, `e2e/AGENTS.md`. ## Workspace directories - Workspace packages come from `pnpm-workspace.yaml`: `apps/*`, `packages/*`, `tools/*`, and `e2e`. -- Top-level content directories: `skills/` (artifact-shape skills), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`). +- Top-level content directories: `skills/` (functional skills the agent invokes mid-task — utilities, briefs, packagers; see `skills/AGENTS.md`), `design-templates/` (rendering catalogue: decks, prototypes, image/video/audio templates; see `design-templates/AGENTS.md` and `specs/current/skills-and-design-templates.md`), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`). - `apps/web` is the Next.js 16 App Router + React 18 web runtime; do not restore `apps/nextjs`. - `apps/daemon` is the local privileged daemon and `od` bin. It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving. - `apps/desktop` is the Electron shell; it discovers the web URL through sidecar IPC. @@ -70,6 +70,15 @@ This file is the single source of truth for agents entering this repository. Rea - Git commits must not include `Co-authored-by` trailers or any other co-author metadata. +## Code review guide + +- Use `docs/code-review-guidelines.md` as the repository-wide review standard. That document is the operational guide; this `AGENTS.md` is the source of truth when the two disagree. +- Walk reviews top-down through `docs/code-review-guidelines.md`: Product relevance test → forbidden surfaces → ownership/scope → matching lane → checklist → comments → approval bar. +- Pick the matching review lane: default code/tests, contract and protocol changes, design-system additions, skill additions, or craft additions. +- Before reviewing changes under `apps/`, `packages/`, `tools/`, or `e2e/`, read that directory's `AGENTS.md` and apply its local boundaries. +- Blocking review feedback should focus on correctness, security/secrets, data integrity, repository boundary violations, contract/migration breakage, missing required validation, or high-risk maintainability issues. +- Only maintainers may close a PR instead of requesting changes, and only when the change is not salvageable on the existing branch (wrong target product, foreign test harness, DOM/API assumptions absent from this repo, or scripts that conflict with lifecycle rules). + ## Validation strategy - After package, workspace, or command-entry changes, run `pnpm install` so workspace links and generated dist entries stay fresh. diff --git a/README.ar.md b/README.ar.md index f46c0eeff..a04882d53 100644 --- a/README.ar.md +++ b/README.ar.md @@ -45,7 +45,7 @@ يرتكز OD على أربعة مشاريع مفتوحة المصدر: -- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — بوصلة فلسفة التصميم. سير عمل Junior-Designer، بروتوكول الأصول البصرية المؤلف من 5 خطوات، checklist مكافحة AI-slop، التقييم الذاتي خماسي الأبعاد، وفكرة "5 مدارس × 20 فلسفة تصميم" خلف منتقي الاتجاه — كل ذلك مكثّف في [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts). +- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — بوصلة فلسفة التصميم. سير عمل Junior-Designer، بروتوكول الأصول البصرية المؤلف من 5 خطوات، checklist مكافحة AI-slop، التقييم الذاتي خماسي الأبعاد، وفكرة "5 مدارس × 20 فلسفة تصميم" خلف منتقي الاتجاه — كل ذلك مكثّف في [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts). - [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — وضع الـ deck. مُضمَّن حرفياً تحت [`skills/guizang-ppt/`](skills/guizang-ppt/) مع الحفاظ على LICENSE الأصلية؛ تخطيطات بأسلوب المجلّات، WebGL hero، و checklist بمستويات P0/P1/P2. - [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — نجم UX الشمالي وأقرب أقراننا. أوّل بديل مفتوح المصدر لـ Claude-Design. اقتبسنا منه حلقة الـ artifact المُتدفّق، نمط معاينة iframe المعزول (مع React 18 + Babel مضمّنين)، لوحة الوكيل الحيّة (todos + tool calls + إمكانية المقاطعة)، وقائمة التصدير بخمسة صيغ (HTML / PDF / PPTX / ZIP / Markdown). تعمّدنا التباعد في الشكل العام — هم تطبيق سطح مكتب Electron يضمّ [`pi-ai`][piai]، ونحن تطبيق ويب + daemon محلي يفوّض المهمة لـ CLI الموجودة لديك. - [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — معمارية الـ daemon ومنظومة التشغيل. اكتشاف الوكلاء بمسح `PATH`، والـ daemon المحلي بوصفه العملية المميَّزة الوحيدة، ورؤية "الوكيل كزميل فريق". @@ -59,7 +59,7 @@ | **أنظمة تصميم مدمجة** | **129** — 2 starters مكتوبة يدوياً + 70 نظاماً للمنتجات (Linear، Stripe، Vercel، Airbnb، Tesla، Notion، Anthropic، Apple، Cursor، Supabase، Figma، Xiaohongshu، …) من [`awesome-design-md`][acd2]، إضافة إلى 57 design skill من [`awesome-design-skills`][ads] أُضيفت مباشرة تحت `design-systems/` | | **Skills مدمجة** | **31** — 27 في وضع `prototype` (web-prototype، saas-landing، dashboard، mobile-app، gamified-app، social-carousel، magazine-poster، dating-web، sprite-animation، motion-frames، critique، tweaks، wireframe-sketch، pm-spec، eng-runbook، finance-report، hr-onboarding، invoice، kanban-board، team-okrs، …) + 4 في وضع `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). مُجمَّعة في الـ picker حسب `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. | | **توليد الوسائط** | تشتغل أسطح الصورة والفيديو والصوت بالتوازي مع حلقة التصميم. **gpt-image-2** (Azure / OpenAI) للملصقات والصور الرمزية والإنفوغرافيك وخرائط المدن المرسومة · **Seedance 2.0** (ByteDance) لـ 15 ثانية t2v + i2v بطابع سينمائي · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) لتحويل HTML→MP4 (إعلانات منتجات، طباعة حركية، رسومات بيانية، بطاقات اجتماعية، Logo outros). معرض **93** برومبت جاهزة للاستنساخ — 43 لـ gpt-image-2 + 39 لـ Seedance + 11 لـ HyperFrames — تحت [`prompt-templates/`](prompt-templates/) مع صور معاينة وبيانات المصدر. نفس واجهة الـ chat كما في الكود؛ المخرجات ملفات `.mp4` / `.png` حقيقية تنزل في مساحة عمل المشروع. | -| **الاتجاهات البصرية** | 5 مدارس منتقاة (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — كل واحدة تأتي بلوحة OKLch حتميّة + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) | +| **الاتجاهات البصرية** | 5 مدارس منتقاة (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — كل واحدة تأتي بلوحة OKLch حتميّة + font stack ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) | | **إطارات الأجهزة** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — دقيقة على مستوى البكسل، مُشتركة عبر الـ skills تحت [`assets/frames/`](assets/frames/) | | **Agent runtime** | الـ daemon المحلي يُشغّل CLI داخل مجلد مشروعك — يحصل الوكيل على أدوات `Read` / `Write` / `Bash` / `WebFetch` حقيقية على نظام ملفات حقيقي، مع fallbacks على Windows لتجاوز قيود `ENAMETOOLONG` (stdin / ملف برومبت مؤقت) في كل adapter | | **الاستيراد** | اسحب ملف ZIP مُصدَّر من [Claude Design][cd] إلى مربّع الترحيب — `POST /api/import/claude-design` يفكّه إلى مشروع حقيقي ليُكمل وكيلك من حيث توقّف Anthropic | @@ -256,7 +256,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq + (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) ``` -كل طبقة قابلة للتركيب. كل طبقة ملف يمكنك تعديله. اقرأ [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) و [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) لرؤية العقد الحقيقي. +كل طبقة قابلة للتركيب. كل طبقة ملف يمكنك تعديله. اقرأ [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) و [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) لرؤية العقد الحقيقي. ## المعمارية @@ -567,7 +567,7 @@ open-design/ | Brutalist | خشن، طباعة عملاقة، بدون ظلال، تفاصيل قاسية | Bloomberg Businessweek · Achtung | | Soft warm | كريم، تباين منخفض، ألوان خوخية محايدة | Notion marketing · Apple Health | -المواصفات الكاملة → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). +المواصفات الكاملة → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). ## توليد الوسائط @@ -657,7 +657,7 @@ OD لا يقف عند الكود. نفس واجهة الـ chat التي تنت ## ميكانيكا مكافحة AI-slop -كل المنظومة أدناه هي playbook الخاص بـ [`huashu-design`](https://github.com/alchaincyf/huashu-design)، نُقل إلى مكدّس برومبت OD وأصبح قابلاً للإنفاذ لكل skill عبر pre-flight لملفات side. اقرأ [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) للاطّلاع على الصياغة الحيّة: +كل المنظومة أدناه هي playbook الخاص بـ [`huashu-design`](https://github.com/alchaincyf/huashu-design)، نُقل إلى مكدّس برومبت OD وأصبح قابلاً للإنفاذ لكل skill عبر pre-flight لملفات side. اقرأ [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) للاطّلاع على الصياغة الحيّة: - **نموذج الأسئلة أوّلاً.** الجولة الأولى `` فقط — لا تفكير، لا أدوات، لا سرد. يختار المستخدم الافتراضيات بسرعة الـ radio. - **استخراج brand-spec.** حين يُرفق المستخدم لقطة شاشة أو URL، يُجري الوكيل بروتوكولاً من خمس خطوات (locate · download · grep hex · تدوين `brand-spec.md` · vocalise) قبل كتابة CSS. **لا يخمّن ألوان الهوية من الذاكرة أبداً.** @@ -732,7 +732,7 @@ OD لا يقف عند الكود. نفس واجهة الـ chat التي تنت | المشروع | الدور هنا | |---|---| | [`Claude Design`][cd] | المنتج المغلق المصدر الذي يُمثّل هذا المستودع البديل المفتوح له. | -| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | نواة فلسفة التصميم. سير عمل Junior-Designer، بروتوكول الأصول البصرية المؤلف من 5 خطوات، checklist مكافحة AI-slop، التقييم الذاتي خماسي الأبعاد، ومكتبة "5 مدارس × 20 فلسفة تصميم" خلف منتقي الاتجاه — كلّها مكثّفة في [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) و [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). | +| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | نواة فلسفة التصميم. سير عمل Junior-Designer، بروتوكول الأصول البصرية المؤلف من 5 خطوات، checklist مكافحة AI-slop، التقييم الذاتي خماسي الأبعاد، ومكتبة "5 مدارس × 20 فلسفة تصميم" خلف منتقي الاتجاه — كلّها مكثّفة في [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) و [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). | | [**`op7418/guizang-ppt-skill`**][guizang] | skill Magazine-web-PPT المضمَّن حرفياً تحت [`skills/guizang-ppt/`](skills/guizang-ppt/) مع الحفاظ على LICENSE الأصلية. الافتراضي لوضع deck. ثقافة checklist بمستويات P0/P1/P2 مستعارة لكل skill أخرى. | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | معمارية الـ daemon + adapter. اكتشاف الوكلاء بمسح PATH، الـ daemon المحلي بوصفه العملية المميَّزة الوحيدة، ورؤية "الوكيل كزميل فريق". نتبنّى النموذج، لا نضمّ الكود. | | [**`OpenCoworkAI/open-codesign`**][ocod] | أوّل بديل مفتوح المصدر لـ Claude-Design، وأقرب أقراننا. أنماط UX المُتبنّاة: حلقة الـ artifact المتدفّقة، معاينة iframe المعزولة (مع React 18 + Babel مضمّنين)، لوحة الوكيل الحيّة (todos + tool calls + قابلة للمقاطعة)، قائمة التصدير بخمس صيغ (HTML/PDF/PPTX/ZIP/Markdown)، مركز تخزين محلي أوّلاً، حقن الذوق عبر `SKILL.md`، والتمرير الأوّل لتعليقات comment-mode على المعاينة. أنماط UX لا تزال على roadmap لدينا: موثوقية surgical-edit الكاملة ولوحة tweaks يطلقها الذكاء. **نتعمّد عدم ضمّ [`pi-ai`][piai]** — open-codesign يحزمه كـ agent runtime؛ نحن نفوّض لأيّ CLI لدى المستخدم. | diff --git a/README.de.md b/README.de.md index a92f1edb8..86033373b 100644 --- a/README.de.md +++ b/README.de.md @@ -43,7 +43,7 @@ Das ist nicht "AI versucht, etwas zu designen". Das ist eine AI, die durch den P OD steht auf den Schultern von vier Open-Source-Projekten: -- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — der Design-Philosophie-Kompass. Junior-Designer Workflow, das 5-step brand-asset protocol, die anti-AI-slop checklist, die fünfdimensionale Self-Critique und die Idee "5 schools × 20 design philosophies" hinter unserem Direction Picker, alles verdichtet in [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts). +- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — der Design-Philosophie-Kompass. Junior-Designer Workflow, das 5-step brand-asset protocol, die anti-AI-slop checklist, die fünfdimensionale Self-Critique und die Idee "5 schools × 20 design philosophies" hinter unserem Direction Picker, alles verdichtet in [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts). - [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — der Deck-Modus. Unverändert unter [`skills/guizang-ppt/`](skills/guizang-ppt/) gebündelt, mit ursprünglicher LICENSE; magazinartige Layouts, WebGL-Hero, P0/P1/P2-Checklists. - [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX North Star und nächster Peer. Die erste Open-Source-Alternative zu Claude Design. Wir übernehmen den Streaming-Artifact-Loop, das sandboxed-iframe Preview Pattern (vendored React 18 + Babel), das Live-Agent-Panel (todos + tool calls + unterbrechbare Generierung) und die fünf Exportformate (HTML / PDF / PPTX / ZIP / Markdown). Wir unterscheiden uns bewusst im Formfaktor: Sie sind eine Desktop-Electron-App mit gebündeltem [`pi-ai`][piai]; wir sind eine Web-App + lokaler daemon, die an Ihre vorhandene CLI delegiert. - [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — die daemon- und runtime-Architektur. PATH-Scan-Agent-Erkennung, der lokale daemon als einziger privilegierter Prozess, die Agent-as-teammate Sichtweise. @@ -57,7 +57,7 @@ OD steht auf den Schultern von vier Open-Source-Projekten: | **Design Systems integriert** | **72** — 2 handgeschriebene Starter + 70 Produktsysteme (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …), importiert aus [`awesome-design-md`][acd2] | | **Skills integriert** | **31** — 27 im `prototype` mode (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 im `deck` mode (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Im Picker nach `scenario` gruppiert: design / marketing / operation / engineering / product / finance / hr / sale / personal. | | **Medienerzeugung** | Image-, Video- und Audio-Surfaces laufen neben dem Design-Loop. **gpt-image-2** (Azure / OpenAI) für Poster, Avatare, Infografiken, illustrierte Karten · **Seedance 2.0** (ByteDance) für 15s-cinematic text-to-video und image-to-video · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) für HTML→MP4 Motion Graphics (Produkt-Reveals, kinetische Typografie, Datendiagramme, Social Overlays, Logo-Outros). **93** sofort reproduzierbare Prompts — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — unter [`prompt-templates/`](prompt-templates/), mit Vorschau-Thumbnails und Quellenangabe. Gleiche Chat-Oberfläche wie Code; gibt einen echten `.mp4` / `.png` Chip in den Projekt-Workspace aus. | -| **Visuelle Richtungen** | 5 kuratierte Schulen (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental), jeweils mit deterministischer OKLch-Palette + Font Stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) | +| **Visuelle Richtungen** | 5 kuratierte Schulen (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental), jeweils mit deterministischer OKLch-Palette + Font Stack ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) | | **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixelgenau, skillübergreifend unter [`assets/frames/`](assets/frames/) geteilt | | **Agent-Runtime** | Der lokale daemon startet die CLI in Ihrem Projektordner: Der Agent bekommt echte `Read`, `Write`, `Bash`, `WebFetch` gegen eine echte Festplattenumgebung, mit Windows-`ENAMETOOLONG` Fallbacks (stdin / prompt-file) in jedem Adapter | | **Imports** | Ziehen Sie einen [Claude Design][cd] Export-ZIP in den Welcome Dialog: `POST /api/import/claude-design` parst ihn zu einem echten Projekt, damit Ihr Agent dort weiterarbeiten kann, wo Anthropic aufgehört hat | @@ -253,7 +253,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq + (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) ``` -Jede Ebene ist kombinierbar. Jede Ebene ist eine Datei, die Sie editieren können. Lesen Sie [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) und [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts), um den echten Vertrag zu sehen. +Jede Ebene ist kombinierbar. Jede Ebene ist eine Datei, die Sie editieren können. Lesen Sie [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) und [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts), um den echten Vertrag zu sehen. ## Architektur @@ -494,7 +494,7 @@ Wenn der Nutzer keine Brand Spec hat, gibt der Agent ein zweites Formular mit f | Brutalist | Roh, übergroße Type, keine Schatten, harte Akzente | Bloomberg Businessweek · Achtung | | Soft warm | Großzügig, niedriger Kontrast, peachy Neutrals | Notion marketing · Apple Health | -Vollständige Spec → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). +Vollständige Spec → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). ## Medienerzeugung @@ -584,7 +584,7 @@ Der Chat-/Artifact-Loop steht im Rampenlicht, aber einige weniger sichtbare Fäh ## Anti-AI-Slop-Maschinerie -Die gesamte Maschinerie unten ist das [`huashu-design`](https://github.com/alchaincyf/huashu-design) Playbook, portiert in ODs Prompt Stack und pro Skill über Side-File-Pre-Flight erzwingbar. Lesen Sie [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) für die Live-Formulierung: +Die gesamte Maschinerie unten ist das [`huashu-design`](https://github.com/alchaincyf/huashu-design) Playbook, portiert in ODs Prompt Stack und pro Skill über Side-File-Pre-Flight erzwingbar. Lesen Sie [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) für die Live-Formulierung: - **Question form first.** Turn 1 ist nur ``: kein Denken, keine Tools, keine Narration. Der Nutzer wählt Defaults mit Radio-Geschwindigkeit. - **Brand-spec extraction.** Wenn der Nutzer Screenshot oder URL anhängt, führt der Agent ein fünfstufiges Protokoll aus (locate · download · grep hex · codify `brand-spec.md` · vocalise), bevor er CSS schreibt. **Er rät Brandfarben niemals aus Erinnerung.** @@ -659,7 +659,7 @@ Jedes externe Projekt, aus dem dieses Repo etwas übernimmt. Jeder Link führt z | Projekt | Rolle hier | |---|---| | [`Claude Design`][cd] | Das closed-source Produkt, zu dem dieses Repo die Open-Source-Alternative ist. | -| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Der Design-Philosophie-Kern. Junior-Designer Workflow, 5-step brand-asset protocol, anti-AI-slop checklist, fünfdimensionale Self-Critique und die "5 schools × 20 design philosophies" Bibliothek hinter unserem Direction Picker, alles verdichtet in [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) und [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). | +| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Der Design-Philosophie-Kern. Junior-Designer Workflow, 5-step brand-asset protocol, anti-AI-slop checklist, fünfdimensionale Self-Critique und die "5 schools × 20 design philosophies" Bibliothek hinter unserem Direction Picker, alles verdichtet in [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) und [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). | | [**`op7418/guizang-ppt-skill`**][guizang] | Web-PPT-Skill im Magazinstil, unverändert unter [`skills/guizang-ppt/`](skills/guizang-ppt/) gebündelt, ursprüngliche LICENSE bewahrt. Default für den Deck-Modus. P0/P1/P2 Checklist-Kultur für jeden anderen Skill übernommen. | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Die daemon + adapter Architektur. PATH-Scan-Agent-Erkennung, lokaler daemon als einziger privilegierter Prozess, Agent-as-teammate Sichtweise. Wir übernehmen das Modell, nicht den Code. | | [**`OpenCoworkAI/open-codesign`**][ocod] | Die erste Open-Source-Alternative zu Claude Design und unser nächster Peer. Übernommene UX Patterns: streaming-artifact loop, sandboxed-iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible), fünf Exportformate (HTML/PDF/PPTX/ZIP/Markdown), local-first storage hub, `SKILL.md` taste-injection. UX Patterns auf unserer Roadmap: comment-mode surgical edits, AI-emitted tweaks panel. **Wir vendoren [`pi-ai`][piai] bewusst nicht**: open-codesign bündelt es als Agent Runtime; wir delegieren an die CLI, die der Nutzer bereits hat. | diff --git a/README.es.md b/README.es.md index db0e61249..e1bf8de81 100644 --- a/README.es.md +++ b/README.es.md @@ -42,7 +42,7 @@ Eso no es "AI tries to design something". Es una IA entrenada por el prompt stac OD se apoya en cuatro hombros open source: -- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design): la brújula de filosofía de diseño. El flujo Junior-Designer, el protocolo de marca en 5 pasos, la checklist anti-AI-slop, la autocrítica de 5 dimensiones y la idea de "5 schools × 20 design philosophies" detrás del selector de dirección, todo destilado en [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts). +- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design): la brújula de filosofía de diseño. El flujo Junior-Designer, el protocolo de marca en 5 pasos, la checklist anti-AI-slop, la autocrítica de 5 dimensiones y la idea de "5 schools × 20 design philosophies" detrás del selector de dirección, todo destilado en [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts). - [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill): el modo deck. Incluido literalmente bajo [`skills/guizang-ppt/`](skills/guizang-ppt/) con la LICENSE original preservada; layouts magazine-style, hero WebGL y checklists P0/P1/P2. - [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign): la estrella norte de UX y nuestro par más cercano. La primera alternativa open source a Claude Design. Tomamos prestados su bucle streaming-artifact, el patrón de preview en iframe sandboxed (React 18 + Babel vendorizados), su panel de agente en vivo (todos + tool calls + generación interrumpible) y su lista de cinco formatos de exportación (HTML / PDF / PPTX / ZIP / Markdown). Divergimos deliberadamente en el formato: ellos son una app Electron de escritorio con [`pi-ai`][piai]; nosotros somos una web app + daemon local que delega en tu CLI existente. - [**`multica-ai/multica`**](https://github.com/multica-ai/multica): la arquitectura daemon-and-runtime. Detección de agentes en `PATH`, el daemon local como único proceso privilegiado y la visión del agente como compañero de equipo. @@ -56,7 +56,7 @@ OD se apoya en cuatro hombros open source: | **Design systems incluidos** | **129**: 2 starters escritos a mano + 70 sistemas de producto (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) desde [`awesome-design-md`][acd2], más 57 design skills desde [`awesome-design-skills`][ads] añadidas directamente bajo `design-systems/` | | **Skills incluidas** | **31**: 27 en modo `prototype` (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 en modo `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Agrupadas en el selector por `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. | | **Generación de medios** | Superficies de imagen · video · audio junto al bucle de diseño. **gpt-image-2** (Azure / OpenAI) para pósters, avatares, infografías y mapas ilustrados · **Seedance 2.0** (ByteDance) para text-to-video e image-to-video cinematográfico de 15s · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) para motion graphics HTML→MP4 (product reveals, tipografía cinética, charts de datos, overlays sociales, logo outros). **93** prompts listos para replicar: 43 gpt-image-2 + 39 Seedance + 11 HyperFrames bajo [`prompt-templates/`](prompt-templates/), con thumbnails de preview y atribución de fuente. La misma superficie de chat que el código; produce chips reales `.mp4` / `.png` en el workspace del proyecto. | -| **Direcciones visuales** | 5 escuelas curadas (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental): cada una trae una paleta OKLch determinista + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) | +| **Direcciones visuales** | 5 escuelas curadas (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental): cada una trae una paleta OKLch determinista + font stack ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) | | **Frames de dispositivo** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome: pixel-perfect, compartidos entre skills bajo [`assets/frames/`](assets/frames/) | | **Runtime de agente** | El daemon local spawnea la CLI en la carpeta del proyecto: el agente recibe `Read`, `Write`, `Bash`, `WebFetch` reales contra un entorno real en disco, con fallbacks de Windows `ENAMETOOLONG` (stdin / prompt-file) en cada adapter | | **Imports** | Suelta un ZIP exportado desde [Claude Design][cd] en el diálogo de bienvenida: `POST /api/import/claude-design` lo parsea en un proyecto real para que tu agente siga editando donde Anthropic lo dejó | @@ -253,7 +253,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq + (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) ``` -Cada capa es componible. Cada capa es un archivo que puedes editar. Lee [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) y [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) para ver el contrato real. +Cada capa es componible. Cada capa es un archivo que puedes editar. Lee [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) y [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) para ver el contrato real. ## Arquitectura @@ -554,7 +554,7 @@ Cuando el usuario no tiene brand spec, el agente emite un segundo formulario con | Brutalist | Crudo, tipografía oversized, sin sombras, acentos duros | Bloomberg Businessweek · Achtung | | Soft warm | Generoso, bajo contraste, neutros melocotón | Notion marketing · Apple Health | -Spec completa → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). +Spec completa → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). ## Generación de medios @@ -644,7 +644,7 @@ El bucle chat / artifact se lleva el foco, pero ya hay varias capacidades menos ## Maquinaria anti-AI-slop -Todo lo siguiente es el playbook de [`huashu-design`](https://github.com/alchaincyf/huashu-design), portado al prompt-stack de OD y hecho exigible por skill mediante el pre-flight de side files. Lee [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) para ver el texto vivo: +Todo lo siguiente es el playbook de [`huashu-design`](https://github.com/alchaincyf/huashu-design), portado al prompt-stack de OD y hecho exigible por skill mediante el pre-flight de side files. Lee [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) para ver el texto vivo: - **Question form first.** El turno 1 es solo ``: sin thinking, sin tools, sin narración. El usuario elige defaults a velocidad de radio buttons. - **Extracción de brand spec.** Cuando el usuario adjunta screenshot o URL, el agente ejecuta un protocolo de cinco pasos (locate · download · grep hex · codify `brand-spec.md` · vocalise) antes de escribir CSS. **Nunca adivina colores de marca de memoria.** @@ -719,7 +719,7 @@ Cada proyecto externo del que este repo toma ideas. Cada link va a la fuente par | Proyecto | Rol aquí | |---|---| | [`Claude Design`][cd] | El producto closed-source del que este repo es alternativa open source. | -| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Núcleo de filosofía de diseño. Junior-Designer workflow, protocolo de brand assets en 5 pasos, checklist anti-AI-slop, autocrítica de 5 dimensiones y la biblioteca "5 schools × 20 design philosophies" detrás del direction picker, destilado en [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) y [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). | +| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Núcleo de filosofía de diseño. Junior-Designer workflow, protocolo de brand assets en 5 pasos, checklist anti-AI-slop, autocrítica de 5 dimensiones y la biblioteca "5 schools × 20 design philosophies" detrás del direction picker, destilado en [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) y [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). | | [**`op7418/guizang-ppt-skill`**][guizang] | Skill Magazine-web-PPT incluida literalmente bajo [`skills/guizang-ppt/`](skills/guizang-ppt/) con LICENSE original preservada. Default para deck mode. La cultura de checklist P0/P1/P2 se toma para cada otra skill. | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Arquitectura daemon + adapter. Detección por PATH, daemon local como único proceso privilegiado, visión agent-as-teammate. Adoptamos el modelo; no vendorizamos el código. | | [**`OpenCoworkAI/open-codesign`**][ocod] | La primera alternativa open source a Claude Design y nuestro par más cercano. Patrones UX adoptados: streaming-artifact loop, sandboxed-iframe preview (React 18 + Babel vendorizados), panel de agente en vivo (todos + tool calls + interruptible), lista de export de cinco formatos (HTML/PDF/PPTX/ZIP/Markdown), hub local-first, taste-injection `SKILL.md` y primer pase de anotaciones comment-mode en preview. Patrones todavía en roadmap: confiabilidad completa de surgical-edit y AI-emitted tweaks panel. **Deliberadamente no vendorizamos [`pi-ai`][piai]**: open-codesign lo incluye como agent runtime; nosotros delegamos en la CLI que ya tenga el usuario. | diff --git a/README.fr.md b/README.fr.md index 6c6bd8860..559bede80 100644 --- a/README.fr.md +++ b/README.fr.md @@ -43,7 +43,7 @@ Le résultat dépasse l’idée d’une IA qui tente simplement de faire du desi OD s’appuie sur quatre projets open source : -- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design), la boussole de design philosophy. Le workflow Junior-Designer, le protocole en 5 étapes pour les assets de marque, la checklist anti-AI-slop, la self-critique en 5 dimensions et l’idée « 5 écoles × 20 philosophies design » derrière notre direction picker sont condensés dans [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts). +- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design), la boussole de design philosophy. Le workflow Junior-Designer, le protocole en 5 étapes pour les assets de marque, la checklist anti-AI-slop, la self-critique en 5 dimensions et l’idée « 5 écoles × 20 philosophies design » derrière notre direction picker sont condensés dans [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts). - [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill), le mode deck. Inclus tel quel sous [`skills/guizang-ppt/`](skills/guizang-ppt/), avec licence originale préservée ; layouts magazine, hero WebGL, checklists P0/P1/P2. - [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign), notre UX north star et le projet le plus proche. Nous reprenons sa streaming-artifact loop, son pattern de preview en iframe sandboxée (React 18 + Babel vendored), son live agent panel (todos + tool calls + génération interruptible) et ses cinq formats d’export (HTML / PDF / PPTX / ZIP / Markdown). Nous divergeons volontairement sur le format : ils livrent une app desktop Electron avec [`pi-ai`][piai] intégré ; nous sommes une web app + daemon local qui délègue à la CLI déjà installée chez vous. - [**`multica-ai/multica`**](https://github.com/multica-ai/multica), l’architecture daemon et runtime. Détection des agents dans le `PATH`, daemon local comme seul processus privilégié, vision agent-as-teammate. @@ -57,7 +57,7 @@ OD s’appuie sur quatre projets open source : | **Design Systems intégrés** | Le menu déroulant charge les Design Systems depuis `design-systems/*/DESIGN.md` : starters écrits à la main, product systems importés depuis [`awesome-design-md`][acd2] et design skills normalisés depuis [`awesome-design-skills`][ads]. | | **Skills intégrés** | Le picker charge les Skills depuis `skills/*/SKILL.md` et les regroupe par `mode` / `scenario` : prototype, deck, image, video, audio, Design System, utility, puis notamment design / marketing / operations / engineering / product / finance / hr / sales / personal. | | **Génération média** | Les surfaces image, vidéo et audio sont livrées avec la design loop. **gpt-image-2** (Azure / OpenAI) pour posters, avatars, infographies et cartes illustrées ; **Seedance 2.0** (ByteDance) pour du text-to-video et image-to-video cinématique de 15 s ; **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) pour des motion graphics HTML→MP4. La galerie [`prompt-templates/`](prompt-templates/) fournit des prompts prêts à reproduire, avec thumbnails et attribution. Même surface de chat que le code ; les sorties deviennent de vrais fichiers `.mp4` / `.png` dans le workspace du projet. | -| **Directions visuelles** | 5 écoles soigneusement sélectionnées (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental), chacune avec palette OKLch déterministe + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) | +| **Directions visuelles** | 5 écoles soigneusement sélectionnées (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental), chacune avec palette OKLch déterministe + font stack ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) | | **Frames d’appareils** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome, pixel-accurate et partagés entre Skills sous [`assets/frames/`](assets/frames/) | | **Agent runtime** | Le daemon local lance la CLI dans le dossier projet. L’agent reçoit de vrais `Read`, `Write`, `Bash`, `WebFetch` sur un environnement disque réel, avec fallback Windows `ENAMETOOLONG` (stdin / prompt-file) sur chaque adapter | | **Imports** | Déposez un ZIP exporté depuis [Claude Design][cd] dans le welcome dialog : `POST /api/import/claude-design` le convertit en vrai projet pour que votre agent continue là où Anthropic s’est arrêté | @@ -254,7 +254,7 @@ DISCOVERY directives (formulaire tour 1, branche marque tour 2, TodoWrite, crit + (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) ``` -Chaque couche est composable. Chaque couche est un fichier éditable. Lisez [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) et [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) pour voir le contrat réel. +Chaque couche est composable. Chaque couche est un fichier éditable. Lisez [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) et [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) pour voir le contrat réel. ## Architecture @@ -500,7 +500,7 @@ Quand l’utilisateur n’a pas de brand spec, l’agent émet un second formula | Brutalist | Brut, typographie oversized, pas d’ombres, accents durs | Bloomberg Businessweek · Achtung | | Soft warm | Généreux, faible contraste, neutres pêche | Notion marketing · Apple Health | -Spec complète → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). +Spec complète → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). ## Génération média diff --git a/README.ja-JP.md b/README.ja-JP.md index 175a72993..4ec25a88b 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -44,7 +44,7 @@ Anthropic の [Claude Design][cd](2026-04-17 リリース、Opus 4.7 搭載) OD は 4 つのオープンソースプロジェクトの上に立っています: -- [**`alchaincyf/huashu-design`**(花叔の画術)](https://github.com/alchaincyf/huashu-design) — デザイン哲学の羅針盤。Junior-Designer ワークフロー、5 ステップのブランドアセットプロトコル、anti-AI-slop チェックリスト、五次元セルフ評価、そしてディレクションピッカーの背後にある「5 流派 × 20 のデザイン哲学」のアイデア — すべて [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) に蒸留されています。 +- [**`alchaincyf/huashu-design`**(花叔の画術)](https://github.com/alchaincyf/huashu-design) — デザイン哲学の羅針盤。Junior-Designer ワークフロー、5 ステップのブランドアセットプロトコル、anti-AI-slop チェックリスト、五次元セルフ評価、そしてディレクションピッカーの背後にある「5 流派 × 20 のデザイン哲学」のアイデア — すべて [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) に蒸留されています。 - [**`op7418/guizang-ppt-skill`**(歸藏の雑誌風 PPT Skill)](https://github.com/op7418/guizang-ppt-skill) — Deck モード。[`skills/guizang-ppt/`](skills/guizang-ppt/) 以下にオリジナルのまま同梱、元の LICENSE を保持。雑誌レイアウト、WebGL hero、P0/P1/P2 チェックリスト。 - [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX の北極星であり、最も近い同類プロジェクト。初のオープンソース Claude-Design 代替。ストリーミング artifact ループ、サンドボックス iframe プレビュー(React 18 + Babel 同梱)、ライブエージェントパネル(todo + tool calls + 中断可能な生成)、5 種類のエクスポート形式リスト(HTML / PDF / PPTX / ZIP / Markdown)を借用。形態では意図的に分岐しています — 彼らは [`pi-ai`][piai] を同梱するデスクトップ Electron アプリ、私たちは既存の CLI に委任する Web アプリ + ローカル daemon です。 - [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — Daemon とランタイムのアーキテクチャ。PATH スキャンによるエージェント検出、ローカル daemon を唯一の特権プロセスとする思想、agent-as-teammate の世界観。 @@ -58,7 +58,7 @@ OD は 4 つのオープンソースプロジェクトの上に立っていま | **組み込み Design System** | **72 種** — 2 つの手書きスターター + [`awesome-design-md`][acd2] からインポートした 70 のプロダクトシステム(Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma、小紅書…) | | **組み込み Skill** | **31 個** — `prototype` モード 27 個(web-prototype、saas-landing、dashboard、mobile-app、gamified-app、social-carousel、magazine-poster、dating-web、sprite-animation、motion-frames、critique、tweaks、wireframe-sketch、pm-spec、eng-runbook、finance-report、hr-onboarding、invoice、kanban-board、team-okrs…)+ `deck` モード 4 個(`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`)。ピッカーは `scenario` でグループ化:design / marketing / operation / engineering / product / finance / hr / sale / personal。 | | **メディア生成** | 画像 · 動画 · 音声サーフェスがデザインループと並走。**gpt-image-2**(Azure / OpenAI)でポスター・アバター・インフォグラフィック・イラスト都市マップ · **Seedance 2.0**(ByteDance)で 15 秒のシネマティック text-to-video / image-to-video · **HyperFrames**([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes))で HTML→MP4 のモーショングラフィック(プロダクトリビール、キネティックタイポグラフィ、データチャート、ソーシャルオーバーレイ、ロゴアウトロ)。**93 件**のすぐ複製できる prompt ギャラリー — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames、すべて [`prompt-templates/`](prompt-templates/) にプレビュー画像と出典付きで配置。Chat の入口はコードと同じ;実体の `.mp4` / `.png` がプロジェクトワークスペースに chip として落ちます。 | -| **ビジュアルディレクション** | 5 つの厳選流派(Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental)— 各々に OKLch パレット + フォントスタック付き([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) | +| **ビジュアルディレクション** | 5 つの厳選流派(Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental)— 各々に OKLch パレット + フォントスタック付き([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) | | **デバイスフレーム** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — ピクセル単位で正確、Skill 間で共有、[`assets/frames/`](assets/frames/) に集約 | | **エージェントランタイム** | ローカル daemon がプロジェクトフォルダ内で CLI を spawn — エージェントは実際のディスク上で `Read` / `Write` / `Bash` / `WebFetch` を使用。各 adapter に Windows `ENAMETOOLONG` フォールバック(stdin / 一時 prompt ファイル)あり | | **インポート** | [Claude Design][cd] のエクスポート ZIP をウェルカムダイアログにドロップ — `POST /api/import/claude-design` が実プロジェクトとして展開し、Anthropic の中断箇所からエージェントが編集を続行 | @@ -254,7 +254,7 @@ DISCOVERY ディレクティブ (turn-1 フォーム、turn-2 ブランド + (deck kind かつ Skill seed なし時) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) ``` -すべてのレイヤーが組み合わせ可能で、すべてのレイヤーが編集可能なファイルです。実際の契約は [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) と [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) で確認できます。 +すべてのレイヤーが組み合わせ可能で、すべてのレイヤーが編集可能なファイルです。実際の契約は [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) と [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) で確認できます。 ## アーキテクチャ @@ -491,7 +491,7 @@ open-design/ | Brutalist | 生々しい、巨大タイプ、シャドウなし、鮮烈なアクセント | Bloomberg Businessweek · Achtung | | Soft warm | おおらか、低コントラスト、ピーチ系ニュートラル | Notion マーケティングページ · Apple Health | -完全な仕様 → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。 +完全な仕様 → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)。 ## メディア生成 @@ -581,7 +581,7 @@ OD はコードで止まりません。`` の HTML を生み出すの ## anti-AI-slop 機構 -以下の機構はすべて [`huashu-design`](https://github.com/alchaincyf/huashu-design) のプレイブックを OD のプロンプトスタックに移植し、Skill 副ファイルの pre-flight で各 Skill に適用可能にしたものです。実際の文言は [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) を参照: +以下の機構はすべて [`huashu-design`](https://github.com/alchaincyf/huashu-design) のプレイブックを OD のプロンプトスタックに移植し、Skill 副ファイルの pre-flight で各 Skill に適用可能にしたものです。実際の文言は [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) を参照: - **まずフォーム。** Turn 1 は `` のみ — thinking 禁止、tools 禁止、ナレーション禁止。ユーザーはラジオの速度でデフォルトを選択。 - **ブランドアセットプロトコル。** ユーザーがスクリーンショットや URL を添付した場合、エージェントは 5 ステップのプロトコル(特定 · ダウンロード · grep hex · `brand-spec.md` 作成 · 復唱)を実行してから CSS を書きます。**記憶からブランドカラーを推測することは絶対にありません。** @@ -656,7 +656,7 @@ Daemon 起動時に `PATH` から自動検出。設定不要。ストリーミ | プロジェクト | 本リポジトリでの役割 | |---|---| | [`Claude Design`][cd] | 本リポジトリがオープンソース代替を提供するクローズドソースプロダクト。 | -| [**`alchaincyf/huashu-design`**(花叔の画術)](https://github.com/alchaincyf/huashu-design) | デザイン哲学のコア。Junior-Designer ワークフロー、5 ステップブランドアセットプロトコル、anti-AI-slop チェックリスト、五次元セルフ評価、ディレクションピッカーの背後にある「5 流派 × 20 のデザイン哲学」ライブラリ — すべて [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) と [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts) に蒸留。 | +| [**`alchaincyf/huashu-design`**(花叔の画術)](https://github.com/alchaincyf/huashu-design) | デザイン哲学のコア。Junior-Designer ワークフロー、5 ステップブランドアセットプロトコル、anti-AI-slop チェックリスト、五次元セルフ評価、ディレクションピッカーの背後にある「5 流派 × 20 のデザイン哲学」ライブラリ — すべて [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) と [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts) に蒸留。 | | [**`op7418/guizang-ppt-skill`**(歸藏)][guizang] | Magazine-web-PPT Skill を [`skills/guizang-ppt/`](skills/guizang-ppt/) にそのまま同梱、元の LICENSE 保持。Deck モードのデフォルト。P0/P1/P2 チェックリスト文化を他のすべての Skill に波及。 | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + adapter アーキテクチャ。PATH スキャンによるエージェント検出、ローカル daemon を唯一の特権プロセスとする思想、agent-as-teammate の世界観。モデルを採用、コードは vendor せず。 | | [**`OpenCoworkAI/open-codesign`**][ocod] | 初のオープンソース Claude-Design 代替、最も近い同類。採用済み UX パターン:ストリーミング artifact ループ、サンドボックス iframe プレビュー(React 18 + Babel 同梱)、ライブエージェントパネル(todo + tool calls + 中断可能)、5 種エクスポート形式リスト(HTML/PDF/PPTX/ZIP/Markdown)、ローカルファーストストレージハブ、`SKILL.md` テイスト注入。ロードマップ上の UX パターン:コメントモード精密編集、AI 出力 tweaks パネル。**[`pi-ai`][piai] は意図的に vendor していません** — open-codesign はそれをエージェントランタイムとして同梱していますが、私たちはユーザーの既存 CLI に委任します。 | diff --git a/README.ko.md b/README.ko.md index 545b8ba2a..cf582618b 100644 --- a/README.ko.md +++ b/README.ko.md @@ -43,7 +43,7 @@ Anthropic의 [Claude Design][cd](2026-04-17 출시, Opus 4.7 기반)은 LLM이 OD는 네 개의 오픈소스 프로젝트의 어깨 위에 서 있습니다: -- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — 디자인 철학의 나침반. Junior-Designer 워크플로, 5단계 브랜드 에셋 프로토콜, anti-AI-slop 체크리스트, 5차원 자기 검토, 그리고 방향 선택기 뒤의 "5가지 학파 × 20가지 디자인 철학" 아이디어 — 모두 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)에 녹아들었습니다. +- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — 디자인 철학의 나침반. Junior-Designer 워크플로, 5단계 브랜드 에셋 프로토콜, anti-AI-slop 체크리스트, 5차원 자기 검토, 그리고 방향 선택기 뒤의 "5가지 학파 × 20가지 디자인 철학" 아이디어 — 모두 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts)에 녹아들었습니다. - [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — 덱 모드. [`skills/guizang-ppt/`](skills/guizang-ppt/) 아래에 원본 그대로 번들됨, 원 LICENSE 보존; 매거진 레이아웃, WebGL hero, P0/P1/P2 체크리스트. - [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX의 북극성이자 가장 가까운 동류. 최초의 오픈소스 Claude-Design 대안. 스트리밍 아티팩트 루프, 샌드박스 iframe 미리보기 패턴(React 18 + Babel 내장), 실시간 에이전트 패널(todos + tool calls + 중단 가능한 생성), 5가지 내보내기 형식(HTML / PDF / PPTX / ZIP / Markdown)을 차용했습니다. 폼 팩터에서는 의도적으로 차별화했습니다 — 그쪽은 [`pi-ai`][piai]를 번들링한 Electron 데스크탑 앱이고, 우리는 에이전트 런타임을 이미 설치된 CLI에 **위임**하는 웹앱 + 로컬 daemon입니다. - [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — Daemon 및 런타임 아키텍처. PATH 스캔 방식의 에이전트 감지, 단일 특권 프로세스로서의 로컬 daemon, 에이전트-동료 세계관. @@ -57,7 +57,7 @@ OD는 네 개의 오픈소스 프로젝트의 어깨 위에 서 있습니다: | **내장 디자인 시스템** | **72개** — 2개의 수작업 스타터 + [`awesome-design-md`][acd2]에서 가져온 70개의 제품 시스템(Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu …) | | **내장 Skill** | **31개** — `prototype` 모드 27개(web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs …) + `deck` 모드 4개(`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). picker에서 `scenario`로 그룹화: design / marketing / operation / engineering / product / finance / hr / sale / personal. | | **미디어 생성** | 이미지 · 비디오 · 오디오 surface가 디자인 루프와 함께 작동합니다. **gpt-image-2**(Azure / OpenAI)로 포스터, 아바타, 인포그래픽, 일러스트 도시 지도 · **Seedance 2.0**(ByteDance)로 15초 시네마틱 text-to-video / image-to-video · **HyperFrames**([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes))로 HTML→MP4 모션 그래픽(제품 리빌, 키네틱 타이포그래피, 데이터 차트, 소셜 오버레이, 로고 아웃트로). **93개**의 즉시 복제 가능한 prompt 갤러리 — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — 모두 [`prompt-templates/`](prompt-templates/) 아래에 미리보기 썸네일과 출처 표기와 함께 배치. 채팅 입구는 코드와 동일; 실제 `.mp4` / `.png`이 프로젝트 워크스페이스에 chip으로 떨어집니다. | -| **시각적 방향** | 5가지 엄선된 학파(Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — 각각 결정론적 OKLch 팔레트 + 폰트 스택 제공([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) | +| **시각적 방향** | 5가지 엄선된 학파(Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — 각각 결정론적 OKLch 팔레트 + 폰트 스택 제공([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) | | **기기 프레임** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — 픽셀 정확도, skill 간 공유, [`assets/frames/`](assets/frames/)에 통합 | | **에이전트 런타임** | 로컬 daemon이 프로젝트 폴더에서 CLI를 실행 — 에이전트가 실제 디스크 환경에 대한 실제 `Read`, `Write`, `Bash`, `WebFetch` 도구 사용; 모든 어댑터에 Windows `ENAMETOOLONG` 폴백(stdin / 임시 prompt 파일) | | **임포트** | [Claude Design][cd] 익스포트 ZIP을 환영 다이얼로그에 드롭하면 `POST /api/import/claude-design`이 진짜 프로젝트로 풀어주고, 로컬 에이전트는 Anthropic이 멈춘 지점에서 그대로 편집을 이어받습니다. | @@ -253,7 +253,7 @@ DISCOVERY 지시문 (turn-1 폼, turn-2 브랜드 분기, TodoWrite, 5차원 + (덱 kind, skill seed 없음) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) ``` -모든 레이어는 조합 가능합니다. 모든 레이어는 편집 가능한 파일입니다. 실제 계약을 보려면 [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts)와 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)를 읽으세요. +모든 레이어는 조합 가능합니다. 모든 레이어는 편집 가능한 파일입니다. 실제 계약을 보려면 [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts)와 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts)를 읽으세요. ## 아키텍처 @@ -492,7 +492,7 @@ open-design/ | Brutalist | 날것, 거대한 타입, 그림자 없음, 강한 액센트 | Bloomberg Businessweek · Achtung | | Soft warm | 여유롭고, 낮은 대비, 복숭아 계열 뉴트럴 | Notion 마케팅 · Apple Health | -전체 스펙 → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). +전체 스펙 → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). ## 미디어 생성 @@ -582,7 +582,7 @@ OD는 코드에서 끝나지 않습니다. `` HTML을 만드는 동일 ## Anti-AI-slop 메커니즘 -아래의 모든 메커니즘은 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 플레이북을 OD의 프롬프트 스택에 이식하고, 사이드 파일 pre-flight를 통해 skill별로 적용 가능하게 만든 것입니다. 실제 문구는 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)를 읽으세요: +아래의 모든 메커니즘은 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 플레이북을 OD의 프롬프트 스택에 이식하고, 사이드 파일 pre-flight를 통해 skill별로 적용 가능하게 만든 것입니다. 실제 문구는 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts)를 읽으세요: - **질문 폼 우선.** Turn 1은 오직 `` — 생각하기 없음, 도구 없음, 내레이션 없음. 사용자는 라디오 속도로 기본값을 선택합니다. - **브랜드 스펙 추출.** 사용자가 스크린샷이나 URL을 첨부하면, 에이전트는 5단계 프로토콜(위치 파악 · 다운로드 · hex grep · `brand-spec.md` 코드화 · 발성)을 실행한 후 CSS를 작성합니다. **절대 기억에서 브랜드 색상을 추측하지 않습니다.** @@ -657,7 +657,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. 스 | 프로젝트 | 역할 | |---|---| | [`Claude Design`][cd] | 이 저장소가 오픈소스 대안을 제공하는 클로즈드 소스 제품. | -| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | 디자인 철학 핵심. Junior-Designer 워크플로, 5단계 브랜드 에셋 프로토콜, anti-AI-slop 체크리스트, 5차원 자기 검토, 그리고 방향 선택기 뒤의 "5가지 학파 × 20가지 디자인 철학" 라이브러리 — 모두 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)와 [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)에 녹아들었습니다. | +| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | 디자인 철학 핵심. Junior-Designer 워크플로, 5단계 브랜드 에셋 프로토콜, anti-AI-slop 체크리스트, 5차원 자기 검토, 그리고 방향 선택기 뒤의 "5가지 학파 × 20가지 디자인 철학" 라이브러리 — 모두 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts)와 [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)에 녹아들었습니다. | | [**`op7418/guizang-ppt-skill`**][guizang] | [`skills/guizang-ppt/`](skills/guizang-ppt/) 아래에 원본 그대로 번들된 Magazine-web-PPT skill, 원 LICENSE 보존. 덱 모드 기본. P0/P1/P2 체크리스트 문화는 다른 모든 skill에도 차용됩니다. | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + 어댑터 아키텍처. PATH 스캔 에이전트 감지, 단일 특권 프로세스로서의 로컬 daemon, 에이전트-동료 세계관. 모델을 채용했지만 코드는 vendor하지 않습니다. | | [**`OpenCoworkAI/open-codesign`**][ocod] | 최초의 오픈소스 Claude-Design 대안이자 가장 가까운 동류. 채택된 UX 패턴: 스트리밍 아티팩트 루프, 샌드박스 iframe 미리보기(React 18 + Babel 내장), 실시간 에이전트 패널(todos + tool calls + 중단 가능), 5가지 내보내기 형식(HTML/PDF/PPTX/ZIP/Markdown), 로컬 우선 designs 허브, `SKILL.md` 취향 주입. 로드맵의 UX 패턴: 코멘트 모드 수술적 편집, AI 제안 트윅 패널. **[`pi-ai`][piai]는 의도적으로 vendor하지 않습니다** — open-codesign은 이를 에이전트 런타임으로 번들링하지만 우리는 사용자가 이미 가진 CLI에 위임합니다. | diff --git a/README.md b/README.md index 09e67ba74..cb76704cc 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ That's not "AI tries to design something". That's an AI that has been trained, b OD stands on four open-source shoulders: -- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — the design-philosophy compass. Junior-Designer workflow, the 5-step brand-asset protocol, the anti-AI-slop checklist, the 5-dimensional self-critique, and the "5 schools × 20 design philosophies" idea behind our direction picker — all distilled into [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts). +- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — the design-philosophy compass. Junior-Designer workflow, the 5-step brand-asset protocol, the anti-AI-slop checklist, the 5-dimensional self-critique, and the "5 schools × 20 design philosophies" idea behind our direction picker — all distilled into [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts). - [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — the deck mode. Bundled verbatim under [`skills/guizang-ppt/`](skills/guizang-ppt/) with original LICENSE preserved; magazine-style layouts, WebGL hero, P0/P1/P2 checklists. - [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — the UX north star and our closest peer. The first open-source Claude-Design alternative. We borrow its streaming-artifact loop, its sandboxed-iframe preview pattern (vendored React 18 + Babel), its live agent panel (todos + tool calls + interruptible generation), and its five-format export list (HTML / PDF / PPTX / ZIP / Markdown). We deliberately diverge on form factor — they are a desktop Electron app bundling [`pi-ai`][piai]; we are a web app + local daemon that delegates to your existing CLI. - [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — the daemon-and-runtime architecture. PATH-scan agent detection, the local daemon as the only privileged process, the agent-as-teammate worldview. @@ -58,7 +58,7 @@ OD stands on four open-source shoulders: | **Design systems built-in** | **129** — 2 hand-authored starters + 70 product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) from [`awesome-design-md`][acd2], plus 57 design skills from [`awesome-design-skills`][ads] added directly under `design-systems/` | | **Skills built-in** | **31** — 27 in `prototype` mode (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 in `deck` mode (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Grouped in the picker by `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. | | **Media generation** | Image · video · audio surfaces ship alongside the design loop. **gpt-image-2** (Azure / OpenAI) for posters, avatars, infographics, illustrated maps · **Seedance 2.0** (ByteDance) for cinematic 15s text-to-video and image-to-video · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) for HTML→MP4 motion graphics (product reveals, kinetic typography, data charts, social overlays, logo outros). **93** ready-to-replicate prompts gallery — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — under [`prompt-templates/`](prompt-templates/), with preview thumbnails and source attribution. Same chat surface as code; outputs a real `.mp4` / `.png` chip into the project workspace. | -| **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — each ships a deterministic OKLch palette + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) | +| **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — each ships a deterministic OKLch palette + font stack ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) | | **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-accurate, shared across skills under [`assets/frames/`](assets/frames/) | | **Agent runtime** | Local daemon spawns the CLI in your project folder — agent gets real `Read`, `Write`, `Bash`, `WebFetch` against a real on-disk environment, with Windows `ENAMETOOLONG` fallbacks (stdin / prompt-file) on every adapter | | **Imports** | Drop a [Claude Design][cd] export ZIP onto the welcome dialog — `POST /api/import/claude-design` parses it into a real project so your agent can keep editing where Anthropic left off | @@ -221,7 +221,7 @@ Adding a skill takes one folder. Read [`docs/skills-protocol.md`](docs/skills-pr ### 1 · We don't ship an agent. Yours is good enough. -The daemon scans your `PATH` for [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), `devin`, [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), `qodercli`, [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi`, [`pi`](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent), [`kiro-cli`](https://kiro.dev), `kilo`, [`vibe-acp`](https://github.com/mistralai/mistral-vibe), and `deepseek` on startup. Whichever ones it finds become candidate design engines — driven over stdio with one adapter per CLI, swappable from the model picker. Inspired by [`multica`](https://github.com/multica-ai/multica) and [`cc-switch`](https://github.com/farion1231/cc-switch). No CLI installed? The API mode is the same pipeline minus the spawn — choose Anthropic, OpenAI-compatible, Azure OpenAI, or Google Gemini and the daemon forwards normalized SSE chunks back, with loopback / link-local / RFC1918 destinations rejected at the edge. +The daemon scans your `PATH` for [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), `devin`, [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), `qodercli`, [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi`, [`pi`](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent), [`kiro-cli`](https://kiro.dev), `kilo`, [`vibe-acp`](https://github.com/mistralai/mistral-vibe), and `deepseek` on startup. Whichever ones it finds become candidate design engines — driven over stdio with one adapter per CLI, swappable from the model picker. Inspired by [`multica`](https://github.com/multica-ai/multica) and [`cc-switch`](https://github.com/farion1231/cc-switch). No CLI installed? The API mode is the same pipeline minus the spawn — choose Anthropic, OpenAI-compatible, Azure OpenAI, or Google Gemini and the daemon forwards normalized SSE chunks back. Loopback is allowed for local LLM providers such as Ollama and LM Studio; non-loopback private, link-local, CGNAT, multicast, reserved, and redirect targets are rejected at the daemon edge. ### 2 · Skills are files, not plugins. @@ -255,7 +255,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq + (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) ``` -Every layer is composable. Every layer is a file you can edit. Read [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) and [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) to see the actual contract. +Every layer is composable. Every layer is a file you can edit. Read [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) and [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) to see the actual contract. ## Architecture @@ -293,7 +293,7 @@ Every layer is composable. Every layer is a file you can edit. Read [`apps/web/s | Frontend | Next.js 16 App Router + React 18 + TypeScript, Vercel-deployable | | Daemon | Node 24 · Express · SSE streaming · `better-sqlite3`; tables: `projects` · `conversations` · `messages` · `tabs` · `templates` | | Agent transport | `child_process.spawn`; typed-event parsers for `claude-stream-json` (Claude Code), `qoder-stream-json` (Qoder CLI), `copilot-stream-json` (Copilot), `json-event-stream` per-CLI parsers (Codex / Gemini / OpenCode / Cursor Agent), `acp-json-rpc` (Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe via Agent Client Protocol), `pi-rpc` (Pi via stdio JSON-RPC), `plain` (Qwen Code / DeepSeek TUI) | -| BYOK proxy | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → provider-specific upstream APIs, normalized `delta/end/error` SSE; rejects loopback / link-local / RFC1918 hosts at the daemon edge | +| BYOK proxy | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → provider-specific upstream APIs, normalized `delta/end/error` SSE; allows loopback local LLM providers, rejects non-loopback private/link-local/CGNAT/multicast/reserved hosts, and disables upstream redirects at the daemon edge | | Storage | Plain files in `.od/projects//` + SQLite at `.od/app.sqlite` + credentials at `.od/media-config.json` (gitignored, auto-created). `OD_DATA_DIR=` relocates all daemon data (used for test isolation and read-only-install setups); `OD_MEDIA_CONFIG_DIR=` further narrows the override to just `media-config.json` for setups that want to keep API keys outside the data dir | | Preview | Sandboxed iframe via `srcdoc` + per-skill `` parser ([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts)) | | Export | HTML (inline assets) · PDF (browser print, deck-aware) · PPTX (agent-driven via skill) · ZIP (archiver) · Markdown | @@ -706,7 +706,7 @@ When the user has no brand spec, the agent emits a second form with five curated | Brutalist | Raw, oversized type, no shadows, harsh accents | Bloomberg Businessweek · Achtung | | Soft warm | Generous, low contrast, peachy neutrals | Notion marketing · Apple Health | -Full spec → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). +Full spec → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). ## Media generation @@ -786,7 +786,7 @@ Pattern is the same as the rest: pick a template, edit the brief, send. The agen The chat / artifact loop gets the spotlight, but a handful of less-visible capabilities are already wired and worth knowing before you compare OD to anything else: - **Claude Design ZIP import.** Drop an export from claude.ai onto the welcome dialog. `POST /api/import/claude-design` extracts it into a real `.od/projects//`, opens the entry file as a tab, and stages a continue-where-Anthropic-left-off prompt for your local agent. No re-prompting, no "ask the model to re-create what we just had". ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`) -- **Multi-provider BYOK proxy.** `POST /api/proxy/{anthropic,openai,azure,google}/stream` takes `{ baseUrl, apiKey, model, messages }`, builds the provider-specific upstream request, normalizes SSE chunks into `delta/end/error`, and rejects loopback / link-local / RFC1918 destinations to head off SSRF. OpenAI-compatible covers OpenAI, Azure AI Foundry `/openai/v1`, DeepSeek, Groq, MiMo, OpenRouter, and self-hosted vLLM; Azure OpenAI adds deployment URL + `api-version`; Google uses Gemini `:streamGenerateContent`. +- **Multi-provider BYOK proxy.** `POST /api/proxy/{anthropic,openai,azure,google}/stream` takes `{ baseUrl, apiKey, model, messages }`, builds the provider-specific upstream request, normalizes SSE chunks into `delta/end/error`, and allows loopback local LLM providers while rejecting non-loopback private, link-local, CGNAT, multicast, reserved, and redirect targets to head off SSRF. OpenAI-compatible covers OpenAI, Azure AI Foundry `/openai/v1`, DeepSeek, Groq, MiMo, OpenRouter, Ollama, LM Studio, and self-hosted vLLM; Azure OpenAI adds deployment URL + `api-version`; Google uses Gemini `:streamGenerateContent`. - **User-saved templates.** Once you like a render, `POST /api/templates` snapshots the HTML + metadata into the SQLite `templates` table. The next project picks it from a "your templates" row in the picker — same surface as the shipped 31, but yours. - **Tab persistence.** Every project remembers its open files and active tab in the `tabs` table. Reopen the project tomorrow and the workspace looks exactly the way you left it. - **Artifact lint API.** `POST /api/artifacts/lint` runs structural checks on a generated artifact (broken `` framing, missing required side files, stale palette tokens) and returns findings the agent can read back into its next turn. The five-dim self-critique uses this to ground its score in real evidence, not vibes. @@ -796,7 +796,7 @@ The chat / artifact loop gets the spotlight, but a handful of less-visible capab ## Anti-AI-slop machinery -The whole machinery below is the [`huashu-design`](https://github.com/alchaincyf/huashu-design) playbook, ported into OD's prompt-stack and made enforceable per-skill via the side-file pre-flight. Read [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) for the live wording: +The whole machinery below is the [`huashu-design`](https://github.com/alchaincyf/huashu-design) playbook, ported into OD's prompt-stack and made enforceable per-skill via the side-file pre-flight. Read [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) for the live wording: - **Question form first.** Turn 1 is `` only — no thinking, no tools, no narration. The user chooses defaults at radio speed. - **Brand-spec extraction.** When the user attaches a screenshot or URL, the agent runs a five-step protocol (locate · download · grep hex · codify `brand-spec.md` · vocalise) before writing CSS. **Never guesses brand colors from memory.** @@ -860,7 +860,7 @@ Auto-detected from `PATH` on daemon boot. No config required. Streaming dispatch | [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` | | DeepSeek TUI | `deepseek` | `plain` (raw stdout chunks) | `deepseek exec --auto [--model …] ` (prompt as positional arg) | | [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) | `pi` | `pi-rpc` (stdio JSON-RPC) | `pi --mode rpc [--model …] [--thinking …]` (prompt sent as RPC `prompt` command) | -| **Multi-provider BYOK** | n/a | SSE normalization | `POST /api/proxy/{provider}/stream` → Anthropic / OpenAI-compatible / Azure OpenAI / Gemini; SSRF-guarded against loopback / link-local / RFC1918 | +| **Multi-provider BYOK** | n/a | SSE normalization | `POST /api/proxy/{provider}/stream` → Anthropic / OpenAI-compatible / Azure OpenAI / Gemini; SSRF-guarded with loopback local providers allowed, non-loopback internal ranges blocked, and upstream redirects disabled | Adding a new CLI is one entry in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts). Streaming format is one of `claude-stream-json`, `qoder-stream-json`, `copilot-stream-json`, `json-event-stream` (with a per-CLI `eventParser`), `acp-json-rpc`, `pi-rpc`, or `plain`. @@ -871,7 +871,7 @@ Every external project this repo borrows from. Each link goes to the source so y | Project | Role here | |---|---| | [`Claude Design`][cd] | The closed-source product this repo is the open-source alternative to. | -| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | The design-philosophy core. Junior-Designer workflow, the 5-step brand-asset protocol, anti-AI-slop checklist, 5-dimensional self-critique, and the "5 schools × 20 design philosophies" library behind our direction picker — all distilled into [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) and [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). | +| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | The design-philosophy core. Junior-Designer workflow, the 5-step brand-asset protocol, anti-AI-slop checklist, 5-dimensional self-critique, and the "5 schools × 20 design philosophies" library behind our direction picker — all distilled into [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) and [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). | | [**`op7418/guizang-ppt-skill`**][guizang] | Magazine-web-PPT skill bundled verbatim under [`skills/guizang-ppt/`](skills/guizang-ppt/) with original LICENSE preserved. Default for deck mode. P0/P1/P2 checklist culture borrowed for every other skill. | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | The daemon + adapter architecture. PATH-scan agent detection, local daemon as the only privileged process, agent-as-teammate worldview. We adopt the model; we do not vendor the code. | | [**`OpenCoworkAI/open-codesign`**][ocod] | The first open-source Claude-Design alternative and our closest peer. UX patterns adopted: streaming-artifact loop, sandboxed-iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible), five-format export list (HTML/PDF/PPTX/ZIP/Markdown), local-first storage hub, `SKILL.md` taste-injection, and the first pass of comment-mode preview annotations. UX patterns still on our roadmap: full surgical-edit reliability and AI-emitted tweaks panel. **We deliberately do not vendor [`pi-ai`][piai]** — open-codesign bundles it as the agent runtime; we delegate to whichever CLI the user already has. | diff --git a/README.pt-BR.md b/README.pt-BR.md index 1ebb89115..bfadcc5dd 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -43,7 +43,7 @@ Isso não é "IA tentando desenhar algo". É uma IA que foi treinada, pela pilha OD se apoia em quatro ombros open-source: -- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — a bússola da filosofia de design. Workflow Junior-Designer, protocolo de 5 passos para asset de marca, checklist anti-AI-slop, autocrítica em 5 dimensões e a ideia "5 escolas × 20 filosofias de design" por trás do nosso direction picker — tudo destilado em [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts). +- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — a bússola da filosofia de design. Workflow Junior-Designer, protocolo de 5 passos para asset de marca, checklist anti-AI-slop, autocrítica em 5 dimensões e a ideia "5 escolas × 20 filosofias de design" por trás do nosso direction picker — tudo destilado em [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts). - [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — o modo deck. Empacotado literalmente sob [`skills/guizang-ppt/`](skills/guizang-ppt/) com o LICENSE original preservado; layouts estilo revista, hero WebGL, checklists P0/P1/P2. - [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — a estrela-guia de UX e nosso peer mais próximo. A primeira alternativa open-source ao Claude Design. Pegamos o loop de streaming-artifact dele, o padrão de preview em iframe sandboxed (React 18 + Babel vendored), o painel de agente ao vivo (todos + tool calls + geração interruptível) e a lista de cinco formatos de export (HTML / PDF / PPTX / ZIP / Markdown). Divergimos de propósito no form factor — eles são um app desktop Electron com [`pi-ai`][piai] embutido; nós somos um web app + daemon local que delega ao seu CLI já existente. - [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — a arquitetura de daemon-and-runtime. Detecção de agente por scan de PATH, daemon local como único processo privilegiado, visão de mundo agente-como-time. @@ -57,7 +57,7 @@ OD se apoia em quatro ombros open-source: | **Design systems built-in** | **129** — 2 starters escritos à mão + 70 sistemas de produto (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) de [`awesome-design-md`][acd2], mais 57 design skills de [`awesome-design-skills`][ads] adicionados direto em `design-systems/` | | **Skills built-in** | **31** — 27 em modo `prototype` (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 em modo `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Agrupadas no picker por `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. | | **Geração de mídia** | Imagem · vídeo · áudio entregues lado a lado com o loop de design. **gpt-image-2** (Azure / OpenAI) para pôsteres, avatares, infográficos, mapas ilustrados · **Seedance 2.0** (ByteDance) para texto-para-vídeo cinematográfico de 15s e imagem-para-vídeo · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) para motion graphics HTML→MP4 (revelações de produto, kinetic typography, gráficos de dados, overlays sociais, logo outros). **93** prompts prontos para replicar — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — em [`prompt-templates/`](prompt-templates/), com thumbnails de preview e atribuição da fonte. Mesma superfície de chat do código; saída é um `.mp4` / `.png` real entrando no workspace do projeto. | -| **Direções visuais** | 5 escolas curadas (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — cada uma trazendo paleta OKLch determinística + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) | +| **Direções visuais** | 5 escolas curadas (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — cada uma trazendo paleta OKLch determinística + font stack ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) | | **Frames de dispositivo** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-accurate, compartilhados entre skills sob [`assets/frames/`](assets/frames/) | | **Runtime de agente** | Daemon local sobe o CLI dentro da pasta do seu projeto — agente recebe `Read`, `Write`, `Bash`, `WebFetch` reais contra um ambiente real em disco, com fallbacks de Windows `ENAMETOOLONG` (stdin / arquivo de prompt) em todos os adapters | | **Imports** | Solte um ZIP exportado do [Claude Design][cd] no welcome dialog — `POST /api/import/claude-design` parseia para um projeto real, então seu agente continua editando de onde a Anthropic parou | @@ -254,7 +254,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq + (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) ``` -Toda camada é compositável. Toda camada é um arquivo que dá pra editar. Leia [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) e [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) para ver o contrato real. +Toda camada é compositável. Toda camada é um arquivo que dá pra editar. Leia [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) e [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) para ver o contrato real. ## Arquitetura @@ -497,7 +497,7 @@ Quando o usuário não tem brand spec, o agente emite um segundo formulário com | Brutalist | Cru, tipografia gigante, sem sombra, acentos duros | Bloomberg Businessweek · Achtung | | Soft warm | Generoso, baixo contraste, neutros pessegos | Marketing da Notion · Apple Health | -Spec completa → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). +Spec completa → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). ## Geração de mídia @@ -587,7 +587,7 @@ O loop chat / artifact é o destaque, mas algumas capacidades menos visíveis j ## Maquinário anti-AI-slop -Toda a maquinaria abaixo é o playbook do [`huashu-design`](https://github.com/alchaincyf/huashu-design), portado para a pilha de prompt do OD e exigível por skill via o pre-flight de side files. Leia [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) para o texto vivo: +Toda a maquinaria abaixo é o playbook do [`huashu-design`](https://github.com/alchaincyf/huashu-design), portado para a pilha de prompt do OD e exigível por skill via o pre-flight de side files. Leia [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) para o texto vivo: - **Formulário de perguntas primeiro.** O turn 1 é só `` — sem pensar, sem tools, sem narração. O usuário escolhe defaults na velocidade de um radio. - **Extração de brand-spec.** Quando o usuário anexa um screenshot ou URL, o agente roda um protocolo de cinco passos (localizar · baixar · grep hex · codificar `brand-spec.md` · vocalizar) antes de escrever CSS. **Nunca chuta cores de marca de memória.** @@ -662,7 +662,7 @@ Todo projeto externo do qual este repo emprestou. Cada link aponta para a fonte | Projeto | Papel aqui | |---|---| | [`Claude Design`][cd] | O produto closed-source ao qual este repo é alternativa open-source. | -| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | O núcleo de filosofia de design. Workflow Junior-Designer, protocolo de 5 passos para asset de marca, checklist anti-AI-slop, autocrítica em 5 dimensões e a biblioteca "5 escolas × 20 filosofias de design" por trás do nosso direction picker — tudo destilado em [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) e [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). | +| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | O núcleo de filosofia de design. Workflow Junior-Designer, protocolo de 5 passos para asset de marca, checklist anti-AI-slop, autocrítica em 5 dimensões e a biblioteca "5 escolas × 20 filosofias de design" por trás do nosso direction picker — tudo destilado em [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) e [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). | | [**`op7418/guizang-ppt-skill`**][guizang] | Skill magazine-web-PPT bundled literalmente sob [`skills/guizang-ppt/`](skills/guizang-ppt/) com LICENSE original preservado. Default do deck mode. Cultura de checklist P0/P1/P2 emprestada para todas as outras skills. | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | A arquitetura de daemon + adapter. Detecção de agente por scan de PATH, daemon local como único processo privilegiado, visão de mundo agente-como-time. Adotamos o modelo; não vendoramos o código. | | [**`OpenCoworkAI/open-codesign`**][ocod] | A primeira alternativa open-source ao Claude Design e nosso peer mais próximo. Padrões UX adotados: loop streaming-artifact, preview em iframe sandboxed (React 18 + Babel vendored), painel de agente ao vivo (todos + tool calls + interruptível), lista de cinco formatos de export (HTML/PDF/PPTX/ZIP/Markdown), hub de storage local-first, injeção de gosto via `SKILL.md` e a primeira passada de anotações de preview em modo comentário. Padrões UX ainda no nosso roadmap: confiabilidade plena de edição cirúrgica e painel de tweaks emitido pela IA. **Deliberadamente não vendoramos [`pi-ai`][piai]** — o open-codesign embute como runtime de agente; nós delegamos para o CLI que o usuário já tem. | diff --git a/README.ru.md b/README.ru.md index b0977bfc1..6014d1ae0 100644 --- a/README.ru.md +++ b/README.ru.md @@ -43,7 +43,7 @@ Anthropic [Claude Design][cd] (выпущен 2026-04-17, на Opus 4.7) пок OD стоит на плечах четырёх open-source проектов: -- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — философский компас дизайна. Junior-Designer workflow, 5-step protocol для brand assets, anti-AI-slop checklist, 5-dimensional self-critique и идея «5 schools × 20 design philosophies» для выбора направления — всё это distilled в [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts). +- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — философский компас дизайна. Junior-Designer workflow, 5-step protocol для brand assets, anti-AI-slop checklist, 5-dimensional self-critique и идея «5 schools × 20 design philosophies» для выбора направления — всё это distilled в [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts). - [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — режим deck. Встроен без изменений в [`skills/guizang-ppt/`](skills/guizang-ppt/) с сохранением исходной LICENSE; журнальные раскладки, WebGL hero и P0/P1/P2 checklists. - [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX-северная звезда и наш ближайший peer. Мы заимствуем streaming-artifact loop, шаблон sandboxed iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible generation) и набор из пяти форматов экспорта (HTML / PDF / PPTX / ZIP / Markdown). Осознанное расхождение — в форм-факторе: они делают desktop Electron app с bundled [`pi-ai`][piai], мы — web app + local daemon, делегирующий работу вашему существующему CLI. - [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — архитектура демона и runtime. PATH-scan detection агентов, local daemon как единственный привилегированный процесс и мировоззрение agent-as-teammate. @@ -57,7 +57,7 @@ OD стоит на плечах четырёх open-source проектов: | **Design systems built-in** | **129** — 2 вручную написанных стартера + 70 продуктовых систем (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) из [`awesome-design-md`][acd2], плюс 57 design skills из [`awesome-design-skills`][ads], добавленных напрямую в `design-systems/` | | **Skills built-in** | **31** — 27 в режиме `prototype` (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 в режиме `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). В picker группируются по `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. | | **Media generation** | Режимы image · video · audio идут рядом с дизайн-циклом. **gpt-image-2** (Azure / OpenAI) — для постеров, аватаров, инфографики и иллюстрированных карт; **Seedance 2.0** (ByteDance) — для кинематографичных 15s text-to-video и image-to-video; **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) — для HTML→MP4 motion graphics (product reveals, kinetic typography, charts, social overlays, logo outros). Галерея из **93 готовых к воспроизведению промптов** — 43 для gpt-image-2, 39 для Seedance и 11 для HyperFrames — лежит в [`prompt-templates/`](prompt-templates/), с preview thumbnail и указанием источника. Тот же чатовый surface, что и для кода; на выходе в проектном workspace появляется реальный `.mp4` / `.png`. | -| **Visual directions** | 5 отобранных школ (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — каждая с детерминированной OKLch-палитрой и стеком шрифтов ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) | +| **Visual directions** | 5 отобранных школ (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — каждая с детерминированной OKLch-палитрой и стеком шрифтов ([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) | | **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-perfect, общие для навыков, хранятся в [`assets/frames/`](assets/frames/) | | **Agent runtime** | Local daemon запускает CLI в папке проекта — агент получает реальные `Read`, `Write`, `Bash`, `WebFetch` поверх реальной on-disk среды, с Windows fallback’ами для `ENAMETOOLONG` (stdin / prompt-file) на каждом адаптере | | **Imports** | Перетащите ZIP-экспорт из [Claude Design][cd] в welcome dialog — `POST /api/import/claude-design` превратит его в реальный проект, чтобы ваш агент продолжил редактирование там, где остановился Anthropic | @@ -254,7 +254,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq + (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) ``` -Каждый слой компонуем. Каждый слой — это файл, который можно редактировать. Откройте [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) и [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts), чтобы увидеть реальный контракт. +Каждый слой компонуем. Каждый слой — это файл, который можно редактировать. Откройте [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) и [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts), чтобы увидеть реальный контракт. ## Архитектура @@ -496,7 +496,7 @@ open-design/ | Brutalist | Сырой, с крупной типографикой, без теней, с жёсткими акцентами | Bloomberg Businessweek · Achtung | | Soft warm | Просторный, низкоконтрастный, в персиково-нейтральной гамме | Notion marketing · Apple Health | -Полная спецификация → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). +Полная спецификация → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). ## Генерация медиа @@ -586,7 +586,7 @@ OD не заканчивается на коде. Тот же чатовый sur ## Механика против AI-slop -Вся эта механика — прямое переложение методологии [`huashu-design`](https://github.com/alchaincyf/huashu-design) в prompt stack OD с enforce’ом через side-file pre-flight. Текущие формулировки можно посмотреть в [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts): +Вся эта механика — прямое переложение методологии [`huashu-design`](https://github.com/alchaincyf/huashu-design) в prompt stack OD с enforce’ом через side-file pre-flight. Текущие формулировки можно посмотреть в [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts): - **Сначала question form.** Ход 1 — только ``, без размышлений, без tools, без narration. Пользователь выбирает дефолты со скоростью radio-click. - **Извлечение brand spec.** Если пользователь прикладывает screenshot или URL, агент перед написанием CSS проходит пятишаговый протокол (locate · download · grep hex · codify `brand-spec.md` · vocalise). **Никогда не угадывает brand colors по памяти.** @@ -661,7 +661,7 @@ OD не заканчивается на коде. Тот же чатовый sur | Project | Роль в проекте | |---|---| | [`Claude Design`][cd] | Закрытый продукт, для которого этот репозиторий служит open-source альтернативой. | -| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Ядро дизайн-философии. Junior-Designer workflow, 5-step protocol для brand assets, anti-AI-slop checklist, пятимерная самокритика и библиотека «5 schools × 20 design philosophies» для direction picker’а — всё это distilled в [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) и [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). | +| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Ядро дизайн-философии. Junior-Designer workflow, 5-step protocol для brand assets, anti-AI-slop checklist, пятимерная самокритика и библиотека «5 schools × 20 design philosophies» для direction picker’а — всё это distilled в [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) и [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts). | | [**`op7418/guizang-ppt-skill`**][guizang] | Skill для magazine-web-PPT, встроенный без изменений в [`skills/guizang-ppt/`](skills/guizang-ppt/) с сохранением оригинальной LICENSE. Используется по умолчанию в режиме deck. Культура P0/P1/P2 checklist’ов позаимствована для всех остальных skills. | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Архитектура демона и адаптеров. PATH-scan detection агентов, local daemon как единственный привилегированный процесс, worldview agent-as-teammate. Мы переняли модель, а не vendored code. | | [**`OpenCoworkAI/open-codesign`**][ocod] | Первая open-source альтернатива Claude Design и наш ближайший peer. Заимствованные UX-паттерны: streaming-artifact loop, sandboxed iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible), список из пяти экспортных форматов (HTML/PDF/PPTX/ZIP/Markdown), local-first storage hub, `SKILL.md`-внедрение вкуса и первая версия preview-аннотаций для comment mode. Всё ещё в roadmap: полная надёжность surgical edits и tweaks panel, генерируемая ИИ. **Мы намеренно не вендорим [`pi-ai`][piai]** — open-codesign включает его как runtime агента, а мы делегируем исполнение тому CLI, который уже установлен у пользователя. | diff --git a/README.zh-CN.md b/README.zh-CN.md index 8568290fa..06e1c687f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -43,7 +43,7 @@ Anthropic 的 [Claude Design][cd](2026-04-17 发布,基于 Opus 4.7)让大 OD 站在四个开源项目的肩膀上: -- [**`alchaincyf/huashu-design`**(花叔的画术)](https://github.com/alchaincyf/huashu-design) —— 设计哲学的指南针。Junior-Designer 工作流、5 步品牌资产协议、anti-AI-slop checklist、五维自评审、以及方向选择器背后的「5 流派 × 20 种设计哲学」思路 —— 全部蒸馏进 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)。 +- [**`alchaincyf/huashu-design`**(花叔的画术)](https://github.com/alchaincyf/huashu-design) —— 设计哲学的指南针。Junior-Designer 工作流、5 步品牌资产协议、anti-AI-slop checklist、五维自评审、以及方向选择器背后的「5 流派 × 20 种设计哲学」思路 —— 全部蒸馏进 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts)。 - [**`op7418/guizang-ppt-skill`**(歸藏的杂志风 PPT skill)](https://github.com/op7418/guizang-ppt-skill) —— Deck 模式。原样捆绑在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留;杂志版式、WebGL hero、P0/P1/P2 checklist。 - [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) —— UX 北极星,也是我们最接近的同类。第一个开源的 Claude-Design 替代品。我们借鉴了它的流式 artifact 循环、沙盒 iframe 预览模式(自带 React 18 + Babel)、实时 agent 面板(todos + tool calls + 可中断生成)、5 种导出格式列表(HTML / PDF / PPTX / ZIP / Markdown)。我们刻意在形态上分流 —— 它是桌面 Electron 应用,把 [`pi-ai`][piai] 打包进去做 agent;我们是 Web 应用 + 本地 daemon,把 agent 运行时**委托**给你已经装好的 CLI。 - [**`multica-ai/multica`**](https://github.com/multica-ai/multica) —— Daemon 与运行时架构。PATH 扫描式 agent 检测,本地 daemon 作为唯一的特权进程,agent-as-teammate 的世界观。 @@ -57,7 +57,7 @@ OD 站在四个开源项目的肩膀上: | **内置 design system** | **72 套** —— 2 套手写起手 + 70 套从 [`awesome-design-md`][acd2] 导入的产品系统(Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma、小红书…) | | **内置 skill** | **31 个** —— 27 个 `prototype` 模式(web-prototype、saas-landing、dashboard、mobile-app、gamified-app、social-carousel、magazine-poster、dating-web、sprite-animation、motion-frames、critique、tweaks、wireframe-sketch、pm-spec、eng-runbook、finance-report、hr-onboarding、invoice、kanban-board、team-okrs…)+ 4 个 `deck` 模式(`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`)。Picker 按 `scenario` 分组:design / marketing / operation / engineering / product / finance / hr / sale / personal。 | | **媒体生成** | 图像 · 视频 · 音频三类 surface 与设计循环并行可用。**gpt-image-2**(Azure / OpenAI)做海报、头像、信息图、城市插画地图 · **Seedance 2.0**(字节跳动)做 15 秒电影感 t2v + i2v · **HyperFrames**([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes))做 HTML→MP4 动态图形(产品揭示、动力学排版、数据图表、社媒卡片、Logo 收尾)。**93 条**可一键复刻的 prompt gallery —— 43 条 gpt-image-2 + 39 条 Seedance + 11 条 HyperFrames,统一放在 [`prompt-templates/`](prompt-templates/) 下,附预览图与来源署名。Chat 入口和写代码同一处;输出真实的 `.mp4` / `.png` 落到项目工作区里。 | -| **视觉方向** | 5 套精选流派(Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental),每套自带 OKLch 色板 + 字体栈([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) | +| **视觉方向** | 5 套精选流派(Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental),每套自带 OKLch 色板 + 字体栈([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) | | **设备外壳** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome —— 像素级精确,跨 skill 共享,统一在 [`assets/frames/`](assets/frames/) | | **Agent 运行时** | 本地 daemon 在你的项目目录里 spawn CLI —— agent 拥有真实的 `Read` / `Write` / `Bash` / `WebFetch`,作用在真实磁盘上;每个 adapter 都有 Windows `ENAMETOOLONG` 兜底(stdin / 临时 prompt 文件) | | **导入** | 把 [Claude Design][cd] 导出的 ZIP 直接拖到欢迎弹窗 —— `POST /api/import/claude-design` 解压成真实项目,agent 接着 Anthropic 停下的地方继续编辑,不用再向模型重述上下文 | @@ -253,7 +253,7 @@ DISCOVERY 指令 (turn-1 表单、turn-2 品牌分支、TodoWrite、 + (deck kind 且无 skill 种子时) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) ``` -每一层都可组合。每一层都是一个你能改的文件。看 [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) 和 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 就知道真实契约长什么样。 +每一层都可组合。每一层都是一个你能改的文件。看 [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) 和 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) 就知道真实契约长什么样。 ## 技术架构 @@ -490,7 +490,7 @@ open-design/ | Brutalist | 粗粝、巨字、无阴影、刺眼强调 | Bloomberg Businessweek · Achtung | | Soft warm | 大方、低对比、桃色中性 | Notion 营销页 · Apple Health | -完整 spec → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。 +完整 spec → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)。 ## 媒体生成 @@ -580,7 +580,7 @@ Chat / artifact 循环最显眼,但这套仓库里还有几个能力被埋得 ## 反 AI Slop 机制 -下面整套机制都是 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 的 playbook,被移植进 OD 的提示词栈,并通过 skill 副文件 pre-flight 让每个 skill 都能落地执行。看 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 是真实文案: +下面整套机制都是 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 的 playbook,被移植进 OD 的提示词栈,并通过 skill 副文件 pre-flight 让每个 skill 都能落地执行。看 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) 是真实文案: - **先表单。** Turn 1 必须是 ``,**不准** thinking、不准 tools、不准旁白。用户用 radio 速度选默认。 - **品牌资产协议。** 用户贴截图或 URL 时,agent 走 5 步流程(定位 · 下载 · grep hex · 写 `brand-spec.md` · 复述)才能开始写 CSS。**绝不从记忆里猜品牌色**。 @@ -655,7 +655,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [ | 项目 | 在这里的角色 | |---|---| | [`Claude Design`][cd] | 本仓库为之提供开源替代的闭源产品。 | -| [**`alchaincyf/huashu-design`**(花叔的画术)](https://github.com/alchaincyf/huashu-design) | 设计哲学的核心。Junior-Designer 工作流、5 步品牌资产协议、anti-AI-slop checklist、五维自评审、以及方向选择器背后的「5 流派 × 20 种设计哲学」库 —— 全部蒸馏进 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 与 [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。 | +| [**`alchaincyf/huashu-design`**(花叔的画术)](https://github.com/alchaincyf/huashu-design) | 设计哲学的核心。Junior-Designer 工作流、5 步品牌资产协议、anti-AI-slop checklist、五维自评审、以及方向选择器背后的「5 流派 × 20 种设计哲学」库 —— 全部蒸馏进 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) 与 [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)。 | | [**`op7418/guizang-ppt-skill`**(歸藏)][guizang] | Magazine-web-PPT skill 原样捆绑在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留。Deck 模式默认。P0/P1/P2 checklist 文化也被借给了所有其他 skill。 | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + adapter 架构。PATH 扫描式 agent 检测、本地 daemon 作为唯一特权进程、agent-as-teammate 世界观。我们采纳模型,不 vendor 代码。 | | [**`OpenCoworkAI/open-codesign`**][ocod] | 第一个开源的 Claude-Design 替代品,也是我们最接近的同类。已采纳的 UX 模式:流式 artifact 循环、沙盒 iframe 预览(自带 React 18 + Babel)、实时 agent 面板(todos + tool calls + 可中断)、5 种导出格式列表(HTML/PDF/PPTX/ZIP/Markdown)、本地优先的 designs hub、`SKILL.md` 品味注入,以及评论模式预览标注的第一版。路线图上的 UX 模式:可靠的局部 patch 和 AI 自吐 tweaks 面板。**我们刻意不 vendor [`pi-ai`][piai]** —— open-codesign 把它打包成 agent 运行时;我们则委托给用户已经装好的 CLI。 | diff --git a/README.zh-TW.md b/README.zh-TW.md index e7c46ca3b..d86ce0970 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -42,7 +42,7 @@ Anthropic 的 [Claude Design][cd](2026-04-17 釋出,基於 Opus 4.7)讓大 OD 站在四個開源專案的肩膀上: -- [**`alchaincyf/huashu-design`**(花叔的畫術)](https://github.com/alchaincyf/huashu-design) —— 設計哲學的指南針。Junior-Designer 工作流、5 步品牌資產協議、anti-AI-slop checklist、五維自評審、以及方向選擇器背後的「5 流派 × 20 種設計哲學」思路 —— 全部蒸餾進 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)。 +- [**`alchaincyf/huashu-design`**(花叔的畫術)](https://github.com/alchaincyf/huashu-design) —— 設計哲學的指南針。Junior-Designer 工作流、5 步品牌資產協議、anti-AI-slop checklist、五維自評審、以及方向選擇器背後的「5 流派 × 20 種設計哲學」思路 —— 全部蒸餾進 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts)。 - [**`op7418/guizang-ppt-skill`**(歸藏的雜誌風 PPT skill)](https://github.com/op7418/guizang-ppt-skill) —— Deck 模式。原樣納入在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留;雜誌版式、WebGL hero、P0/P1/P2 checklist。 - [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) —— UX 北極星,也是我們最接近的同類。第一個開源的 Claude-Design 替代品。我們借鑑了它的流式 artifact 迴圈、沙盒 iframe 預覽模式(自帶 React 18 + Babel)、即時 agent 面板(todos + tool calls + 可中斷生成)、5 種匯出格式列表(HTML / PDF / PPTX / ZIP / Markdown)。我們刻意在形態上做出差異化 —— 它是桌面 Electron 應用,把 [`pi-ai`][piai] 打包進去做 agent;我們是 Web 應用 + 本地 daemon,把 agent 執行時**委託**給你已經裝好的 CLI。 - [**`multica-ai/multica`**](https://github.com/multica-ai/multica) —— Daemon 與執行時架構。PATH 掃描式 agent 檢測,本地 daemon 作為唯一的特權程序,agent-as-teammate 的世界觀。 @@ -55,7 +55,7 @@ OD 站在四個開源專案的肩膀上: | **BYOK 備援** | OpenAI 相容代理 `/api/proxy/stream` —— 填 `baseUrl` + `apiKey` + `model`,任意 vendor(Anthropic-via-OpenAI、DeepSeek、Groq、MiMo、OpenRouter、自託管 vLLM,或任何 OpenAI 相容的 provider)都能直接當引擎用。daemon 邊界拒絕 loopback / link-local / RFC1918 防 SSRF。 | | **內建 design system** | **72 套** —— 2 套手寫起手 + 70 套從 [`awesome-design-md`][acd2] 匯入的產品系統(Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma、小紅書…) | | **內建 skill** | **31 個** —— 27 個 `prototype` 模式(web-prototype、saas-landing、dashboard、mobile-app、gamified-app、social-carousel、magazine-poster、dating-web、sprite-animation、motion-frames、critique、tweaks、wireframe-sketch、pm-spec、eng-runbook、finance-report、hr-onboarding、invoice、kanban-board、team-okrs…)+ 4 個 `deck` 模式(`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`)。Picker 按 `scenario` 分組:design / marketing / operation / engineering / product / finance / hr / sale / personal。 | -| **視覺方向** | 5 套精選流派(Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental),每套自帶 OKLch 色票 + 字型堆疊([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) | +| **視覺方向** | 5 套精選流派(Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental),每套自帶 OKLch 色票 + 字型堆疊([`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)) | | **裝置外殼** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome —— 畫素級精確,跨 skill 共享,統一在 [`assets/frames/`](assets/frames/) | | **Agent 執行時** | 本地 daemon 在你的專案目錄裡 spawn CLI —— agent 擁有真實的 `Read` / `Write` / `Bash` / `WebFetch`,作用在真實磁碟上;每個 adapter 都有 Windows `ENAMETOOLONG` 備援(stdin / 臨時 prompt 檔案) | | **匯入** | 把 [Claude Design][cd] 匯出的 ZIP 直接拖到歡迎彈窗 —— `POST /api/import/claude-design` 解壓成真實專案,agent 接著 Anthropic 停下的地方繼續編輯,不用再向模型重述上下文 | @@ -251,7 +251,7 @@ DISCOVERY 指令 (turn-1 表單、turn-2 品牌分支、TodoWrite、 + (deck kind 且無 skill 種子時) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) ``` -每一層都可組合。每一層都是一個你能改的檔案。看 [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) 和 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 就知道真實契約長什麼樣。 +每一層都可組合。每一層都是一個你能改的檔案。看 [`packages/contracts/src/prompts/system.ts`](packages/contracts/src/prompts/system.ts) 和 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) 就知道真實契約長什麼樣。 ## 技術架構 @@ -557,7 +557,7 @@ open-design/ | Brutalist | 粗糲、巨字、無陰影、刺眼強調 | Bloomberg Businessweek · Achtung | | Soft warm | 大方、低對比、桃色中性 | Notion 營銷頁 · Apple Health | -完整 spec → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。 +完整 spec → [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)。 ## 媒體生成 @@ -647,7 +647,7 @@ Chat / artifact 迴圈最顯眼,但這套倉庫裡還有幾個能力被埋得 ## 反 AI Slop 機制 -下面整套機制都是 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 的 playbook,被移植進 OD 的提示詞堆疊,並透過 skill 副檔案 pre-flight 讓每個 skill 都能實作執行。看 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 是真實文案: +下面整套機制都是 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 的 playbook,被移植進 OD 的提示詞堆疊,並透過 skill 副檔案 pre-flight 讓每個 skill 都能實作執行。看 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) 是真實文案: - **先表單。** Turn 1 必須是 ``,**不準** thinking、不準 tools、不準旁白。使用者用 radio 速度選預設。 - **品牌資產協議。** 使用者貼截圖或 URL 時,agent 走 5 步流程(定位 · 下載 · grep hex · 寫 `brand-spec.md` · 複述)才能開始寫 CSS。**絕不從記憶裡猜品牌色**。 @@ -722,7 +722,7 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [ | 專案 | 在這裡的角色 | |---|---| | [`Claude Design`][cd] | 本倉庫為之提供開源替代的閉源產品。 | -| [**`alchaincyf/huashu-design`**(花叔的畫術)](https://github.com/alchaincyf/huashu-design) | 設計哲學的核心。Junior-Designer 工作流、5 步品牌資產協議、anti-AI-slop checklist、五維自評審、以及方向選擇器背後的「5 流派 × 20 種設計哲學」庫 —— 全部蒸餾進 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 與 [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。 | +| [**`alchaincyf/huashu-design`**(花叔的畫術)](https://github.com/alchaincyf/huashu-design) | 設計哲學的核心。Junior-Designer 工作流、5 步品牌資產協議、anti-AI-slop checklist、五維自評審、以及方向選擇器背後的「5 流派 × 20 種設計哲學」庫 —— 全部蒸餾進 [`packages/contracts/src/prompts/discovery.ts`](packages/contracts/src/prompts/discovery.ts) 與 [`packages/contracts/src/prompts/directions.ts`](packages/contracts/src/prompts/directions.ts)。 | | [**`op7418/guizang-ppt-skill`**(歸藏)][guizang] | Magazine-web-PPT skill 原樣納入在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留。Deck 模式預設。P0/P1/P2 checklist 文化也被借給了所有其他 skill。 | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + adapter 架構。PATH 掃描式 agent 檢測、本地 daemon 作為唯一特權程序、agent-as-teammate 世界觀。我們採納模型,不 vendor 程式碼。 | | [**`OpenCoworkAI/open-codesign`**][ocod] | 第一個開源的 Claude-Design 替代品,也是我們最接近的同類。已採納的 UX 模式:流式 artifact 迴圈、沙盒 iframe 預覽(自帶 React 18 + Babel)、即時 agent 面板(todos + tool calls + 可中斷)、5 種匯出格式列表(HTML/PDF/PPTX/ZIP/Markdown)、本地優先的 designs hub、`SKILL.md` 品味注入。路線圖上的 UX 模式:評論模式手術刀編輯、AI 自吐 tweaks 面板。**我們刻意不 vendor [`pi-ai`][piai]** —— open-codesign 把它打包成 agent 執行時;我們則委託給使用者已經裝好的 CLI。 | diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 43c291d56..6fff6a97b 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -29,7 +29,7 @@ "dev": "pnpm run build && node dist/cli.js --no-open", "start": "pnpm run build && node dist/cli.js", "test": "vitest run -c vitest.config.ts", - "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit" + "typecheck": "pnpm --filter @open-design/contracts build && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", @@ -42,13 +42,13 @@ "chokidar": "^5.0.0", "express": "^4.19.2", "jszip": "^3.10.1", - "multer": "^1.4.5-lts.1", + "multer": "^2.1.1", "undici": "^7.16.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^4.17.21", - "@types/multer": "^1.4.12", + "@types/multer": "^2.1.0", "@types/node": "^20.17.10", "typescript": "^5.6.3", "vitest": "^2.1.8" diff --git a/apps/daemon/src/acp.ts b/apps/daemon/src/acp.ts index 3a80d7c12..53fbebea4 100644 --- a/apps/daemon/src/acp.ts +++ b/apps/daemon/src/acp.ts @@ -1,12 +1,66 @@ -// @ts-nocheck -import { spawn } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; +import type { Writable } from 'node:stream'; import path from 'node:path'; const ACP_PROTOCOL_VERSION = 1; const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_STAGE_TIMEOUT_MS = 180_000; -export function buildAcpSessionNewParams(cwd, { mcpServers } = {}) { +type JsonRpcId = string | number; +type JsonObject = Record; +type RpcWritable = Pick; +type AcpChildProcess = ChildProcess; +type TimerHandle = ReturnType; + +export interface AcpMcpServerInput { + type?: unknown; + name?: unknown; + command?: unknown; + args?: unknown; + env?: unknown; +} + +interface AcpSessionOptions { + mcpServers?: AcpMcpServerInput[]; +} + +export interface ModelOption { + id: string; + label: string; +} + +interface DetectAcpModelsOptions { + bin: string; + args: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + clientName?: string; + clientVersion?: string; + defaultModelOption?: ModelOption; +} + +interface AttachAcpSessionOptions { + child: AcpChildProcess; + prompt: string; + cwd?: string; + model?: string | null; + mcpServers?: AcpMcpServerInput[]; + send: (event: string, payload: unknown) => void; + clientName?: string; + clientVersion?: string; + stageTimeoutMs?: number; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function asObject(value: unknown): JsonObject | null { + return value && typeof value === 'object' ? value as JsonObject : null; +} + +export function buildAcpSessionNewParams(cwd: string, { mcpServers }: AcpSessionOptions = {}) { const servers = Array.isArray(mcpServers) ? mcpServers : []; return { cwd: path.resolve(cwd), @@ -25,49 +79,60 @@ export function buildAcpSessionNewParams(cwd, { mcpServers } = {}) { }; } -function sendRpc(writable, id, method, params) { +function sendRpc(writable: RpcWritable, id: JsonRpcId, method: string, params: unknown): void { writable.write( `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, ); } -function sendRpcResult(writable, id, result) { +function sendRpcResult(writable: RpcWritable, id: JsonRpcId, result: unknown): void { writable.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); } -function isJsonRpcId(value) { +function isJsonRpcId(value: unknown): value is JsonRpcId { return typeof value === 'number' || typeof value === 'string'; } -function rpcErrorMessage(raw) { - if (!raw || typeof raw !== 'object' || !raw.error || typeof raw.error !== 'object') { +function rpcErrorMessage(raw: unknown): string { + const obj = asObject(raw); + const error = asObject(obj?.error); + if (!obj || !error) { return ''; } const message = - typeof raw.error.message === 'string' - ? raw.error.message - : typeof raw.error.code === 'number' - ? String(raw.error.code) + typeof error.message === 'string' + ? error.message + : typeof error.code === 'number' + ? String(error.code) : 'json-rpc error'; - return typeof raw.id === 'number' - ? `json-rpc id ${raw.id}: ${message}` + return typeof obj.id === 'number' + ? `json-rpc id ${obj.id}: ${message}` : message; } -function formatUsage(usage) { - if (!usage || typeof usage !== 'object') return null; - const out = {}; - if (typeof usage.inputTokens === 'number') out.input_tokens = usage.inputTokens; - if (typeof usage.outputTokens === 'number') out.output_tokens = usage.outputTokens; - if (typeof usage.cachedReadTokens === 'number') { - out.cached_read_tokens = usage.cachedReadTokens; +interface FormattedUsage { + input_tokens?: number; + output_tokens?: number; + cached_read_tokens?: number; + thought_tokens?: number; + total_tokens?: number; +} + +function formatUsage(usage: unknown): FormattedUsage | null { + const src = asObject(usage); + if (!src) return null; + const out: FormattedUsage = {}; + if (typeof src.inputTokens === 'number') out.input_tokens = src.inputTokens; + if (typeof src.outputTokens === 'number') out.output_tokens = src.outputTokens; + if (typeof src.cachedReadTokens === 'number') { + out.cached_read_tokens = src.cachedReadTokens; } - if (typeof usage.thoughtTokens === 'number') out.thought_tokens = usage.thoughtTokens; - if (typeof usage.totalTokens === 'number') out.total_tokens = usage.totalTokens; + if (typeof src.thoughtTokens === 'number') out.thought_tokens = src.thoughtTokens; + if (typeof src.totalTokens === 'number') out.total_tokens = src.totalTokens; return Object.keys(out).length > 0 ? out : null; } -function choosePermissionOutcome(options) { +function choosePermissionOutcome(options: unknown): string | null { const list = Array.isArray(options) ? options : []; const approveForSession = list.find((option) => option?.optionId === 'approve_for_session'); if (approveForSession) return 'approve_for_session'; @@ -78,10 +143,11 @@ function choosePermissionOutcome(options) { return null; } -function normalizeModels(models, defaultModelOption) { - const available = Array.isArray(models?.availableModels) ? models.availableModels : []; +function normalizeModels(models: unknown, defaultModelOption: ModelOption): ModelOption[] { + const modelsObj = asObject(models); + const available = Array.isArray(modelsObj?.availableModels) ? modelsObj.availableModels : []; const currentModelId = - typeof models?.currentModelId === 'string' ? models.currentModelId : null; + typeof modelsObj?.currentModelId === 'string' ? modelsObj.currentModelId : null; const seen = new Set([defaultModelOption.id]); const out = [defaultModelOption]; for (const model of available) { @@ -96,10 +162,10 @@ function normalizeModels(models, defaultModelOption) { return out; } -export function createJsonLineStream(onMessage) { +export function createJsonLineStream(onMessage: (message: unknown, rawLine: string) => void) { let buffer = ''; return { - feed(chunk) { + feed(chunk: string) { buffer += chunk; const lines = buffer.split('\n'); buffer = lines.pop() || ''; @@ -135,8 +201,8 @@ export async function detectAcpModels({ clientName = 'open-design-detect', clientVersion = 'runtime-adapter', defaultModelOption = { id: 'default', label: 'Default (CLI config)' }, -}) { - return await new Promise((resolve, reject) => { +}: DetectAcpModelsOptions): Promise { + return await new Promise((resolve, reject) => { const child = spawn(bin, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], @@ -150,7 +216,8 @@ export async function detectAcpModels({ let expectedId = 1; let nextId = 2; - const finish = (fn, value) => { + let timer: TimerHandle; + const finish = (fn: (value: T) => void, value: T) => { if (settled) return; settled = true; clearTimeout(timer); @@ -160,16 +227,16 @@ export async function detectAcpModels({ fn(value); }; - const fail = (message) => { + const fail = (message: string) => { finish(reject, new Error(message)); if (!child.killed) child.kill('SIGTERM'); }; - const writeRpc = (id, method, params) => { + const writeRpc = (id: JsonRpcId, method: string, params: unknown) => { try { sendRpc(child.stdin, id, method, params); } catch (err) { - fail(`stdin write failed: ${err.message}`); + fail(`stdin write failed: ${errorMessage(err)}`); } }; @@ -180,23 +247,26 @@ export async function detectAcpModels({ }; const parser = createJsonLineStream((raw) => { + const obj = asObject(raw); + const error = asObject(obj?.error); + const result = asObject(obj?.result); const rpcErr = rpcErrorMessage(raw); if (rpcErr) { // JSON-RPC -32603 "Internal error" during model detection: // If this is for the current expected-id (initialize/session/new), // it's a real probe failure — reject immediately. // Otherwise it's cleanup noise — suppress it. - if (raw.error?.code === -32603 && raw.id !== expectedId) return; + if (error?.code === -32603 && obj?.id !== expectedId) return; fail(rpcErr); return; } - if (raw.id !== expectedId || !raw.result || typeof raw.result !== 'object') return; + if (obj?.id !== expectedId || !result) return; if (expectedId === 1) { sendSessionNew(); return; } if (expectedId === 2) { - const models = normalizeModels(raw.result.models, defaultModelOption); + const models = normalizeModels(result.models, defaultModelOption); finish(resolve, models); if (!child.killed) child.kill('SIGTERM'); } @@ -218,7 +288,7 @@ export async function detectAcpModels({ } }); - const timer = setTimeout(() => { + timer = setTimeout(() => { fail(`ACP model detection timed out after ${timeoutMs}ms`); }, timeoutMs); @@ -240,34 +310,40 @@ export function attachAcpSession({ clientName = 'open-design', clientVersion = 'runtime-adapter', stageTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS, -}) { +}: AttachAcpSessionOptions) { const runStartedAt = Date.now(); const effectiveCwd = path.resolve(cwd || process.cwd()); + if (!child.stdin || !child.stdout) { + throw new Error('ACP child process must expose stdin and stdout streams'); + } + const stdin = child.stdin; + const stdout = child.stdout; let expectedId = 1; let nextId = 2; - let promptRequestId = null; - let setModelRequestId = null; - let sessionId = null; - let activeModel = null; + let promptRequestId: JsonRpcId | null = null; + let setModelRequestId: JsonRpcId | null = null; + let sessionId: string | null = null; + let activeModel: string | null = null; let emittedThinkingStart = false; let emittedFirstTokenStatus = false; let finished = false; let fatal = false; - let stageTimer = null; + let aborted = false; + let stageTimer: TimerHandle | null = null; - const resetStageTimer = (label) => { - clearTimeout(stageTimer); + const resetStageTimer = (label: string) => { + if (stageTimer) clearTimeout(stageTimer); stageTimer = setTimeout(() => { fail(`ACP ${label} timed out after ${stageTimeoutMs}ms`); }, stageTimeoutMs); }; const clearStageTimer = () => { - clearTimeout(stageTimer); + if (stageTimer) clearTimeout(stageTimer); stageTimer = null; }; - const fail = (message) => { + const fail = (message: string) => { if (finished) return; finished = true; fatal = true; @@ -276,12 +352,12 @@ export function attachAcpSession({ if (!child.killed) child.kill('SIGTERM'); }; - const writeRpc = (id, method, params, timeoutLabel) => { + const writeRpc = (id: JsonRpcId, method: string, params: unknown, timeoutLabel: string) => { resetStageTimer(timeoutLabel); try { - sendRpc(child.stdin, id, method, params); + sendRpc(stdin, id, method, params); } catch (err) { - fail(`stdin write failed: ${err.message}`); + fail(`stdin write failed: ${errorMessage(err)}`); } }; @@ -300,25 +376,32 @@ export function attachAcpSession({ nextId += 1; }; - const replyPermission = (raw) => { - const optionId = choosePermissionOutcome(raw.params?.options); + const replyPermission = (raw: JsonObject) => { + const params = asObject(raw.params); + const optionId = choosePermissionOutcome(params?.options); if (!optionId || !isJsonRpcId(raw.id)) { fail(`unhandled ACP permission request: ${JSON.stringify(raw)}`); return; } resetStageTimer('session/request_permission'); try { - sendRpcResult(child.stdin, raw.id, { + sendRpcResult(stdin, raw.id, { outcome: { outcome: 'selected', optionId }, }); } catch (err) { - fail(`stdin write failed: ${err.message}`); + fail(`stdin write failed: ${errorMessage(err)}`); } }; const parser = createJsonLineStream((raw, rawLine) => { + if (aborted) return; resetStageTimer('response'); - const rpcErr = rpcErrorMessage(raw); + const obj = asObject(raw); + if (!obj) return; + const error = asObject(obj.error); + const params = asObject(obj.params); + const result = asObject(obj.result); + const rpcErr = rpcErrorMessage(obj); if (rpcErr) { // After response completion, any late-arriving errors from the agent // (pipe-broken, cleanup race conditions, etc.) are safe to ignore. @@ -332,33 +415,33 @@ export function attachAcpSession({ // suppress when they match setModelRequestId so the recovery block handles // them. Any other -32602 (unexpected-id or non-set_model expected-id) is // a genuine protocol error — call fail(). - if (raw.error?.code === -32603 && raw.id !== expectedId) { + if (error?.code === -32603 && obj.id !== expectedId) { return; } - if (raw.error?.code === -32602 && raw.id !== setModelRequestId) { + if (error?.code === -32602 && obj.id !== setModelRequestId) { fail(rpcErr); return; } - if (raw.error?.code === -32603 && raw.id === expectedId) { - if (raw.id === setModelRequestId) { + if (error?.code === -32603 && obj.id === expectedId) { + if (obj.id === setModelRequestId) { // Fall through — the recovery block will handle this } else { fail(rpcErr); return; } } - if (raw.error?.code === -32602 && raw.id === setModelRequestId) { + if (error?.code === -32602 && obj.id === setModelRequestId) { // Fall through — the recovery block will handle this } } - if (raw.method === 'session/request_permission') { - replyPermission(raw); + if (obj.method === 'session/request_permission') { + replyPermission(obj); return; } - if (raw.method === 'session/update' && raw.params?.update) { - const update = raw.params.update; + const update = asObject(params?.update); + if (obj.method === 'session/update' && update) { if (update.sessionUpdate === 'agent_thought_chunk') { - const text = update.content?.text; + const text = asObject(update.content)?.text; if (typeof text === 'string' && text.length > 0) { if (!emittedThinkingStart) { emittedThinkingStart = true; @@ -369,7 +452,7 @@ export function attachAcpSession({ return; } if (update.sessionUpdate === 'agent_message_chunk') { - const text = update.content?.text; + const text = asObject(update.content)?.text; if (typeof text === 'string' && text.length > 0) { if (!emittedFirstTokenStatus) { emittedFirstTokenStatus = true; @@ -392,8 +475,8 @@ export function attachAcpSession({ // This is scoped to the exact set_model request id to avoid // triggering on prompt or other request failures. if ( - (raw.error?.code === -32603 || raw.error?.code === -32602) && - raw.id === setModelRequestId && + (error?.code === -32603 || error?.code === -32602) && + obj.id === setModelRequestId && promptRequestId === null ) { setModelRequestId = null; @@ -402,7 +485,7 @@ export function attachAcpSession({ sendPrompt(); return; } - if (raw.id !== expectedId || !raw.result || typeof raw.result !== 'object') { + if (obj.id !== expectedId || !result) { return; } if (expectedId === 1) { @@ -410,17 +493,21 @@ export function attachAcpSession({ writeRpc( nextId, 'session/new', - buildAcpSessionNewParams(effectiveCwd, { mcpServers }), + buildAcpSessionNewParams( + effectiveCwd, + mcpServers ? { mcpServers } : {}, + ), 'session/new', ); nextId += 1; return; } if (expectedId === 2) { - sessionId = typeof raw.result.sessionId === 'string' ? raw.result.sessionId : null; + sessionId = typeof result.sessionId === 'string' ? result.sessionId : null; + const models = asObject(result.models); activeModel = - typeof raw.result.models?.currentModelId === 'string' - ? raw.result.models.currentModelId + typeof models?.currentModelId === 'string' + ? models.currentModelId : null; if (sessionId && activeModel) { send('agent', { type: 'status', label: 'model', model: activeModel }); @@ -447,8 +534,8 @@ export function attachAcpSession({ sendPrompt(); return; } - if (promptRequestId !== null && raw.id === promptRequestId) { - const usage = formatUsage(raw.result.usage); + if (promptRequestId !== null && obj.id === promptRequestId) { + const usage = formatUsage(result.usage); if (usage) { send('agent', { type: 'usage', @@ -458,23 +545,23 @@ export function attachAcpSession({ } finished = true; clearStageTimer(); - child.stdin.end(); + stdin.end(); return; } - if (sessionId && model && model !== 'default' && raw.id === expectedId) { + if (sessionId && model && model !== 'default' && obj.id === expectedId) { activeModel = model; send('agent', { type: 'status', label: 'model', model: activeModel }); sendPrompt(); } }); - child.stdout.on('data', (chunk) => parser.feed(chunk)); + stdout.on('data', (chunk: string) => parser.feed(chunk)); child.on('close', () => { clearStageTimer(); parser.flush(); }); - child.on('error', (err) => fail(err.message)); - child.stdin.on('error', (err) => fail(`stdin error: ${err.message}`)); + child.on('error', (err: Error) => fail(err.message)); + stdin.on('error', (err: Error) => fail(`stdin error: ${err.message}`)); writeRpc(1, 'initialize', { protocolVersion: ACP_PROTOCOL_VERSION, @@ -486,5 +573,18 @@ export function attachAcpSession({ hasFatalError() { return fatal; }, + abort() { + if (aborted || finished) return; + aborted = true; + finished = true; + clearStageTimer(); + if (!sessionId || !child.stdin || child.stdin.destroyed || child.stdin.writableEnded) return; + try { + sendRpc(child.stdin, nextId, 'session/cancel', { sessionId }); + nextId += 1; + } catch { + // The caller owns process-signal fallback if the ACP transport is gone. + } + }, }; } diff --git a/apps/daemon/src/artifact-manifest.ts b/apps/daemon/src/artifact-manifest.ts index e765183ac..d1570cd17 100644 --- a/apps/daemon/src/artifact-manifest.ts +++ b/apps/daemon/src/artifact-manifest.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import path from 'node:path'; const MANIFEST_VERSION = 1; @@ -10,7 +9,13 @@ const MAX_SUPPORTING_FILE_LENGTH = 260; const MAX_SUPPORTING_FILES = 128; const MAX_METADATA_BYTES = 16 * 1024; -const ALLOWED_KINDS = new Set([ +type JsonRecord = Record; + +type ValidationResult = + | { ok: true; value: JsonRecord | null } + | { ok: false; error: string }; + +const ALLOWED_KINDS = new Set([ 'html', 'deck', 'react-component', @@ -22,7 +27,7 @@ const ALLOWED_KINDS = new Set([ 'design-system', ]); -const ALLOWED_RENDERERS = new Set([ +const ALLOWED_RENDERERS = new Set([ 'html', 'deck-html', 'react-component', @@ -34,23 +39,28 @@ const ALLOWED_RENDERERS = new Set([ 'design-system', ]); -const ALLOWED_EXPORTS = new Set(['html', 'pdf', 'zip', 'pptx', 'jsx', 'md', 'svg', 'txt']); -const ALLOWED_STATUS = new Set(['streaming', 'complete', 'error']); +const ALLOWED_EXPORTS = new Set(['html', 'pdf', 'zip', 'pptx', 'jsx', 'md', 'svg', 'txt']); +const ALLOWED_STATUS = new Set(['streaming', 'complete', 'error']); -function isPlainObject(value) { +function isPlainObject(value: unknown): value is JsonRecord { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; } -function validateBoundedString(value, field, maxLen, { allowEmpty = false } = {}) { +function validateBoundedString( + value: unknown, + field: string, + maxLen: number, + { allowEmpty = false }: { allowEmpty?: boolean } = {}, +): string | null { if (typeof value !== 'string') return `${field} must be a string`; if (!allowEmpty && value.length === 0) return `${field} is required`; if (value.length > maxLen) return `${field} exceeds max length (${maxLen})`; return null; } -function validateSupportingPath(value) { +function validateSupportingPath(value: unknown): string | null { if (typeof value !== 'string') return 'supportingFiles entries must be strings'; if (value.length === 0) return 'supportingFiles entries cannot be empty'; if (value.length > MAX_SUPPORTING_FILE_LENGTH) { @@ -69,7 +79,7 @@ function validateSupportingPath(value) { return null; } -export function validateArtifactManifestInput(manifest, entry) { +export function validateArtifactManifestInput(manifest: unknown, entry: unknown): ValidationResult { if (manifest == null) return { ok: true, value: null }; if (!isPlainObject(manifest)) { return { ok: false, error: 'artifactManifest must be an object' }; @@ -77,12 +87,18 @@ export function validateArtifactManifestInput(manifest, entry) { const kindErr = validateBoundedString(manifest.kind, 'artifactManifest.kind', 64); if (kindErr) return { ok: false, error: kindErr }; + if (typeof manifest.kind !== 'string') { + return { ok: false, error: 'artifactManifest.kind must be a string' }; + } if (!ALLOWED_KINDS.has(manifest.kind)) { return { ok: false, error: 'artifactManifest.kind is not allowed' }; } const rendererErr = validateBoundedString(manifest.renderer, 'artifactManifest.renderer', 64); if (rendererErr) return { ok: false, error: rendererErr }; + if (typeof manifest.renderer !== 'string') { + return { ok: false, error: 'artifactManifest.renderer must be a string' }; + } if (!ALLOWED_RENDERERS.has(manifest.renderer)) { return { ok: false, error: 'artifactManifest.renderer is not allowed' }; } @@ -178,7 +194,7 @@ export function validateArtifactManifestInput(manifest, entry) { return { ok: true, value: sanitizeManifest(manifest, safeEntry) }; } -export function sanitizeManifest(manifest, entry) { +export function sanitizeManifest(manifest: JsonRecord, entry: string): JsonRecord { const now = new Date().toISOString(); return { version: MANIFEST_VERSION, @@ -186,10 +202,10 @@ export function sanitizeManifest(manifest, entry) { title: manifest.title || entry, entry, renderer: manifest.renderer, - status: ALLOWED_STATUS.has(manifest.status) ? manifest.status : 'complete', + status: typeof manifest.status === 'string' && ALLOWED_STATUS.has(manifest.status) ? manifest.status : 'complete', exports: manifest.exports, supportingFiles: Array.isArray(manifest.supportingFiles) - ? manifest.supportingFiles.map((x) => x.replace(/\\/g, '/')) + ? manifest.supportingFiles.map((x) => String(x).replace(/\\/g, '/')) : undefined, createdAt: typeof manifest.createdAt === 'string' ? manifest.createdAt : now, updatedAt: now, @@ -199,7 +215,7 @@ export function sanitizeManifest(manifest, entry) { }; } -export function parsePersistedManifest(raw, fallbackEntry) { +export function parsePersistedManifest(raw: string, fallbackEntry: string): JsonRecord | null { try { const parsed = JSON.parse(raw); if (!parsed || parsed.version !== MANIFEST_VERSION) return null; @@ -211,7 +227,7 @@ export function parsePersistedManifest(raw, fallbackEntry) { } } -export function inferLegacyManifest(entry) { +export function inferLegacyManifest(entry: string): JsonRecord | null { const lower = entry.toLowerCase(); const ext = path.extname(lower); // NOTE: This duplicate heuristic must stay in sync with diff --git a/apps/daemon/src/claude-design-import.ts b/apps/daemon/src/claude-design-import.ts index c453751b0..b0831e71a 100644 --- a/apps/daemon/src/claude-design-import.ts +++ b/apps/daemon/src/claude-design-import.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { inflateRawSync } from 'node:zlib'; @@ -12,10 +11,21 @@ const MAX_FILES = 5000; const MAX_TOTAL_BYTES = 100 * 1024 * 1024; const MAX_FILE_BYTES = 25 * 1024 * 1024; -export async function importClaudeDesignZip(zipPath, projectDir) { +type ZipEntry = { + name: string; + method: number; + compressedSize: number; + uncompressedSize: number; + localOffset: number; + isDirectory: boolean; +}; + +type ImportedFile = { path: string; body: Buffer }; + +export async function importClaudeDesignZip(zipPath: string, projectDir: string) { const zip = await readFile(zipPath); const entries = readCentralDirectory(zip); - const files = []; + const files: ImportedFile[] = []; let totalBytes = 0; for (const entry of entries) { @@ -47,8 +57,8 @@ export async function importClaudeDesignZip(zipPath, projectDir) { const entryFile = chooseEntryFile(files.map((f) => f.path)); if (!entryFile) throw new Error('zip does not contain an HTML file'); - const dirCreates = new Map(); - const ensureDir = (dir) => { + const dirCreates = new Map>(); + const ensureDir = (dir: string) => { let pending = dirCreates.get(dir); if (!pending) { pending = mkdir(dir, { recursive: true }); @@ -70,7 +80,7 @@ export async function importClaudeDesignZip(zipPath, projectDir) { }; } -function readCentralDirectory(zip) { +function readCentralDirectory(zip: Buffer): ZipEntry[] { const eocdOffset = findEndOfCentralDirectory(zip); const entryCount = zip.readUInt16LE(eocdOffset + 10); const centralSize = zip.readUInt32LE(eocdOffset + 12); @@ -79,7 +89,7 @@ function readCentralDirectory(zip) { throw new Error('invalid zip central directory'); } - const entries = []; + const entries: ZipEntry[] = []; let offset = centralOffset; for (let i = 0; i < entryCount; i += 1) { if (zip.readUInt32LE(offset) !== CENTRAL_SIG) { @@ -111,7 +121,7 @@ function readCentralDirectory(zip) { return entries; } -function findEndOfCentralDirectory(zip) { +function findEndOfCentralDirectory(zip: Buffer): number { const min = Math.max(0, zip.length - 0xffff - 22); for (let i = zip.length - 22; i >= min; i -= 1) { if (zip.readUInt32LE(i) === EOCD_SIG) return i; @@ -119,7 +129,7 @@ function findEndOfCentralDirectory(zip) { throw new Error('invalid zip: missing central directory'); } -function readEntryBody(zip, entry) { +function readEntryBody(zip: Buffer, entry: ZipEntry): Buffer { const offset = entry.localOffset; if (zip.readUInt32LE(offset) !== LOCAL_SIG) { throw new Error(`invalid zip local header: ${entry.name}`); @@ -143,7 +153,7 @@ function readEntryBody(zip, entry) { return inflateRawSync(compressed, { maxOutputLength: cap }); } -function sanitizeZipPath(name) { +function sanitizeZipPath(name: string): string { if (name.includes('\0')) throw new Error('invalid zip file name'); if (/^[A-Za-z]:/.test(name) || name.startsWith('/')) { throw new Error('absolute zip paths are not allowed'); @@ -151,7 +161,7 @@ function sanitizeZipPath(name) { return validateProjectPath(name); } -function chooseEntryFile(paths) { +function chooseEntryFile(paths: string[]): string | null { const html = paths.filter((p) => /\.html?$/i.test(p)); if (html.length === 0) return null; const lower = new Map(html.map((p) => [p.toLowerCase(), p])); @@ -163,7 +173,7 @@ function chooseEntryFile(paths) { ); } -function safeJoin(root, relPath) { +function safeJoin(root: string, relPath: string): string { const target = path.resolve(root, relPath); if (!target.startsWith(root + path.sep) && target !== root) { throw new Error('path escapes project dir'); diff --git a/apps/daemon/src/claude-stream.ts b/apps/daemon/src/claude-stream.ts index 94e8d8752..d9f32d14e 100644 --- a/apps/daemon/src/claude-stream.ts +++ b/apps/daemon/src/claude-stream.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Parses Claude Code's `--output-format stream-json --verbose` JSONL stream * (with or without `--include-partial-messages`) into a small set of @@ -20,27 +19,35 @@ * `tool_use` event when that block stops. */ -export function createClaudeStreamHandler(onEvent) { +type StreamEvent = Record; +type EventSink = (event: StreamEvent) => void; +type BlockState = { type?: unknown; name?: unknown; id?: unknown; input: string }; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +export function createClaudeStreamHandler(onEvent: EventSink) { let buffer = ''; // Per-content-block scratch, keyed by `${messageId}:${blockIndex}`. - const blocks = new Map(); + const blocks = new Map(); // Most recent assistant message id so content_block_* events without an id // can be attributed correctly. - let currentMessageId = null; + let currentMessageId: string | null = null; // Message ids that already streamed text via `stream_event` deltas. // When `--include-partial-messages` is OFF (older Claude Code, e.g. 1.0.84 // pre-flag), no deltas arrive — only the final `assistant` wrapper carries // text. The fallback below emits that text once, but we must skip it for // newer builds that already streamed deltas, otherwise the message would // duplicate. - const textStreamed = new Set(); + const textStreamed = new Set(); - function blockKey(index) { + function blockKey(index: unknown): string { return `${currentMessageId ?? 'anon'}:${index}`; } - function feed(chunk) { + function feed(chunk: string) { buffer += chunk; let nl; while ((nl = buffer.indexOf('\n')) !== -1) { @@ -69,8 +76,8 @@ export function createClaudeStreamHandler(onEvent) { } } - function handleObject(obj) { - if (!obj || typeof obj !== 'object') return; + function handleObject(obj: unknown) { + if (!isRecord(obj)) return; if (obj.type === 'system' && obj.subtype === 'init') { onEvent({ @@ -87,7 +94,7 @@ export function createClaudeStreamHandler(onEvent) { return; } - if (obj.type === 'stream_event' && obj.event) { + if (obj.type === 'stream_event' && isRecord(obj.event)) { handleStreamEvent(obj.event); return; } @@ -98,11 +105,12 @@ export function createClaudeStreamHandler(onEvent) { // the text as a single delta — but only if no streaming deltas already // covered it (older Claude Code without --include-partial-messages // delivers text only here; newer builds stream it and would duplicate). - if (obj.type === 'assistant' && obj.message?.content) { - currentMessageId = obj.message.id ?? currentMessageId; - const msgId = obj.message.id ?? null; + if (obj.type === 'assistant' && isRecord(obj.message) && Array.isArray(obj.message.content)) { + currentMessageId = typeof obj.message.id === 'string' ? obj.message.id : currentMessageId; + const msgId = typeof obj.message.id === 'string' ? obj.message.id : null; const alreadyStreamed = msgId ? textStreamed.has(msgId) : false; for (const block of obj.message.content) { + if (!isRecord(block)) continue; if (block.type === 'tool_use') { onEvent({ type: 'tool_use', @@ -131,8 +139,9 @@ export function createClaudeStreamHandler(onEvent) { // `user` messages in a stream-json transcript are usually tool_result // wrappers from prior turns. - if (obj.type === 'user' && obj.message?.content) { + if (obj.type === 'user' && isRecord(obj.message) && Array.isArray(obj.message.content)) { for (const block of obj.message.content) { + if (!isRecord(block)) continue; if (block.type === 'tool_result') { onEvent({ type: 'tool_result', @@ -157,16 +166,16 @@ export function createClaudeStreamHandler(onEvent) { } } - function handleStreamEvent(ev) { + function handleStreamEvent(ev: Record) { if (ev.type === 'message_start') { - currentMessageId = ev.message?.id ?? null; + currentMessageId = isRecord(ev.message) && typeof ev.message.id === 'string' ? ev.message.id : null; if (typeof ev.ttft_ms === 'number') { onEvent({ type: 'status', label: 'streaming', ttftMs: ev.ttft_ms }); } return; } - if (ev.type === 'content_block_start' && ev.content_block) { + if (ev.type === 'content_block_start' && isRecord(ev.content_block)) { const key = blockKey(ev.index); const block = ev.content_block; blocks.set(key, { type: block.type, name: block.name, id: block.id, input: '' }); @@ -176,7 +185,7 @@ export function createClaudeStreamHandler(onEvent) { return; } - if (ev.type === 'content_block_delta' && ev.delta) { + if (ev.type === 'content_block_delta' && isRecord(ev.delta)) { const state = blocks.get(blockKey(ev.index)); const delta = ev.delta; @@ -207,11 +216,11 @@ export function createClaudeStreamHandler(onEvent) { return { feed, flush }; } -function stringifyToolResult(content) { +function stringifyToolResult(content: unknown): string { if (typeof content === 'string') return content; if (Array.isArray(content)) { return content - .map((c) => (c?.type === 'text' ? c.text : JSON.stringify(c))) + .map((c) => (isRecord(c) && c.type === 'text' ? String(c.text) : JSON.stringify(c))) .join('\n'); } return JSON.stringify(content); diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index 8dcac7844..72f30f492 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -134,7 +134,43 @@ for (let i = 0; i < argv.length; i++) { } } -startServer({ port, host }).then(url => { +startServer({ port, host, returnServer: true }).then((started) => { + const { url, server, shutdown } = started; + const closeTimeoutMs = 5_000; + const closeServer = () => new Promise((resolve) => { + let resolved = false; + const resolveOnce = () => { + if (resolved) return; + resolved = true; + resolve(); + }; + const idleTimer = setTimeout(() => { + server.closeIdleConnections?.(); + }, Math.min(1_000, closeTimeoutMs)); + const hardTimer = setTimeout(() => { + server.closeAllConnections?.(); + resolveOnce(); + }, closeTimeoutMs); + idleTimer.unref?.(); + hardTimer.unref?.(); + server.close(() => resolveOnce()); + }).finally(() => { + server.closeIdleConnections?.(); + }); + let shuttingDown = false; + const stop = () => { + if (shuttingDown) { + process.exit(0); + } + shuttingDown = true; + const closePromise = closeServer(); + const shutdownPromise = Promise.resolve().then(() => shutdown?.()); + void Promise.resolve() + .then(() => Promise.allSettled([shutdownPromise, closePromise])) + .finally(() => process.exit(0)); + }; + process.on('SIGINT', stop); + process.on('SIGTERM', stop); console.log(`[od] listening on ${url}`); if (open) { const opener = process.platform === 'darwin' ? 'open' diff --git a/apps/daemon/src/connectionTest.ts b/apps/daemon/src/connectionTest.ts index bc798442f..129439c40 100644 --- a/apps/daemon/src/connectionTest.ts +++ b/apps/daemon/src/connectionTest.ts @@ -13,10 +13,8 @@ // discover that the API key, model, base URL, or CLI is broken. // // The streaming counterpart for chat lives in `server.ts` under the -// `/api/proxy/*/stream` routes; this module deliberately duplicates the -// small URL/redaction helpers rather than restructure those routes (the -// chat path is the hot path and keeping changes here local protects it -// from accidental regressions). +// `/api/proxy/*/stream` routes; both paths share the base URL policy from +// contracts so Settings and daemon-side checks reject the same hosts. import { spawn } from 'node:child_process'; import { promises as fsp } from 'node:fs'; @@ -34,14 +32,19 @@ import { createClaudeStreamHandler } from './claude-stream.js'; import { createCopilotStreamHandler } from './copilot-stream.js'; import { createJsonEventStreamHandler } from './json-event-stream.js'; import { agentCliEnvForAgent, validateAgentCliEnv } from './app-config.js'; -import type { - AgentTestRequest, - ConnectionTestKind, - ConnectionTestProtocol, - ConnectionTestResponse, - ProviderTestRequest, +import { + isLoopbackApiHost, + validateBaseUrl, + type AgentTestRequest, + type ConnectionTestKind, + type ConnectionTestProtocol, + type ConnectionTestResponse, + type ParsedBaseUrl, + type ProviderTestRequest, } from '@open-design/contracts/api/connectionTest'; +export { validateBaseUrl } from '@open-design/contracts/api/connectionTest'; + // Aggressive but not punitive — happy paths usually return in under 2 s. const PROVIDER_TIMEOUT_MS = 12_000; // CLI boot time is dominated by adapter auth/session restore; the heavy @@ -86,105 +89,6 @@ export function redactSecrets( type ProviderConnectionInput = ProviderTestRequest & { signal?: AbortSignal }; type AgentConnectionInput = AgentTestRequest & { signal?: AbortSignal }; -function normalizeBracketedIpv6(hostname: string): string { - return hostname.startsWith('[') && hostname.endsWith(']') - ? hostname.slice(1, -1).toLowerCase() - : hostname.toLowerCase(); -} - -function parseIpv4(hostname: string): [number, number, number, number] | null { - const parts = hostname.split('.'); - if (parts.length !== 4) return null; - const parsed = parts.map((part) => { - if (!/^\d{1,3}$/.test(part)) return null; - const value = Number(part); - return value >= 0 && value <= 255 ? value : null; - }); - if (parsed.some((part) => part === null)) return null; - return parsed as [number, number, number, number]; -} - -function isLoopbackIpv4(hostname: string): boolean { - const parts = parseIpv4(hostname); - return Boolean(parts && parts[0] === 127); -} - -function isPrivateIpv4(hostname: string): boolean { - const parts = parseIpv4(hostname); - if (!parts) return false; - const [a, b] = parts; - return ( - (a === 169 && b === 254) || - a === 10 || - (a === 192 && b === 168) || - (a === 172 && b >= 16 && b <= 31) - ); -} - -function ipv4MappedToDotted(hostname: string): string | null { - const host = normalizeBracketedIpv6(hostname); - const mapped = /^::ffff:(.+)$/i.exec(host)?.[1]; - if (!mapped) return null; - if (parseIpv4(mapped.toLowerCase())) return mapped.toLowerCase(); - const hexParts = mapped.split(':'); - if ( - hexParts.length !== 2 || - !hexParts.every((part) => /^[0-9a-f]{1,4}$/i.test(part)) - ) { - return null; - } - const hi = hexParts[0]; - const lo = hexParts[1]; - if (!hi || !lo) return null; - const value = - (Number.parseInt(hi, 16) << 16) | - Number.parseInt(lo, 16); - return [ - (value >>> 24) & 255, - (value >>> 16) & 255, - (value >>> 8) & 255, - value & 255, - ].join('.'); -} - -function isLoopbackHost(hostname: string): boolean { - const host = normalizeBracketedIpv6(hostname); - if (host === 'localhost' || host === '::1') return true; - if (isLoopbackIpv4(host)) return true; - const mapped = ipv4MappedToDotted(host); - return Boolean(mapped && isLoopbackIpv4(mapped)); -} - -function isBlockedInternalHost(hostname: string): boolean { - const host = normalizeBracketedIpv6(hostname); - if (isPrivateIpv4(host)) return true; - if (/^f[cd][0-9a-f]{2}:/i.test(host)) return true; - if (/^fe[89ab][0-9a-f]:/i.test(host)) return true; - const mapped = ipv4MappedToDotted(host); - return Boolean(mapped && isPrivateIpv4(mapped)); -} - -export function validateBaseUrl(baseUrl: string): { - parsed?: URL; - error?: string; - forbidden?: boolean; -} { - let parsed: URL; - try { - parsed = new URL(String(baseUrl).replace(/\/+$/, '')); - } catch { - return { error: 'Invalid baseUrl' }; - } - if (!['http:', 'https:'].includes(parsed.protocol)) { - return { error: 'Only http/https allowed' }; - } - const hostname = parsed.hostname.toLowerCase(); - if (!isLoopbackHost(hostname) && isBlockedInternalHost(hostname)) { - return { error: 'Internal IPs blocked', forbidden: true }; - } - return { parsed }; -} - function appendVersionedApiPath(baseUrl: string, suffix: string): string { const url = new URL(baseUrl); const pathname = url.pathname.replace(/\/+$/, ''); @@ -336,11 +240,11 @@ function networkErrorToKind(err: unknown): ConnectionTestKind { async function validateLocalOpenAiModel( input: ProviderTestRequest, - parsed: URL, + parsed: ParsedBaseUrl, signal: AbortSignal, start: number, ): Promise { - if (input.protocol !== 'openai' || !isLoopbackHost(parsed.hostname)) { + if (input.protocol !== 'openai' || !isLoopbackApiHost(parsed.hostname)) { return null; } @@ -351,6 +255,7 @@ async function validateLocalOpenAiModel( method: 'GET', headers: { authorization: `Bearer ${String(input.apiKey)}` }, signal, + redirect: 'error', }); } catch { // Local OpenAI-compatible servers vary; if model listing is unavailable, @@ -560,6 +465,7 @@ export async function testProviderConnection( headers: call.headers, body: JSON.stringify(call.body), signal: controller.signal, + redirect: 'error', }); const latencyMs = Date.now() - start; if (response.ok) { @@ -588,7 +494,7 @@ export async function testProviderConnection( input.protocol, data, model, - isLoopbackHost(validated.parsed.hostname), + isLoopbackApiHost(validated.parsed.hostname), ); if (completion.kind) { const detail = redactSecrets(completion.detail ?? '', [input.apiKey]); @@ -865,7 +771,6 @@ function attachAgentStreamHandlers( model: model ?? null, send, imagePaths: [], - uploadRoot: undefined, }); } else if (def.streamFormat === 'acp-json-rpc') { acpSession = attachAcpSession({ diff --git a/apps/daemon/src/connectors/catalog.ts b/apps/daemon/src/connectors/catalog.ts index 93acc29b9..3ce4b48ed 100644 --- a/apps/daemon/src/connectors/catalog.ts +++ b/apps/daemon/src/connectors/catalog.ts @@ -43,6 +43,9 @@ export interface ConnectorDetail { status: ConnectorStatus; accountLabel?: string; tools: ConnectorToolDetail[]; + toolCount?: number; + toolsNextCursor?: string; + toolsHasMore?: boolean; featuredToolNames?: string[]; minimumApproval?: ConnectorToolApproval; lastError?: string; @@ -63,6 +66,11 @@ export interface ConnectorCatalogDefinition { tools: ConnectorCatalogToolDefinition[]; /** The complete allowlist of callable tool names for this connector. */ allowedToolNames: string[]; + /** Display-only count of provider tools. This may be known before tool schemas are hydrated. */ + toolCount?: number; + /** Preview pagination state for hydrated tool definitions. Execution code must not rely on partial pages. */ + toolsNextCursor?: string; + toolsHasMore?: boolean; /** How the connector is made available. `none` and `local` connectors require no user OAuth state. */ authentication?: 'local' | 'none' | 'oauth' | 'composio'; /** Provider toolkit slug used by external connector providers such as Composio. */ @@ -173,6 +181,9 @@ export function connectorDefinitionToDetail(definition: ConnectorCatalogDefiniti ...(definition.description === undefined ? {} : { description: definition.description }), status: definition.disabled ? 'disabled' : 'available', tools: definition.tools.map((tool) => toolDefinitionToDetail(tool)), + ...(definition.toolCount === undefined ? {} : { toolCount: definition.toolCount }), + ...(definition.toolsNextCursor === undefined ? {} : { toolsNextCursor: definition.toolsNextCursor }), + ...(definition.toolsHasMore === undefined ? {} : { toolsHasMore: definition.toolsHasMore }), ...(definition.featuredToolNames === undefined ? {} : { featuredToolNames: [...definition.featuredToolNames] }), diff --git a/apps/daemon/src/connectors/composio-config.ts b/apps/daemon/src/connectors/composio-config.ts index 8d08800a0..58a79f11a 100644 --- a/apps/daemon/src/connectors/composio-config.ts +++ b/apps/daemon/src/connectors/composio-config.ts @@ -3,6 +3,7 @@ import path from 'node:path'; export interface ComposioConfig { apiKey: string; + authConfigIds: Record; } export interface PublicComposioConfig { @@ -35,14 +36,42 @@ export function writeComposioConfig(input: unknown): PublicComposioConfig { ? input as Record : {}; const hasApiKey = Object.prototype.hasOwnProperty.call(record, 'apiKey'); + const hasAuthConfigIds = Object.prototype.hasOwnProperty.call(record, 'authConfigIds'); const apiKeyInput = normalizeOptionalString(record.apiKey) ?? ''; + const nextApiKey = hasApiKey ? apiKeyInput : prior.apiKey; + const apiKeyChanged = prior.apiKey !== nextApiKey; const next = normalizeComposioConfig({ - apiKey: hasApiKey ? apiKeyInput : prior.apiKey, + apiKey: nextApiKey, + authConfigIds: apiKeyChanged ? {} : hasAuthConfigIds ? record.authConfigIds : prior.authConfigIds, }); writeRawConfig(next); return readPublicComposioConfig(); } +export function setComposioAuthConfigId(connectorId: string, authConfigId: string): void { + const normalizedConnectorId = normalizeOptionalString(connectorId); + const normalizedAuthConfigId = normalizeOptionalString(authConfigId); + if (!normalizedConnectorId || !normalizedAuthConfigId) return; + const prior = readComposioConfig(); + writeRawConfig(normalizeComposioConfig({ + apiKey: prior.apiKey, + authConfigIds: { + ...prior.authConfigIds, + [normalizedConnectorId]: normalizedAuthConfigId, + }, + })); +} + +export function deleteComposioAuthConfigId(connectorId: string): void { + const normalizedConnectorId = normalizeOptionalString(connectorId); + if (!normalizedConnectorId) return; + const prior = readComposioConfig(); + if (prior.authConfigIds[normalizedConnectorId] === undefined) return; + const authConfigIds = { ...prior.authConfigIds }; + delete authConfigIds[normalizedConnectorId]; + writeRawConfig(normalizeComposioConfig({ apiKey: prior.apiKey, authConfigIds })); +} + function readRawConfig(): unknown { try { return JSON.parse(fs.readFileSync(configFilePath, 'utf8')) as unknown; @@ -66,9 +95,21 @@ function normalizeComposioConfig(value: unknown): ComposioConfig { : {}; return { apiKey: normalizeOptionalString(raw.apiKey) ?? '', + authConfigIds: normalizeAuthConfigIds(raw.authConfigIds), }; } function normalizeOptionalString(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; } + +function normalizeAuthConfigIds(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const next: Record = {}; + for (const [connectorId, authConfigId] of Object.entries(value as Record)) { + const normalizedConnectorId = normalizeOptionalString(connectorId); + const normalizedAuthConfigId = normalizeOptionalString(authConfigId); + if (normalizedConnectorId && normalizedAuthConfigId) next[normalizedConnectorId] = normalizedAuthConfigId; + } + return next; +} diff --git a/apps/daemon/src/connectors/composio-descriptions.ts b/apps/daemon/src/connectors/composio-descriptions.ts index 6e7a1a3fc..4e1c585a6 100644 --- a/apps/daemon/src/connectors/composio-descriptions.ts +++ b/apps/daemon/src/connectors/composio-descriptions.ts @@ -15,6 +15,8 @@ export interface ComposioToolkitMetadata { description: string; /** Preferred category tag for the connector card. */ category: string; + /** Snapshot count for first paint before live toolkit metadata loads. */ + toolCount?: number; } export const COMPOSIO_TOOLKIT_METADATA: Record = { @@ -23,6 +25,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record description: 'Browse repositories, read issues and pull requests, inspect commits, and search code across GitHub.', category: 'Developer', + toolCount: 2, }, GITLAB: { description: @@ -102,6 +105,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record APIFY_MCP: { description: 'Run Apify actors to scrape, crawl, and enrich data for live artifacts.', category: 'Automation', + toolCount: 8, }, TAVILY_MCP: { description: 'Run Tavily web search and extraction for research-grounded artifacts.', @@ -121,6 +125,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record description: 'Search Notion pages and databases, read page content, and pull structured records into artifacts.', category: 'Productivity', + toolCount: 48, }, GOOGLEDOCS: { description: 'Read Google Docs content and comments to source text for live artifacts.', @@ -143,6 +148,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record GOOGLEDRIVE: { description: 'Search and read files and folders stored in Google Drive.', category: 'Storage', + toolCount: 2, }, DROPBOX: { description: 'Search and read files stored in Dropbox for document-grounded artifacts.', @@ -522,6 +528,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record TWITTER: { description: 'Read Twitter/X timelines, tweets, users, and searches.', category: 'Social', + toolCount: 72, }, FACEBOOK: { description: 'Read Facebook pages, posts, and insights.', @@ -602,6 +609,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record CLOCKIFY: { description: 'Read Clockify time entries, projects, and reports.', category: 'Time tracking', + toolCount: 75, }, HARVEST: { description: 'Read Harvest time entries, projects, and invoices.', @@ -658,6 +666,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record AIRTABLE: { description: 'Query Airtable bases, tables, and records for structured data artifacts.', category: 'Database', + toolCount: 25, }, CONTENTFUL: { description: 'Read Contentful content types, entries, and assets.', @@ -696,6 +705,7 @@ export const COMPOSIO_TOOLKIT_METADATA: Record CANVAS: { description: 'Read Canvas LMS courses, assignments, and submissions.', category: 'Education', + toolCount: 574, }, D2LBRIGHTSPACE: { description: 'Read D2L Brightspace courses, enrollments, and gradebooks.', diff --git a/apps/daemon/src/connectors/composio.ts b/apps/daemon/src/connectors/composio.ts index f9b9b2546..c24342bf7 100644 --- a/apps/daemon/src/connectors/composio.ts +++ b/apps/daemon/src/connectors/composio.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import type { BoundedJsonObject, BoundedJsonValue } from '../live-artifacts/schema.js'; import { defineConnectorTool, type ConnectorCatalogDefinition, type ConnectorCatalogToolDefinition } from './catalog.js'; -import { readComposioConfig } from './composio-config.js'; +import { deleteComposioAuthConfigId, readComposioConfig, setComposioAuthConfigId } from './composio-config.js'; import { COMPOSIO_CURATION_OVERLAY } from './composio-curation.js'; import { getComposioToolkitMetadata } from './composio-descriptions.js'; import { ConnectorServiceError, type ConnectorCredentialMaterial } from './service.js'; @@ -14,6 +14,7 @@ const DEFAULT_COMPOSIO_TIMEOUT_MS = 30_000; const DEFAULT_COMPOSIO_USER_ID = 'open-design-local-user'; const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; const DISCOVERY_CACHE_TTL_MS = 60_000; +const CUSTOM_AUTH_REQUIRED_MESSAGE = 'Composio does not have managed credentials for this toolkit.'; const PERSISTED_CATALOG_REFRESH_MS = 24 * 60 * 60 * 1000; interface ComposioToolkitCatalogEntry { @@ -63,6 +64,7 @@ const FEATURED_COMPOSIO_CATALOG: ConnectorCatalogDefinition[] = [ allowedToolNames: ['github.github_search_repositories', 'github.github_get_issue'], featuredToolNames: ['github.github_search_repositories', 'github.github_get_issue'], minimumApproval: 'auto', + toolCount: 2, }, { id: 'notion', @@ -95,6 +97,7 @@ const FEATURED_COMPOSIO_CATALOG: ConnectorCatalogDefinition[] = [ allowedToolNames: ['notion.notion_search', 'notion.notion_fetch_database'], featuredToolNames: ['notion.notion_search', 'notion.notion_fetch_database'], minimumApproval: 'auto', + toolCount: 48, }, { id: 'google_drive', @@ -127,6 +130,7 @@ const FEATURED_COMPOSIO_CATALOG: ConnectorCatalogDefinition[] = [ allowedToolNames: ['google_drive.googledrive_search', 'google_drive.googledrive_get_file'], featuredToolNames: ['google_drive.googledrive_search', 'google_drive.googledrive_get_file'], minimumApproval: 'auto', + toolCount: 2, }, ]; @@ -377,6 +381,12 @@ interface ComposioToolResponse { toolkit?: { slug?: unknown }; } +interface ComposioToolsPage { + items: ComposioToolResponse[]; + nextCursor?: string; + totalItems?: number; +} + interface ComposioToolExecuteResponse { data?: unknown; error?: unknown; @@ -409,13 +419,24 @@ export interface ComposioConnectionCompletion { credentials: ConnectorCredentialMaterial; } +interface ComposioAuthConfigResolution { + authConfigId: string; + fromCache: boolean; +} + +export type ComposioAuthConfigPrepareResult = + | { status: 'ready'; authConfigId: string } + | { status: 'custom_required'; message: string } + | { status: 'error'; message: string }; + export class ComposioConnectorProvider { private discoveredAuthConfigIds: Record | undefined; private readonly locallyCreatedAuthConfigs = new Map(); - private definitionsCache: { definitions: ConnectorCatalogDefinition[]; expiresAtMs: number } | undefined; - private definitionsPromise: Promise | undefined; + private readonly definitionsCache = new Map(); + private readonly definitionsPromises = new Map>(); private definitionsGeneration = 0; - private readonly authConfigCreationPromises = new Map>(); + private readonly authConfigCreationPromises = new Map>(); + private readonly unsupportedManagedAuthConfigs = new Map(); private readonly pendingConnections = new Map(); private persistedDefinitions: ConnectorCatalogDefinition[] | undefined; private persistedFetchedAt: string | undefined; @@ -423,7 +444,7 @@ export class ComposioConnectorProvider { private refreshTimeout: NodeJS.Timeout | undefined; isConfigured(definition: ConnectorCatalogDefinition): boolean { - return Boolean(this.getApiKey() && this.discoveredAuthConfigIds?.[definition.id]); + return Boolean(this.getApiKey() && (this.getPersistedAuthConfigId(definition.id) || this.discoveredAuthConfigIds?.[definition.id])); } clearDiscoveryCache(): void { @@ -431,6 +452,7 @@ export class ComposioConnectorProvider { this.locallyCreatedAuthConfigs.clear(); this.invalidateDefinitionsCache(); this.authConfigCreationPromises.clear(); + this.unsupportedManagedAuthConfigs.clear(); } configureCatalogCache(dataDir: string): void { @@ -480,34 +502,37 @@ export class ComposioConnectorProvider { private invalidateDefinitionsCache(): void { this.definitionsGeneration += 1; - this.definitionsCache = undefined; - this.definitionsPromise = undefined; + this.definitionsCache.clear(); + this.definitionsPromises.clear(); } - async listDefinitions(signal?: AbortSignal): Promise { + async listDefinitions(signal?: AbortSignal, options: { hydrateTools?: boolean } = {}): Promise { + const cacheKey = options.hydrateTools ? 'hydrated' : 'metadata'; const now = Date.now(); - if (this.definitionsCache && this.definitionsCache.expiresAtMs > now) { - return this.definitionsCache.definitions; + const cached = this.definitionsCache.get(cacheKey); + if (cached && cached.expiresAtMs > now) { + return cached.definitions; } - if (this.definitionsPromise) return this.definitionsPromise; + const existing = this.definitionsPromises.get(cacheKey); + if (existing) return existing; const generation = this.definitionsGeneration; - const promise = this.fetchDefinitions(signal) + const promise = this.fetchDefinitions(signal, Boolean(options.hydrateTools)) .then((definitions) => { if (this.definitionsGeneration === generation) { - this.definitionsCache = { definitions, expiresAtMs: Date.now() + DISCOVERY_CACHE_TTL_MS }; + this.definitionsCache.set(cacheKey, { definitions, expiresAtMs: Date.now() + DISCOVERY_CACHE_TTL_MS }); } this.setPersistedDefinitions(definitions, new Date().toISOString()); return definitions; }) .finally(() => { - if (this.definitionsPromise === promise && this.definitionsGeneration === generation) this.definitionsPromise = undefined; + if (this.definitionsPromises.get(cacheKey) === promise && this.definitionsGeneration === generation) this.definitionsPromises.delete(cacheKey); }); - this.definitionsPromise = promise; + this.definitionsPromises.set(cacheKey, promise); return promise; } - private async fetchDefinitions(signal?: AbortSignal): Promise { + private async fetchDefinitions(signal?: AbortSignal, hydrateTools = false): Promise { const apiKey = this.getApiKey(); const authConfigs = apiKey ? await this.listAuthConfigsSafe(signal) : []; const configuredByConnectorId = new Map(); @@ -532,7 +557,7 @@ export class ComposioConnectorProvider { const configuredEntry = configuredByConnectorId.get(staticDefinition.id); const toolkitSlug = configuredEntry?.toolkitSlug ?? staticDefinition.providerConnectorId ?? staticDefinition.id; const toolkit = toolkitBySlug.get(normalizeComposioSlug(toolkitSlug)); - return this.definitionFromToolkit(staticDefinition, toolkitSlug, toolkit, Boolean(apiKey), signal); + return this.definitionFromToolkit(staticDefinition, toolkitSlug, toolkit, Boolean(apiKey && hydrateTools), signal); }); return definitions; } @@ -595,30 +620,40 @@ export class ComposioConnectorProvider { return undefined; } + async getHydratedDefinition(connectorId: string, signal?: AbortSignal): Promise { + const discovered = (await this.listDefinitions(signal, { hydrateTools: true })).find((definition) => definition.id === connectorId); + if (discovered) return discovered; + return undefined; + } + + async getPreviewDefinition(connectorId: string, options: { toolsLimit: number; toolsCursor?: string; signal?: AbortSignal }): Promise { + const metadataDefinition = (await this.listDefinitions(options.signal)).find((definition) => definition.id === connectorId); + if (!metadataDefinition) return undefined; + const toolkitSlug = metadataDefinition.providerConnectorId ?? metadataDefinition.id; + return this.definitionFromToolkit(metadataDefinition, toolkitSlug, undefined, true, options.signal, { + toolsLimit: options.toolsLimit, + ...(options.toolsCursor === undefined ? {} : { toolsCursor: options.toolsCursor }), + }); + } + async connect(definition: ConnectorCatalogDefinition, callbackUrl: string, signal?: AbortSignal): Promise { this.pruneExpiredPendingConnections(); - const authConfigId = await this.getOrCreateManagedAuthConfigId(definition, signal); - if (!authConfigId) { - throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'Composio auth config could not be created for this connector', 503, { - connectorId: definition.id, - setting: 'apiKey', - }); - } + let authConfig = await this.getOrCreateManagedAuthConfigId(definition, signal); const state = crypto.randomBytes(24).toString('base64url'); const expiresAtMs = Date.now() + OAUTH_STATE_TTL_MS; const expiresAt = new Date(expiresAtMs).toISOString(); - const response = await this.requestJson('/api/v3.1/connected_accounts/link', { - method: 'POST', - body: JSON.stringify({ - auth_config_id: authConfigId, - user_id: this.getUserId(), - connection_data: { state_prefix: state }, - callback_url: appendOAuthStateToCallbackUrl(callbackUrl, state), - }), - ...(signal === undefined ? {} : { signal }), - }); + const callbackUrlWithState = appendOAuthStateToCallbackUrl(callbackUrl, state); + let response: ComposioConnectedAccountResponse; + try { + response = await this.createConnectedAccountLink(authConfig.authConfigId, state, callbackUrlWithState, signal); + } catch (error) { + if (!authConfig.fromCache) throw error; + deleteComposioAuthConfigId(definition.id); + authConfig = await this.getOrCreateManagedAuthConfigId(definition, signal, { ignoreCache: true }); + response = await this.createConnectedAccountLink(authConfig.authConfigId, state, callbackUrlWithState, signal); + } const providerConnectionId = getComposioConnectionId(response); const redirectUrl = getString(response.redirect_url) ?? getString(response.redirectUrl); @@ -626,7 +661,7 @@ export class ComposioConnectorProvider { this.pendingConnections.set(state, { connectorId: definition.id, state, ...(providerConnectionId ? { providerConnectionId } : {}), expiresAtMs }); const validatedConnection = status === 'ACTIVE' && providerConnectionId - ? await this.getValidatedConnectedAccount(definition, providerConnectionId, authConfigId, signal) + ? await this.getValidatedConnectedAccount(definition, providerConnectionId, authConfig.authConfigId, signal) : undefined; if (validatedConnection) this.pendingConnections.delete(state); @@ -639,6 +674,35 @@ export class ComposioConnectorProvider { }; } + async prepareAuthConfig(definition: ConnectorCatalogDefinition, signal?: AbortSignal): Promise { + if (definition.authentication !== 'composio') return { status: 'error', message: 'connector is not backed by Composio' }; + const unsupported = this.unsupportedManagedAuthConfigs.get(definition.id); + if (unsupported) return { status: 'custom_required', message: unsupported }; + try { + const resolution = await this.getOrCreateManagedAuthConfigId(definition, signal); + return { status: 'ready', authConfigId: resolution.authConfigId }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isCustomAuthRequiredMessage(message)) { + const customMessage = normalizeCustomAuthRequiredMessage(message); + this.unsupportedManagedAuthConfigs.set(definition.id, customMessage); + return { status: 'custom_required', message: customMessage }; + } + return { status: 'error', message }; + } + } + + cancelPendingConnections(connectorId: string): number { + this.pruneExpiredPendingConnections(); + let cancelled = 0; + for (const [state, pending] of this.pendingConnections.entries()) { + if (pending.connectorId !== connectorId) continue; + this.pendingConnections.delete(state); + cancelled += 1; + } + return cancelled; + } + async completeConnection(input: { definition: ConnectorCatalogDefinition; state: string; providerConnectionId?: string; status?: string; signal?: AbortSignal }): Promise { this.pruneExpiredPendingConnections(); @@ -660,6 +724,8 @@ export class ComposioConnectorProvider { } const expectedAuthConfigId = await this.getAuthConfigId(input.definition, input.signal); const response = await this.getValidatedConnectedAccount(input.definition, providerConnectionId, expectedAuthConfigId, input.signal); + const authConfigId = getString(response.auth_config?.id); + if (authConfigId) this.storeAuthConfigId(input.definition, authConfigId, getString(response.toolkit?.slug) ?? input.definition.providerConnectorId); return this.connectionToCredentials(input.definition, providerConnectionId, response); } @@ -733,30 +799,54 @@ export class ComposioConnectorProvider { } private async getAuthConfigId(definition: ConnectorCatalogDefinition, signal?: AbortSignal): Promise { + const persisted = this.getPersistedAuthConfigId(definition.id); + if (persisted) return persisted; if (!this.discoveredAuthConfigIds) this.discoveredAuthConfigIds = await this.discoverAuthConfigIds(signal); return this.discoveredAuthConfigIds[definition.id]; } - private async getOrCreateManagedAuthConfigId(definition: ConnectorCatalogDefinition, signal?: AbortSignal): Promise { - const existing = await this.getAuthConfigId(definition, signal); - if (existing) return existing; + private async getOrCreateManagedAuthConfigId(definition: ConnectorCatalogDefinition, signal?: AbortSignal, options: { ignoreCache?: boolean } = {}): Promise { + if (!options.ignoreCache) { + const persisted = this.getPersistedAuthConfigId(definition.id); + if (persisted) { + return { authConfigId: persisted, fromCache: true }; + } + const discovered = this.discoveredAuthConfigIds?.[definition.id]; + if (discovered) { + return { authConfigId: discovered, fromCache: true }; + } + } + + const existing = await this.getAuthConfigIdForToolkit(definition, signal); + if (existing) { + this.storeAuthConfigId(definition, existing); + return { authConfigId: existing, fromCache: false }; + } const inFlight = this.authConfigCreationPromises.get(definition.id); - if (inFlight) return inFlight; + if (inFlight) { + const authConfigId = await inFlight; + return { authConfigId, fromCache: false }; + } const promise = this.createAndStoreManagedAuthConfigId(definition, signal) .finally(() => { if (this.authConfigCreationPromises.get(definition.id) === promise) this.authConfigCreationPromises.delete(definition.id); }); this.authConfigCreationPromises.set(definition.id, promise); - return promise; + const authConfigId = await promise; + return { authConfigId, fromCache: false }; } - private async createAndStoreManagedAuthConfigId(definition: ConnectorCatalogDefinition, signal?: AbortSignal): Promise { + private async createAndStoreManagedAuthConfigId(definition: ConnectorCatalogDefinition, signal?: AbortSignal): Promise { const created = await this.createManagedAuthConfig(definition, signal); const authConfigId = getComposioAuthConfigId(created); const toolkitSlug = getComposioToolkitSlug(created) ?? definition.providerConnectorId; - if (!authConfigId || !toolkitSlug) return undefined; + if (!authConfigId || !toolkitSlug) { + throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'Composio auth config response was missing an id or toolkit slug', 502, { + connectorId: definition.id, + }); + } const connectorId = connectorIdForToolkitSlug(toolkitSlug); if (connectorId !== definition.id) { @@ -766,11 +856,7 @@ export class ComposioConnectorProvider { }); } - this.discoveredAuthConfigIds = { - ...(this.discoveredAuthConfigIds ?? {}), - [definition.id]: authConfigId, - }; - this.locallyCreatedAuthConfigs.set(definition.id, { authConfigId, toolkitSlug }); + this.storeAuthConfigId(definition, authConfigId, toolkitSlug); this.invalidateDefinitionsCache(); return authConfigId; } @@ -790,6 +876,49 @@ export class ComposioConnectorProvider { }); } + private async getAuthConfigIdForToolkit(definition: ConnectorCatalogDefinition, signal?: AbortSignal): Promise { + const toolkitSlug = definition.providerConnectorId; + if (!toolkitSlug) { + throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'Composio connector is missing a toolkit slug', 500, { connectorId: definition.id }); + } + const items = await this.listAuthConfigsSafe(signal, toolkitSlug); + for (const item of items) { + const authConfigId = getComposioAuthConfigId(item); + const itemToolkitSlug = getComposioToolkitSlug(item) ?? toolkitSlug; + const status = getString(item.status)?.toUpperCase(); + if (!authConfigId || (status && status !== 'ENABLED')) continue; + if (connectorIdForToolkitSlug(itemToolkitSlug) !== definition.id) continue; + return authConfigId; + } + return undefined; + } + + private async createConnectedAccountLink(authConfigId: string, state: string, callbackUrl: string, signal?: AbortSignal): Promise { + return this.requestJson('/api/v3.1/connected_accounts/link', { + method: 'POST', + body: JSON.stringify({ + auth_config_id: authConfigId, + user_id: this.getUserId(), + connection_data: { state_prefix: state }, + callback_url: callbackUrl, + }), + ...(signal === undefined ? {} : { signal }), + }); + } + + private getPersistedAuthConfigId(connectorId: string): string | undefined { + return getString(readComposioConfig().authConfigIds[connectorId]); + } + + private storeAuthConfigId(definition: ConnectorCatalogDefinition, authConfigId: string, toolkitSlug = definition.providerConnectorId): void { + this.discoveredAuthConfigIds = { + ...(this.discoveredAuthConfigIds ?? {}), + [definition.id]: authConfigId, + }; + if (toolkitSlug) this.locallyCreatedAuthConfigs.set(definition.id, { authConfigId, toolkitSlug }); + setComposioAuthConfigId(definition.id, authConfigId); + } + private async discoverAuthConfigIds(signal?: AbortSignal): Promise> { if (!this.getApiKey()) return {}; const items = await this.listAuthConfigsSafe(signal); @@ -805,17 +934,20 @@ export class ComposioConnectorProvider { return discovered; } - private async listAuthConfigs(signal?: AbortSignal): Promise { - const response = await this.request('/api/v3/auth_configs', { method: 'GET', ...(signal === undefined ? {} : { signal }) }); + private async listAuthConfigs(signal?: AbortSignal, toolkitSlug?: string): Promise { + const path = toolkitSlug + ? `/api/v3/auth_configs?${new URLSearchParams({ toolkit_slug: toolkitSlug }).toString()}` + : '/api/v3/auth_configs'; + const response = await this.request(path, { method: 'GET', ...(signal === undefined ? {} : { signal }) }); if (!response.ok) return []; const payload = await response.json() as { items?: unknown; data?: unknown }; const items = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.data) ? payload.data : []; return items.filter((item): item is ComposioAuthConfigResponse => Boolean(item && typeof item === 'object' && !Array.isArray(item))); } - private async listAuthConfigsSafe(signal?: AbortSignal): Promise { + private async listAuthConfigsSafe(signal?: AbortSignal, toolkitSlug?: string): Promise { try { - return await this.listAuthConfigs(signal); + return await this.listAuthConfigs(signal, toolkitSlug); } catch { return []; } @@ -837,13 +969,27 @@ export class ComposioConnectorProvider { } } - private async listTools(toolkitSlug: string, signal?: AbortSignal): Promise { - const searchParams = new URLSearchParams({ toolkit_slug: toolkitSlug.toLowerCase(), limit: '1000' }); - const response = await this.request(`/api/v3.1/tools?${searchParams.toString()}`, { method: 'GET', ...(signal === undefined ? {} : { signal }) }); - if (!response.ok) return []; - const payload = await response.json() as { items?: unknown; data?: unknown }; + private async listToolsPage(toolkitSlug: string, options: { limit?: number; cursor?: string; signal?: AbortSignal } = {}): Promise { + const searchParams = new URLSearchParams({ toolkit_slug: toolkitSlug.toLowerCase(), limit: String(options.limit ?? 1000) }); + if (options.cursor) searchParams.set('cursor', options.cursor); + const response = await this.request(`/api/v3.1/tools?${searchParams.toString()}`, { method: 'GET', ...(options.signal === undefined ? {} : { signal: options.signal }) }); + if (!response.ok) { + const message = await getComposioErrorMessage(response); + throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', message ?? `Composio tools request failed with HTTP ${response.status}`, response.status === 401 ? 401 : 502, { httpStatus: response.status }); + } + const payload = await response.json() as { items?: unknown; data?: unknown; next_cursor?: unknown; nextCursor?: unknown; total_items?: unknown; totalItems?: unknown }; const items = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.data) ? payload.data : []; - return items.filter((item): item is ComposioToolResponse => Boolean(item && typeof item === 'object' && !Array.isArray(item))); + const nextCursor = getString(payload.next_cursor) ?? getString(payload.nextCursor); + const totalItems = getNonNegativeInteger(payload.total_items) ?? getNonNegativeInteger(payload.totalItems); + return { + items: items.filter((item): item is ComposioToolResponse => Boolean(item && typeof item === 'object' && !Array.isArray(item))), + ...(nextCursor === undefined ? {} : { nextCursor }), + ...(totalItems === undefined ? {} : { totalItems }), + }; + } + + private async listTools(toolkitSlug: string, signal?: AbortSignal): Promise { + return (await this.listToolsPage(toolkitSlug, { limit: 1000, ...(signal === undefined ? {} : { signal }) })).items; } private async listToolsSafe(toolkitSlug: string, signal?: AbortSignal): Promise { @@ -854,10 +1000,24 @@ export class ComposioConnectorProvider { } } - private async definitionFromToolkit(staticDefinition: ConnectorCatalogDefinition, toolkitSlug: string, toolkit: ComposioToolkitResponse | undefined, hydrateTools: boolean, signal?: AbortSignal): Promise { + private async definitionFromToolkit( + staticDefinition: ConnectorCatalogDefinition, + toolkitSlug: string, + toolkit: ComposioToolkitResponse | undefined, + hydrateTools: boolean, + signal?: AbortSignal, + toolPageOptions: { toolsLimit?: number; toolsCursor?: string } = {}, + ): Promise { const connectorId = staticDefinition.id; + const toolPage = hydrateTools && toolPageOptions.toolsLimit !== undefined + ? await this.listToolsPage(toolkitSlug, { + limit: toolPageOptions.toolsLimit, + ...(toolPageOptions.toolsCursor === undefined ? {} : { cursor: toolPageOptions.toolsCursor }), + ...(signal === undefined ? {} : { signal }), + }) + : undefined; const liveTools = hydrateTools - ? (await this.listToolsSafe(toolkitSlug, signal)) + ? (toolPage?.items ?? await this.listToolsSafe(toolkitSlug, signal)) .filter((tool) => { const toolToolkitSlug = getString(tool.toolkit?.slug); return !toolToolkitSlug || normalizeComposioSlug(toolToolkitSlug) === normalizeComposioSlug(toolkitSlug); @@ -878,6 +1038,8 @@ export class ComposioConnectorProvider { const category = firstCategoryName(toolkit?.meta?.categories) ?? firstCategoryName(toolkit?.categories) ?? staticDefinition.category; const liveDescription = getComposioToolkitDescription(toolkit); const description = liveDescription ?? staticDefinition.description; + const liveToolCount = getComposioToolkitToolCount(toolkit); + const toolCount = toolPage?.totalItems ?? liveToolCount ?? staticDefinition.toolCount ?? (tools.length > 0 ? tools.length : undefined); return { ...staticDefinition, id: connectorId, @@ -886,6 +1048,9 @@ export class ComposioConnectorProvider { category, ...(description === undefined ? {} : { description }), tools, + ...(toolCount === undefined ? {} : { toolCount }), + ...(toolPage?.nextCursor === undefined ? {} : { toolsNextCursor: toolPage.nextCursor }), + ...(toolPage === undefined ? {} : { toolsHasMore: toolPage.nextCursor !== undefined }), allowedToolNames, ...(staticDefinition.featuredToolNames === undefined ? tools.length > 0 ? { featuredToolNames: tools.slice(0, 3).map((tool) => tool.name) } : {} @@ -1023,6 +1188,7 @@ function createComposioCatalogDefinition(toolkit: ComposioToolkitCatalogEntry): tools: [], allowedToolNames: [], minimumApproval: 'auto', + ...(curated?.toolCount === undefined ? {} : { toolCount: curated.toolCount }), }; } @@ -1048,6 +1214,7 @@ function cloneConnectorDefinition(definition: ConnectorCatalogDefinition): Conne ...(tool.providerToolId === undefined ? {} : { providerToolId: tool.providerToolId }), })), allowedToolNames: [...definition.allowedToolNames], + ...(definition.toolCount === undefined ? {} : { toolCount: definition.toolCount }), ...(definition.featuredToolNames === undefined ? {} : { featuredToolNames: [...definition.featuredToolNames] }), }; } @@ -1073,6 +1240,9 @@ function normalizePersistedConnectorDefinition(value: unknown): ConnectorCatalog } if (typeof record.providerConnectorId === 'string') definition.providerConnectorId = record.providerConnectorId; if (Array.isArray(record.featuredToolNames)) definition.featuredToolNames = record.featuredToolNames.filter((item): item is string => typeof item === 'string'); + if (typeof record.toolCount === 'number' && Number.isFinite(record.toolCount) && record.toolCount >= 0) { + definition.toolCount = record.toolCount; + } if (record.minimumApproval === 'auto' || record.minimumApproval === 'confirm' || record.minimumApproval === 'disabled') { definition.minimumApproval = record.minimumApproval; } @@ -1144,6 +1314,10 @@ function getString(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; } +function getNonNegativeInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined; +} + function getStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).map((item) => item.trim()); @@ -1169,6 +1343,10 @@ function getComposioToolkitDescription(toolkit: ComposioToolkitResponse | undefi return description; } +function getComposioToolkitToolCount(toolkit: ComposioToolkitResponse | undefined): number | undefined { + return getNonNegativeInteger(toolkit?.meta?.tools_count) ?? getNonNegativeInteger(toolkit?.meta?.toolsCount); +} + function isGenericComposioDescription(description: string): boolean { return /^connect to .+ through composio\.?$/i.test(description.trim()) || /^.+ integration via composio\.?$/i.test(description.trim()); @@ -1257,12 +1435,27 @@ function firstCategoryName(value: unknown): string | undefined { return undefined; } +function isCustomAuthRequiredMessage(message: string): boolean { + return /default auth config not found/i.test(message) || /does not have managed credentials/i.test(message); +} + +function normalizeCustomAuthRequiredMessage(message: string): string { + return message || CUSTOM_AUTH_REQUIRED_MESSAGE; +} + async function getComposioErrorMessage(response: Response): Promise { try { const payload = await response.json() as unknown; if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return undefined; const record = payload as Record; - return getString(record.message) ?? getString(record.error) ?? getString(record.detail); + const error = record.error && typeof record.error === 'object' && !Array.isArray(record.error) + ? record.error as Record + : undefined; + return getString(record.message) + ?? getString(error?.message) + ?? getString(record.error) + ?? getString(record.detail) + ?? getString(error?.suggested_fix); } catch { return undefined; } diff --git a/apps/daemon/src/connectors/routes.ts b/apps/daemon/src/connectors/routes.ts index 12a42625a..3a2237742 100644 --- a/apps/daemon/src/connectors/routes.ts +++ b/apps/daemon/src/connectors/routes.ts @@ -527,7 +527,10 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector const refresh = typeof req.query.refresh === 'string' ? ['1', 'true', 'yes'].includes(req.query.refresh.toLowerCase()) : false; - res.json(await service.listConnectorDiscovery({ refresh })); + const hydrateTools = typeof req.query.hydrateTools === 'string' + ? ['1', 'true', 'yes'].includes(req.query.hydrateTools.toLowerCase()) + : false; + res.json(await service.listConnectorDiscovery({ refresh, hydrateTools })); } catch (err) { sendConnectorRouteError(res, err, options.sendApiError); } @@ -545,12 +548,38 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector try { const connectorId = req.params.connectorId; if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required'); + const hydrateTools = typeof req.query.hydrateTools === 'string' + ? ['1', 'true', 'yes'].includes(req.query.hydrateTools.toLowerCase()) + : false; + if (hydrateTools) { + const parsedLimit = typeof req.query.toolsLimit === 'string' ? Number.parseInt(req.query.toolsLimit, 10) : 50; + const toolsLimit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 1000) : 50; + const toolsCursor = typeof req.query.toolsCursor === 'string' && req.query.toolsCursor.trim().length > 0 ? req.query.toolsCursor : undefined; + res.json({ connector: await service.getPreviewConnector(connectorId, { toolsLimit, ...(toolsCursor === undefined ? {} : { toolsCursor }) }) }); + return; + } res.json({ connector: await service.getConnector(connectorId) }); } catch (err) { sendConnectorRouteError(res, err, options.sendApiError); } }); + app.post('/api/connectors/auth-configs/prepare', requireLocalDaemonRequest, async (req: Request, res: Response) => { + try { + const body = isPlainObject(req.body) ? req.body : {}; + const connectorIds = Array.isArray(body.connectorIds) + ? body.connectorIds.filter((connectorId): connectorId is string => typeof connectorId === 'string') + : []; + if (connectorIds.length === 0) { + options.sendApiError(res, 400, 'VALIDATION_FAILED', 'connectorIds must contain at least one connector id'); + return; + } + res.json(await service.prepareAuthConfigs(connectorIds)); + } catch (err) { + sendConnectorRouteError(res, err, options.sendApiError); + } + }); + app.post('/api/connectors/:connectorId/connect', requireLocalDaemonRequest, async (req: Request, res: Response) => { try { const connectorId = req.params.connectorId; @@ -562,7 +591,7 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector options.sendApiError(res, 400, 'VALIDATION_FAILED', 'credentials must be an object'); return; } - const definition = await service.getDefinition(connectorId); + const definition = service.getFastDefinition(connectorId) ?? await service.getDefinition(connectorId); if (definition?.authentication === 'composio' && credentials !== undefined) { options.sendApiError(res, 400, 'VALIDATION_FAILED', 'Composio connector credentials can only be stored through OAuth callback completion'); return; @@ -600,6 +629,16 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector } }); + app.post('/api/connectors/:connectorId/authorization/cancel', requireLocalDaemonRequest, async (req: Request, res: Response) => { + try { + const connectorId = req.params.connectorId; + if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required'); + res.json({ connector: await service.cancelPendingAuthorization(connectorId) }); + } catch (err) { + sendConnectorRouteError(res, err, options.sendApiError); + } + }); + app.delete('/api/connectors/:connectorId/connection', requireLocalDaemonRequest, async (req: Request, res: Response) => { try { const connectorId = req.params.connectorId; diff --git a/apps/daemon/src/connectors/service.ts b/apps/daemon/src/connectors/service.ts index cfc64a142..2f9a44faf 100644 --- a/apps/daemon/src/connectors/service.ts +++ b/apps/daemon/src/connectors/service.ts @@ -12,7 +12,7 @@ import { type ConnectorToolSafety, type ConnectorStatus, } from './catalog.js'; -import { composioConnectorProvider, getStaticComposioCatalogDefinitions, type ComposioConnectionStart } from './composio.js'; +import { composioConnectorProvider, getStaticComposioCatalogDefinitions, type ComposioAuthConfigPrepareResult, type ComposioConnectionStart } from './composio.js'; export interface ConnectorExecuteRequest { connectorId: string; @@ -37,6 +37,10 @@ export interface ConnectorConnectResult { auth?: Pick; } +export interface ConnectorAuthConfigPrepareResponse { + results: Record; +} + type PublicComposioConnectionStart = Pick; function publicComposioAuthStart(auth: ComposioConnectionStart): PublicComposioConnectionStart { @@ -48,6 +52,17 @@ function publicComposioAuthStart(auth: ComposioConnectionStart): PublicComposioC }; } +function isMissingOrExpiredComposioOAuthState(error: unknown): boolean { + return error instanceof ConnectorServiceError + && error.code === 'CONNECTOR_EXECUTION_FAILED' + && error.message === 'Composio OAuth state is missing or expired'; +} + +function hasStoredComposioConnection(credential: ConnectorCredentialRecord | undefined, providerConnectionId: string): boolean { + return credential?.credentials.provider === 'composio' + && credential.credentials.providerConnectionId === providerConnectionId; +} + export type ConnectorServiceErrorCode = | 'CONNECTOR_NOT_FOUND' | 'CONNECTOR_NOT_CONNECTED' @@ -524,14 +539,31 @@ export class ConnectorService { return composioConnectorProvider.listDefinitions(signal); } + async listHydratedDefinitions(signal?: AbortSignal): Promise { + return composioConnectorProvider.listDefinitions(signal, { hydrateTools: true }); + } + listFastDefinitions(): ConnectorCatalogDefinition[] { return composioConnectorProvider.getFastDefinitions(); } + getFastDefinition(connectorId: string): ConnectorCatalogDefinition | undefined { + return this.listFastDefinitions().find((definition) => definition.id === connectorId); + } + async getDefinition(connectorId: string, signal?: AbortSignal): Promise { return composioConnectorProvider.getDefinition(connectorId, signal); } + async getHydratedDefinition(connectorId: string, signal?: AbortSignal): Promise { + return await composioConnectorProvider.getHydratedDefinition(connectorId, signal) + ?? await this.getDefinition(connectorId, signal); + } + + async getPreviewDefinition(connectorId: string, options: { toolsLimit: number; toolsCursor?: string; signal?: AbortSignal }): Promise { + return composioConnectorProvider.getPreviewDefinition(connectorId, options); + } + getStatus(definition: ConnectorCatalogDefinition): ConnectorConnectionStatus { return this.statusService.getStatus(definition); } @@ -551,12 +583,15 @@ export class ConnectorService { }; } - async listConnectorDiscovery(options: { refresh?: boolean; signal?: AbortSignal } = {}): Promise { + async listConnectorDiscovery(options: { refresh?: boolean; hydrateTools?: boolean; signal?: AbortSignal } = {}): Promise { if (options.refresh) composioConnectorProvider.clearDiscoveryCache(); + const definitions = options.refresh && !options.hydrateTools + ? await composioConnectorProvider.refreshCatalog(options.signal) + : options.hydrateTools + ? await this.listHydratedDefinitions(options.signal) + : await this.listDefinitions(options.signal); return { - connectors: ((options.refresh - ? await composioConnectorProvider.refreshCatalog(options.signal) - : await this.listDefinitions(options.signal))).map((definition) => this.toDetail(definition)), + connectors: definitions.map((definition) => this.toDetail(definition)), meta: { provider: 'composio', ...(options.refresh ? { refreshRequested: true } : {}), @@ -572,8 +607,44 @@ export class ConnectorService { return this.toDetail(definition); } + async getHydratedConnector(connectorId: string, signal?: AbortSignal): Promise { + const definition = await this.getHydratedDefinition(connectorId, signal); + if (!definition) { + throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404); + } + return this.toDetail(definition); + } + + async getPreviewConnector(connectorId: string, options: { toolsLimit: number; toolsCursor?: string; signal?: AbortSignal }): Promise { + const definition = await this.getPreviewDefinition(connectorId, options); + if (!definition) { + throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404); + } + return this.toDetail(definition); + } + + async prepareAuthConfigs(connectorIds: readonly string[], signal?: AbortSignal): Promise { + const results: Record = {}; + const uniqueConnectorIds = [...new Set(connectorIds.map((id) => id.trim()).filter(Boolean))]; + + await Promise.all(uniqueConnectorIds.map(async (connectorId) => { + const definition = this.getFastDefinition(connectorId) ?? await this.getDefinition(connectorId, signal); + if (!definition) { + results[connectorId] = { status: 'error', message: 'connector not found' }; + return; + } + if (definition.authentication !== 'composio') { + results[connectorId] = { status: 'error', message: 'connector is not backed by Composio' }; + return; + } + results[connectorId] = await composioConnectorProvider.prepareAuthConfig(definition, signal); + })); + + return { results }; + } + async connect(connectorId: string, options: { accountLabel?: string; credentials?: ConnectorCredentialMaterial; callbackUrl?: string; signal?: AbortSignal } = {}): Promise { - const definition = await this.getDefinition(connectorId, options.signal); + const definition = this.getFastDefinition(connectorId) ?? await this.getDefinition(connectorId, options.signal); if (!definition) { throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404); } @@ -585,7 +656,6 @@ export class ConnectorService { throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'callbackUrl is required for Composio connectors', 400, { connectorId }); } auth = await composioConnectorProvider.connect(definition, options.callbackUrl, options.signal); - detailDefinition = await this.getDefinition(connectorId, options.signal) ?? definition; if (auth.kind === 'redirect_required' || auth.kind === 'pending') { return { connector: this.toDetail(detailDefinition), auth: publicComposioAuthStart(auth) }; } @@ -602,7 +672,7 @@ export class ConnectorService { } async disconnect(connectorId: string): Promise { - const definition = await this.getDefinition(connectorId); + const definition = this.getFastDefinition(connectorId) ?? await this.getDefinition(connectorId); if (!definition) { throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404); } @@ -613,6 +683,17 @@ export class ConnectorService { return this.toDetail(definition); } + async cancelPendingAuthorization(connectorId: string): Promise { + const definition = this.getFastDefinition(connectorId) ?? await this.getDefinition(connectorId); + if (!definition) { + throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404); + } + if (definition.authentication === 'composio') { + composioConnectorProvider.cancelPendingConnections(connectorId); + } + return this.toDetail(definition); + } + async completeComposioConnection(input: { connectorId: string; state: string; providerConnectionId?: string; status?: string; signal?: AbortSignal }): Promise { const definition = await this.getDefinition(input.connectorId, input.signal); if (!definition) { @@ -621,9 +702,20 @@ export class ConnectorService { if (definition.authentication !== 'composio') { throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'connector is not backed by Composio', 400, { connectorId: input.connectorId }); } - const completed = await composioConnectorProvider.completeConnection({ definition, state: input.state, ...(input.providerConnectionId === undefined ? {} : { providerConnectionId: input.providerConnectionId }), ...(input.status === undefined ? {} : { status: input.status }), ...(input.signal === undefined ? {} : { signal: input.signal }) }); - this.statusService.connect(definition, completed.accountLabel, completed.credentials); - return this.toDetail(definition); + try { + const completed = await composioConnectorProvider.completeConnection({ definition, state: input.state, ...(input.providerConnectionId === undefined ? {} : { providerConnectionId: input.providerConnectionId }), ...(input.status === undefined ? {} : { status: input.status }), ...(input.signal === undefined ? {} : { signal: input.signal }) }); + this.statusService.connect(definition, completed.accountLabel, completed.credentials); + return this.toDetail(definition); + } catch (error) { + if ( + input.providerConnectionId !== undefined + && isMissingOrExpiredComposioOAuthState(error) + && hasStoredComposioConnection(this.getCredential(input.connectorId), input.providerConnectionId) + ) { + return this.toDetail(definition); + } + throw error; + } } async execute(request: ConnectorExecuteRequest, context: ConnectorExecutionContext): Promise { @@ -632,7 +724,7 @@ export class ConnectorService { candidate.allowedToolNames.includes(request.toolName) && candidate.tools.some((tool) => tool.name === request.toolName) )); - const definition = fastDefinition ?? await this.getDefinition(request.connectorId, context.signal); + const definition = fastDefinition ?? await this.getHydratedDefinition(request.connectorId, context.signal); if (!definition) { throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404); } @@ -714,7 +806,7 @@ export class ConnectorService { resolvedDefinition?: ConnectorCatalogDefinition, resolvedTool?: ConnectorCatalogToolDefinition, ): Promise { - const definition = resolvedDefinition ?? await this.getDefinition(request.connectorId, context.signal); + const definition = resolvedDefinition ?? await this.getHydratedDefinition(request.connectorId, context.signal); const tool = resolvedTool ?? definition?.tools.find((candidate) => candidate.name === request.toolName); if (definition?.authentication === 'composio' && tool) { return composioConnectorProvider.execute(definition, tool, request.input, this.getCredential(request.connectorId)?.credentials, context.signal); diff --git a/apps/daemon/src/copilot-stream.ts b/apps/daemon/src/copilot-stream.ts index 8f11cc2a2..caf0086ab 100644 --- a/apps/daemon/src/copilot-stream.ts +++ b/apps/daemon/src/copilot-stream.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Parses GitHub Copilot CLI's `--output-format json` JSONL stream into the * same UI-friendly events that claude-stream.js emits, so the chat panel @@ -22,10 +21,17 @@ * result -> usage */ -export function createCopilotStreamHandler(onEvent) { +type StreamEvent = Record; +type EventSink = (event: StreamEvent) => void; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +export function createCopilotStreamHandler(onEvent: EventSink) { let buffer = ''; - function feed(chunk) { + function feed(chunk: string) { buffer += chunk; let nl; while ((nl = buffer.indexOf('\n')) !== -1) { @@ -54,9 +60,9 @@ export function createCopilotStreamHandler(onEvent) { } } - function handleObject(obj) { - if (!obj || typeof obj !== 'object' || typeof obj.type !== 'string') return; - const data = obj.data || {}; + function handleObject(obj: unknown) { + if (!isRecord(obj) || typeof obj.type !== 'string') return; + const data = isRecord(obj.data) ? obj.data : {}; switch (obj.type) { case 'session.tools_updated': @@ -109,7 +115,7 @@ export function createCopilotStreamHandler(onEvent) { usage: obj.usage ?? null, stopReason: obj.success === true || obj.exitCode === 0 ? 'completed' : 'error', - durationMs: obj.usage?.sessionDurationMs ?? null, + durationMs: isRecord(obj.usage) ? obj.usage.sessionDurationMs ?? null : null, }); return; @@ -121,9 +127,10 @@ export function createCopilotStreamHandler(onEvent) { return { feed, flush }; } -function stringifyResult(r) { +function stringifyResult(r: unknown): string { if (r == null) return ''; if (typeof r === 'string') return r; + if (!isRecord(r)) return JSON.stringify(r); if (typeof r.content === 'string') return r.content; if (typeof r.detailedContent === 'string') return r.detailedContent; return JSON.stringify(r); diff --git a/apps/daemon/src/craft.ts b/apps/daemon/src/craft.ts index 4e1d2205e..02c232f2c 100644 --- a/apps/daemon/src/craft.ts +++ b/apps/daemon/src/craft.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // Craft references loader. The active skill declares which sections it // needs via `od.craft.requires`; this module reads the matching files // from /craft/.md and returns a single concatenated @@ -18,13 +17,13 @@ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; * body is the concatenated markdown (each file preceded by a level-3 * section header). sections lists which slugs actually resolved. */ -export async function loadCraftSections(craftDir, requested) { +export async function loadCraftSections(craftDir: string, requested: unknown[]) { if (!craftDir || !Array.isArray(requested) || requested.length === 0) { return { body: "", sections: [] }; } - const seen = new Set(); - const parts = []; - const sections = []; + const seen = new Set(); + const parts: string[] = []; + const sections: string[] = []; for (const raw of requested) { if (typeof raw !== "string") continue; const slug = raw.trim().toLowerCase(); diff --git a/apps/daemon/src/db.ts b/apps/daemon/src/db.ts index 8fcc0ff8a..3b84b7cc4 100644 --- a/apps/daemon/src/db.ts +++ b/apps/daemon/src/db.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SQLite-backed persistence for projects, conversations, messages, and the // per-project set of open file tabs. The on-disk project folder under // .od/projects// is still the single owner of the user's actual files @@ -11,10 +10,22 @@ import fs from 'node:fs'; import { randomUUID } from 'node:crypto'; import { migrateCritique } from './critique/persistence.js'; -let dbInstance = null; -let dbFile = null; +type SqliteDb = Database.Database; +type DbRow = Record; +type JsonObject = Record; -export function openDatabase(projectRoot, { dataDir } = {}) { +let dbInstance: SqliteDb | null = null; +let dbFile: string | null = null; + +function row(value: unknown): DbRow | null { + return value && typeof value === 'object' ? value as DbRow : null; +} + +function rows(value: unknown[]): DbRow[] { + return value.map((item) => row(item) ?? {}); +} + +export function openDatabase(projectRoot: string, { dataDir }: { dataDir?: string } = {}): SqliteDb { const dir = dataDir ? path.resolve(dataDir) : path.join(projectRoot, '.od'); const file = path.join(dir, 'app.sqlite'); if (dbInstance && dbFile === file) return dbInstance; @@ -36,7 +47,7 @@ export function closeDatabase() { dbFile = null; } -function migrate(db) { +function migrate(db: SqliteDb): void { db.exec(` CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, @@ -149,51 +160,51 @@ function migrate(db) { `); // Forward-compatible column add for databases created before metadata_json. // SQLite has no IF NOT EXISTS for ALTER, so we check pragma_table_info. - const cols = db.prepare(`PRAGMA table_info(projects)`).all(); - if (!cols.some((c) => c.name === 'metadata_json')) { + const cols = db.prepare(`PRAGMA table_info(projects)`).all() as DbRow[]; + if (!cols.some((c: DbRow) => c.name === 'metadata_json')) { db.exec(`ALTER TABLE projects ADD COLUMN metadata_json TEXT`); } - const messageCols = db.prepare(`PRAGMA table_info(messages)`).all(); - if (!messageCols.some((c) => c.name === 'agent_id')) { + const messageCols = db.prepare(`PRAGMA table_info(messages)`).all() as DbRow[]; + if (!messageCols.some((c: DbRow) => c.name === 'agent_id')) { db.exec(`ALTER TABLE messages ADD COLUMN agent_id TEXT`); } - if (!messageCols.some((c) => c.name === 'agent_name')) { + if (!messageCols.some((c: DbRow) => c.name === 'agent_name')) { db.exec(`ALTER TABLE messages ADD COLUMN agent_name TEXT`); } - if (!messageCols.some((c) => c.name === 'run_id')) { + if (!messageCols.some((c: DbRow) => c.name === 'run_id')) { db.exec(`ALTER TABLE messages ADD COLUMN run_id TEXT`); } - if (!messageCols.some((c) => c.name === 'run_status')) { + if (!messageCols.some((c: DbRow) => c.name === 'run_status')) { db.exec(`ALTER TABLE messages ADD COLUMN run_status TEXT`); } - if (!messageCols.some((c) => c.name === 'last_run_event_id')) { + if (!messageCols.some((c: DbRow) => c.name === 'last_run_event_id')) { db.exec(`ALTER TABLE messages ADD COLUMN last_run_event_id TEXT`); } - if (!messageCols.some((c) => c.name === 'comment_attachments_json')) { + if (!messageCols.some((c: DbRow) => c.name === 'comment_attachments_json')) { db.exec(`ALTER TABLE messages ADD COLUMN comment_attachments_json TEXT`); } - const previewCommentCols = db.prepare(`PRAGMA table_info(preview_comments)`).all(); - if (!previewCommentCols.some((c) => c.name === 'selection_kind')) { + const previewCommentCols = db.prepare(`PRAGMA table_info(preview_comments)`).all() as DbRow[]; + if (!previewCommentCols.some((c: DbRow) => c.name === 'selection_kind')) { db.exec(`ALTER TABLE preview_comments ADD COLUMN selection_kind TEXT`); } - if (!previewCommentCols.some((c) => c.name === 'member_count')) { + if (!previewCommentCols.some((c: DbRow) => c.name === 'member_count')) { db.exec(`ALTER TABLE preview_comments ADD COLUMN member_count INTEGER`); } - if (!previewCommentCols.some((c) => c.name === 'pod_members_json')) { + if (!previewCommentCols.some((c: DbRow) => c.name === 'pod_members_json')) { db.exec(`ALTER TABLE preview_comments ADD COLUMN pod_members_json TEXT`); } - const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all(); - if (!deploymentCols.some((c) => c.name === 'status')) { + const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all() as DbRow[]; + if (!deploymentCols.some((c: DbRow) => c.name === 'status')) { db.exec(`ALTER TABLE deployments ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'`); } - if (!deploymentCols.some((c) => c.name === 'status_message')) { + if (!deploymentCols.some((c: DbRow) => c.name === 'status_message')) { db.exec(`ALTER TABLE deployments ADD COLUMN status_message TEXT`); } - if (!deploymentCols.some((c) => c.name === 'reachable_at')) { + if (!deploymentCols.some((c: DbRow) => c.name === 'reachable_at')) { db.exec(`ALTER TABLE deployments ADD COLUMN reachable_at INTEGER`); } - if (!deploymentCols.some((c) => c.name === 'provider_metadata_json')) { + if (!deploymentCols.some((c: DbRow) => c.name === 'provider_metadata_json')) { db.exec(`ALTER TABLE deployments ADD COLUMN provider_metadata_json TEXT`); } migrateCritique(db); @@ -208,41 +219,41 @@ const DEPLOYMENT_COLS = `id, project_id AS projectId, file_name AS fileName, provider_metadata_json AS providerMetadataJson, created_at AS createdAt, updated_at AS updatedAt`; -export function listDeployments(db, projectId) { - return db +export function listDeployments(db: SqliteDb, projectId: string) { + return (db .prepare( `SELECT ${DEPLOYMENT_COLS} FROM deployments WHERE project_id = ? ORDER BY updated_at DESC`, ) - .all(projectId) + .all(projectId) as DbRow[]) .map(normalizeDeployment); } -export function getDeployment(db, projectId, fileName, providerId) { +export function getDeployment(db: SqliteDb, projectId: string, fileName: string, providerId: string) { const row = db .prepare( `SELECT ${DEPLOYMENT_COLS} FROM deployments WHERE project_id = ? AND file_name = ? AND provider_id = ?`, ) - .get(projectId, fileName, providerId); + .get(projectId, fileName, providerId) as DbRow | undefined; return row ? normalizeDeployment(row) : null; } -export function getDeploymentById(db, projectId, id) { +export function getDeploymentById(db: SqliteDb, projectId: string, id: string) { const row = db .prepare( `SELECT ${DEPLOYMENT_COLS} FROM deployments WHERE project_id = ? AND id = ?`, ) - .get(projectId, id); + .get(projectId, id) as DbRow | undefined; return row ? normalizeDeployment(row) : null; } -export function upsertDeployment(db, deployment) { +export function upsertDeployment(db: SqliteDb, deployment: DbRow) { const existing = getDeployment( db, deployment.projectId, @@ -318,7 +329,7 @@ export function upsertDeployment(db, deployment) { return getDeployment(db, next.projectId, next.fileName, next.providerId); } -function normalizeDeployment(row) { +function normalizeDeployment(row: DbRow) { const providerMetadata = parseJsonOrUndef(row.providerMetadataJson); const normalizedProviderMetadata = providerMetadata && typeof providerMetadata === 'object' && !Array.isArray(providerMetadata) @@ -348,7 +359,7 @@ function normalizeDeployment(row) { }; } -function stringifyJsonObjectOrNull(value) { +function stringifyJsonObjectOrNull(value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; return Object.keys(value).length > 0 ? JSON.stringify(value) : null; } @@ -362,18 +373,18 @@ const PROJECT_COLS = `id, name, skill_id AS skillId, created_at AS createdAt, updated_at AS updatedAt`; -export function listProjects(db) { +export function listProjects(db: SqliteDb) { const rows = db .prepare( `SELECT ${PROJECT_COLS} FROM projects ORDER BY updated_at DESC`, ) - .all(); + .all() as DbRow[]; return rows.map(normalizeProject); } -export function listLatestProjectRunStatuses(db) { +export function listLatestProjectRunStatuses(db: SqliteDb) { const rows = db .prepare( `SELECT c.project_id AS projectId, @@ -385,8 +396,8 @@ export function listLatestProjectRunStatuses(db) { WHERE m.run_status IS NOT NULL ORDER BY updatedAt DESC`, ) - .all(); - const latestByProject = new Map(); + .all() as DbRow[]; + const latestByProject = new Map(); for (const row of rows) { if (!latestByProject.has(row.projectId)) { latestByProject.set(row.projectId, { @@ -399,7 +410,7 @@ export function listLatestProjectRunStatuses(db) { return latestByProject; } -export function listProjectsAwaitingInput(db) { +export function listProjectsAwaitingInput(db: SqliteDb) { const rows = db .prepare( `SELECT latest.projectId @@ -429,18 +440,18 @@ export function listProjectsAwaitingInput(db) { ) )`, ) - .all(); - return new Set(rows.map((row) => row.projectId)); + .all() as DbRow[]; + return new Set((rows as DbRow[]).map((row: DbRow) => row.projectId)); } -export function getProject(db, id) { +export function getProject(db: SqliteDb, id: string) { const row = db .prepare(`SELECT ${PROJECT_COLS} FROM projects WHERE id = ?`) - .get(id); + .get(id) as DbRow | undefined; return row ? normalizeProject(row) : null; } -export function insertProject(db, p) { +export function insertProject(db: SqliteDb, p: DbRow) { db.prepare( `INSERT INTO projects (id, name, skill_id, design_system_id, pending_prompt, @@ -459,7 +470,7 @@ export function insertProject(db, p) { return getProject(db, p.id); } -export function updateProject(db, id, patch) { +export function updateProject(db: SqliteDb, id: string, patch: DbRow) { const existing = getProject(db, id); if (!existing) return null; const merged = { @@ -488,11 +499,11 @@ export function updateProject(db, id, patch) { return getProject(db, id); } -export function deleteProject(db, id) { +export function deleteProject(db: SqliteDb, id: string) { db.prepare(`DELETE FROM projects WHERE id = ?`).run(id); } -function normalizeProject(row) { +function normalizeProject(row: DbRow) { let metadata; if (row.metadataJson) { try { @@ -513,7 +524,7 @@ function normalizeProject(row) { }; } -function normalizeProjectRunStatus(status) { +function normalizeProjectRunStatus(status: unknown) { if (status === 'starting') return 'running'; if (status === 'cancelled') return 'canceled'; if ( @@ -530,30 +541,30 @@ function normalizeProjectRunStatus(status) { // ---------- templates ---------- -export function listTemplates(db) { - return db +export function listTemplates(db: SqliteDb) { + return (db .prepare( `SELECT id, name, description, source_project_id AS sourceProjectId, files_json AS filesJson, created_at AS createdAt FROM templates ORDER BY created_at DESC`, ) - .all() + .all() as DbRow[]) .map(normalizeTemplate); } -export function getTemplate(db, id) { +export function getTemplate(db: SqliteDb, id: string) { const row = db .prepare( `SELECT id, name, description, source_project_id AS sourceProjectId, files_json AS filesJson, created_at AS createdAt FROM templates WHERE id = ?`, ) - .get(id); + .get(id) as DbRow | undefined; return row ? normalizeTemplate(row) : null; } -export function insertTemplate(db, t) { +export function insertTemplate(db: SqliteDb, t: DbRow) { db.prepare( `INSERT INTO templates (id, name, description, source_project_id, files_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`, @@ -568,11 +579,11 @@ export function insertTemplate(db, t) { return getTemplate(db, t.id); } -export function deleteTemplate(db, id) { +export function deleteTemplate(db: SqliteDb, id: string) { db.prepare(`DELETE FROM templates WHERE id = ?`).run(id); } -function normalizeTemplate(row) { +function normalizeTemplate(row: DbRow) { let files = []; try { files = JSON.parse(row.filesJson || '[]'); @@ -591,8 +602,8 @@ function normalizeTemplate(row) { // ---------- conversations ---------- -export function listConversations(db, projectId) { - return db +export function listConversations(db: SqliteDb, projectId: string) { + return (db .prepare( `SELECT id, project_id AS projectId, title, created_at AS createdAt, updated_at AS updatedAt @@ -600,8 +611,8 @@ export function listConversations(db, projectId) { WHERE project_id = ? ORDER BY updated_at DESC`, ) - .all(projectId) - .map((r) => ({ + .all(projectId) as DbRow[]) + .map((r: DbRow) => ({ id: r.id, projectId: r.projectId, title: r.title ?? null, @@ -610,14 +621,14 @@ export function listConversations(db, projectId) { })); } -export function getConversation(db, id) { +export function getConversation(db: SqliteDb, id: string) { const r = db .prepare( `SELECT id, project_id AS projectId, title, created_at AS createdAt, updated_at AS updatedAt FROM conversations WHERE id = ?`, ) - .get(id); + .get(id) as DbRow | undefined; if (!r) return null; return { id: r.id, @@ -628,7 +639,7 @@ export function getConversation(db, id) { }; } -export function insertConversation(db, c) { +export function insertConversation(db: SqliteDb, c: DbRow) { db.prepare( `INSERT INTO conversations (id, project_id, title, created_at, updated_at) @@ -637,7 +648,7 @@ export function insertConversation(db, c) { return getConversation(db, c.id); } -export function updateConversation(db, id, patch) { +export function updateConversation(db: SqliteDb, id: string, patch: DbRow) { const existing = getConversation(db, id); if (!existing) return null; const merged = { @@ -652,14 +663,14 @@ export function updateConversation(db, id, patch) { return getConversation(db, id); } -export function deleteConversation(db, id) { +export function deleteConversation(db: SqliteDb, id: string) { db.prepare(`DELETE FROM conversations WHERE id = ?`).run(id); } // ---------- messages ---------- -export function listMessages(db, conversationId) { - return db +export function listMessages(db: SqliteDb, conversationId: string) { + return (db .prepare( `SELECT id, role, content, agent_id AS agentId, agent_name AS agentName, run_id AS runId, run_status AS runStatus, @@ -674,14 +685,14 @@ export function listMessages(db, conversationId) { WHERE conversation_id = ? ORDER BY position ASC`, ) - .all(conversationId) + .all(conversationId) as DbRow[]) .map(normalizeMessage); } -export function upsertMessage(db, conversationId, m) { +export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) { const existing = db .prepare(`SELECT position FROM messages WHERE id = ?`) - .get(m.id); + .get(m.id) as DbRow | undefined; const now = Date.now(); if (existing) { db.prepare( @@ -712,7 +723,7 @@ export function upsertMessage(db, conversationId, m) { .prepare( `SELECT COALESCE(MAX(position), -1) AS m FROM messages WHERE conversation_id = ?`, ) - .get(conversationId); + .get(conversationId) as DbRow | undefined; const position = (max?.m ?? -1) + 1; // 17 values: id, conversation_id, role, content, agent_id, agent_name, // run_id, run_status, last_run_event_id, events_json, attachments_json, @@ -763,11 +774,11 @@ export function upsertMessage(db, conversationId, m) { position FROM messages WHERE id = ?`, ) - .get(m.id); + .get(m.id) as DbRow | undefined; return row ? normalizeMessage(row) : null; } -export function deleteMessage(db, id) { +export function deleteMessage(db: SqliteDb, id: string) { db.prepare(`DELETE FROM messages WHERE id = ?`).run(id); } @@ -782,8 +793,8 @@ const PREVIEW_COMMENT_STATUSES = new Set([ 'failed', ]); -export function listPreviewComments(db, projectId, conversationId) { - return db +export function listPreviewComments(db: SqliteDb, projectId: string, conversationId: string) { + return (db .prepare( `SELECT id, project_id AS projectId, conversation_id AS conversationId, file_path AS filePath, element_id AS elementId, selector, label, @@ -795,11 +806,11 @@ export function listPreviewComments(db, projectId, conversationId) { WHERE project_id = ? AND conversation_id = ? ORDER BY updated_at DESC`, ) - .all(projectId, conversationId) + .all(projectId, conversationId) as DbRow[]) .map(normalizePreviewComment); } -export function upsertPreviewComment(db, projectId, conversationId, input) { +export function upsertPreviewComment(db: SqliteDb, projectId: string, conversationId: string, input: DbRow) { const target = input?.target ?? {}; const note = typeof input?.note === 'string' ? input.note.trim() : ''; if (!note) throw new Error('comment note required'); @@ -826,7 +837,7 @@ export function upsertPreviewComment(db, projectId, conversationId, input) { FROM preview_comments WHERE project_id = ? AND conversation_id = ? AND file_path = ? AND element_id = ?`, ) - .get(projectId, conversationId, filePath, elementId); + .get(projectId, conversationId, filePath, elementId) as DbRow | undefined; const id = existing?.id ?? randomCommentId(); const createdAt = existing?.createdAt ?? now; db.prepare( @@ -869,7 +880,7 @@ export function upsertPreviewComment(db, projectId, conversationId, input) { return getPreviewComment(db, projectId, conversationId, id); } -export function updatePreviewCommentStatus(db, projectId, conversationId, id, status) { +export function updatePreviewCommentStatus(db: SqliteDb, projectId: string, conversationId: string, id: string, status: string) { if (!PREVIEW_COMMENT_STATUSES.has(status)) throw new Error('invalid comment status'); const now = Date.now(); db.prepare( @@ -880,7 +891,7 @@ export function updatePreviewCommentStatus(db, projectId, conversationId, id, st return getPreviewComment(db, projectId, conversationId, id); } -export function deletePreviewComment(db, projectId, conversationId, id) { +export function deletePreviewComment(db: SqliteDb, projectId: string, conversationId: string, id: string) { const result = db .prepare( `DELETE FROM preview_comments @@ -890,7 +901,7 @@ export function deletePreviewComment(db, projectId, conversationId, id) { return result.changes > 0; } -function getPreviewComment(db, projectId, conversationId, id) { +function getPreviewComment(db: SqliteDb, projectId: string, conversationId: string, id: string) { const row = db .prepare( `SELECT id, project_id AS projectId, conversation_id AS conversationId, @@ -902,11 +913,11 @@ function getPreviewComment(db, projectId, conversationId, id) { FROM preview_comments WHERE id = ? AND project_id = ? AND conversation_id = ?`, ) - .get(id, projectId, conversationId); + .get(id, projectId, conversationId) as DbRow | undefined; return row ? normalizePreviewComment(row) : null; } -function normalizePreviewComment(row) { +function normalizePreviewComment(row: DbRow) { const podMembers = parseJsonOrUndef(row.podMembersJson); const normalizedPodMembers = Array.isArray(podMembers) ? podMembers : undefined; return { @@ -935,12 +946,12 @@ function normalizePreviewComment(row) { }; } -function cleanRequiredString(value, name) { +function cleanRequiredString(value: unknown, name: string): string { if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} required`); return value.trim(); } -function normalizePodMembers(input) { +function normalizePodMembers(input: unknown) { if (!Array.isArray(input)) return []; return input .map((member) => { @@ -966,12 +977,12 @@ function normalizePodMembers(input) { .filter(Boolean); } -function compactWhitespace(value) { +function compactWhitespace(value: string): string { return value.replace(/\s+/g, ' ').trim(); } -function normalizePosition(input) { - const value = input && typeof input === 'object' ? input : {}; +function normalizePosition(input: unknown) { + const value: DbRow = input && typeof input === 'object' ? input as DbRow : {}; return { x: finiteNumber(value.x), y: finiteNumber(value.y), @@ -980,15 +991,15 @@ function normalizePosition(input) { }; } -function finiteNumber(value) { - return Number.isFinite(value) ? Math.round(value) : 0; +function finiteNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 0; } -function randomCommentId() { +function randomCommentId(): string { return `cmt_${randomUUID().slice(0, 8)}`; } -function normalizeMessage(row) { +function normalizeMessage(row: DbRow) { return { id: row.id, role: row.role, @@ -1008,8 +1019,8 @@ function normalizeMessage(row) { }; } -function parseJsonOrUndef(s) { - if (!s) return undefined; +function parseJsonOrUndef(s: unknown): any { + if (typeof s !== 'string' || !s) return undefined; try { return JSON.parse(s); } catch { @@ -1019,28 +1030,28 @@ function parseJsonOrUndef(s) { // ---------- tabs ---------- -export function listTabs(db, projectId) { +export function listTabs(db: SqliteDb, projectId: string) { const rows = db .prepare( `SELECT name, position, is_active AS isActive FROM tabs WHERE project_id = ? ORDER BY position ASC`, ) - .all(projectId); - const active = rows.find((r) => r.isActive) ?? null; + .all(projectId) as DbRow[]; + const active = (rows as DbRow[]).find((r: DbRow) => r.isActive) ?? null; return { - tabs: rows.map((r) => r.name), + tabs: (rows as DbRow[]).map((r: DbRow) => r.name), active: active ? active.name : null, }; } -export function setTabs(db, projectId, names, activeName) { +export function setTabs(db: SqliteDb, projectId: string, names: string[], activeName: string | null) { const tx = db.transaction(() => { db.prepare(`DELETE FROM tabs WHERE project_id = ?`).run(projectId); const ins = db.prepare( `INSERT INTO tabs (project_id, name, position, is_active) VALUES (?, ?, ?, ?)`, ); - names.forEach((name, i) => { + names.forEach((name: string, i: number) => { ins.run(projectId, name, i, name === activeName ? 1 : 0); }); }); diff --git a/apps/daemon/src/deploy.ts b/apps/daemon/src/deploy.ts index db57c1df1..eb36d3a4e 100644 --- a/apps/daemon/src/deploy.ts +++ b/apps/daemon/src/deploy.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import fs from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import os from 'node:os'; @@ -12,6 +11,39 @@ export const CLOUDFLARE_PAGES_PROVIDER_ID = 'cloudflare-pages'; export const SAVED_TOKEN_MASK = 'saved-vercel-token'; export const SAVED_CLOUDFLARE_TOKEN_MASK = 'saved-cloudflare-token'; +type JsonObject = Record; +type DeployProviderId = typeof VERCEL_PROVIDER_ID | typeof CLOUDFLARE_PAGES_PROVIDER_ID; +type DeployErrorDetails = JsonObject | string | undefined; +type DeployConfig = { + token: string; + teamId?: string | undefined; + teamSlug?: string | undefined; + accountId?: string | undefined; + projectName?: string | undefined; + cloudflarePages?: CloudflarePagesConfigHints | undefined; +}; +type CloudflarePagesConfigHints = { + lastZoneId?: string; + lastZoneName?: string; + lastDomainPrefix?: string; +}; +type DeployFile = { file: string; data: Buffer | Uint8Array | string; contentType?: string; sourcePath?: string }; +type DeployFilePlan = { entryPath: string; html: string; files: DeployFile[]; missing: string[]; invalid: string[] }; +type DeployOptions = { metadata?: unknown; hookScriptUrl?: string; providerId?: DeployProviderId }; +type CloudflarePagesDeploySelection = { zoneId: string; zoneName: string; domainPrefix: string; hostname: string }; +type CloudflareDnsRecord = JsonObject & { id?: string; type?: string; name?: string; content?: string; comment?: string }; +type DeployLinkStatus = 'ready' | 'protected' | 'failed' | 'link-delayed'; +type DeploymentUrlCheck = { reachable: boolean; status?: DeployLinkStatus; statusCode?: number; statusMessage?: string }; +type MaybeJsonObject = JsonObject | null | undefined; + +function isErrnoException(err: unknown): err is NodeJS.ErrnoException { + return err instanceof Error && 'code' in err; +} + +function errorMessage(err: unknown, fallback: string): string { + return err instanceof Error && err.message ? err.message : fallback; +} + const VERCEL_API = 'https://api.vercel.com'; const CLOUDFLARE_API = 'https://api.cloudflare.com/client/v4'; const CLOUDFLARE_API_PAGE_SIZE = 100; @@ -23,20 +55,25 @@ const VERCEL_PROTECTED_MESSAGE = 'Deployment is protected by Vercel. Disable Deployment Protection or use a custom domain to make this link public.'; export class DeployError extends Error { - constructor(message, status = 400, details = undefined) { + status: number; + details: DeployErrorDetails; + code?: string | undefined; + + constructor(message: string, status = 400, details: DeployErrorDetails = undefined, code?: string) { super(message); this.name = 'DeployError'; this.status = status; this.details = details; + this.code = code; } } -export function deployConfigPath(providerId = VERCEL_PROVIDER_ID) { +export function deployConfigPath(providerId: DeployProviderId = VERCEL_PROVIDER_ID) { const base = process.env.OD_USER_STATE_DIR || path.join(os.homedir(), '.open-design'); return path.join(base, providerId === CLOUDFLARE_PAGES_PROVIDER_ID ? 'cloudflare-pages.json' : 'vercel.json'); } -export async function readVercelConfig() { +export async function readVercelConfig(): Promise { try { const raw = await readFile(deployConfigPath(VERCEL_PROVIDER_ID), 'utf8'); const parsed = JSON.parse(raw); @@ -46,12 +83,12 @@ export async function readVercelConfig() { teamSlug: typeof parsed.teamSlug === 'string' ? parsed.teamSlug : '', }; } catch (err) { - if (err && err.code === 'ENOENT') return { token: '', teamId: '', teamSlug: '' }; + if (isErrnoException(err) && err.code === 'ENOENT') return { token: '', teamId: '', teamSlug: '' }; throw err; } } -export async function readCloudflarePagesConfig() { +export async function readCloudflarePagesConfig(): Promise { try { const raw = await readFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), 'utf8'); const parsed = JSON.parse(raw); @@ -62,12 +99,12 @@ export async function readCloudflarePagesConfig() { cloudflarePages: normalizeCloudflarePagesConfigHints(parsed.cloudflarePages), }; } catch (err) { - if (err && err.code === 'ENOENT') return { token: '', accountId: '', projectName: '', cloudflarePages: {} }; + if (isErrnoException(err) && err.code === 'ENOENT') return { token: '', accountId: '', projectName: '', cloudflarePages: {} }; throw err; } } -export async function writeVercelConfig(input) { +export async function writeVercelConfig(input: Partial) { const current = await readVercelConfig(); const tokenInput = typeof input?.token === 'string' ? input.token.trim() : ''; const next = { @@ -83,11 +120,11 @@ export async function writeVercelConfig(input) { return publicDeployConfig(next); } -export async function writeCloudflarePagesConfig(input) { +export async function writeCloudflarePagesConfig(input: Partial) { const current = await readCloudflarePagesConfig(); const tokenInput = typeof input?.token === 'string' ? input.token.trim() : ''; const cloudflarePages = normalizeCloudflarePagesConfigHints(input?.cloudflarePages, current.cloudflarePages); - const next = { + const next: DeployConfig = { token: tokenInput && tokenInput !== SAVED_CLOUDFLARE_TOKEN_MASK ? tokenInput @@ -106,7 +143,7 @@ export async function writeCloudflarePagesConfig(input) { return publicCloudflarePagesConfig(next); } -async function writeDeployConfigFile(file, config) { +async function writeDeployConfigFile(file: string, config: DeployConfig) { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); try { @@ -116,7 +153,7 @@ async function writeDeployConfigFile(file, config) { } } -export function publicDeployConfig(config) { +export function publicDeployConfig(config: Partial) { return { providerId: VERCEL_PROVIDER_ID, configured: Boolean(config?.token), @@ -127,9 +164,9 @@ export function publicDeployConfig(config) { }; } -export function publicCloudflarePagesConfig(config) { +export function publicCloudflarePagesConfig(config: Partial) { const cloudflarePages = normalizeCloudflarePagesConfigHints(config?.cloudflarePages); - const body = { + const body: JsonObject = { providerId: CLOUDFLARE_PAGES_PROVIDER_ID, configured: Boolean(config?.token && config?.accountId), tokenMask: config?.token ? SAVED_CLOUDFLARE_TOKEN_MASK : '', @@ -143,29 +180,29 @@ export function publicCloudflarePagesConfig(config) { return body; } -export async function readDeployConfig(providerId = VERCEL_PROVIDER_ID) { +export async function readDeployConfig(providerId: DeployProviderId = VERCEL_PROVIDER_ID) { if (providerId === CLOUDFLARE_PAGES_PROVIDER_ID) return readCloudflarePagesConfig(); return readVercelConfig(); } -export async function writeDeployConfig(providerId = VERCEL_PROVIDER_ID, input = {}) { +export async function writeDeployConfig(providerId: DeployProviderId = VERCEL_PROVIDER_ID, input: Partial = {}) { if (providerId === CLOUDFLARE_PAGES_PROVIDER_ID) return writeCloudflarePagesConfig(input); return writeVercelConfig(input); } -export function publicDeployConfigForProvider(providerId = VERCEL_PROVIDER_ID, config = {}) { +export function publicDeployConfigForProvider(providerId: DeployProviderId = VERCEL_PROVIDER_ID, config: Partial = {}) { if (providerId === CLOUDFLARE_PAGES_PROVIDER_ID) return publicCloudflarePagesConfig(config); return publicDeployConfig(config); } -export function isDeployProviderId(value) { +export function isDeployProviderId(value: unknown): value is DeployProviderId { return value === VERCEL_PROVIDER_ID || value === CLOUDFLARE_PAGES_PROVIDER_ID; } -function normalizeCloudflarePagesConfigHints(input, fallback = {}) { +function normalizeCloudflarePagesConfigHints(input: unknown, fallback: CloudflarePagesConfigHints = {}): CloudflarePagesConfigHints { const hasSource = Boolean(input && typeof input === 'object'); - const source = hasSource ? input : {}; - const prior = !hasSource && fallback && typeof fallback === 'object' ? fallback : {}; + const source = (hasSource ? input : {}) as CloudflarePagesConfigHints; + const prior = (!hasSource && fallback && typeof fallback === 'object' ? fallback : {}) as CloudflarePagesConfigHints; const lastZoneId = typeof source.lastZoneId === 'string' ? source.lastZoneId.trim() @@ -196,7 +233,7 @@ function normalizeCloudflarePagesConfigHints(input, fallback = {}) { // missing and invalid references. Does not throw on a partial result so // callers can distinguish between "ready to ship" and "ready except for // these specific issues" without parsing an error string. -export async function buildDeployFilePlan(projectsRoot, projectId, entryName, options = {}) { +export async function buildDeployFilePlan(projectsRoot: string, projectId: string, entryName: string, options: DeployOptions = {}): Promise { const entryPath = validateProjectPath(entryName); if (!/\.html?$/i.test(entryPath)) { throw new DeployError('Only HTML files can be deployed.', 400); @@ -209,7 +246,7 @@ export async function buildDeployFilePlan(projectsRoot, projectId, entryName, op rewriteEntryHtmlReferences(html, entryBase), options.hookScriptUrl ?? process.env.OD_DEPLOY_HOOK_SCRIPT_URL, ); - const files = new Map(); + const files = new Map(); files.set('index.html', { file: 'index.html', data: Buffer.from(deployHtml, 'utf8'), @@ -217,10 +254,10 @@ export async function buildDeployFilePlan(projectsRoot, projectId, entryName, op sourcePath: entryPath, }); - const visited = new Set([entryPath]); - const missing = []; - const invalid = []; - const pending = extractHtmlReferences(html).map((ref) => ({ + const visited = new Set([entryPath]); + const missing: string[] = []; + const invalid: string[] = []; + const pending: { ref: string; base: string }[] = extractHtmlReferences(html).map((ref) => ({ ref, base: entryBase, })); @@ -232,12 +269,16 @@ export async function buildDeployFilePlan(projectsRoot, projectId, entryName, op pending.push({ ref, base: entryBase }); } - for (const manifestRef of entry.artifactManifest?.supportingFiles ?? []) { + const supportingFiles = 'supportingFiles' in (entry.artifactManifest ?? {}) + ? ((entry.artifactManifest as { supportingFiles?: string[] }).supportingFiles ?? []) + : []; + for (const manifestRef of supportingFiles) { pending.push({ ref: manifestRef, base: entryBase }); } while (pending.length > 0) { const item = pending.shift(); + if (!item) break; const resolved = resolveReferencedPath(item.ref, item.base); if (!resolved) continue; let safePath; @@ -254,7 +295,7 @@ export async function buildDeployFilePlan(projectsRoot, projectId, entryName, op try { projectFile = await readProjectFile(projectsRoot, projectId, safePath, options.metadata); } catch (err) { - if (err && err.code === 'ENOENT') { + if (isErrnoException(err) && err.code === 'ENOENT') { missing.push(safePath); continue; } @@ -286,7 +327,7 @@ export async function buildDeployFilePlan(projectsRoot, projectId, entryName, op }; } -export async function buildDeployFileSet(projectsRoot, projectId, entryName, options = {}) { +export async function buildDeployFileSet(projectsRoot: string, projectId: string, entryName: string, options: DeployOptions = {}) { const plan = await buildDeployFilePlan(projectsRoot, projectId, entryName, options); if (plan.missing.length || plan.invalid.length) { const parts = []; @@ -300,7 +341,7 @@ export async function buildDeployFileSet(projectsRoot, projectId, entryName, opt return plan.files; } -export async function deployToVercel({ config, files, projectId }) { +export async function deployToVercel({ config, files, projectId }: { config: DeployConfig; files: DeployFile[]; projectId: string }) { if (!config?.token) { throw new DeployError('Vercel token is required.', 400); } @@ -351,14 +392,15 @@ export async function deployToVercel({ config, files, projectId }) { }; } -export async function listCloudflarePagesZones(config) { +export async function listCloudflarePagesZones(config: DeployConfig) { if (!config?.token) throw new DeployError('Cloudflare API token is required.', 400); if (!config?.accountId) throw new DeployError('Cloudflare account ID is required.', 400); + const accountId = config.accountId; const zones = await fetchCloudflarePaginatedResult( config, (page, perPage) => { const params = new URLSearchParams({ - 'account.id': config.accountId, + 'account.id': accountId, status: 'active', type: 'full', page: String(page), @@ -381,7 +423,7 @@ export async function listCloudflarePagesZones(config) { }; } -export async function deployToCloudflarePages(input) { +export async function deployToCloudflarePages(input: { config: DeployConfig; files: DeployFile[]; projectId?: string; cloudflarePages?: unknown; priorMetadata?: JsonObject | undefined }) { const { config, files, @@ -404,7 +446,7 @@ export async function deployToCloudflarePages(input) { await uploadCloudflarePagesAssets(uploadToken, files); const form = new FormData(); - const manifest = {}; + const manifest: Record = {}; for (const file of files) { manifest[`/${file.file}`] = cloudflarePagesAssetHash(file); } @@ -463,17 +505,18 @@ export async function deployToCloudflarePages(input) { }; } -function normalizeDeploymentLinkStatus(status) { +function normalizeDeploymentLinkStatus(status: unknown): DeployLinkStatus { return status === 'ready' || status === 'protected' || status === 'failed' ? status : 'link-delayed'; } -function normalizeCloudflarePagesDeploySelection(input) { +function normalizeCloudflarePagesDeploySelection(input: unknown): CloudflarePagesDeploySelection | null { if (!input || typeof input !== 'object') return null; - const rawZoneId = typeof input.zoneId === 'string' ? input.zoneId.trim() : ''; - const rawZoneName = typeof input.zoneName === 'string' ? input.zoneName.trim() : ''; - const rawPrefix = typeof input.domainPrefix === 'string' ? input.domainPrefix.trim() : ''; + const source = input as JsonObject; + const rawZoneId = typeof source.zoneId === 'string' ? source.zoneId.trim() : ''; + const rawZoneName = typeof source.zoneName === 'string' ? source.zoneName.trim() : ''; + const rawPrefix = typeof source.domainPrefix === 'string' ? source.domainPrefix.trim() : ''; if (!rawZoneId && !rawZoneName && !rawPrefix) return null; const zoneName = normalizeCloudflareZoneName(rawZoneName); const domainPrefix = normalizeCloudflareDomainPrefix(rawPrefix); @@ -492,7 +535,7 @@ function normalizeCloudflarePagesDeploySelection(input) { }; } -async function validateCloudflarePagesDeploySelection(config, selection) { +async function validateCloudflarePagesDeploySelection(config: DeployConfig, selection: CloudflarePagesDeploySelection | null): Promise { if (!selection) return null; const resp = await fetch(`${CLOUDFLARE_API}/zones/${encodeURIComponent(selection.zoneId)}`, { headers: cloudflareHeaders(config), @@ -521,7 +564,8 @@ async function validateCloudflarePagesDeploySelection(config, selection) { return { ...selection, zoneName }; } -async function setupCloudflarePagesCustomDomain({ config, projectId, selection, pagesDevUrl, priorMetadata }) { +async function setupCloudflarePagesCustomDomain({ config, projectId, selection, pagesDevUrl, priorMetadata }: { config: DeployConfig; projectId: string; selection: CloudflarePagesDeploySelection; pagesDevUrl: string; priorMetadata?: JsonObject | undefined }) { + if (!config.projectName) throw new DeployError('Cloudflare Pages project name could not be generated.', 400); const pagesTarget = normalizeHostname(hostnameFromUrl(pagesDevUrl) || `${config.projectName}.pages.dev`); const marker = cloudflarePagesDnsMarker(projectId, config.projectName, pagesTarget); const base = { @@ -548,9 +592,9 @@ async function setupCloudflarePagesCustomDomain({ config, projectId, selection, return { ...base, status: details.errorCode === 'cloudflare_dns_record_conflict' ? 'conflict' : 'failed', - statusMessage: err?.message || 'Cloudflare DNS record setup failed.', + statusMessage: errorMessage(err, 'Cloudflare DNS record setup failed.'), errorCode: details.errorCode || 'cloudflare_dns_record_failed', - errorMessage: err?.message || 'Cloudflare DNS record setup failed.', + errorMessage: errorMessage(err, 'Cloudflare DNS record setup failed.'), dnsStatus: details.dnsStatus || (details.errorCode === 'cloudflare_dns_record_conflict' ? 'conflict' : 'failed'), dnsRecordId: details.dnsRecordId, dnsOwnership: details.dnsOwnership || 'external', @@ -568,9 +612,9 @@ async function setupCloudflarePagesCustomDomain({ config, projectId, selection, return { ...base, status: details.errorCode === 'cloudflare_domain_already_bound' ? 'conflict' : 'failed', - statusMessage: err?.message || 'Cloudflare Pages custom domain setup failed.', + statusMessage: errorMessage(err, 'Cloudflare Pages custom domain setup failed.'), errorCode: details.errorCode || 'cloudflare_domain_setup_failed', - errorMessage: err?.message || 'Cloudflare Pages custom domain setup failed.', + errorMessage: errorMessage(err, 'Cloudflare Pages custom domain setup failed.'), dnsStatus: dns.dnsStatus, dnsRecordId: dns.dnsRecordId, dnsOwnership: dns.dnsOwnership, @@ -603,7 +647,7 @@ async function setupCloudflarePagesCustomDomain({ config, projectId, selection, }; } -async function ensureCloudflarePagesCnameRecord({ config, selection, target, marker, priorMetadata }) { +async function ensureCloudflarePagesCnameRecord({ config, selection, target, marker, priorMetadata }: { config: DeployConfig; selection: CloudflarePagesDeploySelection; target: string; marker: string; priorMetadata?: JsonObject | undefined }) { const records = await listCloudflareDnsRecords(config, selection.zoneId, selection.hostname); const targetHost = normalizeHostname(target); const exact = findExactCloudflarePagesCname(records, selection, targetHost); @@ -614,7 +658,9 @@ async function ensureCloudflarePagesCnameRecord({ config, selection, target, mar const conflicting = findCloudflarePagesHostnameRecord(records, selection); if (conflicting) { if (canPatchCloudflarePagesCname(conflicting, selection, marker, priorMetadata)) { - const patched = await patchCloudflareDnsRecord(config, selection.zoneId, conflicting.id, { + const conflictingId = conflicting.id; + if (!conflictingId) throw new DeployError('Cloudflare DNS record id is missing.', 502); + const patched = await patchCloudflareDnsRecord(config, selection.zoneId, conflictingId, { type: 'CNAME', name: selection.hostname, content: targetHost, @@ -624,7 +670,7 @@ async function ensureCloudflarePagesCnameRecord({ config, selection, target, mar }); return { dnsStatus: 'patched', - dnsRecordId: patched?.id || conflicting.id, + dnsRecordId: patched?.id || conflictingId, dnsOwnership: 'marked', marker, }; @@ -685,7 +731,7 @@ async function ensureCloudflarePagesCnameRecord({ config, selection, target, mar } } -function findExactCloudflarePagesCname(records, selection, targetHost) { +function findExactCloudflarePagesCname(records: CloudflareDnsRecord[], selection: CloudflarePagesDeploySelection, targetHost: string) { return records.find((record) => ( String(record?.type || '').toUpperCase() === 'CNAME' && normalizeHostname(record?.name) === selection.hostname && @@ -693,11 +739,11 @@ function findExactCloudflarePagesCname(records, selection, targetHost) { )); } -function findCloudflarePagesHostnameRecord(records, selection) { +function findCloudflarePagesHostnameRecord(records: CloudflareDnsRecord[], selection: CloudflarePagesDeploySelection) { return records.find((record) => normalizeHostname(record?.name) === selection.hostname); } -function cloudflarePagesCnameReuseResult(record, marker) { +function cloudflarePagesCnameReuseResult(record: CloudflareDnsRecord, marker: string) { return { dnsStatus: 'reused', dnsRecordId: typeof record.id === 'string' ? record.id : undefined, @@ -706,7 +752,7 @@ function cloudflarePagesCnameReuseResult(record, marker) { }; } -function cloudflarePagesDnsConflictError(selection, conflicting) { +function cloudflarePagesDnsConflictError(selection: CloudflarePagesDeploySelection, conflicting: CloudflareDnsRecord) { return new DeployError( `Cloudflare DNS already has a different record for ${selection.hostname}.`, 409, @@ -719,7 +765,7 @@ function cloudflarePagesDnsConflictError(selection, conflicting) { ); } -async function maybeReuseCloudflarePagesCnameAfterDuplicate({ err, config, selection, targetHost, marker }) { +async function maybeReuseCloudflarePagesCnameAfterDuplicate({ err, config, selection, targetHost, marker }: { err: unknown; config: DeployConfig; selection: CloudflarePagesDeploySelection; targetHost: string; marker: string }) { if (!(err instanceof DeployError) || !isCloudflareAlreadyExists(err.details || err.message)) return null; const racedRecords = await listCloudflareDnsRecords(config, selection.zoneId, selection.hostname); const exact = findExactCloudflarePagesCname(racedRecords, selection, targetHost); @@ -729,7 +775,7 @@ async function maybeReuseCloudflarePagesCnameAfterDuplicate({ err, config, selec throw err; } -async function listCloudflareDnsRecords(config, zoneId, hostname) { +async function listCloudflareDnsRecords(config: DeployConfig, zoneId: string, hostname: string): Promise { const params = new URLSearchParams({ name: hostname, per_page: '100', @@ -744,7 +790,7 @@ async function listCloudflareDnsRecords(config, zoneId, hostname) { return Array.isArray(json?.result) ? json.result : []; } -async function createCloudflareDnsRecord(config, zoneId, body) { +async function createCloudflareDnsRecord(config: DeployConfig, zoneId: string, body: JsonObject) { const resp = await fetch(cloudflareZoneDnsRecordsUrl(zoneId), { method: 'POST', headers: cloudflareHeaders(config, { 'Content-Type': 'application/json' }), @@ -757,7 +803,7 @@ async function createCloudflareDnsRecord(config, zoneId, body) { return json?.result ?? json; } -async function patchCloudflareDnsRecord(config, zoneId, dnsRecordId, body) { +async function patchCloudflareDnsRecord(config: DeployConfig, zoneId: string, dnsRecordId: string, body: JsonObject) { const resp = await fetch(`${cloudflareZoneDnsRecordsUrl(zoneId)}/${encodeURIComponent(dnsRecordId)}`, { method: 'PATCH', headers: cloudflareHeaders(config, { 'Content-Type': 'application/json' }), @@ -770,7 +816,7 @@ async function patchCloudflareDnsRecord(config, zoneId, dnsRecordId, body) { return json?.result ?? json; } -function canPatchCloudflarePagesCname(record, selection, marker, priorMetadata) { +function canPatchCloudflarePagesCname(record: CloudflareDnsRecord, selection: CloudflarePagesDeploySelection, marker: string, priorMetadata?: JsonObject) { const prior = priorMetadata?.cloudflarePagesCustomDomain; return ( record && @@ -784,7 +830,7 @@ function canPatchCloudflarePagesCname(record, selection, marker, priorMetadata) ); } -async function ensureCloudflarePagesDomain(config, hostname) { +async function ensureCloudflarePagesDomain(config: DeployConfig, hostname: string) { const existing = await findCloudflarePagesDomain(config, hostname); if (existing) return existing; @@ -812,36 +858,36 @@ async function ensureCloudflarePagesDomain(config, hostname) { return json?.result ?? json; } -async function findCloudflarePagesDomain(config, hostname) { - const domains = await fetchCloudflarePaginatedResult( - config, - (page, perPage) => { - const params = new URLSearchParams({ - page: String(page), - per_page: String(perPage), - }); - return `${cloudflarePagesProjectUrl(config, 'domains')}?${params.toString()}`; - }, - 'Cloudflare Pages custom domain lookup failed.', - ); - return domains.find((domain) => normalizeHostname(domain?.name) === normalizeHostname(hostname)) || null; +async function findCloudflarePagesDomain(config: DeployConfig, hostname: string) { + const normalizedHostname = normalizeHostname(hostname); + if (!normalizedHostname) return null; + const resp = await fetch(cloudflarePagesProjectDomainUrl(config, normalizedHostname), { + headers: cloudflareHeaders(config), + }); + const json = await readCloudflareJson(resp); + if (resp.status === 404) return null; + if (!resp.ok || json?.success === false) { + throw cloudflareError(json, resp.status, 'Cloudflare Pages custom domain lookup failed.'); + } + const domain = json?.result ?? json; + return normalizeHostname(domain?.name) === normalizedHostname ? domain : null; } -export async function readCloudflarePagesDomain(config, hostname) { +export async function readCloudflarePagesDomain(config: DeployConfig, hostname: string) { if (!config?.token) throw new DeployError('Cloudflare API token is required.', 400); if (!config?.accountId) throw new DeployError('Cloudflare account ID is required.', 400); if (!config?.projectName) throw new DeployError('Cloudflare Pages project name could not be generated.', 400); return findCloudflarePagesDomain(config, hostname); } -function normalizeCloudflarePagesDomainStatus(status) { +function normalizeCloudflarePagesDomainStatus(status: unknown) { const value = String(status || '').toLowerCase(); if (value === 'active') return 'active'; if (value === 'error' || value === 'blocked' || value === 'deactivated') return 'failed'; return 'pending'; } -export function aggregateCloudflarePagesStatus(pagesDev, customDomain) { +export function aggregateCloudflarePagesStatus(pagesDev: JsonObject, customDomain?: JsonObject) { if (!customDomain) { return { status: pagesDev.status, @@ -871,7 +917,7 @@ export function aggregateCloudflarePagesStatus(pagesDev, customDomain) { }; } -function cloudflarePagesProviderMetadata(projectName, cloudflarePagesInfo, { projectId = '' } = {}) { +function cloudflarePagesProviderMetadata(projectName: string, cloudflarePagesInfo: JsonObject, { projectId = '' }: { projectId?: string } = {}) { const custom = cloudflarePagesInfo?.customDomain; return { cloudflarePagesProjectName: projectName, @@ -892,7 +938,7 @@ function cloudflarePagesProviderMetadata(projectName, cloudflarePagesInfo, { pro }; } -async function ensureCloudflarePagesProject(config) { +async function ensureCloudflarePagesProject(config: DeployConfig) { const getResp = await fetch(cloudflarePagesProjectUrl(config), { headers: cloudflareHeaders(config), }); @@ -926,7 +972,7 @@ async function ensureCloudflarePagesProject(config) { return created?.result ?? created; } -function isCloudflarePagesProjectAlreadyExists(body) { +function isCloudflarePagesProjectAlreadyExists(body: unknown) { const text = JSON.stringify(body || {}).toLowerCase(); return ( text.includes('already exists') || @@ -937,7 +983,7 @@ function isCloudflarePagesProjectAlreadyExists(body) { ); } -async function getCloudflarePagesUploadToken(config) { +async function getCloudflarePagesUploadToken(config: DeployConfig): Promise { const tokenResp = await fetch(cloudflarePagesProjectUrl(config, 'upload-token'), { headers: cloudflareHeaders(config), }); @@ -949,8 +995,8 @@ async function getCloudflarePagesUploadToken(config) { return jwt; } -async function uploadCloudflarePagesAssets(uploadToken, files) { - const uniqueFiles = new Map(); +async function uploadCloudflarePagesAssets(uploadToken: string, files: DeployFile[]) { + const uniqueFiles = new Map(); for (const file of files) { const data = Buffer.from(file.data); if (data.length > CLOUDFLARE_PAGES_ASSET_MAX_BYTES) { @@ -1013,14 +1059,14 @@ async function uploadCloudflarePagesAssets(uploadToken, files) { } export function chunkCloudflarePagesAssetUploads( - files, + files: { hash: string; data: Buffer | Uint8Array | string; contentType?: string }[], { maxFiles = CLOUDFLARE_PAGES_ASSET_UPLOAD_MAX_FILES, maxBytes = CLOUDFLARE_PAGES_ASSET_UPLOAD_MAX_BODY_BYTES, } = {}, ) { - const chunks = []; - let current = []; + const chunks: typeof files[] = []; + let current: typeof files = []; let currentBytes = 2; // JSON array brackets. for (const file of files) { @@ -1040,7 +1086,7 @@ export function chunkCloudflarePagesAssetUploads( return chunks; } -function estimateCloudflarePagesAssetUploadPayloadBytes(file) { +function estimateCloudflarePagesAssetUploadPayloadBytes(file: { hash?: string; data?: Buffer | Uint8Array | string; contentType?: string }) { const data = Buffer.from(file?.data ?? ''); const encodedBytes = Math.ceil(data.length / 3) * 4; const contentTypeBytes = Buffer.byteLength(file?.contentType || 'application/octet-stream'); @@ -1049,7 +1095,7 @@ function estimateCloudflarePagesAssetUploadPayloadBytes(file) { return encodedBytes + contentTypeBytes + hashBytes + 128; } -async function cloudflarePagesMissingAssetHashes(uploadToken, hashes) { +async function cloudflarePagesMissingAssetHashes(uploadToken: string, hashes: string[]): Promise { const resp = await fetch(`${CLOUDFLARE_API}/pages/assets/check-missing`, { method: 'POST', headers: cloudflareAssetHeaders(uploadToken, { 'Content-Type': 'application/json' }), @@ -1063,14 +1109,14 @@ async function cloudflarePagesMissingAssetHashes(uploadToken, hashes) { return Array.isArray(result) ? result : Array.isArray(result?.hashes) ? result.hashes : hashes; } -export function cloudflarePagesAssetHash(file) { +export function cloudflarePagesAssetHash(file: Pick) { const data = Buffer.from(file.data); const extension = path.posix.extname(file.file).slice(1); return blake3Hash(`${data.toString('base64')}${extension}`).toString('hex').slice(0, 32); } -export function extractHtmlReferences(html) { - const refs = []; +export function extractHtmlReferences(html: string) { + const refs: string[] = []; for (const tag of parseHtmlTags(html)) { const attrs = parseHtmlAttributes(tag.attrs); for (const name of ['src', 'poster']) { @@ -1098,13 +1144,13 @@ export function extractHtmlReferences(html) { const CSS_URL_REGEX = /url\(\s*(['"]?)([^)]*?)\1\s*\)/gi; const CSS_IMPORT_REGEX = /@import\s+(?:url\(\s*)?(['"])([^'"]*?)\1/gi; -export function extractCssReferences(css) { - const refs = []; +export function extractCssReferences(css: string) { + const refs: string[] = []; const urlRe = new RegExp(CSS_URL_REGEX.source, CSS_URL_REGEX.flags); let match; - while ((match = urlRe.exec(css))) refs.push(match[2]); + while ((match = urlRe.exec(css))) refs.push(match[2] ?? ''); const importRe = new RegExp(CSS_IMPORT_REGEX.source, CSS_IMPORT_REGEX.flags); - while ((match = importRe.exec(css))) refs.push(match[2]); + while ((match = importRe.exec(css))) refs.push(match[2] ?? ''); return refs; } @@ -1116,16 +1162,16 @@ export function extractCssReferences(css) { // Style-like text that lives inside `