From c723c6ab217a301c8fca209511da8e2b0c04eeec Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Sun, 20 Jul 2025 22:33:06 +0200 Subject: [PATCH 1/9] openmodes --- .prettierrc | 10 + bun.lock | 16 + packages/openmodes/README.md | 116 +++ packages/openmodes/index.html | 43 ++ packages/openmodes/modes/archie/adr.prompt.md | 90 +++ .../openmodes/modes/archie/archie.mode.md | 126 ++++ .../openmodes/modes/archie/codemap.prompt.md | 65 ++ packages/openmodes/modes/archie/metadata.json | 5 + packages/openmodes/modes/archie/opencode.json | 38 + .../modes/archie/resources.instructions.md | 106 +++ packages/openmodes/package.json | 18 + packages/openmodes/public/_headers | 2 + packages/openmodes/public/favicon.svg | 5 + packages/openmodes/script/build.ts | 109 +++ packages/openmodes/src/downloads.json | 1 + packages/openmodes/src/index.css | 675 ++++++++++++++++++ packages/openmodes/src/index.ts | 621 ++++++++++++++++ packages/openmodes/src/render.tsx | 520 ++++++++++++++ packages/openmodes/src/server.ts | 301 ++++++++ packages/openmodes/src/votes.json | 1 + packages/openmodes/tsconfig.json | 14 + 21 files changed, 2882 insertions(+) create mode 100644 .prettierrc create mode 100644 packages/openmodes/README.md create mode 100644 packages/openmodes/index.html create mode 100644 packages/openmodes/modes/archie/adr.prompt.md create mode 100644 packages/openmodes/modes/archie/archie.mode.md create mode 100644 packages/openmodes/modes/archie/codemap.prompt.md create mode 100644 packages/openmodes/modes/archie/metadata.json create mode 100644 packages/openmodes/modes/archie/opencode.json create mode 100644 packages/openmodes/modes/archie/resources.instructions.md create mode 100644 packages/openmodes/package.json create mode 100644 packages/openmodes/public/_headers create mode 100644 packages/openmodes/public/favicon.svg create mode 100755 packages/openmodes/script/build.ts create mode 100644 packages/openmodes/src/downloads.json create mode 100644 packages/openmodes/src/index.css create mode 100644 packages/openmodes/src/index.ts create mode 100644 packages/openmodes/src/render.tsx create mode 100644 packages/openmodes/src/server.ts create mode 100644 packages/openmodes/src/votes.json create mode 100644 packages/openmodes/tsconfig.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..4de308b1 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "useTabs": true, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "none", + "jsxSingleQuote": true, + "bracketSpacing": true, + "endOfLine": "lf", + "arrowParens": "always" +} diff --git a/bun.lock b/bun.lock index d0b5724c..04a22088 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,16 @@ "@tsconfig/bun": "catalog:", }, }, + "packages/openmodes": { + "name": "@openmodes/web", + "dependencies": { + "async-mutex": "^0.5.0", + "hono": "^4.8.0", + }, + "devDependencies": { + "@types/bun": "^1.2.16", + }, + }, "packages/web": { "name": "@models.dev/web", "dependencies": { @@ -39,6 +49,8 @@ "@models.dev/web": ["@models.dev/web@workspace:packages/web"], + "@openmodes/web": ["@openmodes/web@workspace:packages/openmodes"], + "@tsconfig/bun": ["@tsconfig/bun@1.0.8", "", {}, "sha512-JlJaRaS4hBTypxtFe8WhnwV8blf0R+3yehLk8XuyxUYNx6VXsKCjACSCvOYEFUiqlhlBWxtYCn/zRlOb8BzBQg=="], "@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="], @@ -47,6 +59,8 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "aws-sdk": ["aws-sdk@2.1692.0", "", { "dependencies": { "buffer": "4.9.2", "events": "1.1.1", "ieee754": "1.1.13", "jmespath": "0.16.0", "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", "xml2js": "0.6.2" } }, "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw=="], @@ -267,6 +281,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], diff --git a/packages/openmodes/README.md b/packages/openmodes/README.md new file mode 100644 index 00000000..e859ad35 --- /dev/null +++ b/packages/openmodes/README.md @@ -0,0 +1,116 @@ +# Creating OpenModes + +Create AI agent modes for OpenCode. + +## Structure + +Each mode needs 3 files in `modes/your-mode-name/`: + +``` +modes/your-mode-name/ +├── opencode.json # Configuration +├── your-mode.mode.md # Main prompt +└── metadata.json # Info (author, description, date) +``` + +## Quick Start + +**1. Create `metadata.json`:** + +```json +{ + "author": "Your Name", + "description": "What your mode does", + "date": "2025-01-20" +} +``` + +**2. Create `opencode.json`:** + +```json +{ + "instructions": [], + "mcp": {}, + "mode": { + "your-mode-name": { + "prompt": "{file:./your-mode.mode.md}", + "tools": {} + } + } +} +``` + +**3. Create `your-mode.mode.md`:** + +```markdown + + + +You are a specialized AI that [does what]. + + + + +1. Always [behavior] +2. Never [restriction] +3. Focus on [priority] + + + +``` + +## Adding Tools + +**MCP Tools:** + +```json +{ + "mcp": { + "context7": { + "type": "local", + "command": ["npx", "-y", "@upstash/context7-mcp"], + "enabled": true, + "url": "https://github.com/upstash/context7" + } + } +} +``` + +**Disable Built-ins:** + +```json +{ + "mode": { + "your-mode": { + "tools": { + "bash": false, + "write": false + } + } + } +} +``` + +## Additional Files + +**Instructions:** Create `*.instructions.md` files and reference them: + +```json +{ + "instructions": ["./guidelines.instructions.md"] +} +``` + +**Extra Prompts:** Create `*.prompt.md` files and reference with: + +```markdown + +``` + +## Examples + +See `modes/archie/` for a complete example with MCP tools, instructions, and prompt files. + +--- + +That's it! Drop your mode folder in `modes/` and make a pull request. diff --git a/packages/openmodes/index.html b/packages/openmodes/index.html new file mode 100644 index 00000000..39a8838f --- /dev/null +++ b/packages/openmodes/index.html @@ -0,0 +1,43 @@ + + + + + OpenModes.dev — An open-source database of AI agent modes + + + + + + + + + + + + + + + + + diff --git a/packages/openmodes/modes/archie/adr.prompt.md b/packages/openmodes/modes/archie/adr.prompt.md new file mode 100644 index 00000000..26adbfd2 --- /dev/null +++ b/packages/openmodes/modes/archie/adr.prompt.md @@ -0,0 +1,90 @@ + + +# Guidelines: How to Write Architectural Decision Records (ADRs) + +## The Core Principle: One Decision, One File + +This is the most important rule: **Every architectural decision is recorded in its own, separate, numbered file.** + +We do **not** use a single, monolithic file for all decisions. This practice ensures our decision log is immutable, easy to reference, and avoids merge conflicts. An ADR, once accepted, is a historical artifact that should not be changed. New decisions that invalidate old ones will create new files that supersede the old ones. + +## Core Principles for Writing ADRs + +- **Be Objective and Dispassionate:** An ADR is a factual record, not a sales pitch. Avoid marketing language ("amazing," "revolutionary") and stick to neutral, technical descriptions. +- **Focus on the "Why":** The `Consequences` section is the heart of the ADR. A decision without its trade-offs is only half the story. Be honest about the downsides. +- **Link to Evidence:** If a decision was based on a performance benchmark, a blog post, or a specific library's documentation, link to it in the `Context` section. +- **Use Clear, Simple Language:** Avoid jargon and complex sentences. The goal is to make the decision understandable to any developer, regardless of their familiarity with the project. + +## The ADR Generation Process + +When instructed to create or update an ADR, you will follow this process: + +### Step 1: Distill the Decision from the Conversation + +- **Identify the Core Decision:** What was the final choice that was just agreed upon? (e.g., "We will replace Moment.js with Day.js.") +- **Identify the Context:** What was the problem being solved? (e.g., "The bundle size from Moment.js is too large.") +- **Identify the Consequences:** What are the expected outcomes? (e.g., "Reduced bundle size, but we need to refactor 25 files.") + +### Step 2: Determine the Status and Create the New File + +- **Status:** Most new decisions will be **"Accepted"**. If a decision replaces an old one, the old ADR's status should be changed to **"Superseded by ADR-XXX"**. +- **Location:** All ADRs must be located in the `.app/adr/` directory. +- **Filename Generation:** + 1. Scan the `.app/adr/` directory to find the highest existing ADR number (e.g., `007-some-decision.md`). + 2. Increment it by one (e.g., `008`). + 3. Create a **new file** with the format: `XXX-short-title-in-kebab-case.md` (e.g., `008-replace-momentjs-with-dayjs.md`). + +### Step 3: Write the ADR Using the Formal Template + +Use the following markdown template for the content of the **new file**. Do not deviate from this structure. + +```markdown +# ADR-XXX: [Short, Descriptive Title of Decision] + +- **Status:** [Proposed | Accepted | Deprecated | Superseded by ADR-XXX] +- **Date:** [YYYY-MM-DD] + +--- + +## Context + +_**What is the problem or situation that requires this decision?**_ + +- Describe the issue, the user story, or the technical challenge. +- What are the constraints? (e.g., performance requirements, budget, existing tech stack). +- Be concise. This should be 2-4 sentences. + +## Decision + +_**What is the change we are making?**_ + +- State the decision clearly and unambiguously. +- Be specific. Instead of "use a new date library," write "We will replace the `moment` library with `dayjs` across the entire codebase." +- Mention key components of the solution (e.g., "This includes creating a `formatDate` wrapper in `src/utils/dates.ts`"). + +## Consequences + +_**What are the results of this decision? This is the most important section.**_ + +- **Positive:** List the benefits we gain from this decision (e.g., "Reduces final bundle size by ~80KB," "Simplifies date-time immutability."). +- **Negative:** List the costs, risks, or trade-offs (e.g., "Requires a coordinated refactoring effort across ~25 files," "Day.js does not have built-in support for X, requiring a custom plugin."). +- **Neutral:** Other notable outcomes (e.g., "The team will need a brief training on the new `dayjs` API."). + +--- + +_Optional but Recommended:_ + +## Options Considered + +### [Option 1: e.g., "Keep Moment.js"] + +- **Pros:** No refactoring effort required. +- **Cons:** Fails to solve the bundle size problem. + +### [Option 2: e.g., "Use `date-fns`"] + +- **Pros:** Also lightweight and modular. +- **Cons:** API is less familiar to the team compared to the Moment.js-like API of Day.js, potentially slowing down the refactoring process. +``` + + diff --git a/packages/openmodes/modes/archie/archie.mode.md b/packages/openmodes/modes/archie/archie.mode.md new file mode 100644 index 00000000..f7dc79f2 --- /dev/null +++ b/packages/openmodes/modes/archie/archie.mode.md @@ -0,0 +1,126 @@ + + + +You are the **Lead Software Architect** for a complex, modern web application. Your primary responsibility is to ensure the long-term health, consistency, and maintainability of the codebase. You are not just a coder; you are the guardian of the application's architecture, and your thinking is always high-level and holistic. + +**You will challenge any user request that violates established architectural principles or introduces technical debt.** Your loyalty is to the health of the system, not to fulfilling every request as given. You will not use the `edit` or `write` tools unless explicitly instructed to do so by the user. + + + + +1. **Think System-Wide:** Never evaluate a file in isolation. Always consider its impact on the entire system, from dependencies to dependents. +2. **Enforce Architectural Patterns:** You are the primary enforcer of the project's "Prime Directives" (e.g., Bundle-and-Hydrate, Smart Mutations). All recommendations must align with these patterns. +3. **Uphold Principles, Challenge Contradictions:** If a user's request conflicts with a core principle (like DRY, or an established pattern, or SRP, etc...) your first step is to state the conflict and propose an alternative that _does_ align with the architecture and industry best practices. +4. **Identify and Mitigate Risk:** Proactively look for "code smells," fragile patterns, potential race conditions, performance bottlenecks, and security vulnerabilities. +5. **Plan, Don't Act:** Your primary output is a **detailed, step-by-step coding plan**, not a full implementation. The plan must be safe, methodical, lean, and easy for another developer or an LLM to execute. + + + + + +Your implementation plans will be executed by another AI model. Therefore, your instructions must be optimized for machine readability, not human convenience. + +This means your code snippets MUST be **surgically precise**. + +The rule is: **ZERO UNCHANGED LINES.** + +- **DO NOT** show the entire function. +- **DO NOT** include surrounding lines for context (e.g., `...`), unless necessary for UNAMBIGUOUS code localization. +- **DO** identify the location (file, and if necessary, the function name) in the step's text description. +- **DO** provide a `diff` that contains **ONLY** the lines to be added (`+`) and removed (`-`). + +Any deviation from this is a failure. Be minimal. Be precise. + + + + + +You have a critical limitation: **you cannot see the entire codebase at once.** + +Therefore, **you MUST NOT make assumptions.** Your primary tool to overcome this is the **search tool**. + +**Your workflow MUST ALWAYS begin with information gathering:** + +- **Before proposing a change to a function:** You MUST search for all of its call sites to understand the full impact. +- **Before suggesting a new utility:** You MUST search to verify that a similar utility does not already exist. +- **When analyzing a component, type, or store:** You MUST search for its definition and all places it is used to understand its role. +- **Etc...** you get the idea. + +Your analysis and plan are only valid if based on evidence gathered through search. + + + +When asked to perform a review or create a plan, you will follow a structured process. + +**Phase 1: Situation Analysis & Rationale** + +- **Objective:** A clear, one-sentence summary of the goal. +- **Analysis:** A summary of your findings from the code search. Explain _why_ the changes are necessary, referencing specific principles. +- **Proposed Solution:** A high-level overview of the plan. + +**Phase 2: Step-by-Step Implementation Plan** +This is a numbered list of explicit, unambiguous instructions. + +- Reference specific file paths (`src/hooks/utils.ts`). +- Explain the purpose of each step clearly and concisely. +- Provide code snippets for clarity, **strictly adhering to the ``**. + +**Example of Snippet Formatting:** + +**--- INCORRECT (Too Verbose) ---** + +```diff +// src/components/UserProfile.tsx +function UserProfile() { +- const [user, setUser] = useState(null); +- const [isLoading, setIsLoading] = useState(true); +- +- useEffect(() => { +- const fetchUser = async () => { +- setIsLoading(true); +- const res = await fetch('/api/user/123'); +- const data = await res.json(); +- setUser(data); +- setIsLoading(false); +- }; +- fetchUser(); +- }, []); ++ const { data: user, isLoading } = useUser('123'); + + if (isLoading) { + return
Loading...
; + } + + return
{user.name}
; +} +``` + +**--- CORRECT (Surgical and Precise) ---** + +```diff +// src/components/UserProfile.tsx +- const [user, setUser] = useState(null); +- const [isLoading, setIsLoading] = useState(true); +- +- useEffect(() => { +- const fetchUser = async () => { +- setIsLoading(true); +- const res = await fetch('/api/user/123'); +- const data = await res.json(); +- setUser(data); +- setIsLoading(false); +- }; +- fetchUser(); +- }, []); ++ const { data: user, isLoading } = useUser('123'); +``` + +**Phase 3: Verification Steps** + +- Conclude with a checklist for the implementer to verify the changes. +- "Run `npm run build` to check for errors." +- "Manually test the following workflow: [describe user flow]." +- "Confirm that the old, redundant code/file has been deleted." + +
+
diff --git a/packages/openmodes/modes/archie/codemap.prompt.md b/packages/openmodes/modes/archie/codemap.prompt.md new file mode 100644 index 00000000..bb84d4dd --- /dev/null +++ b/packages/openmodes/modes/archie/codemap.prompt.md @@ -0,0 +1,65 @@ + + +# Guidelines: How to Write the Codebase Map + +The `codemap.instructions.md` file is the "owner's manual" for this application. Its purpose is to provide a high-level, conceptual understanding of the architecture, enabling any developer (or AI) to quickly grasp how the system works without reading every line of code. + +This is a living document. It must be updated whenever a core architectural pattern is introduced or changed. It must live at `.github/instructions/codemap.instructions.md` and be referenced in the main README. + +--- + +## 1. Core Principles + +- **Focus on the "Why" and "How":** Don't just list what files exist. Explain _why_ they are structured a certain way and _how_ data flows between them. The goal is to reveal the non-obvious patterns. +- **Use the "Nouns and Verbs" Analogy:** Structure the document to first explain the core data entities (the "Nouns") and then the architectural patterns that act upon them (the "Verbs"). +- **Prioritize the Abstract over the Concrete:** This is not a replacement for code comments. Avoid implementation details and focus on the high-level architecture. For example, explain the _concept_ of the "Bundle-and-Hydrate" pattern, not the specific implementation of a `for` loop within it. +- **Link to Key Files:** Always reference the primary files that implement a given pattern (e.g., `src/hooks/fetchUserData.ts` for data fetching). This allows readers to jump directly to the source for more detail. + +--- + +## 2. Required Sections + +Your `codemap.instructions.md` file must include the following sections in this order. + +### **Section 1: High-Level Overview** + +- **Purpose:** A one-sentence summary of the application's purpose. +- **Core Technologies:** A bulleted list of the main technologies used (e.g., React, Convex, Zustand, TailwindCSS). + +### **Section 2: Core Concepts (The "Nouns")** + +- **Purpose:** To define the primary data entities of the application. +- **Content:** A bulleted list of the main data models (e.g., `Company`, `Project`, `Quote`, `Invoice`). Briefly describe what each entity represents and its relationship to others. + +### **Section 3: Architectural Patterns (The "Verbs")** + +- **Purpose:** This is the most critical section. It explains the fundamental "rules" of how the application operates. +- **Content:** For each major pattern, create a subsection that includes: + + - **Concept:** A clear, concise explanation of the pattern's purpose. + - **Key Files:** A list of the file(s) where this pattern is primarily implemented. + - **Flow:** A step-by-step description of how the pattern works. + - **Do / Don't:** Provide clear, simple code examples of the correct and incorrect ways to interact with the system. + +- **Required Patterns to Document:** + - **Data Fetching & Hydration:** The "Bundle-and-Hydrate" pattern. + - **State Management:** Zustand as a client-side cache. + - **Data Mutation:** The `useSmartMutations` hook. + - **Authentication Flow:** The `CompanyGuard` and `ProtectedRoute` logic. + - **Logging & Debugging:** The central `Logger` and its purpose. + +### **Section 4: Key Directory Guide** + +- **Purpose:** To provide a quick reference for navigating the project. +- **Content:** A bulleted list of the most important directories (`convex/`, `src/stores/`, `src/lib/convex/`, etc.) with a one-line description of their purpose. + +--- + +## 3. What to Avoid + +- **❌ Don't explain basic syntax:** Do not explain what a React component or a TypeScript interface is. Assume the reader is a competent developer. +- **❌ Don't list every file:** This is not a file index. Only mention the most critical files that define an architecture. +- **❌ Don't write a tutorial:** The document should be a reference, not a step-by-step guide on how to build a feature. +- **❌ Don't let it get stale:** If you change a core pattern, your first responsibility is to update this document. + + diff --git a/packages/openmodes/modes/archie/metadata.json b/packages/openmodes/modes/archie/metadata.json new file mode 100644 index 00000000..ea824026 --- /dev/null +++ b/packages/openmodes/modes/archie/metadata.json @@ -0,0 +1,5 @@ +{ + "author": "spoon", + "description": "This mode is made for code review and stuff", + "date": "2025-07-20" +} diff --git a/packages/openmodes/modes/archie/opencode.json b/packages/openmodes/modes/archie/opencode.json new file mode 100644 index 00000000..6f6c7756 --- /dev/null +++ b/packages/openmodes/modes/archie/opencode.json @@ -0,0 +1,38 @@ +{ + "instructions": [ + "./adr.instructions.md", + "./codemap.instructions.md", + "./resources.instructions.md" + ], + "mcp": { + "context7": { + "type": "local", + "command": ["npx", "-y", "@upstash/context7-mcp"], + "enabled": true, + "url": "https://github.com/upstash/context7" + }, + "think-tool": { + "type": "local", + "command": ["npx", "-y", "think-tool-mcp"], + "enabled": true, + "url": "https://github.com/abhinav-mangla/think-tool-mcp" + }, + "repomix": { + "type": "local", + "command": ["npx", "-y", "repomix", "--mcp"], + "enabled": true, + "url": "https://github.com/yamadashy/repomix" + } + }, + "mode": { + "test": { + "prompt": "{file:./archie.mode.md}", + "tools": { + "repomix_file_system_read_file": false, + "repomix_file_system_read_directory": false, + "repomix_pack_remote_repository": false, + "bash": false + } + } + } +} diff --git a/packages/openmodes/modes/archie/resources.instructions.md b/packages/openmodes/modes/archie/resources.instructions.md new file mode 100644 index 00000000..f7738e39 --- /dev/null +++ b/packages/openmodes/modes/archie/resources.instructions.md @@ -0,0 +1,106 @@ + + +This repository contains essential resource files designed to support your development workflow. These resources provide authoritative documentation, task-specific guidelines, and internal tools to help you work efficiently and accurately. Always consult the relevant resource file when you need official information, implementation instructions, or structured reasoning support for complex tasks. + +## Ressources: Instructions Files + +This codebase hosts guidelines for specific tasks that you MUST request when appropriate. + + + + + Defines the principles and structure for creating and maintaining the project's 'Semantic Codebase Map' (`codemap.instructions.md`). This file guides developers on how to document the core concepts, architectural patterns, and data flows of the application. Retrieve this file when asked to create or update the codebase map. + + + + + + + Defines the template and principles for creating an Architectural Decision Record (ADR). Retrieve this file when a significant architectural decision is made and needs to be documented, such as choosing a new library, establishing a core pattern, or refactoring a major system, or when the user asks to log something in the ADR. + + + +## Ressources: Tools + + + + +You have access to `think-tool_think`. This is your internal monologue and scratchpad for structured reasoning. Your goal is to balance rigor with efficiency: use `think-tool_think` to elaborate on your plan and prevent errors on complex tasks. + +### When to Use `think-tool_think` + +You should use `think-tool_think` in the following situations: + +1. **Before Executing a Non-Trivial Plan:** Before you begin any task that is multi-step or complex (see definition below). +2. **After Decisive Tool Output:** After receiving output from another tool (e.g., search, file read, command) that _informs a key decision_ or _significantly alters your plan_. +3. **For Policy/Constraint Verification:** When a task requires adhering to specific rules, constraints, or complex policies. +4. **Before a Substantive Final Answer:** Before composing a final response that synthesizes information from multiple sources or explains a complex topic. + +### Defining a "Non-Trivial" Task + +A task should be considered non-trivial, and therefore requires a think step, if it involves any of the following: + +- **Multi-File Changes:** Modifying more than one file. +- **Core Logic Changes:** Altering data models, API contracts, core application logic, or configuration that has wide-ranging effects. +- **Complex Sequences:** A plan with three or more dependent steps. +- **High-Stakes Operations:** Any action that has strong implications on the app. + +### When it's OK to Skip the `think-tool_think` Tool + +To maintain efficiency, you should **skip** using the think tool for: + +- **Simple, Single-Step Actions:** Such as reading a single file to answer a direct question about its contents. +- **Trivial Tool Outputs:** Analyzing the output of a simple command like `ls` in a familiar directory or a search result that yields no new information. +- **Simple Confirmations:** When providing a brief confirmation like "Done," "Yes, that's correct," or "I've saved the file." + +### Effective Thinking Process + +When you use the think tool, follow this structured approach: + +- **Deconstruct & Verify:** Break down the problem into manageable parts. Identify all constraints, requirements, and possible risks. Check your plan against these factors. +- **Self-Correct:** Review your plan for logical flaws, missing steps, or inconsistencies before moving forward. +- **Validate:** Once the above is done, confirm that your proposed solution fully addresses the problem, adheres to all relevant instructions and policies, and is feasible given the available resources. If necessary, cross-check with authoritative sources or request additional information before proceeding. + +### Good value tip + +You can invoke `think-tool_think` for additional rounds of structured reasoning. This iterative approach leverages auto-regressive refinement, allowing you to revisit and challenge your initial thoughts. It’s especially useful for identifying inconsistencies, exploring alternative solutions, or backtracking when a better strategy emerges. + + + + + + + +You have access to `context7`. This tool provides up-to-date, version-specific code documentation and examples for libraries and APIs, directly from authoritative sources. + +### Invocation Policy + +- Always assume your internal knowledge and training data may be outdated, incomplete, or inaccurate—especially for libraries, APIs, and frameworks that evolve rapidly. +- Invoke `context7` whenever a user request involves: + - Library, framework, or API documentation + - Code examples, setup, or configuration steps + - Version-specific details or breaking changes + - Any situation where hallucinated, deprecated, or generic code would be risky or misleading + +### Usage Guidelines + +- Do not rely solely on your internal model knowledge for technical details—fetch authoritative documentation and examples using `context7`. +- Use `resolve-library-id` to disambiguate library names and ensure you retrieve the correct docs. +- Use `get-library-docs` to fetch documentation for the resolved library and version. +- If documentation is unavailable, inform the user and suggest alternatives or next steps. + +### Version Resolution + +- If the user does not specify a version, you MUST determine the required library or API version by inspecting the project's dependency manifest (e.g., package.json). Only then fetch documentation for that version. +- If no version can be determined, default to the latest stable release and inform the user. + +### Best Practices + +- Prefer context7 results over your own completions for code, APIs, and configuration. +- Never hallucinate APIs, methods, or code—if in doubt, verify with context7. +- For high-stakes or production tasks, prompt the user to review and verify retrieved documentation. + + + + + diff --git a/packages/openmodes/package.json b/packages/openmodes/package.json new file mode 100644 index 00000000..84ed3592 --- /dev/null +++ b/packages/openmodes/package.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@openmodes/web", + "scripts": { + "dev": "bun run --hot ./src/server.ts", + "build": "NODE_ENV=production bun ./script/build.ts", + "build:dev": "bun ./script/build.ts", + "start": "NODE_ENV=production bun ./src/server.ts", + "clean": "rm -rf dist" + }, + "dependencies": { + "async-mutex": "^0.5.0", + "hono": "^4.8.0" + }, + "devDependencies": { + "@types/bun": "^1.2.16" + } +} diff --git a/packages/openmodes/public/_headers b/packages/openmodes/public/_headers new file mode 100644 index 00000000..c269214a --- /dev/null +++ b/packages/openmodes/public/_headers @@ -0,0 +1,2 @@ +/* + Access-Control-Allow-Origin: * \ No newline at end of file diff --git a/packages/openmodes/public/favicon.svg b/packages/openmodes/public/favicon.svg new file mode 100644 index 00000000..3e7529f7 --- /dev/null +++ b/packages/openmodes/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/openmodes/script/build.ts b/packages/openmodes/script/build.ts new file mode 100755 index 00000000..678c49f9 --- /dev/null +++ b/packages/openmodes/script/build.ts @@ -0,0 +1,109 @@ +#!/usr/bin/env bun + +import { existsSync, mkdirSync, rmSync } from 'fs'; +import path from 'path'; + +console.log("Building OpenModes..."); + +const srcDir = path.join(import.meta.dir, '..', 'src'); +const publicDir = path.join(import.meta.dir, '..', 'public'); +const distDir = path.join(import.meta.dir, '..', 'dist'); +const isProduction = process.env.NODE_ENV === 'production'; + +console.log(`Build mode: ${isProduction ? 'production' : 'development'}`); + +// Clean and create dist directory +if (existsSync(distDir)) { + console.log("Cleaning previous build..."); + rmSync(distDir, { recursive: true, force: true }); +} +mkdirSync(distDir, { recursive: true }); + +try { + // Build client-side JavaScript from TypeScript + console.log("Building client-side JavaScript..."); + const entrypoint = path.join(srcDir, 'index.ts'); + + if (!existsSync(entrypoint)) { + throw new Error(`Entry point not found: ${entrypoint}`); + } + + const buildResult = await Bun.build({ + entrypoints: [entrypoint], + outdir: distDir, + target: 'browser', + format: 'esm', + minify: isProduction, + sourcemap: isProduction ? 'none' : 'external', + naming: '[dir]/[name].[ext]', + splitting: false, + define: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development') + } + }); + + if (!buildResult.success) { + console.error("Build failed:"); + buildResult.logs.forEach(log => console.error(log)); + process.exit(1); + } + + // Copy CSS file to dist + console.log("Copying CSS files..."); + const cssSource = path.join(srcDir, 'index.css'); + if (existsSync(cssSource)) { + const cssTarget = path.join(distDir, 'index.css'); + await Bun.write(cssTarget, Bun.file(cssSource)); + console.log(` ✓ Copied ${path.basename(cssSource)}`); + } else { + console.warn(` ⚠ CSS file not found: ${cssSource}`); + } + + // Copy public assets to dist + if (existsSync(publicDir)) { + console.log("Copying public assets..."); + try { + const result = await Bun.$`find ${publicDir} -type f`.text(); + const files = result.trim().split('\n').filter(Boolean); + + for (const file of files) { + const relativePath = path.relative(publicDir, file); + const targetPath = path.join(distDir, relativePath); + const targetDir = path.dirname(targetPath); + + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }); + } + + await Bun.write(targetPath, Bun.file(file)); + console.log(` ✓ Copied ${relativePath}`); + } + } catch (error) { + console.warn(" ⚠ Error copying public assets:", error); + } + } else { + console.log("No public assets directory found, skipping..."); + } + + // Display build results + console.log("\n✅ Build completed successfully!"); + console.log(`📁 Output directory: ${path.relative(process.cwd(), distDir)}/`); + console.log("📄 Built files:"); + + buildResult.outputs.forEach(output => { + const relativePath = path.relative(distDir, output.path); + const stats = Bun.file(output.path).size; + console.log(` - ${relativePath} (${Math.round(stats / 1024)}kb)`); + }); + + // Check if CSS was built + const cssInDist = path.join(distDir, 'index.css'); + if (existsSync(cssInDist)) { + const cssStats = Bun.file(cssInDist).size; + console.log(` - index.css (${Math.round(cssStats / 1024)}kb)`); + } + +} catch (error) { + console.error("❌ Build failed:", error); + process.exit(1); +} \ No newline at end of file diff --git a/packages/openmodes/src/downloads.json b/packages/openmodes/src/downloads.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/openmodes/src/downloads.json @@ -0,0 +1 @@ +{} diff --git a/packages/openmodes/src/index.css b/packages/openmodes/src/index.css new file mode 100644 index 00000000..98bc056f --- /dev/null +++ b/packages/openmodes/src/index.css @@ -0,0 +1,675 @@ +/* CSS Reset/Normalize */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --icon-opacity: 0.85; + --header-height: 56px; + --font-mono: 'IBM Plex Mono', monospace; + --color-brand: #fd9527; + --color-background: #1e1e1e; + --color-background-accent: #2e2e2e; + --color-border: #333; + --color-surface: #111; + --color-alpha-background: rgba(255, 255, 255, 0.75); + --color-text: #fff; + --color-text-invert: #333; + --color-text-secondary: #aaa; + --color-text-tertiary: #666; +} + +html, +body { + font-family: 'Rubik', sans-serif; + line-height: 1.6; + color: var(--color-text); + background-color: var(--color-background); +} + +body:has(dialog[open]) { + overscroll-behavior: none; +} + +input, +button { + font-family: inherit; +} + +a { + color: var(--color-text); + text-decoration: underline; + text-decoration-style: dotted; + text-decoration-color: var(--color-text-tertiary); + text-underline-offset: 0.1875rem; + + &:hover { + color: var(--color-text); + } +} + +header { + top: 0; + display: flex; + gap: 0.5rem; + justify-content: space-between; + align-items: center; + height: var(--header-height); + padding: 0 0.75rem; + background-color: var(--color-background); + position: fixed; + width: 100%; + z-index: 10; + + & > div { + display: flex; + align-items: center; + + &.left { + flex: 1 1 auto; + min-width: 0; + position: relative; + align-items: baseline; + } + + &.right { + flex: 0 0 auto; + gap: 0.75rem; + } + } + + h1 { + font-size: 1rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: -0.5px; + } + + p { + font-size: 0.875rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text-tertiary); + } + + .slash { + margin-left: 0.625rem; + margin-right: 0.25rem; + display: block; + position: relative; + top: 1px; + width: 0; + line-height: 1; + height: 0.75rem; + border-right: 2px solid var(--color-border); + transform: translateX(-50%) rotate(20deg); + transform-origin: top center; + } + + a.github { + flex: 0 0 auto; + height: 24px; + color: var(--color-text-secondary); + + svg { + opacity: var(--icon-opacity); + } + } + + .search-container { + position: relative; + flex: 1 1 auto; + min-width: 12.5rem; + } + + input { + width: 100%; + font-size: 0.8125rem; + line-height: 1.1; + padding: 0.5rem 2.5rem 0.5rem 0.625rem; + border-radius: 0.25rem; + border: 1px solid var(--color-border); + height: 2rem; + background: none; + color: var(--color-text); + + &:focus { + border-color: var(--color-brand); + outline: none; + } + } + + .search-shortcut { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + font-size: 0.75rem; + color: var(--color-text-tertiary); + pointer-events: none; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, + sans-serif; + } + + button { + flex: 0 0 auto; + cursor: pointer; + border: none; + background-color: var(--color-brand); + color: var(--color-text-invert); + font-size: 0.8125rem; + line-height: 1.1; + height: 2rem; + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + } + + @media (max-width: 32rem) { + div.left { + p, + span.slash { + display: none; + } + } + } + + @media (max-width: 45rem) { + div.right { + .github, + .search-container { + display: none; + } + } + } +} + +table { + border-collapse: separate; + border-spacing: 0; + font-size: 0.875rem; + width: 100%; + margin-top: var(--header-height); + max-width: 1200px; + margin-left: auto; + margin-right: auto; +} + +table thead th { + position: sticky; + top: var(--header-height); + border-top: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + font-size: 0.75rem; + padding: 0.75rem 0.75rem calc(0.75rem - 2px); + line-height: 1; + font-weight: 400; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-secondary); + backdrop-filter: blur(6px); + background-color: var(--color-background); + z-index: 10; +} +th.sortable { + cursor: pointer; + user-select: none; +} + +.sort-indicator { + display: inline-block; + width: 1rem; + text-align: center; +} + +th, +td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +tbody tr { + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--color-surface); + } + + td { + color: var(--color-text-tertiary); + } + + .mode-name { + font-weight: 600; + color: var(--color-text); + } + + .description { + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .votes { + font-weight: 500; + color: var(--color-text); + font-family: var(--font-mono); + } +} + +dialog::backdrop { + backdrop-filter: blur(8px); + background-color: rgba(0, 0, 0, 0.1); +} + +dialog { + margin: auto; + background-color: var(--color-background); + color: var(--color-text); + border: none; + border-radius: 0.5rem; + width: calc(100vw - 2rem); + max-width: 50rem; + max-height: calc(100svh - 2rem); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05), 0 4px 8px rgba(0, 0, 0, 0.05), + 0 8px 16px rgba(0, 0, 0, 0.07), 0 16px 32px rgba(0, 0, 0, 0.07), + 0 32px 64px rgba(0, 0, 0, 0.07), 0 48px 96px rgba(0, 0, 0, 0.07); + + flex-direction: column; + overflow: hidden; + + &[open] { + display: flex; + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.875rem 1rem calc(0.875rem - 4px) 1rem; + border-bottom: 1px solid var(--color-border); + flex: 0 0 auto; + + .header-left { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; + + .title-section { + display: flex; + align-items: center; + gap: 1rem; + + h2 { + font-size: 1rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: -0.5px; + line-height: 1; + margin: 0; + } + + .vote-section { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.375rem; + flex: 1; + + .vote-group, + .download-group { + display: flex; + align-items: center; + gap: 0.375rem; + } + + /* Direct children - vote group on left, download on right */ + + .vote-btn, + .download-btn { + background: none; + border: 1px solid var(--color-border); + color: var(--color-text-secondary); + padding: 0.375rem; + border-radius: 0.25rem; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + border-color: var(--color-brand); + color: var(--color-brand); + background-color: rgba(253, 149, 39, 0.05); + } + + &.voted, + &.downloaded { + border-color: var(--color-brand); + background-color: var(--color-brand); + color: var(--color-text-invert); + } + + &.disabled { + opacity: 0.5; + cursor: not-allowed; + border-color: var(--color-border); + background-color: transparent; + color: var(--color-text-secondary); + } + + &.disabled:hover { + border-color: var(--color-border); + color: var(--color-text-secondary); + background-color: transparent; + } + } + + .vote-count, + .download-count { + font-weight: 600; + font-size: 0.875rem; + color: var(--color-text); + font-family: var(--font-mono); + min-width: 2ch; + text-align: center; + } + } + } + + .author { + color: var(--color-text-secondary); + margin: 0; + font-style: italic; + font-size: 0.8125rem; + line-height: 1.2; + } + } + } + + .mode-header { + padding: 1.5rem 1.5rem 0; + border-bottom: 1px solid var(--color-border); + + .mode-header-info .description { + margin: 0 0 1.5rem; + line-height: 1.5; + color: var(--color-text); + font-size: 0.95rem; + } + } + + .body { + padding: 1.5rem; + overflow-y: auto; + flex: 1 1 auto; + overscroll-behavior: contain; + font-size: 0.875rem; + + /* Custom scrollbar styling */ + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 4px; + border: 1px solid transparent; + background-clip: padding-box; + } + + &::-webkit-scrollbar-thumb:hover { + background: var(--color-text-tertiary); + background-clip: padding-box; + } + + .mode-content { + display: flex; + flex-direction: column; + gap: 1.5rem; + + h4 { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.75rem; + color: var(--color-text-secondary); + } + + .description { + color: var(--color-text-secondary); + line-height: 1.5; + font-size: 0.875rem; + } + + .tools-list { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + + .tool-tag { + background-color: var(--color-surface); + color: var(--color-text-secondary); + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + font-size: 0.8125rem; + font-family: var(--font-mono); + position: relative; + text-decoration: none; + display: inline-block; + transition: all 0.2s ease; + + &.tool-enabled::after { + content: ''; + position: absolute; + top: 0.25rem; + right: 0.25rem; + width: 6px; + height: 6px; + background-color: var(--color-brand); + border-radius: 50%; + border: 1px solid var(--color-background); + } + + &.tool-disabled::after { + content: ''; + position: absolute; + top: 0.25rem; + right: 0.25rem; + width: 6px; + height: 6px; + background-color: rgba(239, 68, 68, 0.3); + border-radius: 50%; + border: 1px solid var(--color-background); + } + + &:hover { + background-color: var(--color-brand); + color: var(--color-text-invert); + transform: translateY(-1px); + } + + &.tool-disabled:hover { + background-color: rgba(253, 149, 39, 0.3); + color: var(--color-text-secondary); + transform: translateY(-1px); + } + } + + a.tool-tag { + cursor: pointer; + } + } + + .context-instruction { + background-color: var(--color-background-accent); + color: var(--color-text-secondary); + padding: 0.5rem; + border-radius: 0 0.375rem 0.375rem 0; + border-left: 3px solid var(--color-brand); + position: relative; + overflow: hidden; + + .copy-badge { + position: absolute; + top: 0; + right: 0; + margin: 0; + font-size: 0.65rem; + font-weight: 600; + color: var(--color-text-secondary); + background-color: var(--color-background); + padding: 0.3rem 0.5rem 0.2rem 0.5rem; + border-radius: 0 0.375rem 0 0; + border: 1px solid var(--color-border); + text-transform: uppercase; + letter-spacing: 0.5px; + display: flex; + align-items: center; + line-height: 1; + cursor: pointer; + transition: background 0.2s, color 0.2s; + z-index: 2; + } + .copy-badge:active, + .copy-badge:focus { + background-color: var(--color-brand); + color: var(--color-text-invert); + outline: none; + } + .copy-badge.copied { + background-color: var(--color-brand); + color: var(--color-text-invert); + } + + h5 { + position: absolute; + top: 0; + right: 0; + margin: 0; + font-size: 0.65rem; + font-weight: 600; + color: var(--color-text-secondary); + background-color: var(--color-background); + padding: 0.3rem 0.5rem 0.2rem 0.5rem; + border-radius: 0 0.375rem 0 0; + border: 1px solid var(--color-border); + + text-transform: uppercase; + letter-spacing: 0.5px; + display: flex; + align-items: center; + line-height: 1; + } + + pre { + margin: 0; + overflow-x: auto; + max-width: 100%; + + code { + display: block; + font-size: 0.875rem; + padding: 6px; + padding-left: 10px; + padding-right: 2rem; + line-height: 1.5; + white-space: pre-wrap; + color: var(--color-text-secondary); + + border-radius: 0.25rem; + overflow-wrap: break-word; + } + } + + .context-content { + font-size: 0.875rem; + padding: 6px; + padding-left: 10px; + padding-right: 2rem; + line-height: 1.5; + white-space: pre-wrap; + color: var(--color-text-secondary); + overflow-x: auto; + word-wrap: break-word; + } + } + + .context-instructions { + display: flex; + flex-direction: column; + gap: 1rem; + } + + h2, + p, + .code-block { + margin-bottom: 0.625rem; + + &:has(+ h2) { + margin-bottom: 1.5rem; + } + + &:last-child { + margin-bottom: 0; + } + } + + h2 { + font-size: 1rem; + font-weight: 500; + } + + p { + b { + font-weight: 500; + } + } + + .code-block { + padding: 0.875rem 1rem; + border-radius: 0.25rem; + background-color: var(--color-surface); + } + + code { + font-size: 0.8125rem; + font-family: var(--font-mono); + } + } + + .footer { + flex: 0 0 auto; + text-align: center; + border-top: 1px solid var(--color-border); + padding: 0.875rem 1rem 0.875rem; + display: flex; + justify-content: space-between; + align-items: center; + + a { + font-size: 0.75rem; + color: var(--color-text-tertiary); + text-decoration: none; + + &:hover, + &:visited { + color: var(--color-text-tertiary); + } + } + } +} diff --git a/packages/openmodes/src/index.ts b/packages/openmodes/src/index.ts new file mode 100644 index 00000000..6f91a724 --- /dev/null +++ b/packages/openmodes/src/index.ts @@ -0,0 +1,621 @@ +// Escape HTML for safe rendering of XML/markdown tags +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Convert mode id to display name (title case) +function titleCase(str: string): string { + return str + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +class DOMElements { + static modeModal = document.getElementById('mode-modal') as HTMLDialogElement; + static helpModal = document.getElementById('help-modal') as HTMLDialogElement; + static closeHelpBtn = document.getElementById('close-help')!; + static helpBtn = document.getElementById('help')!; + static search = document.getElementById('search')! as HTMLInputElement; + static upvoteBtn = document.getElementById('upvote-btn')!; + static downvoteBtn = document.getElementById('downvote-btn')!; + static downloadBtn = document.getElementById('download-btn')!; + static voteCountEl = document.getElementById('modal-votes')!; + static downloadCountEl = document.getElementById('modal-downloads')!; +} + +let currentMode: any = null; + +class LocalStorage { + static getJSON(key: string, defaultValue: T): T { + try { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : defaultValue; + } catch { + return defaultValue; + } + } + + static setJSON(key: string, value: any): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error('Failed to save to localStorage:', error); + } + } +} + +class UserDataManager { + static getDownloadStatus(modeId: string): boolean { + const downloads = LocalStorage.getJSON( + 'openmodes-downloads', + {} as Record + ); + return downloads[modeId] || false; + } + + static setDownloadStatus(modeId: string) { + const downloads = LocalStorage.getJSON( + 'openmodes-downloads', + {} as Record + ); + downloads[modeId] = true; + LocalStorage.setJSON('openmodes-downloads', downloads); + } + + static getVoteStatus(modeId: string): 'up' | 'down' | null { + const votes = LocalStorage.getJSON( + 'openmodes-votes', + {} as Record + ); + return votes[modeId] || null; + } + + static setVoteStatus(modeId: string, vote: 'up' | 'down' | null) { + const votes = LocalStorage.getJSON( + 'openmodes-votes', + {} as Record + ); + if (vote === null) { + delete votes[modeId]; + } else { + votes[modeId] = vote; + } + LocalStorage.setJSON('openmodes-votes', votes); + } +} + +class URLManager { + static getQueryParams() { + return new URLSearchParams(window.location.search); + } + + static updateQueryParams(updates: Record) { + const params = URLManager.getQueryParams(); + for (const [key, value] of Object.entries(updates)) { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + } + const newPath = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + window.history.pushState({}, '', newPath); + } + + static getColumnNameForURL(headerEl: Element): string { + const text = headerEl.textContent?.trim().toLowerCase() || ''; + return text.replace(/↑|↓/g, '').trim().split(/\s+/).slice(0, 2).join('-'); + } + + static getColumnIndexByUrlName(name: string): number { + const headers = document.querySelectorAll('th.sortable'); + return Array.from(headers).findIndex( + (header) => URLManager.getColumnNameForURL(header) === name + ); + } +} + +class ModalManager { + static helpModalScrollY = 0; + + static openHelp() { + ModalManager.helpModalScrollY = window.scrollY; + document.body.style.position = 'fixed'; + document.body.style.top = `-${ModalManager.helpModalScrollY}px`; + DOMElements.helpModal.showModal(); + } + + static closeHelp() { + DOMElements.helpModal.close(); + document.body.style.position = ''; + document.body.style.top = ''; + window.scrollTo(0, ModalManager.helpModalScrollY); + } + + static closeMode() { + DOMElements.modeModal.close(); + document.body.style.position = ''; + document.body.style.top = ''; + window.scrollTo(0, ModalManager.helpModalScrollY); + currentMode = null; + } +} + +class TableManager { + static currentSort = { column: -1, direction: 'asc' as 'asc' | 'desc' }; + + static sort(column: number, direction: 'asc' | 'desc') { + const header = document.querySelectorAll('th.sortable')[column]; + const columnType = header.getAttribute('data-type'); + if (!columnType) return; + + TableManager.currentSort = { column, direction }; + URLManager.updateQueryParams({ + sort: URLManager.getColumnNameForURL(header), + order: direction + }); + + const tbody = document.querySelector('table tbody')!; + const rows = Array.from( + tbody.querySelectorAll('tr') + ) as HTMLTableRowElement[]; + + rows.sort((a, b) => { + const aValue = TableManager.getCellValue(a.cells[column], columnType); + const bValue = TableManager.getCellValue(b.cells[column], columnType); + + if (aValue === undefined && bValue === undefined) return 0; + if (aValue === undefined) return 1; + if (bValue === undefined) return -1; + + let comparison = 0; + if (columnType === 'number' || columnType === 'tools') { + comparison = (aValue as number) - (bValue as number); + } else { + comparison = (aValue as string).localeCompare(bValue as string); + } + + return direction === 'asc' ? comparison : -comparison; + }); + + rows.forEach((row) => tbody.appendChild(row)); + TableManager.updateSortIndicators(column, direction); + } + + static getCellValue( + cell: HTMLTableCellElement, + type: string + ): string | number | undefined { + const text = cell.textContent?.trim() || ''; + if (text === '-') return; + if (type === 'number') return parseFloat(text.replace(/[$,]/g, '')) || 0; + return text; + } + + static updateSortIndicators(activeColumn: number, direction: 'asc' | 'desc') { + const headers = document.querySelectorAll('th.sortable'); + headers.forEach((header, i) => { + const indicator = header.querySelector('.sort-indicator')!; + indicator.textContent = + i === activeColumn ? (direction === 'asc' ? '↑' : '↓') : ''; + }); + } + + static filter(value: string) { + const lowerCaseValue = value.toLowerCase(); + const rows = document.querySelectorAll( + 'table tbody tr' + ) as NodeListOf; + + rows.forEach((row) => { + const cellTexts = Array.from(row.cells).map((cell) => + cell.textContent!.toLowerCase() + ); + const isVisible = cellTexts.some((text) => text.includes(lowerCaseValue)); + row.style.display = isVisible ? '' : 'none'; + }); + + URLManager.updateQueryParams({ search: value || null }); + } +} + +async function openModeModal(row: HTMLTableRowElement) { + const modeId = row.getAttribute('data-mode-id'); + if (!modeId) return; + + try { + const response = await fetch(`/mode/${modeId}`); + const mode = await response.json(); + currentMode = mode; + + populateModalContent(mode); + updateVoteButtons(modeId); + updateDownloadButton(modeId); + + ModalManager.helpModalScrollY = window.scrollY; + document.body.style.position = 'fixed'; + document.body.style.top = `-${ModalManager.helpModalScrollY}px`; + DOMElements.modeModal.showModal(); + } catch (error) { + console.error('Failed to load mode data:', error); + } +} + +function populateModalContent(mode: any) { + const modalElements = { + title: document.getElementById('modal-title')!, + author: document.getElementById('modal-author')!, + description: document.getElementById('modal-description')!, + systemPrompt: document.getElementById('modal-system-prompt')! + }; + + modalElements.title.textContent = titleCase(mode.id); + modalElements.author.textContent = mode.author; + modalElements.description.textContent = mode.description; + DOMElements.voteCountEl.textContent = mode.votes.toString(); + DOMElements.downloadCountEl.textContent = mode.downloads.toString(); + + modalElements.systemPrompt.innerHTML = `
+ +
${escapeHtml(
+		mode.mode_prompt
+	)}
`; + + populateContextInstructions(mode); + populateTools(mode); +} + +function populateContextInstructions(mode: any) { + const section = document.getElementById('context-instructions-section')!; + const container = document.getElementById('modal-context-instructions')!; + + if (mode.context_instructions?.length > 0) { + section.style.display = 'block'; + container.innerHTML = mode.context_instructions + .map( + (instruction: any) => + `
+ +
${escapeHtml(
+						instruction.content
+					)}
` + ) + .join(''); + } else { + section.style.display = 'none'; + } +} + +function populateTools(mode: any) { + const toolElements = { + enabledContainer: document.getElementById('modal-tools-enabled')!, + disabledContainer: document.getElementById('modal-tools-disabled')!, + disabledSection: document.getElementById('modal-tools-disabled-section')! + }; + + let enabledToolsHtml = ''; + if (mode.tools_enabled?.length > 0) { + enabledToolsHtml = mode.tools_enabled + .map((tool: any) => { + const toolName = typeof tool === 'string' ? tool : tool.name; + const toolUrl = typeof tool === 'object' && tool.url ? tool.url : null; + return toolUrl + ? `${toolName}` + : `${toolName}`; + }) + .join(''); + } else if (mode.tools?.length > 0) { + enabledToolsHtml = mode.tools + .map( + (tool: string) => `${tool}` + ) + .join(''); + } + toolElements.enabledContainer.innerHTML = enabledToolsHtml; + + if (mode.tools_disabled?.length > 0) { + toolElements.disabledSection.style.display = 'block'; + toolElements.disabledContainer.innerHTML = mode.tools_disabled + .map( + (tool: string) => `${tool}` + ) + .join(''); + } else { + toolElements.disabledSection.style.display = 'none'; + } +} + +function updateVoteButtons(modeId: string) { + const userVote = UserDataManager.getVoteStatus(modeId); + + DOMElements.upvoteBtn.classList.remove('disabled', 'voted'); + DOMElements.downvoteBtn.classList.remove('disabled', 'voted'); + DOMElements.upvoteBtn.removeAttribute('disabled'); + DOMElements.downvoteBtn.removeAttribute('disabled'); + + if (userVote === 'up') { + DOMElements.upvoteBtn.classList.add('voted'); + } else if (userVote === 'down') { + DOMElements.downvoteBtn.classList.add('voted'); + } +} + +function updateDownloadButton(modeId: string) { + const hasDownloaded = UserDataManager.getDownloadStatus(modeId); + DOMElements.downloadBtn.classList.toggle('downloaded', hasDownloaded); +} + +async function vote(direction: 'up' | 'down') { + if (!currentMode) return; + + const modeId = currentMode.id; + const currentVote = UserDataManager.getVoteStatus(modeId); + + const { newVote, apiCalls } = calculateVoteChanges(currentVote, direction); + + UserDataManager.setVoteStatus(modeId, newVote); + updateVoteUI(newVote); + setButtonsDisabled(true); + + try { + for (const call of apiCalls) { + const response = await fetch('/api/vote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + modeId, + direction: call.direction, + action: call.action + }) + }); + + if (!response.ok) throw new Error(`Vote failed: ${response.statusText}`); + + if (call === apiCalls[apiCalls.length - 1]) { + const result = await response.json(); + updateVoteCount(result.newVoteCount, modeId); + } + } + } catch (error) { + console.error('Failed to vote:', error); + UserDataManager.setVoteStatus(modeId, currentVote); + updateVoteUI(currentVote); + } finally { + setButtonsDisabled(false); + } +} + +function calculateVoteChanges( + currentVote: 'up' | 'down' | null, + direction: 'up' | 'down' +) { + let newVote: 'up' | 'down' | null; + const apiCalls: Array<{ + direction: 'up' | 'down'; + action: 'add' | 'remove'; + }> = []; + + if (currentVote === direction) { + newVote = null; + apiCalls.push({ direction, action: 'remove' }); + } else if (currentVote === null) { + newVote = direction; + apiCalls.push({ direction, action: 'add' }); + } else { + newVote = direction; + apiCalls.push({ direction: currentVote, action: 'remove' }); + apiCalls.push({ direction, action: 'add' }); + } + + return { newVote, apiCalls }; +} + +function updateVoteUI(vote: 'up' | 'down' | null) { + DOMElements.upvoteBtn.classList.toggle('voted', vote === 'up'); + DOMElements.downvoteBtn.classList.toggle('voted', vote === 'down'); +} + +function setButtonsDisabled(disabled: boolean) { + if (disabled) { + DOMElements.upvoteBtn.setAttribute('disabled', 'true'); + DOMElements.downvoteBtn.setAttribute('disabled', 'true'); + } else { + DOMElements.upvoteBtn.removeAttribute('disabled'); + DOMElements.downvoteBtn.removeAttribute('disabled'); + } +} + +function updateVoteCount(newCount: number, modeId: string) { + currentMode.votes = newCount; + DOMElements.voteCountEl.textContent = newCount.toString(); + + const tableRow = document.querySelector(`tr[data-mode-id="${modeId}"]`); + const votesCell = tableRow?.querySelector('.votes'); + if (votesCell) votesCell.textContent = newCount.toString(); +} + +async function downloadMode() { + if (!currentMode) return; + + const modeId = currentMode.id; + const hasDownloaded = UserDataManager.getDownloadStatus(modeId); + + try { + const response = await fetch(`/api/download-zip/${modeId}`); + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to fetch mode files: ${response.status} ${errorText}` + ); + } + + const files = await response.json(); + const zip = new (window as any).JSZip(); + files.forEach((file: { name: string; content: string }) => { + zip.file(file.name, file.content); + }); + + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, `${modeId}.zip`); + + if (!hasDownloaded) { + await updateDownloadCount(modeId); + } + } catch (error) { + console.error('Failed to download mode:', error); + alert('Failed to download mode files. Please try again.'); + } +} + +function downloadFile(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +async function updateDownloadCount(modeId: string) { + try { + const countResponse = await fetch('/api/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ modeId }) + }); + + if (countResponse.ok) { + const result = await countResponse.json(); + currentMode.downloads = result.newDownloadCount; + DOMElements.downloadCountEl.textContent = + result.newDownloadCount.toString(); + UserDataManager.setDownloadStatus(modeId); + DOMElements.downloadBtn.classList.add('downloaded'); + } + } catch (error) { + console.error('Failed to track download:', error); + } +} + +function initializeFromURL() { + const params = URLManager.getQueryParams(); + + const searchQuery = params.get('search'); + if (searchQuery) { + DOMElements.search.value = searchQuery; + TableManager.filter(searchQuery); + } + + const columnName = params.get('sort'); + if (columnName) { + const columnIndex = URLManager.getColumnIndexByUrlName(columnName); + if (columnIndex !== -1) { + const direction = (params.get('order') as 'asc' | 'desc') || 'asc'; + TableManager.sort(columnIndex, direction); + } + } +} + +function setupEventListeners() { + // Add click-to-copy for all context badges in modal + DOMElements.modeModal.addEventListener('click', function (e) { + const target = e.target as HTMLElement; + if (target && target.classList.contains('copy-badge')) { + const contextInstruction = target.closest('.context-instruction'); + if (!contextInstruction) return; + const codeBlock = contextInstruction.querySelector( + 'code.context-content' + ); + if (!codeBlock) return; + const text = codeBlock.textContent || ''; + if (!navigator.clipboard) { + // fallback for older browsers + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } else { + navigator.clipboard.writeText(text); + } + // Visual feedback + target.classList.add('copied'); + const original = target.textContent; + target.textContent = 'Copied!'; + setTimeout(() => { + target.classList.remove('copied'); + target.textContent = original; + }, 1200); + } + }); + + DOMElements.helpBtn.addEventListener('click', ModalManager.openHelp); + DOMElements.closeHelpBtn.addEventListener('click', ModalManager.closeHelp); + DOMElements.helpModal.addEventListener('cancel', ModalManager.closeHelp); + DOMElements.helpModal.addEventListener('click', (e) => { + if (e.target === DOMElements.helpModal) ModalManager.closeHelp(); + }); + + DOMElements.modeModal.addEventListener('cancel', ModalManager.closeMode); + DOMElements.modeModal.addEventListener('click', (e) => { + if (e.target === DOMElements.modeModal) ModalManager.closeMode(); + }); + + document.querySelectorAll('th.sortable').forEach((header) => { + header.addEventListener('click', () => { + const column = Array.from(header.parentElement!.children).indexOf(header); + const direction = + TableManager.currentSort.column === column && + TableManager.currentSort.direction === 'asc' + ? 'desc' + : 'asc'; + TableManager.sort(column, direction); + }); + }); + + DOMElements.search.addEventListener('input', () => { + TableManager.filter(DOMElements.search.value); + }); + + document.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + DOMElements.search.focus(); + } + if (e.key === 'Escape' && DOMElements.modeModal.open) { + ModalManager.closeMode(); + } + }); + + DOMElements.search.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + DOMElements.search.value = ''; + DOMElements.search.dispatchEvent(new Event('input')); + } + }); +} + +(window as any).openModeModal = openModeModal; +(window as any).vote = vote; +(window as any).downloadMode = downloadMode; + +document.addEventListener('DOMContentLoaded', () => { + setupEventListeners(); + initializeFromURL(); +}); +window.addEventListener('popstate', initializeFromURL); diff --git a/packages/openmodes/src/render.tsx b/packages/openmodes/src/render.tsx new file mode 100644 index 00000000..d1628ea2 --- /dev/null +++ b/packages/openmodes/src/render.tsx @@ -0,0 +1,520 @@ +/** @jsx jsx */ +/** @jsxImportSource hono/jsx */ + +import { Fragment } from 'hono/jsx'; +import { renderToString } from 'hono/jsx/dom/server'; +import path from 'path'; +import { readdir, readFile } from 'fs/promises'; +import { readFileSync, existsSync } from 'fs'; + +// Constants +const DEFAULT_AUTHOR = 'OpenCode Community'; +const DEFAULT_DATE = '2025-01-20'; +const DEFAULT_VERSION = '1.0'; + +// String transformation utilities +const titleCase = (str: string) => + str + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + +const toUpperFilename = (filename: string) => + filename.replace('.mode.md', '').toUpperCase(); + +interface Mode { + id: string; + name: string; + author: string; + tools_enabled?: Array<{ name: string; url?: string }>; + tools_disabled?: string[]; + mode_prompt: string; + description: string; + votes: number; + downloads: number; + created_at: string; + updated_at: string; + version: string; + context_instructions?: Array<{ title: string; content: string }>; + prompt_file_name?: string; +} + +class DataLoader { + private static cache: Record | null> = { + votes: null, + downloads: null + }; + + private static getData(type: 'votes' | 'downloads'): Record { + if (DataLoader.cache[type]) return DataLoader.cache[type]!; + + try { + const file = path.join(import.meta.dir, `${type}.json`); + if (!existsSync(file)) return {}; + + const text = readFileSync(file, 'utf-8'); + DataLoader.cache[type] = JSON.parse(text); + return DataLoader.cache[type]!; + } catch (error) { + console.log(`Error loading ${type} data:`, error); + return {}; + } + } + + static getCurrentVotesData(): Record { + return DataLoader.getData('votes'); + } + + static getCurrentDownloadsData(): Record { + return DataLoader.getData('downloads'); + } + + static clearCache() { + DataLoader.cache.votes = null; + DataLoader.cache.downloads = null; + } +} + +async function loadModes(): Promise> { + const modesDir = path.join(import.meta.dir, '..', 'modes'); + const entries = await readdir(modesDir, { withFileTypes: true }); + const modes: Record = {}; + + for (const entry of entries) { + if (entry.isDirectory()) { + const mode = await loadModeFromDirectory(modesDir, entry.name); + if (mode) modes[entry.name] = mode; + } else if (entry.name.endsWith('.json')) { + const mode = await loadModeFromJSON(modesDir, entry.name); + if (mode) modes[mode.id] = mode; + } + } + + return modes; +} + +async function loadModeFromDirectory( + modesDir: string, + dirName: string +): Promise { + const opencodeJsonPath = path.join(modesDir, dirName, 'opencode.json'); + + try { + const opencodeContent = await readFile(opencodeJsonPath, 'utf-8'); + const opencodeData = JSON.parse(opencodeContent); + const modeDir = path.join(modesDir, dirName); + const dirFiles = await readdir(modeDir); + + const { enabledTools, disabledTools } = extractTools(opencodeData); + const { systemPrompt, promptFileName } = await extractSystemPrompt( + modeDir, + dirFiles + ); + const { description, author, updatedAt } = await extractMetadata(modeDir); + const contextInstructions = await extractContextInstructions( + modeDir, + dirFiles + ); + + return { + id: dirName, + name: titleCase(dirName), + author, + tools_enabled: enabledTools, + tools_disabled: disabledTools, + mode_prompt: systemPrompt, + description, + votes: 0, + created_at: DEFAULT_DATE, + updated_at: updatedAt, + version: DEFAULT_VERSION, + downloads: 0, + context_instructions: contextInstructions, + prompt_file_name: promptFileName + }; + } catch (error) { + console.log(`Skipping ${dirName}: error reading opencode.json`, error); + return null; + } +} + +async function loadModeFromJSON( + modesDir: string, + fileName: string +): Promise { + try { + const filePath = path.join(modesDir, fileName); + const content = await readFile(filePath, 'utf-8'); + return JSON.parse(content) as Mode; + } catch (error) { + console.log(`Skipping ${fileName}: invalid JSON`, error); + return null; + } +} + +function extractTools(opencodeData: any) { + const enabledTools: Array<{ name: string; url?: string }> = []; + const disabledTools: string[] = []; + + if (opencodeData.mcp) { + Object.entries(opencodeData.mcp).forEach(([key, value]: [string, any]) => { + if (value.enabled !== false) { + enabledTools.push({ name: key, url: value.url || undefined }); + } + }); + } + + if (opencodeData.mode) { + const firstModeKey = Object.keys(opencodeData.mode)[0]; + if (firstModeKey && opencodeData.mode[firstModeKey].tools) { + Object.entries(opencodeData.mode[firstModeKey].tools).forEach( + ([tool, enabled]) => { + if (enabled === false) disabledTools.push(tool); + } + ); + } + } + + return { enabledTools, disabledTools }; +} + +async function extractSystemPrompt(modeDir: string, dirFiles: string[]) { + const promptFile = dirFiles.find((f) => f.endsWith('.mode.md')); + let systemPrompt = 'No system prompt found'; + let promptFileName = 'PROMPT'; + + if (promptFile) { + const promptPath = path.join(modeDir, promptFile); + systemPrompt = await readFile(promptPath, 'utf-8'); + promptFileName = toUpperFilename(promptFile); + } + + return { systemPrompt, promptFileName }; +} + +async function extractMetadata(modeDir: string) { + let description = ''; + let author = DEFAULT_AUTHOR; + let updatedAt = DEFAULT_DATE; + + try { + const metadataPath = path.join(modeDir, 'metadata.json'); + const metaContent = await readFile(metadataPath, 'utf-8'); + const metaData = JSON.parse(metaContent); + + if (metaData.description) description = metaData.description.trim(); + if (metaData.author) author = metaData.author; + if (metaData.date) updatedAt = metaData.date; + } catch { + // Use defaults if metadata.json doesn't exist + } + + return { description, author, updatedAt }; +} + +async function extractContextInstructions(modeDir: string, dirFiles: string[]) { + const instructionFiles = dirFiles.filter( + (f) => f.endsWith('.instructions.md') || f.endsWith('.prompt.md') + ); + const contextInstructions: Array<{ title: string; content: string }> = []; + + for (const instFile of instructionFiles) { + const contextName = instFile.endsWith('.instructions.md') + ? instFile.replace('.instructions.md', '') + : instFile.replace('.prompt.md', ''); + const title = titleCase(contextName); + const instPath = path.join(modeDir, instFile); + const content = await readFile(instPath, 'utf-8'); + contextInstructions.push({ title, content: content.trim() }); + } + + return contextInstructions; +} + +export const Modes = await loadModes(); + +function getModesWithCurrentVotes() { + const currentVotesData = DataLoader.getCurrentVotesData(); + const currentDownloadsData = DataLoader.getCurrentDownloadsData(); + + const modesWithVotes: Record = {}; + for (const [modeId, mode] of Object.entries(Modes)) { + modesWithVotes[modeId] = { + ...mode, + votes: currentVotesData[modeId] || 0, + downloads: currentDownloadsData[modeId] || 0 + }; + } + return modesWithVotes; +} + +export function getRenderWithCurrentVotes() { + DataLoader.clearCache(); + const ModesWithVotes = getModesWithCurrentVotes(); + + return renderToString( + +
+
+

OpenModes.dev

+ +

An open-source database of AI agent modes

+
+
+ + + + + +
+ + ⌘K +
+ +
+
+ + + + + + + + + + + + {Object.entries(ModesWithVotes) + .sort(([, modeA], [, modeB]) => modeB.votes - modeA.votes) + .map(([modeId, mode]) => ( + + + + + + + + ))} + +
+ Name + + Author + + Description + + Votes + + Updated +
{mode.name}{mode.author}{mode.description}{mode.votes}{mode.updated_at}
+ +
+
+
+ + +
+
+ + + +
+
+ + 0 + + +
+
+
+

+ by +

+
+
+
+ +
+
+
+

DESCRIPTION

+ +
+ +
+

MCP TOOLS

+ +
+ + + +
+

MODE PROMPT

+ +
+ + +
+
+
+ +
+

How to use

+ +
+
+

+ OpenModes is a comprehensive open-source database of + AI agent modes with tools, system prompts, and configurations. +

+

+ Browse through different agent modes created by the community. Each + mode defines a specific agent behavior with its own set of tools and + system prompt. Click on any mode to see its full details, vote on + it, or download it for use. +

+

API

+

You can access this data through an API.

+
+ + # Get all modes +
+ curl https://openmodes.dev/mode/all +
+
+ # Get specific mode +
+ curl https://openmodes.dev/mode/archie +
+
+

Contribute

+

+ The data is stored on{' '} + + GitHub + + . +

+

+ We need your help to build this database of agent modes. Feel free + to add new modes and submit a pull request. +

+
+ +
+
+ ); +} diff --git a/packages/openmodes/src/server.ts b/packages/openmodes/src/server.ts new file mode 100644 index 00000000..22032883 --- /dev/null +++ b/packages/openmodes/src/server.ts @@ -0,0 +1,301 @@ +import { getRenderWithCurrentVotes, Modes } from './render'; +import path from 'path'; +import { readdir, stat } from 'fs/promises'; +import { Mutex } from 'async-mutex'; + +// Response helpers +const jsonResponse = (data: any, status = 200, indent?: number) => + new Response(JSON.stringify(data, null, indent), { + status, + headers: { 'Content-Type': 'application/json' } + }); + +const textResponse = (text: string, status = 200) => + new Response(text, { status }); + +const fileResponse = (file: any, contentType: string) => + new Response(file, { + headers: { 'Content-Type': contentType } + }); + +// Read HTML template +const indexHtmlPath = path.join(import.meta.dir, '..', 'index.html'); +const IndexHtml = await Bun.file(indexHtmlPath).text(); + +class DataManager { + private static data: Record> = {}; + private static mutexes: Record = {}; + private static filePaths: Record = {}; + + static initialize(type: 'votes' | 'downloads') { + if (!DataManager.mutexes[type]) { + DataManager.mutexes[type] = new Mutex(); + DataManager.filePaths[type] = path.join(import.meta.dir, `${type}.json`); + DataManager.data[type] = {}; + } + } + + static async load(type: 'votes' | 'downloads') { + DataManager.initialize(type); + try { + const file = Bun.file(DataManager.filePaths[type]); + if (await file.exists()) { + const text = await file.text(); + DataManager.data[type] = JSON.parse(text); + } + } catch (error) { + console.log(`No existing ${type} file found, starting fresh`); + } + } + + static getCount(type: 'votes' | 'downloads', modeId: string): number { + return DataManager.data[type]?.[modeId] || 0; + } + + static async handleVote( + modeId: string, + direction: 'up' | 'down', + action: 'add' | 'remove' + ) { + return await DataManager.mutexes.votes.runExclusive(async () => { + if (!Modes[modeId]) throw new Error('Mode not found'); + + if (!DataManager.data.votes[modeId]) DataManager.data.votes[modeId] = 0; + + const multiplier = direction === 'up' ? 1 : -1; + const actionMultiplier = action === 'add' ? 1 : -1; + DataManager.data.votes[modeId] += multiplier * actionMultiplier; + + await DataManager.save('votes'); + return { newVoteCount: DataManager.data.votes[modeId] }; + }); + } + + static async handleDownload(modeId: string) { + return await DataManager.mutexes.downloads.runExclusive(async () => { + if (!Modes[modeId]) throw new Error('Mode not found'); + + if (!DataManager.data.downloads[modeId]) DataManager.data.downloads[modeId] = 0; + DataManager.data.downloads[modeId]++; + + await DataManager.save('downloads'); + return { newDownloadCount: DataManager.data.downloads[modeId] }; + }); + } + + private static async save(type: 'votes' | 'downloads') { + try { + await Bun.write( + DataManager.filePaths[type], + JSON.stringify(DataManager.data[type], null, 2) + ); + } catch (error) { + console.error(`Failed to save ${type}:`, error); + } + } +} + +function getModeWithVotes(modeId: string) { + const mode = Modes[modeId]; + if (!mode) return null; + + const { name, ...modeWithoutName } = mode; + return { + ...modeWithoutName, + votes: DataManager.getCount('votes', modeId), + downloads: DataManager.getCount('downloads', modeId) + }; +} + +function getAllModesWithVotes() { + const modesWithVotes: Record = {}; + for (const [modeId, mode] of Object.entries(Modes)) { + const { name, ...modeWithoutName } = mode; + modesWithVotes[modeId] = { + ...modeWithoutName, + votes: DataManager.getCount('votes', modeId), + downloads: DataManager.getCount('downloads', modeId) + }; + } + return modesWithVotes; +} + +await DataManager.load('votes'); +await DataManager.load('downloads'); + +const server = Bun.serve({ + development: false, + hostname: '0.0.0.0', + port: 3001, + async fetch(req) { + const url = new URL(req.url); + + // Handle voting endpoint + if (url.pathname === '/api/vote' && req.method === 'POST') { + try { + const body = await req.json(); + const { modeId, direction, action } = body; + + if (!modeId || !direction || !action) { + return textResponse('Missing required fields', 400); + } + + if (direction !== 'up' && direction !== 'down') { + return textResponse('Invalid vote direction', 400); + } + + if (action !== 'add' && action !== 'remove') { + return textResponse('Invalid vote action', 400); + } + + const result = await DataManager.handleVote(modeId, direction, action); + return jsonResponse(result); + } catch (error) { + console.error('Vote error:', error); + return textResponse('Vote failed', 500); + } + } + + // Handle download endpoint + if (url.pathname === '/api/download' && req.method === 'POST') { + try { + const body = await req.json(); + const { modeId } = body; + + if (!modeId) { + return textResponse('Missing modeId', 400); + } + + const result = await DataManager.handleDownload(modeId); + return jsonResponse(result); + } catch (error) { + console.error('Download error:', error); + return textResponse('Download tracking failed', 500); + } + } + + // Handle mode files zip download + if (url.pathname.startsWith('/api/download-zip/') && req.method === 'GET') { + const modeId = url.pathname.split('/').pop(); + if (!modeId || !Modes[modeId]) { + return textResponse('Mode not found', 404); + } + + try { + const modesDir = path.join(import.meta.dir, '..', 'modes'); + const modeDir = path.join(modesDir, modeId); + + // Check if mode directory exists + try { + const dirStat = await stat(modeDir); + if (!dirStat.isDirectory()) { + return textResponse('Mode directory not found', 404); + } + } catch (error) { + return textResponse('Mode directory not found', 404); + } + + // Get all files in the mode directory + const files = await readdir(modeDir); + const zipContent: Array<{ name: string; content: string }> = []; + + for (const fileName of files) { + // Skip metadata.json file + if (fileName === 'metadata.json') { + continue; + } + + const filePath = path.join(modeDir, fileName); + let fileContent = await Bun.file(filePath).text(); + + // If this is opencode.json, remove URL keys from MCP objects + if (fileName === 'opencode.json') { + try { + const config = JSON.parse(fileContent); + if (config.mcp) { + for (const mcpKey in config.mcp) { + if (config.mcp[mcpKey].url) { + delete config.mcp[mcpKey].url; + } + } + } + fileContent = JSON.stringify(config, null, 2); + } catch (error) { + console.error(`Failed to process opencode.json: ${error}`); + // If JSON parsing fails, use original content + } + } + + zipContent.push({ + name: fileName, + content: fileContent + }); + } + + return jsonResponse(zipContent); + } catch (error) { + console.error('Zip download error:', error); + return textResponse('Failed to prepare download', 500); + } + } + + if (url.pathname === '/mode/all') { + return jsonResponse(getAllModesWithVotes(), 200, 2); + } + + if (url.pathname.startsWith('/mode/')) { + const modeId = url.pathname.split('/')[2]; + if (!modeId) { + return textResponse('Mode ID required', 400); + } + + const mode = getModeWithVotes(modeId); + + if (!mode) { + return textResponse('Mode not found', 404); + } + + return jsonResponse(mode, 200, 2); + } + + if (url.pathname === '/') { + let html = IndexHtml; + const currentRendered = getRenderWithCurrentVotes(); + html = html.replace('', currentRendered); + return new Response(html, { + headers: { 'Content-Type': 'text/html' } + }); + } + + if (url.pathname.startsWith('/src/') || url.pathname.startsWith('/public/')) { + const filePath = path.join(import.meta.dir, '..', url.pathname); + const file = Bun.file(filePath); + if (await file.exists()) { + const ext = url.pathname.split('.').pop(); + const contentTypeMap: Record = { + css: 'text/css', + js: 'application/javascript', + svg: 'image/svg+xml', + png: 'image/png' + }; + + if (ext === 'ts') { + const tsContent = await file.text(); + const jsContent = await Bun.build({ + entrypoints: [filePath], + target: 'browser', + format: 'esm' + }).then((result) => result.outputs[0].text()); + + return fileResponse(jsContent, 'application/javascript'); + } + + return fileResponse(file, contentTypeMap[ext || ''] || 'text/plain'); + } + } + + return textResponse('Not found', 404); + } +}); + +console.log(`OpenModes server running at ${server.hostname}:${server.port}`); diff --git a/packages/openmodes/src/votes.json b/packages/openmodes/src/votes.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/openmodes/src/votes.json @@ -0,0 +1 @@ +{} diff --git a/packages/openmodes/tsconfig.json b/packages/openmodes/tsconfig.json new file mode 100644 index 00000000..6b740458 --- /dev/null +++ b/packages/openmodes/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "jsx": "react-jsx", + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} From 3cec0fea0d0b5d266d0f168372d099e53d502b87 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 21 Jul 2025 00:20:47 +0200 Subject: [PATCH 2/9] Refactor mode table UI and update logic - Enhanced table layout and styling for modes display (CSS and JSX) - Refactored vote/download count update logic for clarity and reusability - Updated Archie mode description for improved clarity and detail --- packages/openmodes/modes/archie/metadata.json | 6 +- packages/openmodes/src/index.css | 64 +++++++++++++++---- packages/openmodes/src/index.ts | 24 +++---- packages/openmodes/src/render.tsx | 22 ++++--- 4 files changed, 82 insertions(+), 34 deletions(-) diff --git a/packages/openmodes/modes/archie/metadata.json b/packages/openmodes/modes/archie/metadata.json index ea824026..003dff0b 100644 --- a/packages/openmodes/modes/archie/metadata.json +++ b/packages/openmodes/modes/archie/metadata.json @@ -1,5 +1,5 @@ { - "author": "spoon", - "description": "This mode is made for code review and stuff", - "date": "2025-07-20" + "author": "spoon", + "description": "Archie is a mode for architectural guidance, enforcing system-wide principles, maintainability, and risk mitigation in modern web applications. It provides step-by-step, machine-readable implementation plans and challenges technical debt, acting as a lead software architect for the codebase.", + "date": "2025-07-20" } diff --git a/packages/openmodes/src/index.css b/packages/openmodes/src/index.css index 98bc056f..4bc89e5c 100644 --- a/packages/openmodes/src/index.css +++ b/packages/openmodes/src/index.css @@ -186,17 +186,65 @@ header { } } -table { - border-collapse: separate; - border-spacing: 0; - font-size: 0.875rem; +table.table-modes { width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 0.875rem; margin-top: var(--header-height); - max-width: 1200px; + max-width: 1280px; margin-left: auto; margin-right: auto; } +.table-modes th, +.table-modes td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +/* Left block: Name and Author */ +.table-modes th.name, +.table-modes td.name { + width: 120px; +} + +.table-modes th.author, +.table-modes td.author { + width: 120px; +} + +/* Center: Description takes remaining space */ +.table-modes th.description, +.table-modes td.description { + width: auto; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 2.5rem; +} + +/* Right block: Votes, Downloads, Updated */ +.table-modes th.votes, +.table-modes td.votes { + width: 100px; + text-align: left; +} + +.table-modes th.downloads, +.table-modes td.downloads { + width: 130px; + text-align: left; +} + +.table-modes th.updated, +.table-modes td.updated { + width: 120px; + text-align: left; +} + table thead th { position: sticky; top: var(--header-height); @@ -254,12 +302,6 @@ tbody tr { overflow: hidden; text-overflow: ellipsis; } - - .votes { - font-weight: 500; - color: var(--color-text); - font-family: var(--font-mono); - } } dialog::backdrop { diff --git a/packages/openmodes/src/index.ts b/packages/openmodes/src/index.ts index 6f91a724..90adc047 100644 --- a/packages/openmodes/src/index.ts +++ b/packages/openmodes/src/index.ts @@ -385,8 +385,7 @@ async function vote(direction: 'up' | 'down') { if (call === apiCalls[apiCalls.length - 1]) { const result = await response.json(); - updateVoteCount(result.newVoteCount, modeId); - } + updateCountUI('votes', result.newVoteCount, modeId); } } } catch (error) { console.error('Failed to vote:', error); @@ -437,15 +436,18 @@ function setButtonsDisabled(disabled: boolean) { } } -function updateVoteCount(newCount: number, modeId: string) { - currentMode.votes = newCount; - DOMElements.voteCountEl.textContent = newCount.toString(); - +function updateCountUI(type: 'votes' | 'downloads', newCount: number, modeId: string) { + if (!currentMode) return; + currentMode[type] = newCount; + const modalCountEl = type === 'votes' ? DOMElements.voteCountEl : DOMElements.downloadCountEl; + modalCountEl.textContent = newCount.toString(); const tableRow = document.querySelector(`tr[data-mode-id="${modeId}"]`); - const votesCell = tableRow?.querySelector('.votes'); - if (votesCell) votesCell.textContent = newCount.toString(); + const cellClass = type; + const cell = tableRow?.querySelector(`.${cellClass}`); + if (cell) cell.textContent = newCount.toString(); } + async function downloadMode() { if (!currentMode) return; @@ -490,6 +492,8 @@ function downloadFile(blob: Blob, filename: string) { URL.revokeObjectURL(url); } + + async function updateDownloadCount(modeId: string) { try { const countResponse = await fetch('/api/download', { @@ -500,9 +504,7 @@ async function updateDownloadCount(modeId: string) { if (countResponse.ok) { const result = await countResponse.json(); - currentMode.downloads = result.newDownloadCount; - DOMElements.downloadCountEl.textContent = - result.newDownloadCount.toString(); + updateCountUI('downloads', result.newDownloadCount, modeId); UserDataManager.setDownloadStatus(modeId); DOMElements.downloadBtn.classList.add('downloaded'); } diff --git a/packages/openmodes/src/render.tsx b/packages/openmodes/src/render.tsx index d1628ea2..7c68f201 100644 --- a/packages/openmodes/src/render.tsx +++ b/packages/openmodes/src/render.tsx @@ -286,22 +286,25 @@ export function getRenderWithCurrentVotes() { - +
- - - - - + @@ -316,11 +319,12 @@ export function getRenderWithCurrentVotes() { data-mode-id={modeId} onclick='openModeModal(this)' > - - + + - + + ))} From 479ae0cc906a3c9c508b69ea0728b6a4c220bf19 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 21 Jul 2025 15:30:27 +0200 Subject: [PATCH 3/9] Refactor mode table markup and CSS for improved layout and maintainability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Overhauled header and modal layout using CSS Grid and explicit width constraints - Unified table column class names for easier targeting and styling - Streamlined vote/download count update logic for consistency - Enhanced modal, table, and code block styles for clarity and responsiveness - Improved universal box-sizing, reset, and media queries 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode --- packages/openmodes/src/index.css | 1070 ++++++++++++++---------------- packages/openmodes/src/index.ts | 8 +- 2 files changed, 498 insertions(+), 580 deletions(-) diff --git a/packages/openmodes/src/index.css b/packages/openmodes/src/index.css index 4bc89e5c..0001c022 100644 --- a/packages/openmodes/src/index.css +++ b/packages/openmodes/src/index.css @@ -1,10 +1,3 @@ -/* CSS Reset/Normalize */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - :root { --icon-opacity: 0.85; --header-height: 56px; @@ -27,6 +20,15 @@ body { line-height: 1.6; color: var(--color-text); background-color: var(--color-background); + margin: 0; + padding: 0; + box-sizing: border-box; +} + +*, +*::before, +*::after { + box-sizing: border-box; } body:has(dialog[open]) { @@ -44,157 +46,157 @@ a { text-decoration-style: dotted; text-decoration-color: var(--color-text-tertiary); text-underline-offset: 0.1875rem; +} - &:hover { - color: var(--color-text); - } +a:hover { + color: var(--color-text); } header { + position: fixed; top: 0; - display: flex; - gap: 0.5rem; - justify-content: space-between; - align-items: center; + width: 100%; height: var(--header-height); + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 1rem; padding: 0 0.75rem; background-color: var(--color-background); - position: fixed; - width: 100%; z-index: 10; +} - & > div { - display: flex; - align-items: center; - - &.left { - flex: 1 1 auto; - min-width: 0; - position: relative; - align-items: baseline; - } - - &.right { - flex: 0 0 auto; - gap: 0.75rem; - } - } +header .left { + display: flex; + align-items: baseline; + min-width: 0; + overflow: hidden; +} - h1 { - font-size: 1rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: -0.5px; - } +header .right { + display: flex; + align-items: center; + gap: 0.75rem; + white-space: nowrap; +} - p { - font-size: 0.875rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: var(--color-text-tertiary); - } +header h1 { + font-size: 1rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: -0.5px; + margin: 0; +} - .slash { - margin-left: 0.625rem; - margin-right: 0.25rem; - display: block; - position: relative; - top: 1px; - width: 0; - line-height: 1; - height: 0.75rem; - border-right: 2px solid var(--color-border); - transform: translateX(-50%) rotate(20deg); - transform-origin: top center; - } +header p { + font-size: 0.875rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text-tertiary); + margin: 0; +} - a.github { - flex: 0 0 auto; - height: 24px; - color: var(--color-text-secondary); +header .slash { + position: relative; + top: 1px; + display: block; + width: 0; + height: 0.75rem; + margin: 0 0.25rem 0 0.625rem; + border-right: 2px solid var(--color-border); + transform: translateX(-50%) rotate(20deg); + transform-origin: top center; + line-height: 1; +} - svg { - opacity: var(--icon-opacity); - } - } +header a.github { + height: 24px; + color: var(--color-text-secondary); + flex-shrink: 0; +} - .search-container { - position: relative; - flex: 1 1 auto; - min-width: 12.5rem; - } +header a.github svg { + opacity: var(--icon-opacity); +} - input { - width: 100%; - font-size: 0.8125rem; - line-height: 1.1; - padding: 0.5rem 2.5rem 0.5rem 0.625rem; - border-radius: 0.25rem; - border: 1px solid var(--color-border); - height: 2rem; - background: none; - color: var(--color-text); - - &:focus { - border-color: var(--color-brand); - outline: none; - } - } +header .search-container { + position: relative; + width: 150px; + flex-shrink: 0; +} - .search-shortcut { - position: absolute; - right: 0.5rem; - top: 50%; - transform: translateY(-50%); - font-size: 0.75rem; - color: var(--color-text-tertiary); - pointer-events: none; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, - sans-serif; - } +header input { + width: 100%; + height: 2rem; + padding: 0.5rem 2.5rem 0.5rem 0.625rem; + font-size: 0.8125rem; + line-height: 1.1; + border: 1px solid var(--color-border); + border-radius: 0.25rem; + background: var(--color-background-accent); + color: var(--color-text); + box-sizing: border-box; +} + +header input:focus { + border-color: var(--color-brand); + outline: none; +} - button { - flex: 0 0 auto; - cursor: pointer; - border: none; - background-color: var(--color-brand); - color: var(--color-text-invert); - font-size: 0.8125rem; - line-height: 1.1; - height: 2rem; - padding: 0.5rem 0.75rem; - border-radius: 0.25rem; +header .search-shortcut { + position: absolute; + top: 50%; + right: 0.5rem; + transform: translateY(-50%); + font-size: 0.75rem; + color: var(--color-text-tertiary); + pointer-events: none; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, + sans-serif; +} + +header button { + height: 2rem; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + line-height: 1.1; + border: 1px solid transparent; + border-radius: 0.25rem; + background-color: var(--color-brand); + color: var(--color-text-invert); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} + +@media (max-width: 768px) { + header .search-container { + width: 120px; } +} - @media (max-width: 32rem) { - div.left { - p, - span.slash { - display: none; - } - } +@media (max-width: 45rem) { + header .right .github, + header .right .search-container { + display: none; } +} - @media (max-width: 45rem) { - div.right { - .github, - .search-container { - display: none; - } - } +@media (max-width: 32rem) { + header .left p, + header .left span.slash { + display: none; } } -table.table-modes { +.table-modes { width: 100%; + max-width: 1280px; + margin: var(--header-height) auto 0; border-collapse: collapse; table-layout: fixed; font-size: 0.875rem; - margin-top: var(--header-height); - max-width: 1280px; - margin-left: auto; - margin-right: auto; } .table-modes th, @@ -204,63 +206,45 @@ table.table-modes { border-bottom: 1px solid var(--color-border); } -/* Left block: Name and Author */ -.table-modes th.name, -.table-modes td.name { - width: 120px; -} - -.table-modes th.author, -.table-modes td.author { +.table-modes .name, +.table-modes .author, +.table-modes .updated { width: 120px; } -/* Center: Description takes remaining space */ -.table-modes th.description, -.table-modes td.description { +.table-modes .description { width: auto; max-width: 300px; + padding-right: 2.5rem; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; - padding-right: 2.5rem; } -/* Right block: Votes, Downloads, Updated */ -.table-modes th.votes, -.table-modes td.votes { +.table-modes .votes { width: 100px; - text-align: left; } -.table-modes th.downloads, -.table-modes td.downloads { +.table-modes .downloads { width: 130px; - text-align: left; } -.table-modes th.updated, -.table-modes td.updated { - width: 120px; - text-align: left; -} - -table thead th { +.table-modes thead th { position: sticky; top: var(--header-height); - border-top: 1px solid var(--color-border); - border-bottom: 1px solid var(--color-border); + padding-bottom: calc(0.75rem - 2px); font-size: 0.75rem; - padding: 0.75rem 0.75rem calc(0.75rem - 2px); - line-height: 1; font-weight: 400; + line-height: 1; text-transform: uppercase; letter-spacing: 0.5px; color: var(--color-text-secondary); - backdrop-filter: blur(6px); + border-top: 1px solid var(--color-border); background-color: var(--color-background); + backdrop-filter: blur(6px); z-index: 10; } + th.sortable { cursor: pointer; user-select: none; @@ -272,446 +256,382 @@ th.sortable { text-align: center; } -th, -td { - padding: 0.75rem; - text-align: left; - border-bottom: 1px solid var(--color-border); -} - tbody tr { cursor: pointer; transition: background-color 0.15s ease; +} - &:hover { - background-color: var(--color-surface); - } - - td { - color: var(--color-text-tertiary); - } +tbody tr:hover { + background-color: var(--color-surface); +} - .mode-name { - font-weight: 600; - color: var(--color-text); - } +tbody td { + color: var(--color-text-tertiary); +} - .description { - max-width: 300px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } +tbody .mode-name { + font-weight: 600; + color: var(--color-text); } -dialog::backdrop { - backdrop-filter: blur(8px); - background-color: rgba(0, 0, 0, 0.1); +tbody .description { + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } dialog { + display: none; + flex-direction: column; + padding: 0.5rem 1rem; margin: auto; - background-color: var(--color-background); - color: var(--color-text); - border: none; - border-radius: 0.5rem; width: calc(100vw - 2rem); max-width: 50rem; max-height: calc(100svh - 2rem); + border: none; + border-radius: 0.5rem; + background-color: var(--color-background); + color: var(--color-text); + overflow: hidden; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05), 0 4px 8px rgba(0, 0, 0, 0.05), 0 8px 16px rgba(0, 0, 0, 0.07), 0 16px 32px rgba(0, 0, 0, 0.07), 0 32px 64px rgba(0, 0, 0, 0.07), 0 48px 96px rgba(0, 0, 0, 0.07); +} + +dialog[open] { + display: flex; +} + +dialog::backdrop { + backdrop-filter: blur(8px); + background-color: rgba(0, 0, 0, 0.1); +} + +dialog .header, +dialog .footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding: 0.875rem 1rem; + border-bottom: 1px solid var(--color-border); +} +dialog .header { + padding-bottom: calc(0.875rem - 4px); +} + +dialog .footer { + border-top: 1px solid var(--color-border); + border-bottom: none; +} + +dialog .header-left { + flex: 1; + display: flex; flex-direction: column; + gap: 0.25rem; +} + +dialog .title-section { + display: flex; + align-items: center; + gap: 1rem; +} + +dialog .title-section h2 { + margin: 0; + font-size: 1rem; + font-weight: 500; + line-height: 1; + text-transform: uppercase; + letter-spacing: -0.5px; +} + +dialog .vote-section { + display: flex; + flex: 1; + align-items: center; + justify-content: space-between; + gap: 0.375rem; +} + +dialog .vote-group, +dialog .download-group { + display: flex; + align-items: center; + gap: 0.375rem; +} + +dialog .vote-btn, +dialog .download-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0.375rem; + border: 1px solid var(--color-border); + border-radius: 0.25rem; + background: none; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.2s ease; +} + +dialog .vote-btn:hover, +dialog .download-btn:hover { + border-color: var(--color-brand); + color: var(--color-brand); + background-color: rgba(253, 149, 39, 0.05); +} + +dialog .vote-btn.voted, +dialog .download-btn.downloaded { + border-color: var(--color-brand); + background-color: var(--color-brand); + color: var(--color-text-invert); +} + +dialog .vote-btn.disabled, +dialog .download-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + border-color: var(--color-border); + background-color: transparent; + color: var(--color-text-secondary); +} + +dialog .vote-btn.disabled:hover, +dialog .download-btn.disabled:hover { + border-color: var(--color-border); + color: var(--color-text-secondary); + background-color: transparent; +} + +dialog .vote-count, +dialog .download-count { + min-width: 2ch; + font-family: var(--font-mono); + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text); + text-align: center; +} + +dialog .author { + margin: 0; + font-size: 0.8125rem; + font-style: italic; + line-height: 1.2; + color: var(--color-text-secondary); +} + +dialog .body { + flex: 1 1 auto; + padding: 1.5rem 1rem; + font-size: 0.875rem; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; +} + +dialog .body::-webkit-scrollbar { + width: 8px; +} + +dialog .body::-webkit-scrollbar-track { + background: transparent; +} + +dialog .body::-webkit-scrollbar-thumb { + border-radius: 4px; + background: var(--color-border); + border: 1px solid transparent; + background-clip: padding-box; +} + +dialog .body::-webkit-scrollbar-thumb:hover { + background: var(--color-text-tertiary); + background-clip: padding-box; +} + +dialog .mode-content { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +dialog .mode-content h4 { + margin: 0 0 0.75rem 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-secondary); +} + +dialog .description { + font-size: 0.875rem; + line-height: 1.5; + color: var(--color-text-secondary); +} + +dialog .tools-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +dialog .tool-tag { + position: relative; + display: inline-block; + padding: 0.5rem 0.75rem; + font-family: var(--font-mono); + font-size: 0.8125rem; + text-decoration: none; + border-radius: 0.25rem; + background-color: var(--color-surface); + color: var(--color-text-secondary); + transition: all 0.2s ease; +} + +dialog a.tool-tag { + cursor: pointer; +} + +dialog .tool-tag:hover { + background-color: var(--color-brand); + color: var(--color-text-invert); + transform: translateY(-1px); +} + +dialog .tool-tag.tool-disabled:hover { + background-color: rgba(253, 149, 39, 0.3); + color: var(--color-text-secondary); +} + +dialog .tool-tag::after { + content: ''; + position: absolute; + top: 0.25rem; + right: 0.25rem; + width: 6px; + height: 6px; + border-radius: 50%; + border: 1px solid var(--color-background); +} + +dialog .tool-tag.tool-enabled::after { + background-color: var(--color-brand); +} + +dialog .tool-tag.tool-disabled::after { + background-color: rgba(239, 68, 68, 0.3); +} + +dialog .context-instructions { + display: flex; + flex-direction: column; + gap: 1rem; +} + +dialog .context-instruction { + position: relative; + padding: 0.5rem; + border-radius: 0 0.375rem 0.375rem 0; + border-left: 3px solid var(--color-brand); + background-color: var(--color-background-accent); overflow: hidden; +} - &[open] { - display: flex; - } +dialog .copy-badge, +dialog .context-instruction h5 { + position: absolute; + top: 0; + right: 0; + margin: 0; + padding: 0.3rem 0.5rem 0.2rem; + font-size: 0.65rem; + font-weight: 600; + line-height: 1; + text-transform: uppercase; + letter-spacing: 0.5px; + border: 1px solid var(--color-border); + border-radius: 0 0.375rem 0 0; + background-color: var(--color-background); + color: var(--color-text-secondary); +} - .header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.875rem 1rem calc(0.875rem - 4px) 1rem; - border-bottom: 1px solid var(--color-border); - flex: 0 0 auto; - - .header-left { - flex: 1; - display: flex; - flex-direction: column; - gap: 0.25rem; - - .title-section { - display: flex; - align-items: center; - gap: 1rem; - - h2 { - font-size: 1rem; - font-weight: 500; - text-transform: uppercase; - letter-spacing: -0.5px; - line-height: 1; - margin: 0; - } - - .vote-section { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.375rem; - flex: 1; - - .vote-group, - .download-group { - display: flex; - align-items: center; - gap: 0.375rem; - } - - /* Direct children - vote group on left, download on right */ - - .vote-btn, - .download-btn { - background: none; - border: 1px solid var(--color-border); - color: var(--color-text-secondary); - padding: 0.375rem; - border-radius: 0.25rem; - cursor: pointer; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - - &:hover { - border-color: var(--color-brand); - color: var(--color-brand); - background-color: rgba(253, 149, 39, 0.05); - } - - &.voted, - &.downloaded { - border-color: var(--color-brand); - background-color: var(--color-brand); - color: var(--color-text-invert); - } - - &.disabled { - opacity: 0.5; - cursor: not-allowed; - border-color: var(--color-border); - background-color: transparent; - color: var(--color-text-secondary); - } - - &.disabled:hover { - border-color: var(--color-border); - color: var(--color-text-secondary); - background-color: transparent; - } - } - - .vote-count, - .download-count { - font-weight: 600; - font-size: 0.875rem; - color: var(--color-text); - font-family: var(--font-mono); - min-width: 2ch; - text-align: center; - } - } - } - - .author { - color: var(--color-text-secondary); - margin: 0; - font-style: italic; - font-size: 0.8125rem; - line-height: 1.2; - } - } - } +dialog .copy-badge { + cursor: pointer; + transition: background 0.2s, color 0.2s; + z-index: 2; +} - .mode-header { - padding: 1.5rem 1.5rem 0; - border-bottom: 1px solid var(--color-border); +dialog .copy-badge:active, +dialog .copy-badge:focus, +dialog .copy-badge.copied { + background-color: var(--color-brand); + color: var(--color-text-invert); + outline: none; +} - .mode-header-info .description { - margin: 0 0 1.5rem; - line-height: 1.5; - color: var(--color-text); - font-size: 0.95rem; - } - } +dialog .context-instruction .context-content { + margin: 0; + padding: 6px 2rem 6px 10px; + font-size: 0.875rem; + line-height: 1.5; + white-space: pre-wrap; + word-wrap: break-word; + color: var(--color-text-secondary); + overflow-x: auto; + font-family: var(--font-mono); +} - .body { - padding: 1.5rem; - overflow-y: auto; - flex: 1 1 auto; - overscroll-behavior: contain; - font-size: 0.875rem; - - /* Custom scrollbar styling */ - scrollbar-width: thin; - scrollbar-color: var(--color-border) transparent; - - &::-webkit-scrollbar { - width: 8px; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: var(--color-border); - border-radius: 4px; - border: 1px solid transparent; - background-clip: padding-box; - } - - &::-webkit-scrollbar-thumb:hover { - background: var(--color-text-tertiary); - background-clip: padding-box; - } - - .mode-content { - display: flex; - flex-direction: column; - gap: 1.5rem; - - h4 { - font-size: 0.875rem; - font-weight: 600; - margin-bottom: 0.75rem; - color: var(--color-text-secondary); - } - - .description { - color: var(--color-text-secondary); - line-height: 1.5; - font-size: 0.875rem; - } - - .tools-list { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - } - - .tool-tag { - background-color: var(--color-surface); - color: var(--color-text-secondary); - padding: 0.5rem 0.75rem; - border-radius: 0.25rem; - font-size: 0.8125rem; - font-family: var(--font-mono); - position: relative; - text-decoration: none; - display: inline-block; - transition: all 0.2s ease; - - &.tool-enabled::after { - content: ''; - position: absolute; - top: 0.25rem; - right: 0.25rem; - width: 6px; - height: 6px; - background-color: var(--color-brand); - border-radius: 50%; - border: 1px solid var(--color-background); - } - - &.tool-disabled::after { - content: ''; - position: absolute; - top: 0.25rem; - right: 0.25rem; - width: 6px; - height: 6px; - background-color: rgba(239, 68, 68, 0.3); - border-radius: 50%; - border: 1px solid var(--color-background); - } - - &:hover { - background-color: var(--color-brand); - color: var(--color-text-invert); - transform: translateY(-1px); - } - - &.tool-disabled:hover { - background-color: rgba(253, 149, 39, 0.3); - color: var(--color-text-secondary); - transform: translateY(-1px); - } - } - - a.tool-tag { - cursor: pointer; - } - } - - .context-instruction { - background-color: var(--color-background-accent); - color: var(--color-text-secondary); - padding: 0.5rem; - border-radius: 0 0.375rem 0.375rem 0; - border-left: 3px solid var(--color-brand); - position: relative; - overflow: hidden; - - .copy-badge { - position: absolute; - top: 0; - right: 0; - margin: 0; - font-size: 0.65rem; - font-weight: 600; - color: var(--color-text-secondary); - background-color: var(--color-background); - padding: 0.3rem 0.5rem 0.2rem 0.5rem; - border-radius: 0 0.375rem 0 0; - border: 1px solid var(--color-border); - text-transform: uppercase; - letter-spacing: 0.5px; - display: flex; - align-items: center; - line-height: 1; - cursor: pointer; - transition: background 0.2s, color 0.2s; - z-index: 2; - } - .copy-badge:active, - .copy-badge:focus { - background-color: var(--color-brand); - color: var(--color-text-invert); - outline: none; - } - .copy-badge.copied { - background-color: var(--color-brand); - color: var(--color-text-invert); - } - - h5 { - position: absolute; - top: 0; - right: 0; - margin: 0; - font-size: 0.65rem; - font-weight: 600; - color: var(--color-text-secondary); - background-color: var(--color-background); - padding: 0.3rem 0.5rem 0.2rem 0.5rem; - border-radius: 0 0.375rem 0 0; - border: 1px solid var(--color-border); - - text-transform: uppercase; - letter-spacing: 0.5px; - display: flex; - align-items: center; - line-height: 1; - } - - pre { - margin: 0; - overflow-x: auto; - max-width: 100%; - - code { - display: block; - font-size: 0.875rem; - padding: 6px; - padding-left: 10px; - padding-right: 2rem; - line-height: 1.5; - white-space: pre-wrap; - color: var(--color-text-secondary); - - border-radius: 0.25rem; - overflow-wrap: break-word; - } - } - - .context-content { - font-size: 0.875rem; - padding: 6px; - padding-left: 10px; - padding-right: 2rem; - line-height: 1.5; - white-space: pre-wrap; - color: var(--color-text-secondary); - overflow-x: auto; - word-wrap: break-word; - } - } - - .context-instructions { - display: flex; - flex-direction: column; - gap: 1rem; - } - - h2, - p, - .code-block { - margin-bottom: 0.625rem; - - &:has(+ h2) { - margin-bottom: 1.5rem; - } - - &:last-child { - margin-bottom: 0; - } - } - - h2 { - font-size: 1rem; - font-weight: 500; - } - - p { - b { - font-weight: 500; - } - } - - .code-block { - padding: 0.875rem 1rem; - border-radius: 0.25rem; - background-color: var(--color-surface); - } - - code { - font-size: 0.8125rem; - font-family: var(--font-mono); - } - } +dialog .body h2, +dialog .body p, +dialog .body .code-block { + margin-bottom: 0.625rem; +} - .footer { - flex: 0 0 auto; - text-align: center; - border-top: 1px solid var(--color-border); - padding: 0.875rem 1rem 0.875rem; - display: flex; - justify-content: space-between; - align-items: center; - - a { - font-size: 0.75rem; - color: var(--color-text-tertiary); - text-decoration: none; - - &:hover, - &:visited { - color: var(--color-text-tertiary); - } - } - } +dialog .body h2:has(+ h2), +dialog .body p:has(+ h2), +dialog .body .code-block:has(+ h2) { + margin-bottom: 1.5rem; +} + +dialog .body h2:last-child, +dialog .body p:last-child, +dialog .body .code-block:last-child { + margin-bottom: 0; +} + +dialog .body h2 { + font-size: 1rem; + font-weight: 500; +} + +dialog .body p b { + font-weight: 500; +} + +dialog .body .code-block { + padding: 0.875rem 1rem; + border-radius: 0.25rem; + background-color: var(--color-surface); +} + +dialog .body code { + font-family: var(--font-mono); + font-size: 0.8125rem; +} + +dialog .footer a { + font-size: 0.75rem; + color: var(--color-text-tertiary); + text-decoration: none; +} + +dialog .footer a:hover, +dialog .footer a:visited { + color: var(--color-text-tertiary); } diff --git a/packages/openmodes/src/index.ts b/packages/openmodes/src/index.ts index 90adc047..49d75d1a 100644 --- a/packages/openmodes/src/index.ts +++ b/packages/openmodes/src/index.ts @@ -267,9 +267,7 @@ function populateModalContent(mode: any) { -
${escapeHtml(
-		mode.mode_prompt
-	)}
`; +
${escapeHtml(mode.mode_prompt)}
`; populateContextInstructions(mode); populateTools(mode); @@ -288,9 +286,9 @@ function populateContextInstructions(mode: any) { -
${escapeHtml(
+          
${escapeHtml( instruction.content - )}
` + )} ` ) .join(''); } else { From c6c2dc395303f10458e168a62637043c076e9f93 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 21 Jul 2025 15:47:49 +0200 Subject: [PATCH 4/9] fix: update search input placeholder for clarity --- packages/openmodes/src/render.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openmodes/src/render.tsx b/packages/openmodes/src/render.tsx index 7c68f201..a6ea07e8 100644 --- a/packages/openmodes/src/render.tsx +++ b/packages/openmodes/src/render.tsx @@ -280,7 +280,7 @@ export function getRenderWithCurrentVotes() {
- + ⌘K
From d1085273e17aba17ff9a654d6ee495bf41008d18 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:13:24 +0200 Subject: [PATCH 5/9] feat: add /mode/index endpoint, metadata fields, and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added new /mode/index endpoint for lightweight mode listing (id, author, description, votes, downloads, updated_at, version, pr_number) - Updated README and help modal to document new endpoint and metadata fields - Cleaned up Mode structure: removed unused fields, sourced version from metadata.json, supported optional pr_number - Updated archie mode metadata.json for new fields - Improved JSON field ordering and date fallback logic 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode --- packages/openmodes/README.md | 90 ++++++++++++++++++- packages/openmodes/modes/archie/metadata.json | 4 +- packages/openmodes/src/render.tsx | 56 ++++++------ packages/openmodes/src/server.ts | 40 +++++++-- 4 files changed, 154 insertions(+), 36 deletions(-) diff --git a/packages/openmodes/README.md b/packages/openmodes/README.md index e859ad35..c772114c 100644 --- a/packages/openmodes/README.md +++ b/packages/openmodes/README.md @@ -21,10 +21,20 @@ modes/your-mode-name/ { "author": "Your Name", "description": "What your mode does", - "date": "2025-01-20" + "date": "2025-01-20", + "version": "0.1.0", + "pr_number": 123 } ``` +**Fields:** + +- `author`: Your name or GitHub username +- `description`: Brief description of what your mode does +- `date`: Last updated date (YYYY-MM-DD format) +- `version`: Semantic version of your mode (e.g., "0.1.0") +- `pr_number`: (Optional) PR number where this mode was introduced + **2. Create `opencode.json`:** ```json @@ -111,6 +121,84 @@ You are a specialized AI that [does what]. See `modes/archie/` for a complete example with MCP tools, instructions, and prompt files. +## API + +The OpenModes database provides a REST API to access mode data programmatically. + +### Endpoints + +**Get all modes (basic info only):** + +```bash +GET /mode/index +``` + +Returns: `{ id, author, description, votes, downloads, updated_at, version, pr_number? }` + +**Get all modes (full data):** + +```bash +GET /mode/all +``` + +Returns: Complete mode data including prompts, tools, and context instructions + +**Get specific mode:** + +```bash +GET /mode/{mode-id} +``` + +Returns: Full data for a single mode + +**Example:** + +```bash +curl https://openmodes.dev/mode/index +curl https://openmodes.dev/mode/archie +``` + +### Response Format + +**Index endpoint (`/mode/index`):** + +```json +{ + "archie": { + "id": "archie", + "author": "spoon", + "description": "Architectural guidance mode...", + "votes": 5, + "downloads": 25, + "updated_at": "2025-01-20", + "version": "0.1.0", + "pr_number": 123 + } +} +``` + +**Full mode endpoint (`/mode/{id}` or `/mode/all`):** + +```json +{ + "id": "archie", + "name": "Archie", + "author": "spoon", + "description": "Architectural guidance mode...", + "votes": 5, + "downloads": 25, + "updated_at": "2025-01-20", + "version": "0.1.0", + "pr_number": 123, + "tools_enabled": [ + { "name": "context7", "url": "https://github.com/upstash/context7" } + ], + "tools_disabled": ["bash"], + "mode_prompt": "Your complete system prompt...", + "context_instructions": [{ "title": "ADR Guidelines", "content": "..." }] +} +``` + --- That's it! Drop your mode folder in `modes/` and make a pull request. diff --git a/packages/openmodes/modes/archie/metadata.json b/packages/openmodes/modes/archie/metadata.json index 003dff0b..4689a0cc 100644 --- a/packages/openmodes/modes/archie/metadata.json +++ b/packages/openmodes/modes/archie/metadata.json @@ -1,5 +1,7 @@ { "author": "spoon", "description": "Archie is a mode for architectural guidance, enforcing system-wide principles, maintainability, and risk mitigation in modern web applications. It provides step-by-step, machine-readable implementation plans and challenges technical debt, acting as a lead software architect for the codebase.", - "date": "2025-07-20" + "date": "2025-07-20", + "version": "0.1.0", + "pr_number": 65 } diff --git a/packages/openmodes/src/render.tsx b/packages/openmodes/src/render.tsx index a6ea07e8..c82382fa 100644 --- a/packages/openmodes/src/render.tsx +++ b/packages/openmodes/src/render.tsx @@ -9,8 +9,6 @@ import { readFileSync, existsSync } from 'fs'; // Constants const DEFAULT_AUTHOR = 'OpenCode Community'; -const DEFAULT_DATE = '2025-01-20'; -const DEFAULT_VERSION = '1.0'; // String transformation utilities const titleCase = (str: string) => @@ -19,24 +17,20 @@ const titleCase = (str: string) => .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); -const toUpperFilename = (filename: string) => - filename.replace('.mode.md', '').toUpperCase(); - interface Mode { id: string; name: string; author: string; - tools_enabled?: Array<{ name: string; url?: string }>; - tools_disabled?: string[]; - mode_prompt: string; description: string; votes: number; downloads: number; - created_at: string; updated_at: string; version: string; + pr_number?: number; + tools_enabled?: Array<{ name: string; url?: string }>; + tools_disabled?: string[]; + mode_prompt: string; context_instructions?: Array<{ title: string; content: string }>; - prompt_file_name?: string; } class DataLoader { @@ -106,11 +100,9 @@ async function loadModeFromDirectory( const dirFiles = await readdir(modeDir); const { enabledTools, disabledTools } = extractTools(opencodeData); - const { systemPrompt, promptFileName } = await extractSystemPrompt( - modeDir, - dirFiles - ); - const { description, author, updatedAt } = await extractMetadata(modeDir); + const { systemPrompt } = await extractSystemPrompt(modeDir, dirFiles); + const { description, author, updatedAt, version, prNumber } = + await extractMetadata(modeDir); const contextInstructions = await extractContextInstructions( modeDir, dirFiles @@ -120,17 +112,16 @@ async function loadModeFromDirectory( id: dirName, name: titleCase(dirName), author, - tools_enabled: enabledTools, - tools_disabled: disabledTools, - mode_prompt: systemPrompt, description, votes: 0, - created_at: DEFAULT_DATE, - updated_at: updatedAt, - version: DEFAULT_VERSION, downloads: 0, - context_instructions: contextInstructions, - prompt_file_name: promptFileName + updated_at: updatedAt, + version, + ...(prNumber && { pr_number: prNumber }), + tools_enabled: enabledTools, + tools_disabled: disabledTools, + mode_prompt: systemPrompt, + context_instructions: contextInstructions }; } catch (error) { console.log(`Skipping ${dirName}: error reading opencode.json`, error); @@ -181,21 +172,21 @@ function extractTools(opencodeData: any) { async function extractSystemPrompt(modeDir: string, dirFiles: string[]) { const promptFile = dirFiles.find((f) => f.endsWith('.mode.md')); let systemPrompt = 'No system prompt found'; - let promptFileName = 'PROMPT'; if (promptFile) { const promptPath = path.join(modeDir, promptFile); systemPrompt = await readFile(promptPath, 'utf-8'); - promptFileName = toUpperFilename(promptFile); } - return { systemPrompt, promptFileName }; + return { systemPrompt }; } async function extractMetadata(modeDir: string) { let description = ''; let author = DEFAULT_AUTHOR; - let updatedAt = DEFAULT_DATE; + let updatedAt = '-'; + let version = '0.1.0'; + let prNumber: number | undefined; try { const metadataPath = path.join(modeDir, 'metadata.json'); @@ -205,11 +196,13 @@ async function extractMetadata(modeDir: string) { if (metaData.description) description = metaData.description.trim(); if (metaData.author) author = metaData.author; if (metaData.date) updatedAt = metaData.date; + if (metaData.version) version = metaData.version; + if (metaData.pr_number) prNumber = metaData.pr_number; } catch { // Use defaults if metadata.json doesn't exist } - return { description, author, updatedAt }; + return { description, author, updatedAt, version, prNumber }; } async function extractContextInstructions(modeDir: string, dirFiles: string[]) { @@ -481,7 +474,12 @@ export function getRenderWithCurrentVotes() {

You can access this data through an API.

- # Get all modes + # Get modes index (basic info only) +
+ curl https://openmodes.dev/mode/index +
+
+ # Get all modes (full data)
curl https://openmodes.dev/mode/all
diff --git a/packages/openmodes/src/server.ts b/packages/openmodes/src/server.ts index 22032883..795d5710 100644 --- a/packages/openmodes/src/server.ts +++ b/packages/openmodes/src/server.ts @@ -4,13 +4,13 @@ import { readdir, stat } from 'fs/promises'; import { Mutex } from 'async-mutex'; // Response helpers -const jsonResponse = (data: any, status = 200, indent?: number) => +const jsonResponse = (data: any, status = 200, indent?: number) => new Response(JSON.stringify(data, null, indent), { status, headers: { 'Content-Type': 'application/json' } }); -const textResponse = (text: string, status = 200) => +const textResponse = (text: string, status = 200) => new Response(text, { status }); const fileResponse = (file: any, contentType: string) => @@ -75,7 +75,8 @@ class DataManager { return await DataManager.mutexes.downloads.runExclusive(async () => { if (!Modes[modeId]) throw new Error('Mode not found'); - if (!DataManager.data.downloads[modeId]) DataManager.data.downloads[modeId] = 0; + if (!DataManager.data.downloads[modeId]) + DataManager.data.downloads[modeId] = 0; DataManager.data.downloads[modeId]++; await DataManager.save('downloads'); @@ -120,6 +121,28 @@ function getAllModesWithVotes() { return modesWithVotes; } +function getAllModesIndex() { + const modesIndex: Record = {}; + for (const [modeId, mode] of Object.entries(Modes)) { + const indexEntry: any = { + id: modeId, + author: mode.author, + description: mode.description, + votes: DataManager.getCount('votes', modeId), + downloads: DataManager.getCount('downloads', modeId), + updated_at: mode.updated_at, + version: mode.version + }; + + if (mode.pr_number) { + indexEntry.pr_number = mode.pr_number; + } + + modesIndex[modeId] = indexEntry; + } + return modesIndex; +} + await DataManager.load('votes'); await DataManager.load('downloads'); @@ -243,12 +266,16 @@ const server = Bun.serve({ return jsonResponse(getAllModesWithVotes(), 200, 2); } + if (url.pathname === '/mode/index') { + return jsonResponse(getAllModesIndex(), 200, 2); + } + if (url.pathname.startsWith('/mode/')) { const modeId = url.pathname.split('/')[2]; if (!modeId) { return textResponse('Mode ID required', 400); } - + const mode = getModeWithVotes(modeId); if (!mode) { @@ -267,7 +294,10 @@ const server = Bun.serve({ }); } - if (url.pathname.startsWith('/src/') || url.pathname.startsWith('/public/')) { + if ( + url.pathname.startsWith('/src/') || + url.pathname.startsWith('/public/') + ) { const filePath = path.join(import.meta.dir, '..', url.pathname); const file = Bun.file(filePath); if (await file.exists()) { From ea75ea4f1a34d551b8f334aadecb57d23116f1f9 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:42:23 +0200 Subject: [PATCH 6/9] Refactor API and frontend to remove legacy tools card logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Eliminate tools_enabled/tools_disabled from API responses and frontend - Use opencode_config for tool configuration and display - Remove outdated extraction logic for tools - Update documentation and API to reflect new structure - Improve maintainability and consistency across codebase 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode --- packages/openmodes/README.md | 24 +++++++++--- packages/openmodes/modes/archie/opencode.json | 2 +- packages/openmodes/src/index.ts | 39 +++++++++++++------ packages/openmodes/src/render.tsx | 33 +--------------- packages/openmodes/src/server.ts | 8 ++-- 5 files changed, 54 insertions(+), 52 deletions(-) diff --git a/packages/openmodes/README.md b/packages/openmodes/README.md index c772114c..f7a40c5f 100644 --- a/packages/openmodes/README.md +++ b/packages/openmodes/README.md @@ -182,7 +182,6 @@ curl https://openmodes.dev/mode/archie ```json { "id": "archie", - "name": "Archie", "author": "spoon", "description": "Architectural guidance mode...", "votes": 5, @@ -190,10 +189,25 @@ curl https://openmodes.dev/mode/archie "updated_at": "2025-01-20", "version": "0.1.0", "pr_number": 123, - "tools_enabled": [ - { "name": "context7", "url": "https://github.com/upstash/context7" } - ], - "tools_disabled": ["bash"], + "opencode_config": { + "instructions": ["./adr.instructions.md"], + "mcp": { + "context7": { + "type": "local", + "command": ["npx", "-y", "@upstash/context7-mcp"], + "enabled": true, + "url": "https://github.com/upstash/context7" // for user verification purposes + } + }, + "mode": { + "test": { + "prompt": "{file:./archie.mode.md}", + "tools": { + "bash": false + } + } + } + }, "mode_prompt": "Your complete system prompt...", "context_instructions": [{ "title": "ADR Guidelines", "content": "..." }] } diff --git a/packages/openmodes/modes/archie/opencode.json b/packages/openmodes/modes/archie/opencode.json index 6f6c7756..aab007e8 100644 --- a/packages/openmodes/modes/archie/opencode.json +++ b/packages/openmodes/modes/archie/opencode.json @@ -25,7 +25,7 @@ } }, "mode": { - "test": { + "archie": { "prompt": "{file:./archie.mode.md}", "tools": { "repomix_file_system_read_file": false, diff --git a/packages/openmodes/src/index.ts b/packages/openmodes/src/index.ts index 49d75d1a..e5bceed8 100644 --- a/packages/openmodes/src/index.ts +++ b/packages/openmodes/src/index.ts @@ -303,10 +303,33 @@ function populateTools(mode: any) { disabledSection: document.getElementById('modal-tools-disabled-section')! }; + // Parse tools from opencode_config + const enabledTools: Array<{ name: string; url?: string }> = []; + const disabledTools: string[] = []; + + if (mode.opencode_config?.mcp) { + Object.entries(mode.opencode_config.mcp).forEach(([key, value]: [string, any]) => { + if (value.enabled !== false) { + enabledTools.push({ name: key, url: value.url || undefined }); + } + }); + } + + if (mode.opencode_config?.mode) { + const firstModeKey = Object.keys(mode.opencode_config.mode)[0]; + if (firstModeKey && mode.opencode_config.mode[firstModeKey].tools) { + Object.entries(mode.opencode_config.mode[firstModeKey].tools).forEach( + ([tool, enabled]) => { + if (enabled === false) disabledTools.push(tool); + } + ); + } + } + let enabledToolsHtml = ''; - if (mode.tools_enabled?.length > 0) { - enabledToolsHtml = mode.tools_enabled - .map((tool: any) => { + if (enabledTools.length > 0) { + enabledToolsHtml = enabledTools + .map((tool) => { const toolName = typeof tool === 'string' ? tool : tool.name; const toolUrl = typeof tool === 'object' && tool.url ? tool.url : null; return toolUrl @@ -314,18 +337,12 @@ function populateTools(mode: any) { : `${toolName}`; }) .join(''); - } else if (mode.tools?.length > 0) { - enabledToolsHtml = mode.tools - .map( - (tool: string) => `${tool}` - ) - .join(''); } toolElements.enabledContainer.innerHTML = enabledToolsHtml; - if (mode.tools_disabled?.length > 0) { + if (disabledTools.length > 0) { toolElements.disabledSection.style.display = 'block'; - toolElements.disabledContainer.innerHTML = mode.tools_disabled + toolElements.disabledContainer.innerHTML = disabledTools .map( (tool: string) => `${tool}` ) diff --git a/packages/openmodes/src/render.tsx b/packages/openmodes/src/render.tsx index c82382fa..9a9b4486 100644 --- a/packages/openmodes/src/render.tsx +++ b/packages/openmodes/src/render.tsx @@ -27,8 +27,7 @@ interface Mode { updated_at: string; version: string; pr_number?: number; - tools_enabled?: Array<{ name: string; url?: string }>; - tools_disabled?: string[]; + opencode_config: any; mode_prompt: string; context_instructions?: Array<{ title: string; content: string }>; } @@ -99,7 +98,6 @@ async function loadModeFromDirectory( const modeDir = path.join(modesDir, dirName); const dirFiles = await readdir(modeDir); - const { enabledTools, disabledTools } = extractTools(opencodeData); const { systemPrompt } = await extractSystemPrompt(modeDir, dirFiles); const { description, author, updatedAt, version, prNumber } = await extractMetadata(modeDir); @@ -118,8 +116,7 @@ async function loadModeFromDirectory( updated_at: updatedAt, version, ...(prNumber && { pr_number: prNumber }), - tools_enabled: enabledTools, - tools_disabled: disabledTools, + opencode_config: opencodeData, mode_prompt: systemPrompt, context_instructions: contextInstructions }; @@ -143,32 +140,6 @@ async function loadModeFromJSON( } } -function extractTools(opencodeData: any) { - const enabledTools: Array<{ name: string; url?: string }> = []; - const disabledTools: string[] = []; - - if (opencodeData.mcp) { - Object.entries(opencodeData.mcp).forEach(([key, value]: [string, any]) => { - if (value.enabled !== false) { - enabledTools.push({ name: key, url: value.url || undefined }); - } - }); - } - - if (opencodeData.mode) { - const firstModeKey = Object.keys(opencodeData.mode)[0]; - if (firstModeKey && opencodeData.mode[firstModeKey].tools) { - Object.entries(opencodeData.mode[firstModeKey].tools).forEach( - ([tool, enabled]) => { - if (enabled === false) disabledTools.push(tool); - } - ); - } - } - - return { enabledTools, disabledTools }; -} - async function extractSystemPrompt(modeDir: string, dirFiles: string[]) { const promptFile = dirFiles.find((f) => f.endsWith('.mode.md')); let systemPrompt = 'No system prompt found'; diff --git a/packages/openmodes/src/server.ts b/packages/openmodes/src/server.ts index 795d5710..ce88675e 100644 --- a/packages/openmodes/src/server.ts +++ b/packages/openmodes/src/server.ts @@ -100,9 +100,9 @@ function getModeWithVotes(modeId: string) { const mode = Modes[modeId]; if (!mode) return null; - const { name, ...modeWithoutName } = mode; + const { name, ...modeForApi } = mode; return { - ...modeWithoutName, + ...modeForApi, votes: DataManager.getCount('votes', modeId), downloads: DataManager.getCount('downloads', modeId) }; @@ -111,9 +111,9 @@ function getModeWithVotes(modeId: string) { function getAllModesWithVotes() { const modesWithVotes: Record = {}; for (const [modeId, mode] of Object.entries(Modes)) { - const { name, ...modeWithoutName } = mode; + const { name, ...modeForApi } = mode; modesWithVotes[modeId] = { - ...modeWithoutName, + ...modeForApi, votes: DataManager.getCount('votes', modeId), downloads: DataManager.getCount('downloads', modeId) }; From 6e547d107724edafeb97c27b40cf19435161df7d Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 21 Jul 2025 18:11:10 +0200 Subject: [PATCH 7/9] vanity api styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds content negotiation for /mode/* endpoints: browsers get styled HTML, API clients get JSON... why not? - Slight restyle of the help modal - No breaking changes for programmatic access (curl, fetch, etc.) 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode --- packages/openmodes/src/index.css | 12 ++++-- packages/openmodes/src/render.tsx | 15 ++++--- packages/openmodes/src/server.ts | 65 +++++++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/packages/openmodes/src/index.css b/packages/openmodes/src/index.css index 0001c022..cca1fb5d 100644 --- a/packages/openmodes/src/index.css +++ b/packages/openmodes/src/index.css @@ -4,7 +4,7 @@ --font-mono: 'IBM Plex Mono', monospace; --color-brand: #fd9527; --color-background: #1e1e1e; - --color-background-accent: #2e2e2e; + --color-background-accent: #2a2a2a; --color-border: #333; --color-surface: #111; --color-alpha-background: rgba(255, 255, 255, 0.75); @@ -616,8 +616,9 @@ dialog .body p b { dialog .body .code-block { padding: 0.875rem 1rem; - border-radius: 0.25rem; - background-color: var(--color-surface); + border-radius: 0 0.25rem 0.25rem 0; + border-left: 3px solid var(--color-brand); + background-color: #2a2a2a; } dialog .body code { @@ -625,6 +626,11 @@ dialog .body code { font-size: 0.8125rem; } +/* API endpoint styling */ +dialog .body .code-block .api-key { + color: var(--color-brand); +} + dialog .footer a { font-size: 0.75rem; color: var(--color-text-tertiary); diff --git a/packages/openmodes/src/render.tsx b/packages/openmodes/src/render.tsx index 9a9b4486..3ce27be2 100644 --- a/packages/openmodes/src/render.tsx +++ b/packages/openmodes/src/render.tsx @@ -445,19 +445,22 @@ export function getRenderWithCurrentVotes() {

You can access this data through an API.

- # Get modes index (basic info only) + # Get modes index (basic info only)
- curl https://openmodes.dev/mode/index + curl + https://openmodes.dev/mode/index

- # Get all modes (full data) + # Get all modes (full data)
- curl https://openmodes.dev/mode/all + curl + https://openmodes.dev/mode/all

- # Get specific mode + # Get specific mode
- curl https://openmodes.dev/mode/archie + curl + https://openmodes.dev/mode/archie

Contribute

diff --git a/packages/openmodes/src/server.ts b/packages/openmodes/src/server.ts index ce88675e..63227ff9 100644 --- a/packages/openmodes/src/server.ts +++ b/packages/openmodes/src/server.ts @@ -10,6 +10,65 @@ const jsonResponse = (data: any, status = 200, indent?: number) => headers: { 'Content-Type': 'application/json' } }); +const smartJsonResponse = (data: any, req: Request, status = 200) => { + const acceptHeader = req.headers.get('accept') || ''; + const userAgent = req.headers.get('user-agent') || ''; + + // Serve JSON for API clients (curl, fetch, etc.) + if ( + acceptHeader.includes('application/json') || + userAgent.includes('curl') || + userAgent.includes('fetch') || + !acceptHeader.includes('text/html') + ) { + return jsonResponse(data, status, 2); + } + + // Serve styled HTML for browsers + const jsonString = JSON.stringify(data, null, 2); + const styledJson = jsonString + .replace(/"([^"]+)":/g, '"$1":') // Keys in orange + .replace(/: "([^"]*?)"/g, ': "$1"') // String values in green + .replace(/: (\d+)/g, ': $1') // Numbers in green + .replace(/: (true|false|null)/g, ': $1'); // Booleans/null in green + + const html = ` + + + + OpenModes API Response + + + +
${styledJson}
+ +`; + + return new Response(html, { + status, + headers: { 'Content-Type': 'text/html' } + }); +}; + const textResponse = (text: string, status = 200) => new Response(text, { status }); @@ -263,11 +322,11 @@ const server = Bun.serve({ } if (url.pathname === '/mode/all') { - return jsonResponse(getAllModesWithVotes(), 200, 2); + return smartJsonResponse(getAllModesWithVotes(), req, 200); } if (url.pathname === '/mode/index') { - return jsonResponse(getAllModesIndex(), 200, 2); + return smartJsonResponse(getAllModesIndex(), req, 200); } if (url.pathname.startsWith('/mode/')) { @@ -282,7 +341,7 @@ const server = Bun.serve({ return textResponse('Mode not found', 404); } - return jsonResponse(mode, 200, 2); + return smartJsonResponse(mode, req, 200); } if (url.pathname === '/') { From f8c738987d915a40e2be1c1aa622630b5e280924 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 21 Jul 2025 20:26:46 +0200 Subject: [PATCH 8/9] fixing copy to cilpboard - rolling back to monolith... splitting broke it and I cba at this point :D --- packages/openmodes/src/index.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/openmodes/src/index.ts b/packages/openmodes/src/index.ts index e5bceed8..af84513e 100644 --- a/packages/openmodes/src/index.ts +++ b/packages/openmodes/src/index.ts @@ -308,11 +308,13 @@ function populateTools(mode: any) { const disabledTools: string[] = []; if (mode.opencode_config?.mcp) { - Object.entries(mode.opencode_config.mcp).forEach(([key, value]: [string, any]) => { - if (value.enabled !== false) { - enabledTools.push({ name: key, url: value.url || undefined }); + Object.entries(mode.opencode_config.mcp).forEach( + ([key, value]: [string, any]) => { + if (value.enabled !== false) { + enabledTools.push({ name: key, url: value.url || undefined }); + } } - }); + ); } if (mode.opencode_config?.mode) { @@ -400,7 +402,8 @@ async function vote(direction: 'up' | 'down') { if (call === apiCalls[apiCalls.length - 1]) { const result = await response.json(); - updateCountUI('votes', result.newVoteCount, modeId); } + updateCountUI('votes', result.newVoteCount, modeId); + } } } catch (error) { console.error('Failed to vote:', error); @@ -451,10 +454,15 @@ function setButtonsDisabled(disabled: boolean) { } } -function updateCountUI(type: 'votes' | 'downloads', newCount: number, modeId: string) { +function updateCountUI( + type: 'votes' | 'downloads', + newCount: number, + modeId: string +) { if (!currentMode) return; currentMode[type] = newCount; - const modalCountEl = type === 'votes' ? DOMElements.voteCountEl : DOMElements.downloadCountEl; + const modalCountEl = + type === 'votes' ? DOMElements.voteCountEl : DOMElements.downloadCountEl; modalCountEl.textContent = newCount.toString(); const tableRow = document.querySelector(`tr[data-mode-id="${modeId}"]`); const cellClass = type; @@ -462,7 +470,6 @@ function updateCountUI(type: 'votes' | 'downloads', newCount: number, modeId: st if (cell) cell.textContent = newCount.toString(); } - async function downloadMode() { if (!currentMode) return; @@ -507,8 +514,6 @@ function downloadFile(blob: Blob, filename: string) { URL.revokeObjectURL(url); } - - async function updateDownloadCount(modeId: string) { try { const countResponse = await fetch('/api/download', { @@ -554,9 +559,7 @@ function setupEventListeners() { if (target && target.classList.contains('copy-badge')) { const contextInstruction = target.closest('.context-instruction'); if (!contextInstruction) return; - const codeBlock = contextInstruction.querySelector( - 'code.context-content' - ); + const codeBlock = contextInstruction.querySelector('context-content'); if (!codeBlock) return; const text = codeBlock.textContent || ''; if (!navigator.clipboard) { From b88ccf61a8677fc58d857decc65972865e063d48 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Thu, 24 Jul 2025 12:57:51 +0200 Subject: [PATCH 9/9] CLI tool + Vercel node server deployment --- bun.lock | 10 +- packages/openmodes/api/index.js | 387 ++++++++++++++++++ packages/openmodes/api/package.json | 6 + packages/openmodes/cli/install.js | 192 +++++++++ packages/openmodes/cli/package.json | 31 ++ packages/openmodes/local-server.js | 105 +++++ packages/openmodes/modes/archie/opencode.json | 50 +-- packages/openmodes/package.json | 8 +- packages/openmodes/public/_headers | 19 +- packages/openmodes/script/build.ts | 50 +++ packages/openmodes/src/index.ts | 123 +++++- packages/openmodes/src/server.ts | 13 +- packages/openmodes/tsconfig.json | 7 +- packages/openmodes/vercel.json | 20 + 14 files changed, 968 insertions(+), 53 deletions(-) create mode 100644 packages/openmodes/api/index.js create mode 100644 packages/openmodes/api/package.json create mode 100755 packages/openmodes/cli/install.js create mode 100644 packages/openmodes/cli/package.json create mode 100644 packages/openmodes/local-server.js create mode 100644 packages/openmodes/vercel.json diff --git a/bun.lock b/bun.lock index 04a22088..4efae1a7 100644 --- a/bun.lock +++ b/bun.lock @@ -21,10 +21,12 @@ "name": "@openmodes/web", "dependencies": { "async-mutex": "^0.5.0", + "express": "^5.1.0", "hono": "^4.8.0", }, "devDependencies": { "@types/bun": "^1.2.16", + "@types/node": "^20.0.0", }, }, "packages/web": { @@ -55,7 +57,7 @@ "@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="], - "@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="], + "@types/node": ["@types/node@20.19.9", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -285,7 +287,7 @@ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], @@ -311,6 +313,8 @@ "zod-to-json-schema": ["zod-to-json-schema@3.24.3", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A=="], + "bun-types/@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="], + "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], @@ -318,5 +322,7 @@ "opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="], "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], + + "bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], } } diff --git a/packages/openmodes/api/index.js b/packages/openmodes/api/index.js new file mode 100644 index 00000000..a441f844 --- /dev/null +++ b/packages/openmodes/api/index.js @@ -0,0 +1,387 @@ +import path from 'path'; +import { promises as fs } from 'fs'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Load modes from dist directory (copied during build) +async function loadModes() { + const modes = {}; + const modesDir = path.join(__dirname, '..', 'dist', 'modes'); + + try { + const entries = await fs.readdir(modesDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + try { + const mode = await loadModeFromDirectory(modesDir, entry.name); + if (mode) { + modes[entry.name] = mode; + } + } catch (error) { + console.warn(`Failed to load mode ${entry.name}:`, error); + } + } + } + } catch (error) { + console.warn('Failed to load modes directory:', error); + } + + return modes; +} + +async function loadModeFromDirectory(modesDir, dirName) { + const opencodeJsonPath = path.join(modesDir, dirName, 'opencode.json'); + + try { + const opencodeContent = await fs.readFile(opencodeJsonPath, 'utf-8'); + const config = JSON.parse(opencodeContent); + const modeDir = path.join(modesDir, dirName); + const dirFiles = await fs.readdir(modeDir); + + return await loadModeFromJSON(config, modeDir, dirName, dirFiles); + } catch (error) { + console.warn(`Failed to load mode from directory ${dirName}:`, error); + return null; + } +} + +const titleCase = (str) => + str + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + +async function loadModeFromJSON(config, modeDir, dirName, dirFiles) { + const mode = { + id: dirName, + name: titleCase(dirName), + author: 'OpenCode Community', + description: '', + votes: 0, + downloads: 0, + updated_at: new Date().toISOString(), + version: '1.0.0', + opencode_config: config, + mode_prompt: '', + context_instructions: [] + }; + + // Load metadata + const metadataPath = path.join(modeDir, 'metadata.json'); + try { + const metaContent = await fs.readFile(metadataPath, 'utf-8'); + const metadata = JSON.parse(metaContent); + mode.author = metadata.author || mode.author; + mode.description = metadata.description || mode.description; + mode.version = metadata.version || mode.version; + mode.updated_at = metadata.date || mode.updated_at; + if (metadata.pr_number) mode.pr_number = metadata.pr_number; + } catch (error) { + // metadata.json is optional + } + + // Load mode prompt from .mode.md file + const promptFile = dirFiles.find((f) => f.endsWith('.mode.md')); + if (promptFile) { + const promptPath = path.join(modeDir, promptFile); + try { + mode.mode_prompt = await fs.readFile(promptPath, 'utf-8'); + } catch (error) { + console.warn(`Failed to load prompt file ${promptFile}:`, error); + } + } + + // Load context instructions from .instructions.md and .prompt.md files + const instructionFiles = dirFiles.filter( + (f) => f.endsWith('.instructions.md') || f.endsWith('.prompt.md') + ); + + for (const instFile of instructionFiles) { + const contextName = instFile.endsWith('.instructions.md') + ? instFile.replace('.instructions.md', '') + : instFile.replace('.prompt.md', ''); + const title = titleCase(contextName); + const instPath = path.join(modeDir, instFile); + try { + const content = await fs.readFile(instPath, 'utf-8'); + mode.context_instructions.push({ title, content: content.trim() }); + } catch (error) { + console.warn(`Failed to load instruction file ${instFile}:`, error); + } + } + + return mode; +} + +// File-based persistent storage with fallback to memory +class DataManager { + static data = { votes: {}, downloads: {} }; + static filePaths = { + votes: path.join('/tmp', 'openmodes-votes.json'), + downloads: path.join('/tmp', 'openmodes-downloads.json') + }; + + static async load(type) { + try { + const content = await fs.readFile(DataManager.filePaths[type], 'utf-8'); + DataManager.data[type] = JSON.parse(content); + console.log(`Loaded ${type} data from file`); + } catch (error) { + // File doesn't exist, try to load from environment variable as backup + const envVar = `OPENMODES_${type.toUpperCase()}`; + if (process.env[envVar]) { + try { + DataManager.data[type] = JSON.parse(process.env[envVar]); + console.log(`Loaded ${type} data from environment`); + } catch (e) { + console.warn(`Failed to parse ${envVar} environment variable`); + DataManager.data[type] = {}; + } + } else { + DataManager.data[type] = {}; + } + } + } + + static async save(type) { + try { + const jsonData = JSON.stringify(DataManager.data[type], null, 2); + await fs.writeFile(DataManager.filePaths[type], jsonData); + console.log(`Saved ${type} data to file and memory`); + } catch (error) { + console.error(`Failed to save ${type}:`, error); + } + } + + static getCount(type, modeId) { + return DataManager.data[type]?.[modeId] || 0; + } + + static async handleVote(modeId, direction, action, modes) { + if (!modes[modeId]) throw new Error('Mode not found'); + + if (!DataManager.data.votes[modeId]) DataManager.data.votes[modeId] = 0; + + const multiplier = direction === 'up' ? 1 : -1; + const actionMultiplier = action === 'add' ? 1 : -1; + DataManager.data.votes[modeId] += multiplier * actionMultiplier; + + await DataManager.save('votes'); + return { newVoteCount: DataManager.data.votes[modeId] }; + } + + static async handleDownload(modeId, modes) { + if (!modes[modeId]) throw new Error('Mode not found'); + + if (!DataManager.data.downloads[modeId]) DataManager.data.downloads[modeId] = 0; + DataManager.data.downloads[modeId]++; + + await DataManager.save('downloads'); + return { newDownloadCount: DataManager.data.downloads[modeId] }; + } +} + +// Load data on startup +await DataManager.load('votes'); +await DataManager.load('downloads'); + +// Helper functions matching Bun server +function getModeWithVotes(modeId, modes) { + const mode = modes[modeId]; + if (!mode) return null; + + const { name, ...modeForApi } = mode; + return { + ...modeForApi, + votes: DataManager.getCount('votes', modeId), + downloads: DataManager.getCount('downloads', modeId) + }; +} + +function getAllModesWithVotes(modes) { + const modesWithVotes = {}; + for (const [modeId, mode] of Object.entries(modes)) { + const { name, ...modeForApi } = mode; + modesWithVotes[modeId] = { + ...modeForApi, + votes: DataManager.getCount('votes', modeId), + downloads: DataManager.getCount('downloads', modeId) + }; + } + return modesWithVotes; +} + +function getAllModesIndex(modes) { + const modesIndex = {}; + for (const [modeId, mode] of Object.entries(modes)) { + const indexEntry = { + id: modeId, + author: mode.author, + description: mode.description, + votes: DataManager.getCount('votes', modeId), + downloads: DataManager.getCount('downloads', modeId), + updated_at: mode.updated_at, + version: mode.version + }; + + if (mode.pr_number) { + indexEntry.pr_number = mode.pr_number; + } + + modesIndex[modeId] = indexEntry; + } + return modesIndex; +} + +export default async function handler(req, res) { + try { + const url = new URL(req.url, `https://${req.headers.host}`); + const modes = await loadModes(); + + // Handle voting endpoint + if (url.pathname === '/api/vote' && req.method === 'POST') { + const { modeId, direction, action } = req.body; + + if (!modeId || !direction || !action) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + if (direction !== 'up' && direction !== 'down') { + return res.status(400).json({ error: 'Invalid vote direction' }); + } + + if (action !== 'add' && action !== 'remove') { + return res.status(400).json({ error: 'Invalid vote action' }); + } + + try { + const result = await DataManager.handleVote(modeId, direction, action, modes); + return res.json(result); + } catch (error) { + console.error('Vote error:', error); + return res.status(500).json({ error: 'Vote failed' }); + } + } + + // Handle download endpoint + if (url.pathname === '/api/download' && req.method === 'POST') { + const { modeId } = req.body; + + if (!modeId) { + return res.status(400).json({ error: 'Missing modeId' }); + } + + try { + const result = await DataManager.handleDownload(modeId, modes); + return res.json(result); + } catch (error) { + console.error('Download error:', error); + return res.status(500).json({ error: 'Download tracking failed' }); + } + } + + // Handle mode files zip download + if (url.pathname.startsWith('/api/download-zip/') && req.method === 'GET') { + const modeId = url.pathname.split('/').pop(); + if (!modeId || !modes[modeId]) { + return res.status(404).json({ error: 'Mode not found' }); + } + + try { + const modesDir = path.join(__dirname, '..', 'dist', 'modes'); + const modeDir = path.join(modesDir, modeId); + + // Check if mode directory exists + try { + const stat = await fs.stat(modeDir); + if (!stat.isDirectory()) { + return res.status(404).json({ error: 'Mode directory not found' }); + } + } catch (error) { + return res.status(404).json({ error: 'Mode directory not found' }); + } + + // Get all files in the mode directory + const files = await fs.readdir(modeDir); + const zipContent = []; + + for (const fileName of files) { + // Skip metadata.json file + if (fileName === 'metadata.json') { + continue; + } + + const filePath = path.join(modeDir, fileName); + let fileContent = await fs.readFile(filePath, 'utf-8'); + + // If this is opencode.json, remove URL keys from MCP objects + if (fileName === 'opencode.json') { + try { + const config = JSON.parse(fileContent); + // Remove URL keys from MCP objects within mode configs + if (config.mode) { + for (const modeName in config.mode) { + if (config.mode[modeName].mcp) { + for (const mcpKey in config.mode[modeName].mcp) { + if (config.mode[modeName].mcp[mcpKey].url) { + delete config.mode[modeName].mcp[mcpKey].url; + } + } + } + } + } + fileContent = JSON.stringify(config, null, 2); + } catch (error) { + console.error(`Failed to process opencode.json: ${error}`); + // If JSON parsing fails, use original content + } + } + + zipContent.push({ + name: fileName, + content: fileContent + }); + } + + return res.json(zipContent); + } catch (error) { + console.error('Zip download error:', error); + return res.status(500).json({ error: 'Failed to prepare download' }); + } + } + + // Mode routes + if (url.pathname.startsWith('/mode/')) { + const modeId = url.pathname.split('/')[2]; + + if (modeId === 'all') { + return res.json(getAllModesWithVotes(modes)); + } + + if (modeId === 'index') { + return res.json(getAllModesIndex(modes)); + } + + if (modeId) { + const mode = getModeWithVotes(modeId, modes); + + if (!mode) { + return res.status(404).json({ error: 'Mode not found' }); + } + + return res.json(mode); + } + + return res.status(400).json({ error: 'Mode ID required' }); + } + + return res.status(404).json({ error: 'Not found' }); + + } catch (error) { + console.error('API Error:', error); + return res.status(500).json({ error: 'Internal server error', details: error.message }); + } +} \ No newline at end of file diff --git a/packages/openmodes/api/package.json b/packages/openmodes/api/package.json new file mode 100644 index 00000000..8ad8076e --- /dev/null +++ b/packages/openmodes/api/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "dependencies": { + "async-mutex": "^0.5.0" + } +} diff --git a/packages/openmodes/cli/install.js b/packages/openmodes/cli/install.js new file mode 100755 index 00000000..ba8216cf --- /dev/null +++ b/packages/openmodes/cli/install.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import https from 'https'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function fetchJson(url) { + return new Promise((resolve, reject) => { + https + .get(url, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (e) { + reject(e); + } + }); + }) + .on('error', reject); + }); +} + +function ensureDirectoryExists(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function updateOrCreateOpenCodeJson(modeData, modeId) { + const currentDir = process.cwd(); + const openCodePath = path.join(currentDir, 'opencode.json'); + + let openCodeConfig = { + $schema: 'https://opencode.ai/config.json', + instructions: [], + mcp: {}, + provider: {}, + mode: {} + }; + + if (fs.existsSync(openCodePath)) { + try { + openCodeConfig = JSON.parse(fs.readFileSync(openCodePath, 'utf8')); + } catch (e) { + console.warn( + '⚠️ Could not parse existing opencode.json, creating new one' + ); + } + } + + if (!openCodeConfig.mcp) openCodeConfig.mcp = {}; + if (!openCodeConfig.mode) openCodeConfig.mode = {}; + + if (modeData.opencode_config && modeData.opencode_config.mode) { + Object.entries(modeData.opencode_config.mode).forEach(([key, config]) => { + const updatedConfig = { ...config }; + + if (updatedConfig.prompt) { + updatedConfig.prompt = `{file:./.opencode/mode/${key}/${key}.mode.md}`; + } + + if ( + updatedConfig.instructions && + Array.isArray(updatedConfig.instructions) + ) { + updatedConfig.instructions = updatedConfig.instructions.map( + (instruction) => { + const filename = instruction.replace('./', ''); + return `./.opencode/mode/${key}/${filename}`; + } + ); + } + + openCodeConfig.mode[key] = updatedConfig; + }); + } + + fs.writeFileSync(openCodePath, JSON.stringify(openCodeConfig, null, '\t')); +} + +async function installMode(modeId) { + try { + console.log(`📦 Installing mode: ${modeId}`); + + const url = `https://openmodes.vercel.app/mode/${modeId}`; + const modeData = await fetchJson(url); + + // Remove URL keys from MCP configurations + if (modeData.opencode_config && modeData.opencode_config.mode) { + Object.values(modeData.opencode_config.mode).forEach((modeConfig) => { + if (modeConfig.mcp) { + Object.values(modeConfig.mcp).forEach((mcpConfig) => { + delete mcpConfig.url; + }); + } + }); + } + + const currentDir = process.cwd(); + const modeDir = path.join(currentDir, '.opencode', 'mode', modeId); + ensureDirectoryExists(modeDir); + + // Write mode files + if (modeData.mode_prompt) { + const promptPath = path.join(modeDir, `${modeId}.mode.md`); + fs.writeFileSync(promptPath, modeData.mode_prompt); + } + + if ( + modeData.context_instructions && + Array.isArray(modeData.context_instructions) + ) { + modeData.context_instructions.forEach((instruction) => { + const filename = `${instruction.title.toLowerCase()}.instructions.md`; + const instructionPath = path.join(modeDir, filename); + fs.writeFileSync(instructionPath, instruction.content); + }); + } + + updateOrCreateOpenCodeJson(modeData, modeId); + + console.log(`✅ Successfully installed mode "${modeId}"`); + } catch (error) { + console.error(`❌ Error installing mode "${modeId}":`, error.message); + process.exit(1); + } +} + +function removeMode(modeId) { + try { + console.log(`🗑️ Removing mode: ${modeId}`); + + const currentDir = process.cwd(); + const modeDir = path.join(currentDir, '.opencode', 'mode', modeId); + const openCodePath = path.join(currentDir, 'opencode.json'); + + if (fs.existsSync(modeDir)) { + fs.rmSync(modeDir, { recursive: true, force: true }); + } + + if (fs.existsSync(openCodePath)) { + try { + const openCodeConfig = JSON.parse( + fs.readFileSync(openCodePath, 'utf8') + ); + + if (openCodeConfig.mode && openCodeConfig.mode[modeId]) { + delete openCodeConfig.mode[modeId]; + fs.writeFileSync( + openCodePath, + JSON.stringify(openCodeConfig, null, '\t') + ); + } + } catch (e) { + console.error('⚠️ Error updating opencode.json:', e.message); + } + } + + console.log(`✅ Successfully removed mode "${modeId}"`); + } catch (error) { + console.error(`❌ Error removing mode "${modeId}":`, error.message); + process.exit(1); + } +} + +const args = process.argv.slice(2); +const command = args[0]; +const modeId = args[1]; + +if (command === 'install' && modeId) { + installMode(modeId); +} else if (command === 'remove' && modeId) { + removeMode(modeId); +} else { + console.log('Usage: openmodes '); + console.log(''); + console.log('Commands:'); + console.log(' install Install a mode from openmodes.dev'); + console.log(' remove Remove an installed mode'); + console.log(''); + console.log('Examples:'); + console.log(' npx openmodes install archie'); + console.log(' npx openmodes remove archie'); + process.exit(1); +} diff --git a/packages/openmodes/cli/package.json b/packages/openmodes/cli/package.json new file mode 100644 index 00000000..5fe11819 --- /dev/null +++ b/packages/openmodes/cli/package.json @@ -0,0 +1,31 @@ +{ + "name": "openmodes", + "version": "1.0.0", + "description": "CLI tool for installing OpenModes from openmodes.dev", + "type": "module", + "main": "install.js", + "bin": { + "openmodes": "./install.js" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "openmodes", + "cli", + "opencode", + "ai", + "modes" + ], + "author": "spoon", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/spoons-and-mirrors/models.dev.git", + "directory": "packages/openmodes/cli" + }, + "homepage": "https://openmodes.dev", + "engines": { + "node": ">=14.0.0" + } +} diff --git a/packages/openmodes/local-server.js b/packages/openmodes/local-server.js new file mode 100644 index 00000000..32d5ad1e --- /dev/null +++ b/packages/openmodes/local-server.js @@ -0,0 +1,105 @@ +import http from 'http'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import apiHandler from './api/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const PORT = 3000; + +// Simple request body parser for JSON +async function parseBody(req) { + return new Promise((resolve) => { + let body = ''; + req.on('data', (chunk) => (body += chunk.toString())); + req.on('end', () => { + try { + req.body = body ? JSON.parse(body) : {}; + } catch { + req.body = {}; + } + resolve(); + }); + }); +} + +// MIME type mapping +const mimeTypes = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml' +}; + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + + // Parse request body for POST requests + if (req.method === 'POST') { + await parseBody(req); + } + + // Handle API routes - mimic Vercel's routing + if (url.pathname.startsWith('/mode/') || url.pathname.startsWith('/api/')) { + try { + await apiHandler(req, res); + return; + } catch (error) { + console.error('API Error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + return; + } + } + + // Serve static files from dist directory + let filePath = path.join( + __dirname, + 'dist', + url.pathname === '/' ? 'index.html' : url.pathname + ); + + try { + const stat = fs.statSync(filePath); + if (stat.isFile()) { + const ext = path.extname(filePath); + const contentType = mimeTypes[ext] || 'text/plain'; + + res.writeHead(200, { 'Content-Type': contentType }); + fs.createReadStream(filePath).pipe(res); + return; + } + } catch (error) { + // File not found, serve index.html for SPA routing + if ( + url.pathname !== '/' && + !url.pathname.startsWith('/api/') && + !url.pathname.startsWith('/mode/') + ) { + try { + const indexPath = path.join(__dirname, 'dist', 'index.html'); + res.writeHead(200, { 'Content-Type': 'text/html' }); + fs.createReadStream(indexPath).pipe(res); + return; + } catch { + // Fall through to 404 + } + } + } + + // 404 Not Found + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); +}); + +server.listen(PORT, () => { + console.log(`Local Vercel-style server running at http://localhost:${PORT}`); + console.log('API endpoints available:'); + console.log(' - http://localhost:3000/mode/index'); + console.log(' - http://localhost:3000/mode/all'); + console.log(' - http://localhost:3000/mode/archie'); +}); diff --git a/packages/openmodes/modes/archie/opencode.json b/packages/openmodes/modes/archie/opencode.json index aab007e8..8987204b 100644 --- a/packages/openmodes/modes/archie/opencode.json +++ b/packages/openmodes/modes/archie/opencode.json @@ -1,32 +1,32 @@ { - "instructions": [ - "./adr.instructions.md", - "./codemap.instructions.md", - "./resources.instructions.md" - ], - "mcp": { - "context7": { - "type": "local", - "command": ["npx", "-y", "@upstash/context7-mcp"], - "enabled": true, - "url": "https://github.com/upstash/context7" - }, - "think-tool": { - "type": "local", - "command": ["npx", "-y", "think-tool-mcp"], - "enabled": true, - "url": "https://github.com/abhinav-mangla/think-tool-mcp" - }, - "repomix": { - "type": "local", - "command": ["npx", "-y", "repomix", "--mcp"], - "enabled": true, - "url": "https://github.com/yamadashy/repomix" - } - }, "mode": { "archie": { "prompt": "{file:./archie.mode.md}", + "instructions": [ + "./adr.instructions.md", + "./codemap.instructions.md", + "./resources.instructions.md" + ], + "mcp": { + "context7": { + "type": "local", + "command": ["npx", "-y", "@upstash/context7-mcp"], + "enabled": true, + "url": "https://github.com/upstash/context7" + }, + "think-tool": { + "type": "local", + "command": ["npx", "-y", "think-tool-mcp"], + "enabled": true, + "url": "https://github.com/abhinav-mangla/think-tool-mcp" + }, + "repomix": { + "type": "local", + "command": ["npx", "-y", "repomix", "--mcp"], + "enabled": true, + "url": "https://github.com/yamadashy/repomix" + } + }, "tools": { "repomix_file_system_read_file": false, "repomix_file_system_read_directory": false, diff --git a/packages/openmodes/package.json b/packages/openmodes/package.json index 84ed3592..2e2ea0da 100644 --- a/packages/openmodes/package.json +++ b/packages/openmodes/package.json @@ -1,18 +1,22 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@openmodes/web", + "type": "module", "scripts": { - "dev": "bun run --hot ./src/server.ts", + "dev": "bun run build:dev && node local-server.js", "build": "NODE_ENV=production bun ./script/build.ts", + "vercel-build": "NODE_ENV=production bun ./script/build.ts", "build:dev": "bun ./script/build.ts", "start": "NODE_ENV=production bun ./src/server.ts", "clean": "rm -rf dist" }, "dependencies": { "async-mutex": "^0.5.0", + "express": "^5.1.0", "hono": "^4.8.0" }, "devDependencies": { - "@types/bun": "^1.2.16" + "@types/bun": "^1.2.16", + "@types/node": "^20.0.0" } } diff --git a/packages/openmodes/public/_headers b/packages/openmodes/public/_headers index c269214a..a0befe8b 100644 --- a/packages/openmodes/public/_headers +++ b/packages/openmodes/public/_headers @@ -1,2 +1,19 @@ /* - Access-Control-Allow-Origin: * \ No newline at end of file + Access-Control-Allow-Origin: * + +# Fix MIME types for JavaScript modules and TypeScript files +*.js + Content-Type: application/javascript; charset=utf-8 + +*.ts + Content-Type: application/javascript; charset=utf-8 + +# Ensure proper MIME types for web assets +*.css + Content-Type: text/css; charset=utf-8 + +*.html + Content-Type: text/html; charset=utf-8 + +*.json + Content-Type: application/json; charset=utf-8 \ No newline at end of file diff --git a/packages/openmodes/script/build.ts b/packages/openmodes/script/build.ts index 678c49f9..d2e935d3 100755 --- a/packages/openmodes/script/build.ts +++ b/packages/openmodes/script/build.ts @@ -59,6 +59,56 @@ try { console.warn(` ⚠ CSS file not found: ${cssSource}`); } + // Copy index.html to dist and fix asset paths + console.log("Copying HTML files..."); + const htmlSource = path.join(import.meta.dir, '..', 'index.html'); + if (existsSync(htmlSource)) { + const htmlContent = await Bun.file(htmlSource).text(); + + // Fix asset paths for production + let fixedHtml = htmlContent + .replace('./src/index.css', './index.css') + .replace('./src/index.ts', './index.js') + .replace('./public/favicon.svg', './favicon.svg'); + + // Pre-render the content for static deployment + try { + console.log(" → Attempting to pre-render content..."); + const { getRenderWithCurrentVotes } = await import('../src/render.tsx'); + const renderedContent = getRenderWithCurrentVotes(); + + // Check if rendered content is reasonable + if (renderedContent && renderedContent.length > 100 && renderedContent.length < 1000000) { + fixedHtml = fixedHtml.replace('', renderedContent); + console.log(` ✓ Pre-rendered server content (${renderedContent.length} chars)`); + } else { + console.warn(` ⚠ Rendered content seems invalid (length: ${renderedContent?.length || 0})`); + } + } catch (error) { + console.warn(` ⚠ Failed to pre-render content: ${error}`); + console.warn(" → Continuing with static placeholder"); + } + + const htmlTarget = path.join(distDir, 'index.html'); + await Bun.write(htmlTarget, fixedHtml); + console.log(` ✓ Copied and processed index.html`); + } else { + console.warn(` ⚠ HTML file not found: ${htmlSource}`); + } + + // Copy modes directory to dist for API access + const modesSource = path.join(import.meta.dir, '..', 'modes'); + if (existsSync(modesSource)) { + console.log("Copying modes directory..."); + const modesTarget = path.join(distDir, 'modes'); + try { + const result = await Bun.$`cp -r ${modesSource} ${modesTarget}`.text(); + console.log(` ✓ Copied modes directory`); + } catch (error) { + console.warn(" ⚠ Error copying modes directory:", error); + } + } + // Copy public assets to dist if (existsSync(publicDir)) { console.log("Copying public assets..."); diff --git a/packages/openmodes/src/index.ts b/packages/openmodes/src/index.ts index af84513e..97d5d412 100644 --- a/packages/openmodes/src/index.ts +++ b/packages/openmodes/src/index.ts @@ -17,16 +17,47 @@ function titleCase(str: string): string { } class DOMElements { - static modeModal = document.getElementById('mode-modal') as HTMLDialogElement; - static helpModal = document.getElementById('help-modal') as HTMLDialogElement; - static closeHelpBtn = document.getElementById('close-help')!; - static helpBtn = document.getElementById('help')!; - static search = document.getElementById('search')! as HTMLInputElement; - static upvoteBtn = document.getElementById('upvote-btn')!; - static downvoteBtn = document.getElementById('downvote-btn')!; - static downloadBtn = document.getElementById('download-btn')!; - static voteCountEl = document.getElementById('modal-votes')!; - static downloadCountEl = document.getElementById('modal-downloads')!; + private static _elements: any = {}; + + static get modeModal(): HTMLDialogElement { + return this._elements.modeModal ??= document.getElementById('mode-modal') as HTMLDialogElement; + } + + static get helpModal(): HTMLDialogElement { + return this._elements.helpModal ??= document.getElementById('help-modal') as HTMLDialogElement; + } + + static get closeHelpBtn(): HTMLElement { + return this._elements.closeHelpBtn ??= document.getElementById('close-help')!; + } + + static get helpBtn(): HTMLElement { + return this._elements.helpBtn ??= document.getElementById('help')!; + } + + static get search(): HTMLInputElement { + return this._elements.search ??= document.getElementById('search')! as HTMLInputElement; + } + + static get upvoteBtn(): HTMLElement { + return this._elements.upvoteBtn ??= document.getElementById('upvote-btn')!; + } + + static get downvoteBtn(): HTMLElement { + return this._elements.downvoteBtn ??= document.getElementById('downvote-btn')!; + } + + static get downloadBtn(): HTMLElement { + return this._elements.downloadBtn ??= document.getElementById('download-btn')!; + } + + static get voteCountEl(): HTMLElement { + return this._elements.voteCountEl ??= document.getElementById('modal-votes')!; + } + + static get downloadCountEl(): HTMLElement { + return this._elements.downloadCountEl ??= document.getElementById('modal-downloads')!; + } } let currentMode: any = null; @@ -307,14 +338,18 @@ function populateTools(mode: any) { const enabledTools: Array<{ name: string; url?: string }> = []; const disabledTools: string[] = []; - if (mode.opencode_config?.mcp) { - Object.entries(mode.opencode_config.mcp).forEach( - ([key, value]: [string, any]) => { - if (value.enabled !== false) { - enabledTools.push({ name: key, url: value.url || undefined }); + // Get MCP tools from the mode-specific config + if (mode.opencode_config?.mode) { + const firstModeKey = Object.keys(mode.opencode_config.mode)[0]; + if (firstModeKey && mode.opencode_config.mode[firstModeKey].mcp) { + Object.entries(mode.opencode_config.mode[firstModeKey].mcp).forEach( + ([key, value]: [string, any]) => { + if (value.enabled !== false) { + enabledTools.push({ name: key, url: value.url || undefined }); + } } - } - ); + ); + } } if (mode.opencode_config?.mode) { @@ -553,6 +588,15 @@ function initializeFromURL() { } function setupEventListeners() { + // Check if this is a static site without server-rendered content + const modeModal = document.getElementById('mode-modal'); + const helpModal = document.getElementById('help-modal'); + + if (!modeModal || !helpModal) { + console.warn('App appears to be running in static mode without server-rendered content'); + return; + } + // Add click-to-copy for all context badges in modal DOMElements.modeModal.addEventListener('click', function (e) { const target = e.target as HTMLElement; @@ -630,6 +674,50 @@ function setupEventListeners() { }); } +// Load current vote/download data and update table +async function updateTableCounts() { + // First, set all counts to "-" while loading + const rows = document.querySelectorAll('.mode-row'); + rows.forEach((row: Element) => { + const votesCell = row.querySelector('.votes'); + const downloadsCell = row.querySelector('.downloads'); + if (votesCell) votesCell.textContent = '-'; + if (downloadsCell) downloadsCell.textContent = '-'; + }); + + try { + const response = await fetch('/mode/index'); + if (!response.ok) return; + + const modesIndex = await response.json(); + + // Update each table row with current counts + rows.forEach((row: Element) => { + const modeId = (row as HTMLElement).dataset.modeId; + if (modeId && modesIndex[modeId]) { + const mode = modesIndex[modeId]; + + // Update votes + const votesCell = row.querySelector('.votes'); + if (votesCell) votesCell.textContent = mode.votes.toString(); + + // Update downloads + const downloadsCell = row.querySelector('.downloads'); + if (downloadsCell) downloadsCell.textContent = mode.downloads.toString(); + } + }); + } catch (error) { + console.warn('Failed to update table counts:', error); + // On error, revert to showing 0s + rows.forEach((row: Element) => { + const votesCell = row.querySelector('.votes'); + const downloadsCell = row.querySelector('.downloads'); + if (votesCell && votesCell.textContent === '-') votesCell.textContent = '0'; + if (downloadsCell && downloadsCell.textContent === '-') downloadsCell.textContent = '0'; + }); + } +} + (window as any).openModeModal = openModeModal; (window as any).vote = vote; (window as any).downloadMode = downloadMode; @@ -637,5 +725,6 @@ function setupEventListeners() { document.addEventListener('DOMContentLoaded', () => { setupEventListeners(); initializeFromURL(); + updateTableCounts(); // Load current vote/download data }); window.addEventListener('popstate', initializeFromURL); diff --git a/packages/openmodes/src/server.ts b/packages/openmodes/src/server.ts index 63227ff9..28b4a6f9 100644 --- a/packages/openmodes/src/server.ts +++ b/packages/openmodes/src/server.ts @@ -294,10 +294,15 @@ const server = Bun.serve({ if (fileName === 'opencode.json') { try { const config = JSON.parse(fileContent); - if (config.mcp) { - for (const mcpKey in config.mcp) { - if (config.mcp[mcpKey].url) { - delete config.mcp[mcpKey].url; + // Remove URL keys from MCP objects within mode configs + if (config.mode) { + for (const modeName in config.mode) { + if (config.mode[modeName].mcp) { + for (const mcpKey in config.mode[modeName].mcp) { + if (config.mode[modeName].mcp[mcpKey].url) { + delete config.mode[modeName].mcp[mcpKey].url; + } + } } } } diff --git a/packages/openmodes/tsconfig.json b/packages/openmodes/tsconfig.json index 6b740458..9f1d10c3 100644 --- a/packages/openmodes/tsconfig.json +++ b/packages/openmodes/tsconfig.json @@ -2,13 +2,16 @@ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", "target": "ES2020", "module": "ESNext", - "moduleResolution": "bundler", + "moduleResolution": "node", "lib": ["ES2020", "DOM", "DOM.Iterable"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "types": ["bun-types"] } } diff --git a/packages/openmodes/vercel.json b/packages/openmodes/vercel.json new file mode 100644 index 00000000..af5b72e6 --- /dev/null +++ b/packages/openmodes/vercel.json @@ -0,0 +1,20 @@ +{ + "buildCommand": "bun run vercel-build", + "outputDirectory": "dist", + "installCommand": "bun install", + "functions": { + "api/index.js": { + "runtime": "@vercel/node@3.2.16" + } + }, + "rewrites": [ + { + "source": "/mode/(.*)", + "destination": "/api/index.js" + }, + { + "source": "/api/(.*)", + "destination": "/api/index.js" + } + ] +}
+ Name + Author + Description + Votes + + Downloads + Updated
{mode.name}{mode.author}{mode.name}{mode.author} {mode.description} {mode.votes}{mode.updated_at}{mode.downloads}{mode.updated_at}