diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..f30cf85c --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "mymir", + "interface": { + "displayName": "Mymir" + }, + "plugins": [ + { + "name": "mymir", + "source": { + "source": "git-subdir", + "url": "https://github.com/FrkAk/mymir.git", + "path": "plugins/codex" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Coding" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..dd6428c2 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "mymir", + "owner": { + "name": "Mymir" + }, + "plugins": [ + { + "name": "mymir", + "source": { + "source": "git-subdir", + "url": "https://github.com/FrkAk/mymir.git", + "path": "plugins/claude-code" + }, + "description": "Persistent context network for coding projects. Tracks tasks, dependencies, and decisions across sessions." + } + ] +} diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 00000000..2f8017f9 --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "mymir", + "owner": { + "name": "Mymir", + "email": "hello@mymir.dev" + }, + "metadata": { + "description": "Persistent context network for coding agents." + }, + "plugins": [ + { + "name": "mymir", + "source": "plugins/cursor", + "description": "Persistent context network for coding projects. Tracks tasks, dependencies, and decisions across sessions." + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1b29f58..c2ff17dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,3 +64,5 @@ jobs: run: bun run test - name: Check plugin drift run: bun run check:plugins + - name: Check version drift + run: bun run check:version diff --git a/.version-bump.json b/.version-bump.json new file mode 100644 index 00000000..5a871785 --- /dev/null +++ b/.version-bump.json @@ -0,0 +1,15 @@ +{ + "files": [ + { + "path": "plugins/claude-code/.claude-plugin/plugin.json", + "field": "version" + }, + { "path": "plugins/codex/.codex-plugin/plugin.json", "field": "version" }, + { "path": "plugins/cursor/.cursor-plugin/plugin.json", "field": "version" }, + { "path": "plugins/antigravity/plugin.json", "field": "version" }, + { + "path": "lib/mcp/create-server.ts", + "pattern": "name: \"mymir\", version: \"{version}\"" + } + ] +} diff --git a/README.md b/README.md index a451fcd4..37893215 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@    Cursor    - Gemini CLI + Antigravity

@@ -21,78 +21,84 @@ Mymir replaces that cycle. It's not just a context layer your agents read from, --- -## How to set it up +## Use the hosted version (no clone) -You need [Bun](https://bun.sh) (v1.0+) and [Docker](https://docs.docker.com/get-docker/) for PostgreSQL. Linux or macOS or Windows with WSL2. +Mymir is hosted at [app.mymir.dev](https://app.mymir.dev). The plugin installs into your coding agent once, at the user level, and works in every project you open, no clone required. Run the one-time install for your agent and sign in when prompted (OAuth, once per machine). -Clone the repo and install dependencies: +### Claude Code ```bash -git clone git@github.com:FrkAk/mymir.git -cd mymir -bun install --production -cp .env.local.example .env.local +claude plugin marketplace add FrkAk/mymir +claude plugin install mymir@mymir ``` -**Bring your own coding agent.** Mymir works directly inside the coding agent you already use: Claude Code, Codex, Cursor, or Gemini CLI. Brainstorm, decompose, and project activation happen there. The web app is for refining specs, planning, and tracking progress on `active` projects from the browser. +Then run `/mcp`, select **mymir**, and complete the browser sign-in. -Fill in `.env.local` by following the numbered steps at the top of `.env.local.example`. You generate three `openssl rand -hex 32` passwords for the Postgres roles (same value in each `*_PASSWORD` and its matching URL) and one `openssl rand -base64 32` for `BETTER_AUTH_SECRET`. - -Spin up Postgres and push the schema: +### Codex ```bash -bun run db:setup +codex plugin marketplace add FrkAk/mymir ``` -Build and start the server and open [localhost:3000](http://localhost:3000): +Open Codex, run `/plugin`, install **Mymir**, restart, and authenticate when prompted. Invoke the main skill with `$mymir`. (If your Codex build can't resolve the root marketplace, append `--sparse plugins`.) -```bash -bun run build -bun run start -``` +### Cursor -Mymir ships as four standalone plugin/extension dirs, one per supported CLI under `plugins//`. With the dev server running, install the one that matches your tool. +- **MCP only, any plan (quick start):** open the install deeplink, then sign in on the first tool call: -### Claude Code + ```text + cursor://anysphere.cursor-deeplink/mcp/install?name=mymir&config=eyJ1cmwiOiJodHRwczovL2FwcC5teW1pci5kZXYvYXBpL21jcCJ9 + ``` -```bash -claude plugin marketplace add ./plugins/claude-code -claude plugin install mymir@mymir-local +- **Team/Enterprise (skills + MCP):** *Dashboard → Settings → Plugins → Team Marketplaces → Add Marketplace → Import from Repo*, paste `https://github.com/FrkAk/mymir`. Team Marketplaces is a Teams/Enterprise feature. +- **Public Marketplace:** listing in the [Cursor Marketplace](https://cursor.com/marketplace) requires submission and manual review. Search-and-install lands once Mymir is published. + +### Antigravity + +Add the Mymir MCP server to your global config and authenticate (Antigravity handles OAuth automatically). The IDE and the CLI share one config at `~/.gemini/config/mcp_config.json` (in the IDE: MCP Store → Manage MCP Servers → View raw config): + +```json +{ + "mcpServers": { + "mymir": { "serverUrl": "https://app.mymir.dev/api/mcp" } + } +} ``` -Authenticate with `/mcp`, select **mymir**, and complete the browser sign-in (once per machine). +Then run `/mcp` (CLI) or open the MCP manager (IDE) and Authenticate. The workflow skills ship as a bundled plugin: clone this repo and copy `plugins/antigravity/` into `~/.gemini/config/plugins/` (global) or `.agents/plugins/` at your workspace root. The bundled `mcp_config.json` also includes a `mymir-local` server for self-host. -Update with `claude plugin update mymir@mymir-local` and restart Claude Code. MCP server changes (`lib/mcp/`) apply immediately without an update. +> **Gemini CLI users:** Antigravity replaces Gemini CLI (consumer access ends 2026-06-18). Run `agy plugin import gemini` to migrate, then use the Antigravity setup above. -### Codex +--- -```bash -codex plugin marketplace add ./plugins -``` +## Self-host / contribute -Open Codex, run `/plugin`, search for **Mymir**, install, then restart. Invoke the main skill explicitly with `$mymir` when needed. +Self-hosting is free under AGPL-3.0. You run the Mymir server yourself and point the plugin's **`mymir-local`** server at it, no env vars required. -### Gemini +You need [Bun](https://bun.sh) (v1.0+) and [Docker](https://docs.docker.com/get-docker/) for PostgreSQL. Linux, macOS, or Windows with WSL2. ```bash -gemini extensions install ./plugins/gemini +git clone git@github.com:FrkAk/mymir.git +cd mymir +bun install --production +cp .env.local.example .env.local ``` -Authenticate with `/mcp auth mymir` and complete the browser sign-in. - -Update with `gemini extensions update mymir`; remove with `gemini extensions uninstall mymir`. - -### Cursor +Fill in `.env.local` by following the numbered steps at the top of `.env.local.example`. Then bring up Postgres, build, start, and open [localhost:3000](http://localhost:3000): ```bash -ln -s "$(pwd)/plugins/cursor" ~/.cursor/plugins/local/mymir +bun run db:setup +bun run build +bun run start ``` -Restart Cursor (or run **Developer: Reload Window**). The MCP server and five skills (`mymir`, `brainstorm`, `decompose`, `manage`, `onboarding`) load automatically. First MCP tool call triggers OAuth in your browser. Trigger a skill with `/mymir`, `/brainstorm`, etc., or let the agent auto-invoke based on your prompt. +Install the plugin for your agent as above, but select the **`mymir-local`** server (it points at `http://localhost:3000/api/mcp`). Advanced self-hosters on a custom domain can set `MYMIR_URL` to repoint the default `mymir` server in Claude Code; Codex and Cursor read a hardcoded hosted URL, so edit their `mcp.json` directly if you need a custom domain. -Self-hosted: edit `plugins/cursor/mcp.json` to point at your deployment URL before symlinking. +Contributors install from the local checkout: `claude plugin marketplace add ./plugins/claude-code` (Claude Code), `codex plugin marketplace add ./plugins` (Codex), or copy `plugins/cursor` into `~/.cursor/plugins/local/`. Shared skills live in `plugins/claude-code/` (canonical); after editing them run `bun run sync:plugins` to regenerate every brand's copy (`bun run check:plugins` is CI-enforced). + +--- -### What gets installed +## What gets installed All four plugins bundle the shared components: @@ -105,7 +111,7 @@ All four plugins bundle the shared components: | **Decompose workflow** | Break a project brief into a dependency graph | | **Manage workflow** | Strategic CTO-mode review: rebalance the graph, audit dependencies, prune orphans, consolidate categories | -In Codex, Cursor, and Gemini each workflow is a skill invoked by slash command. In Claude Code each is also available as a dispatchable agent (via the Task tool) so the main `/mymir` skill can hand off work in a clean per-agent context. +In Codex, Cursor, and Antigravity each workflow is a skill invoked by slash command. In Claude Code each is also available as a dispatchable agent (via the Task tool) so the main `/mymir` skill can hand off work in a clean per-agent context. **Claude Code additionally bundles:** @@ -116,13 +122,13 @@ In Codex, Cursor, and Gemini each workflow is a skill invoked by slash command. | **`mymir:decompose-task` agent** | Splits an existing oversize task in an active project into 2 to N children, rewires every dependency edge touching the parent, cancels the parent with rationale citing the children. Composer's oversize handler routes here. | | **`mymir:decompose-feature` agent** | Adds a new feature or capability cluster to an active project. Reuses existing categories and tag vocabulary; creates 5 to 20 tasks plus internal and integration edges. | -(Composer depends on a subagent dispatch primitive for clean per-phase contexts and tool-restriction enforcement. Codex, Cursor, and Gemini do not yet have an equivalent, so composer is Claude Code only for now.) +(Composer depends on a subagent dispatch primitive for clean per-phase contexts and tool-restriction enforcement. Codex, Cursor, and Antigravity do not yet have an equivalent, so composer is Claude Code only for now.) --- ## How it runs -Mymir ships as a Next.js web app plus vendor-native plugins for Claude Code, Codex, Cursor, and Gemini. Each plugin bundles 6 MCP tools, the four core workflows (brainstorm, onboarding, decompose, manage), and a `/mymir` skill that auto-invokes when you talk about projects, tasks, or planning. Claude Code adds end-to-end task orchestration via `/mymir:composer` plus `decompose-task` and `decompose-feature` for surgical decomposition within active projects. You don't call tools manually, you just talk. +Mymir ships as a Next.js web app plus vendor-native plugins for Claude Code, Codex, Cursor, and Antigravity. Each plugin bundles 6 MCP tools, the four core workflows (brainstorm, onboarding, decompose, manage), and a `/mymir` skill that auto-invokes when you talk about projects, tasks, or planning. Claude Code adds end-to-end task orchestration via `/mymir:composer` plus `decompose-task` and `decompose-feature` for surgical decomposition within active projects. You don't call tools manually, you just talk. **Three entry paths, one graph.** @@ -154,7 +160,7 @@ Mymir ships as a Next.js web app plus vendor-native plugins for Claude Code, Cod **Add and refine mid-flow.** Spot something missing, describe it, and push back until it's right: ```text -❯ Add a task for an onboarding agent that records shipped work as done tasks. Relate it to the codex/gemini support task. +❯ Add a task for an onboarding agent that records shipped work as done tasks. Relate it to the codex/antigravity support task. ``` ```text diff --git a/app/settings/_components/AgentsTab.tsx b/app/settings/_components/AgentsTab.tsx index 7f89dace..b5610290 100644 --- a/app/settings/_components/AgentsTab.tsx +++ b/app/settings/_components/AgentsTab.tsx @@ -16,7 +16,7 @@ interface AgentsTabProps { } /** Canonical brands rendered as fixed sections, in display order. */ -const KNOWN_BRANDS = ["Claude Code", "Codex", "Gemini", "Cursor"] as const; +const KNOWN_BRANDS = ["Claude Code", "Codex", "Antigravity", "Cursor"] as const; type KnownBrand = (typeof KNOWN_BRANDS)[number]; const KNOWN_BRAND_SET: ReadonlySet = new Set(KNOWN_BRANDS); @@ -34,7 +34,7 @@ function groupSessions(sessions: OAuthSessionView[]): { const byBrand: Record = { "Claude Code": [], Codex: [], - Gemini: [], + Antigravity: [], Cursor: [], }; const otherSessions: OAuthSessionView[] = []; @@ -53,7 +53,7 @@ function groupSessions(sessions: OAuthSessionView[]): { /** * Agents & devices tab — H1 + subhead + four fixed brand cards (Claude Code, - * Codex, Cursor, Gemini) plus a catch-all card when non-canonical clients + * Codex, Antigravity, Cursor) plus a catch-all card when non-canonical clients * have authorized sessions. Optimistically removes a row on revoke and * surfaces an inline error if the server rejects. * diff --git a/components/home/GetStartedModal.tsx b/components/home/GetStartedModal.tsx index fa76ac89..7feda0d4 100644 --- a/components/home/GetStartedModal.tsx +++ b/components/home/GetStartedModal.tsx @@ -18,35 +18,70 @@ interface CliInstall { setupNote: string; } -const CLI_INSTALLS: readonly CliInstall[] = [ +const HOSTED_DEPLOY_TARGET = "cloudflare"; + +const HOSTED_CLI_INSTALLS: readonly CliInstall[] = [ + { + name: "Claude Code", + install: + "claude plugin marketplace add FrkAk/mymir\nclaude plugin install mymir@mymir", + setupNote: + "Run /mcp, select mymir, and complete the browser sign-in. The mymir skill auto-invokes when you talk about projects.", + }, + { + name: "Codex", + install: "codex plugin marketplace add FrkAk/mymir", + setupNote: + "Run /plugin, install Mymir, restart Codex, and authenticate when prompted. Invoke the main skill with $mymir.", + }, + { + name: "Antigravity", + install: + '{\n "mcpServers": {\n "mymir": { "serverUrl": "https://app.mymir.dev/api/mcp" }\n }\n}', + setupNote: + "Add this to ~/.gemini/config/mcp_config.json, then run /mcp and Authenticate. Antigravity handles OAuth automatically.", + }, + { + name: "Cursor", + install: + "cursor://anysphere.cursor-deeplink/mcp/install?name=mymir&config=eyJ1cmwiOiJodHRwczovL2FwcC5teW1pci5kZXYvYXBpL21jcCJ9", + setupNote: + "Open the deeplink, then sign in when the first Mymir MCP tool call triggers OAuth.", + }, +]; + +const SELF_HOST_CLI_INSTALLS: readonly CliInstall[] = [ { name: "Claude Code", install: "claude plugin marketplace add ./plugins/claude-code\nclaude plugin install mymir@mymir-local", setupNote: - "Authenticate with /mcp, select mymir, and complete the browser sign-in. The mymir skill auto-invokes when you talk about projects.", + "Authenticate with /mcp, select mymir-local, and complete the browser sign-in against http://localhost:3000.", }, { name: "Codex", install: "codex plugin marketplace add ./plugins", setupNote: - "Run /plugin, search for mymir, install, then restart Codex. Invoke the skill explicitly with $mymir.", + "Run /plugin, search for mymir, install, then restart Codex. Select mymir-local for http://localhost:3000/api/mcp.", }, { - name: "Gemini", - install: "gemini extensions install ./plugins/gemini", + name: "Antigravity", + install: "cp -r ./plugins/antigravity ~/.gemini/config/plugins/mymir", setupNote: - "Authenticate with /mcp auth mymir and complete the browser sign-in.", + "Run /mcp, select mymir-local, Authenticate, and complete the browser sign-in against http://localhost:3000.", }, { name: "Cursor", install: 'ln -s "$(pwd)/plugins/cursor" ~/.cursor/plugins/local/mymir', setupNote: - "Restart Cursor. The MCP server and skills load automatically; the first MCP tool call triggers OAuth.", + "Restart Cursor. The MCP server and skills load automatically; mymir-local points at http://localhost:3000/api/mcp.", }, ]; -const README_SETUP_URL = "https://github.com/FrkAk/mymir#how-to-set-it-up"; +const HOSTED_README_SETUP_URL = + "https://github.com/FrkAk/mymir#use-the-hosted-version-no-clone"; +const SELF_HOST_README_SETUP_URL = + "https://github.com/FrkAk/mymir#self-host-contribute"; const SECTION_LABEL_CLASS = "font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted"; @@ -54,22 +89,61 @@ const SECTION_LABEL_CLASS = const MULTI_TEAM_HINT = "If you belong to more than one team, your coding agent will ask which team a new project belongs to before creating it."; +interface FirstTimeBodyProps { + /** Target-specific install snippets to render. */ + cliInstalls: readonly CliInstall[]; + /** Target-specific README setup anchor. */ + readmeSetupUrl: string; +} + +interface ReturningBodyProps { + /** Target-specific README setup anchor. */ + readmeSetupUrl: string; +} + +/** + * Select install snippets for the active deploy target. + * @param deployTarget - Build-time deploy target exposed to client bundles. + * @returns Hosted snippets for Cloudflare, otherwise self-host snippets. + */ +export function getCliInstalls( + deployTarget = process.env.NEXT_PUBLIC_DEPLOY_TARGET ?? "", +): readonly CliInstall[] { + return deployTarget === HOSTED_DEPLOY_TARGET + ? HOSTED_CLI_INSTALLS + : SELF_HOST_CLI_INSTALLS; +} + +/** + * Select the setup guide anchor for the active deploy target. + * @param deployTarget - Build-time deploy target exposed to client bundles. + * @returns Hosted or self-host README setup URL. + */ +export function getReadmeSetupUrl( + deployTarget = process.env.NEXT_PUBLIC_DEPLOY_TARGET ?? "", +): string { + return deployTarget === HOSTED_DEPLOY_TARGET + ? HOSTED_README_SETUP_URL + : SELF_HOST_README_SETUP_URL; +} + /** * Body for users who haven't created a project yet — emphasizes plugin * install commands across the four supported coding agents. + * @param props - Target-specific install copy. * @returns First-time install instructions. */ -function FirstTimeBody() { +function FirstTimeBody({ cliInstalls, readmeSetupUrl }: FirstTimeBodyProps) { return ( <>

mymir runs in your coding agent, which has the file context an in-app - chat never will. Install the plugin for your tool, then describe what - you're building. + chat never will. Install or configure Mymir for your tool, then describe + what you're building.

    - {CLI_INSTALLS.map((cli) => ( + {cliInstalls.map((cli) => (
  1. {cli.name}

    @@ -98,7 +172,7 @@ function FirstTimeBody() {

    Full setup details (auth, updates, self-hosting) in the{" "}

    @@ -139,7 +214,7 @@ function ReturningBody() { Setting up another tool, or starting from a fresh machine? Install commands live in the{" "}

    - {hasProjects ? : } + {hasProjects ? ( + + ) : ( + + )}
    ); diff --git a/lib/mcp/create-server.ts b/lib/mcp/create-server.ts index 95c2b908..ebedfe8d 100644 --- a/lib/mcp/create-server.ts +++ b/lib/mcp/create-server.ts @@ -80,7 +80,7 @@ function toMcp(result: ToolResult) { const INSTRUCTIONS = `Mymir is an agentic project management server for software projects. It tracks tasks, dependencies, decisions, and execution records across sessions and teammates so coding agents and engineers can hand work to each other. Stateless HTTP endpoint with no server-side session state; pass \`projectId\` explicitly on every call. -This file documents the canonical flows the skill expects the server to cover: session start, find work, implement, plan, refine, the Completion Protocol, and propagation. Everything else, including persona, the three-dimension tag taxonomy plus the first-class \`priority\` / \`estimate\` / \`assigneeIds\` fields, the category vocabulary by project type, the full per-status lifecycle table, the dispatch / decompose / onboarding / brainstorm / manage agents, parallel-agent orchestration, and the resume-after-compaction pattern, lives in the \`mymir\` skill on your platform (Claude Code, Codex, Cursor, Gemini) and its references (\`conventions.md\`, \`artifacts.md\`, \`lifecycle.md\`, \`resilience.md\`). The skill is the ground truth. +This file documents the canonical flows the skill expects the server to cover: session start, find work, implement, plan, refine, the Completion Protocol, and propagation. Everything else, including persona, the three-dimension tag taxonomy plus the first-class \`priority\` / \`estimate\` / \`assigneeIds\` fields, the category vocabulary by project type, the full per-status lifecycle table, the dispatch / decompose / onboarding / brainstorm / manage agents, parallel-agent orchestration, and the resume-after-compaction pattern, lives in the \`mymir\` skill on your platform (Claude Code, Codex, Cursor, Antigravity) and its references (\`conventions.md\`, \`artifacts.md\`, \`lifecycle.md\`, \`resilience.md\`). The skill is the ground truth. ## Multi-team awareness The caller's account spans every membership. There is no 'active' team. Read tools span every team you belong to; writes name \`organizationId\` or auto-resolve when the account has exactly one membership. @@ -639,7 +639,7 @@ export function registerAllTools(server: McpServer, ctx: AuthContext): void { */ export function createMcpServer(ctx: AuthContext): McpServer { const server = new McpServer( - { name: "mymir", version: "1.7.3" }, + { name: "mymir", version: "1.8.0" }, { instructions: INSTRUCTIONS }, ); registerAllTools(server, ctx); diff --git a/lib/ui/oauth-client-name.ts b/lib/ui/oauth-client-name.ts index 111430e9..835496ce 100644 --- a/lib/ui/oauth-client-name.ts +++ b/lib/ui/oauth-client-name.ts @@ -7,6 +7,7 @@ const CLIENT_BRAND_LABELS: readonly { { match: /^claude code\b/i, label: "Claude Code" }, { match: /^codex\b/i, label: "Codex" }, { match: /^cursor\b/i, label: "Cursor" }, + { match: /^(?:google )?antigravity\b/i, label: "Antigravity" }, { match: /^gemini(?: cli)?\b/i, label: "Gemini" }, ]; diff --git a/package.json b/package.json index adba0cec..1692f51a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "typecheck": "tsc --noEmit", "check:plugins": "bun run scripts/check-plugins.ts", "sync:plugins": "bun run scripts/check-plugins.ts --fix", + "check:version": "bun run scripts/bump-version.ts --check", + "bump:version": "bun run scripts/bump-version.ts", "db:setup": "docker compose --env-file .env.local up -d --wait && docker exec -i mymir-db-1 psql -U mymir -d mymir < docker/init-auth.sql && docker exec mymir-db-1 /docker-entrypoint-initdb.d/02-rls.sh && bun run db:push && docker exec -i mymir-db-1 psql -U mymir -d mymir < docker/grants.sql && docker exec -i mymir-db-1 psql -U mymir -d mymir < docker/rls-functions.sql && docker exec -i mymir-db-1 psql -U mymir -d mymir < docker/rls-policies.sql", "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", diff --git a/plugins/antigravity/mcp_config.json b/plugins/antigravity/mcp_config.json new file mode 100644 index 00000000..d20ab293 --- /dev/null +++ b/plugins/antigravity/mcp_config.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "mymir": { + "serverUrl": "https://app.mymir.dev/api/mcp" + }, + "mymir-local": { + "serverUrl": "http://localhost:3000/api/mcp" + } + } +} diff --git a/plugins/antigravity/plugin.json b/plugins/antigravity/plugin.json new file mode 100644 index 00000000..35893945 --- /dev/null +++ b/plugins/antigravity/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "mymir", + "version": "1.8.0", + "description": "Persistent context network for coding projects. Tracks tasks, dependencies, and decisions across sessions." +} diff --git a/plugins/gemini/skills/brainstorm/SKILL.md b/plugins/antigravity/skills/brainstorm/SKILL.md similarity index 100% rename from plugins/gemini/skills/brainstorm/SKILL.md rename to plugins/antigravity/skills/brainstorm/SKILL.md diff --git a/plugins/gemini/skills/decompose-feature/SKILL.md b/plugins/antigravity/skills/decompose-feature/SKILL.md similarity index 100% rename from plugins/gemini/skills/decompose-feature/SKILL.md rename to plugins/antigravity/skills/decompose-feature/SKILL.md diff --git a/plugins/gemini/skills/decompose-task/SKILL.md b/plugins/antigravity/skills/decompose-task/SKILL.md similarity index 100% rename from plugins/gemini/skills/decompose-task/SKILL.md rename to plugins/antigravity/skills/decompose-task/SKILL.md diff --git a/plugins/gemini/skills/decompose/SKILL.md b/plugins/antigravity/skills/decompose/SKILL.md similarity index 100% rename from plugins/gemini/skills/decompose/SKILL.md rename to plugins/antigravity/skills/decompose/SKILL.md diff --git a/plugins/gemini/skills/manage/SKILL.md b/plugins/antigravity/skills/manage/SKILL.md similarity index 100% rename from plugins/gemini/skills/manage/SKILL.md rename to plugins/antigravity/skills/manage/SKILL.md diff --git a/plugins/gemini/skills/mymir/SKILL.md b/plugins/antigravity/skills/mymir/SKILL.md similarity index 100% rename from plugins/gemini/skills/mymir/SKILL.md rename to plugins/antigravity/skills/mymir/SKILL.md diff --git a/plugins/gemini/skills/mymir/references/artifacts.md b/plugins/antigravity/skills/mymir/references/artifacts.md similarity index 100% rename from plugins/gemini/skills/mymir/references/artifacts.md rename to plugins/antigravity/skills/mymir/references/artifacts.md diff --git a/plugins/gemini/skills/mymir/references/conventions.md b/plugins/antigravity/skills/mymir/references/conventions.md similarity index 100% rename from plugins/gemini/skills/mymir/references/conventions.md rename to plugins/antigravity/skills/mymir/references/conventions.md diff --git a/plugins/gemini/skills/mymir/references/lifecycle.md b/plugins/antigravity/skills/mymir/references/lifecycle.md similarity index 100% rename from plugins/gemini/skills/mymir/references/lifecycle.md rename to plugins/antigravity/skills/mymir/references/lifecycle.md diff --git a/plugins/gemini/skills/mymir/references/resilience.md b/plugins/antigravity/skills/mymir/references/resilience.md similarity index 100% rename from plugins/gemini/skills/mymir/references/resilience.md rename to plugins/antigravity/skills/mymir/references/resilience.md diff --git a/plugins/gemini/skills/onboarding/SKILL.md b/plugins/antigravity/skills/onboarding/SKILL.md similarity index 100% rename from plugins/gemini/skills/onboarding/SKILL.md rename to plugins/antigravity/skills/onboarding/SKILL.md diff --git a/plugins/gemini/skills/review/SKILL.md b/plugins/antigravity/skills/review/SKILL.md similarity index 100% rename from plugins/gemini/skills/review/SKILL.md rename to plugins/antigravity/skills/review/SKILL.md diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index c0b76756..17922fff 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "mymir", "description": "Persistent context network for coding projects. Tracks tasks, dependencies, and decisions across sessions.", - "version": "1.7.3", + "version": "1.8.0", "author": { "name": "Mymir" }, diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index df09b4f6..03c6e34e 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mymir", - "version": "1.7.3", + "version": "1.8.0", "description": "Persistent context network for coding projects. Tracks tasks, dependencies, and decisions across sessions.", "author": { "name": "Mymir", diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json index fef22525..21cc8634 100644 --- a/plugins/cursor/.cursor-plugin/plugin.json +++ b/plugins/cursor/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mymir", - "version": "1.7.3", + "version": "1.8.0", "description": "Persistent context network for coding projects. Tracks tasks, dependencies, and decisions across sessions.", "author": { "name": "Mymir", diff --git a/plugins/gemini/commands/mymir.toml b/plugins/gemini/commands/mymir.toml deleted file mode 100644 index 62539394..00000000 --- a/plugins/gemini/commands/mymir.toml +++ /dev/null @@ -1,2 +0,0 @@ -description = "Manage project context with Mymir — tasks, dependencies, decisions across sessions" -prompt = """Use the mymir skill to handle the following intent: {{args}}""" diff --git a/plugins/gemini/gemini-extension.json b/plugins/gemini/gemini-extension.json deleted file mode 100644 index 962cd5ae..00000000 --- a/plugins/gemini/gemini-extension.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "mymir", - "version": "1.7.3", - "description": "Persistent context network for coding projects. Tracks tasks, dependencies, and decisions across sessions.", - "mcpServers": { - "mymir": { - "httpUrl": "https://app.mymir.dev/api/mcp" - }, - "mymir-local": { - "httpUrl": "http://localhost:3000/api/mcp" - } - } -} diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts new file mode 100644 index 00000000..4ae5b8a8 --- /dev/null +++ b/scripts/bump-version.ts @@ -0,0 +1,190 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +const CONFIG_PATH = ".version-bump.json"; +export const SEMVER = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?$/; +const VERSION_CAPTURE = "(\\d+\\.\\d+\\.\\d+(?:-[A-Za-z0-9.]+)?)"; + +export interface FieldEntry { + path: string; + field: string; +} + +export interface PatternEntry { + path: string; + pattern: string; +} + +export type Entry = FieldEntry | PatternEntry; + +export interface Config { + files: Entry[]; +} + +export interface VersionLocation { + path: string; + version: string; +} + +/** + * Type guard for JSON-field version entries. + * @param entry - Entry to test. + * @returns True when the entry targets a JSON field. + */ +export function isFieldEntry(entry: Entry): entry is FieldEntry { + return "field" in entry; +} + +/** + * Escape a string for literal use inside a regular expression. + * @param value - Raw string. + * @returns Regex-safe string. + */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Build a regex from a config `pattern` by replacing the `{version}` token + * with a capturing semver group; all other characters match literally. + * @param pattern - Pattern string containing exactly one `{version}` token. + * @returns Compiled regex with the version as capture group 1. + * @throws Error when the pattern has zero or more than one `{version}` token. + */ +export function patternToRegExp(pattern: string): RegExp { + const tokenCount = (pattern.match(/\{version\}/g) ?? []).length; + if (tokenCount === 0) { + throw new Error(`pattern is missing a {version} token: ${pattern}`); + } + if (tokenCount > 1) { + throw new Error(`pattern has more than one {version} token: ${pattern}`); + } + const escaped = escapeRegExp(pattern).replace( + escapeRegExp("{version}"), + VERSION_CAPTURE, + ); + return new RegExp(escaped); +} + +/** + * Read the current version recorded at one config entry. + * @param entry - Field or pattern entry. + * @returns The version string found at the entry. + * @throws Error when the field or pattern is absent. + */ +export function readVersion(entry: Entry): string { + const content = readFileSync(entry.path, "utf8"); + if (isFieldEntry(entry)) { + const value = (JSON.parse(content) as Record)[entry.field]; + if (typeof value !== "string") { + throw new Error(`${entry.path} has no string ${entry.field} field`); + } + return value; + } + const match = content.match(patternToRegExp(entry.pattern)); + if (!match) { + throw new Error(`${entry.path} does not match pattern: ${entry.pattern}`); + } + return match[1]; +} + +/** + * Write a new version into one config entry, preserving file formatting. + * @param entry - Field or pattern entry. + * @param version - New version string. + * @throws Error when the field or pattern is absent, or when the textual + * replacement would update a nested occurrence instead of the top-level field. + */ +export function writeVersion(entry: Entry, version: string): void { + const content = readFileSync(entry.path, "utf8"); + if (isFieldEntry(entry)) { + const re = new RegExp(`("${entry.field}"\\s*:\\s*")[^"]*(")`); + if (!re.test(content)) { + throw new Error(`${entry.path} has no ${entry.field} field to bump`); + } + const next = content.replace(re, `$1${version}$2`); + const topLevel = (JSON.parse(next) as Record)[entry.field]; + if (topLevel !== version) { + throw new Error( + `${entry.path}: a nested ${entry.field} occurrence precedes the top-level field; refusing to write`, + ); + } + writeFileSync(entry.path, next); + return; + } + const next = content.replace(patternToRegExp(entry.pattern), () => + entry.pattern.replace("{version}", version), + ); + writeFileSync(entry.path, next); +} + +/** + * Read the recorded version at every config entry. + * @param entries - Config file entries. + * @returns One location record per entry, in config order. + */ +export function readVersions(entries: Entry[]): VersionLocation[] { + return entries.map((entry) => ({ + path: entry.path, + version: readVersion(entry), + })); +} + +/** + * Find locations whose version differs from the canonical (first) entry. + * @param locations - Version locations to compare. + * @returns Locations that drift from the canonical version; empty when aligned. + */ +export function findDrift(locations: VersionLocation[]): VersionLocation[] { + if (locations.length === 0) { + return []; + } + const canonical = locations[0].version; + return locations.filter((location) => location.version !== canonical); +} + +/** + * CLI entry point: `--check` reports drift, no argument prints the canonical + * version, and a semver argument bumps every configured location. + */ +function main(): void { + const config = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Config; + if (config.files.length === 0) { + console.error(`No version locations configured in ${CONFIG_PATH}.`); + process.exit(1); + } + + const arg = process.argv[2]; + + if (arg === "--check") { + const locations = readVersions(config.files); + const drift = findDrift(locations); + const canonical = locations[0].version; + if (drift.length > 0) { + console.error(`Version drift (canonical ${canonical}):`); + for (const location of drift) { + console.error(` ${location.version} ${location.path}`); + } + console.error(`\nRun \`bun run bump:version ${canonical}\` to align.`); + process.exit(1); + } + console.log(`All ${locations.length} version locations at ${canonical}.`); + process.exit(0); + } + + if (!arg) { + console.log(readVersion(config.files[0])); + process.exit(0); + } + + if (!SEMVER.test(arg)) { + console.error(`Not a valid semver: ${arg}`); + process.exit(1); + } + + for (const entry of config.files) writeVersion(entry, arg); + console.log(`Bumped ${config.files.length} version locations to ${arg}.`); +} + +if (import.meta.main) { + main(); +} diff --git a/scripts/check-plugins.ts b/scripts/check-plugins.ts index 66f17d9a..8152d198 100644 --- a/scripts/check-plugins.ts +++ b/scripts/check-plugins.ts @@ -35,18 +35,18 @@ const platformSubs: PlatformSubs[] = [ }, }, { - pathPrefix: "plugins/gemini/", + pathPrefix: "plugins/cursor/", subs: { - "the AskUserQuestion tool": - "the ask_user tool (prefer type:'choice'; type:'yesno' for confirmations; type:'text' only when the answer is genuinely open)", - AskUserQuestion: "ask_user", + "the AskUserQuestion tool": "the ask question tool", + AskUserQuestion: "ask question tool", }, }, { - pathPrefix: "plugins/cursor/", + pathPrefix: "plugins/antigravity/", subs: { - "the AskUserQuestion tool": "the ask question tool", - AskUserQuestion: "ask question tool", + "the AskUserQuestion tool": + "the ask_user tool (prefer type:'choice'; type:'yesno' for confirmations; type:'text' only when the answer is genuinely open)", + AskUserQuestion: "ask_user", }, }, ]; @@ -57,8 +57,8 @@ const shared: SharedGroup[] = [ canonical: "plugins/claude-code/skills/mymir/SKILL.md", copies: [ "plugins/codex/skills/mymir/SKILL.md", - "plugins/gemini/skills/mymir/SKILL.md", "plugins/cursor/skills/mymir/SKILL.md", + "plugins/antigravity/skills/mymir/SKILL.md", ], }, { @@ -66,8 +66,8 @@ const shared: SharedGroup[] = [ canonical: "plugins/claude-code/skills/mymir/references/conventions.md", copies: [ "plugins/codex/skills/mymir/references/conventions.md", - "plugins/gemini/skills/mymir/references/conventions.md", "plugins/cursor/skills/mymir/references/conventions.md", + "plugins/antigravity/skills/mymir/references/conventions.md", ], }, { @@ -75,8 +75,8 @@ const shared: SharedGroup[] = [ canonical: "plugins/claude-code/skills/mymir/references/artifacts.md", copies: [ "plugins/codex/skills/mymir/references/artifacts.md", - "plugins/gemini/skills/mymir/references/artifacts.md", "plugins/cursor/skills/mymir/references/artifacts.md", + "plugins/antigravity/skills/mymir/references/artifacts.md", ], }, { @@ -84,8 +84,8 @@ const shared: SharedGroup[] = [ canonical: "plugins/claude-code/skills/mymir/references/lifecycle.md", copies: [ "plugins/codex/skills/mymir/references/lifecycle.md", - "plugins/gemini/skills/mymir/references/lifecycle.md", "plugins/cursor/skills/mymir/references/lifecycle.md", + "plugins/antigravity/skills/mymir/references/lifecycle.md", ], }, { @@ -93,8 +93,8 @@ const shared: SharedGroup[] = [ canonical: "plugins/claude-code/skills/mymir/references/resilience.md", copies: [ "plugins/codex/skills/mymir/references/resilience.md", - "plugins/gemini/skills/mymir/references/resilience.md", "plugins/cursor/skills/mymir/references/resilience.md", + "plugins/antigravity/skills/mymir/references/resilience.md", ], }, { @@ -102,8 +102,8 @@ const shared: SharedGroup[] = [ canonical: "plugins/claude-code/agents/brainstorm.md", copies: [ "plugins/codex/skills/brainstorm/SKILL.md", - "plugins/gemini/skills/brainstorm/SKILL.md", "plugins/cursor/skills/brainstorm/SKILL.md", + "plugins/antigravity/skills/brainstorm/SKILL.md", ], }, { @@ -111,8 +111,8 @@ const shared: SharedGroup[] = [ canonical: "plugins/claude-code/agents/decompose.md", copies: [ "plugins/codex/skills/decompose/SKILL.md", - "plugins/gemini/skills/decompose/SKILL.md", "plugins/cursor/skills/decompose/SKILL.md", + "plugins/antigravity/skills/decompose/SKILL.md", ], }, { @@ -120,8 +120,8 @@ const shared: SharedGroup[] = [ canonical: "plugins/claude-code/agents/decompose-task.md", copies: [ "plugins/codex/skills/decompose-task/SKILL.md", - "plugins/gemini/skills/decompose-task/SKILL.md", "plugins/cursor/skills/decompose-task/SKILL.md", + "plugins/antigravity/skills/decompose-task/SKILL.md", ], }, { @@ -129,8 +129,8 @@ const shared: SharedGroup[] = [ canonical: "plugins/claude-code/agents/decompose-feature.md", copies: [ "plugins/codex/skills/decompose-feature/SKILL.md", - "plugins/gemini/skills/decompose-feature/SKILL.md", "plugins/cursor/skills/decompose-feature/SKILL.md", + "plugins/antigravity/skills/decompose-feature/SKILL.md", ], }, { @@ -138,8 +138,8 @@ const shared: SharedGroup[] = [ canonical: "plugins/claude-code/agents/manage.md", copies: [ "plugins/codex/skills/manage/SKILL.md", - "plugins/gemini/skills/manage/SKILL.md", "plugins/cursor/skills/manage/SKILL.md", + "plugins/antigravity/skills/manage/SKILL.md", ], }, { @@ -147,8 +147,8 @@ const shared: SharedGroup[] = [ canonical: "plugins/claude-code/agents/onboarding.md", copies: [ "plugins/codex/skills/onboarding/SKILL.md", - "plugins/gemini/skills/onboarding/SKILL.md", "plugins/cursor/skills/onboarding/SKILL.md", + "plugins/antigravity/skills/onboarding/SKILL.md", ], }, { @@ -156,29 +156,13 @@ const shared: SharedGroup[] = [ canonical: "plugins/claude-code/agents/review.md", copies: [ "plugins/codex/skills/review/SKILL.md", - "plugins/gemini/skills/review/SKILL.md", "plugins/cursor/skills/review/SKILL.md", + "plugins/antigravity/skills/review/SKILL.md", ], }, ]; const fieldSyncs: FieldSync[] = [ - { - name: "version", - canonicalPath: "plugins/claude-code/.claude-plugin/plugin.json", - canonicalJsonPath: ["version"], - copies: [ - { - path: "plugins/codex/.codex-plugin/plugin.json", - jsonPath: ["version"], - }, - { path: "plugins/gemini/gemini-extension.json", jsonPath: ["version"] }, - { - path: "plugins/cursor/.cursor-plugin/plugin.json", - jsonPath: ["version"], - }, - ], - }, { name: "description", canonicalPath: "plugins/claude-code/.claude-plugin/plugin.json", @@ -188,14 +172,11 @@ const fieldSyncs: FieldSync[] = [ path: "plugins/codex/.codex-plugin/plugin.json", jsonPath: ["description"], }, - { - path: "plugins/gemini/gemini-extension.json", - jsonPath: ["description"], - }, { path: "plugins/cursor/.cursor-plugin/plugin.json", jsonPath: ["description"], }, + { path: "plugins/antigravity/plugin.json", jsonPath: ["description"] }, ], }, ]; @@ -396,4 +377,4 @@ if (failures > 0) { process.exit(1); } -console.log(`\nAll shared content and versions are in sync.`); +console.log(`\nAll shared plugin content is in sync.`); diff --git a/tests/plugins/bump-version.test.ts b/tests/plugins/bump-version.test.ts new file mode 100644 index 00000000..d2fa0a09 --- /dev/null +++ b/tests/plugins/bump-version.test.ts @@ -0,0 +1,133 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + SEMVER, + patternToRegExp, + readVersion, + writeVersion, + readVersions, + findDrift, + type Entry, +} from "@/scripts/bump-version"; + +const root = process.cwd(); +const readJson = (p: string) => JSON.parse(readFileSync(join(root, p), "utf8")); + +/** + * Write content to a throwaway file in a fresh temp dir. + * @param name - File name within the temp dir. + * @param content - File body. + * @returns Absolute path to the created file. + */ +function tempFile(name: string, content: string): string { + const path = join(mkdtempSync(join(tmpdir(), "bumpver-")), name); + writeFileSync(path, content); + return path; +} + +test("readVersion reads a JSON field", () => { + const path = tempFile("plugin.json", `{"name":"x","version":"1.2.3"}`); + expect(readVersion({ path, field: "version" })).toBe("1.2.3"); +}); + +test("readVersion throws when the field is absent", () => { + const path = tempFile("plugin.json", `{"name":"x"}`); + expect(() => readVersion({ path, field: "version" })).toThrow(); +}); + +test("writeVersion rewrites a JSON field and preserves formatting", () => { + const path = tempFile( + "plugin.json", + `{\n "name": "x",\n "version": "1.0.0",\n "keep": true\n}\n`, + ); + writeVersion({ path, field: "version" }, "2.0.0"); + expect(readFileSync(path, "utf8")).toBe( + `{\n "name": "x",\n "version": "2.0.0",\n "keep": true\n}\n`, + ); +}); + +test("writeVersion throws when the field is absent", () => { + const path = tempFile("plugin.json", `{"name":"x"}`); + expect(() => writeVersion({ path, field: "version" }, "2.0.0")).toThrow(); +}); + +test("writeVersion refuses a nested field that precedes the top-level one", () => { + const original = `{\n "engines": { "version": "9.9.9" },\n "version": "1.0.0"\n}\n`; + const path = tempFile("plugin.json", original); + expect(() => writeVersion({ path, field: "version" }, "2.0.0")).toThrow( + /nested/, + ); + expect(readFileSync(path, "utf8")).toBe(original); +}); + +test("pattern round-trips and leaves surrounding code untouched", () => { + const path = tempFile( + "create-server.ts", + `const s = { name: "mymir", version: "1.0.0" };\n`, + ); + const entry: Entry = { path, pattern: `name: "mymir", version: "{version}"` }; + expect(readVersion(entry)).toBe("1.0.0"); + writeVersion(entry, "2.0.0"); + expect(readFileSync(path, "utf8")).toBe( + `const s = { name: "mymir", version: "2.0.0" };\n`, + ); +}); + +test("writeVersion does not interpret $ sequences in the pattern replacement", () => { + // A literal `$1` in the pattern must survive verbatim; a naive string + // replacement would expand it to the matched version group. + const path = tempFile("v.txt", `tag$1 = "1.0.0"\n`); + writeVersion({ path, pattern: `tag$1 = "{version}"` }, "2.0.0"); + expect(readFileSync(path, "utf8")).toBe(`tag$1 = "2.0.0"\n`); +}); + +test("patternToRegExp rejects zero or multiple {version} tokens", () => { + expect(() => patternToRegExp("no token here")).toThrow(); + expect(() => patternToRegExp("{version} and {version}")).toThrow(); +}); + +test("findDrift returns empty when every location matches the canonical", () => { + const entries: Entry[] = [ + { path: tempFile("a.json", `{"version":"1.0.0"}`), field: "version" }, + { path: tempFile("b.json", `{"version":"1.0.0"}`), field: "version" }, + ]; + expect(findDrift(readVersions(entries))).toHaveLength(0); +}); + +test("findDrift flags the location that diverges from the canonical", () => { + const drifted = tempFile("b.json", `{"version":"9.9.9"}`); + const entries: Entry[] = [ + { path: tempFile("a.json", `{"version":"1.0.0"}`), field: "version" }, + { path: drifted, field: "version" }, + ]; + const drift = findDrift(readVersions(entries)); + expect(drift).toHaveLength(1); + expect(drift[0].path).toBe(drifted); +}); + +test("SEMVER accepts releases and prereleases, rejects malformed input", () => { + for (const ok of ["1.2.3", "0.0.1", "1.2.3-rc.1"]) { + expect(SEMVER.test(ok)).toBe(true); + } + for (const bad of ["1.2", "v1.2.3", "1.2.3.4", "1.2.x"]) { + expect(SEMVER.test(bad)).toBe(false); + } +}); + +test(".version-bump.json entries all resolve against the live files", () => { + const config = readJson(".version-bump.json") as { files: Entry[] }; + expect(config.files.length).toBeGreaterThan(0); + for (const entry of config.files) { + const hasField = "field" in entry; + const hasPattern = "pattern" in entry; + expect(hasField).not.toBe(hasPattern); + if (hasPattern) { + expect(() => + patternToRegExp((entry as { pattern: string }).pattern), + ).not.toThrow(); + } + expect(SEMVER.test(readVersion(entry))).toBe(true); + } +}); diff --git a/tests/plugins/manifests.test.ts b/tests/plugins/manifests.test.ts new file mode 100644 index 00000000..b596d2a1 --- /dev/null +++ b/tests/plugins/manifests.test.ts @@ -0,0 +1,96 @@ +import { test, expect } from "bun:test"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +const root = process.cwd(); +const readJson = (p: string) => JSON.parse(readFileSync(join(root, p), "utf8")); + +test("Claude root marketplace sources the claude-code subdir via git-subdir", () => { + const mkt = readJson(".claude-plugin/marketplace.json"); + expect(mkt.name).toBe("mymir"); + expect(mkt.owner?.name).toBe("Mymir"); + const plugin = mkt.plugins.find((p: { name: string }) => p.name === "mymir"); + expect(plugin).toBeDefined(); + expect(plugin.source.source).toBe("git-subdir"); + expect(plugin.source.url).toBe("https://github.com/FrkAk/mymir.git"); + expect(plugin.source.path).toBe("plugins/claude-code"); +}); + +test("Codex root marketplace sources the codex subdir via git-subdir", () => { + const mkt = readJson(".agents/plugins/marketplace.json"); + expect(mkt.name).toBe("mymir"); + expect(mkt.interface?.displayName).toBe("Mymir"); + const plugin = mkt.plugins.find((p: { name: string }) => p.name === "mymir"); + expect(plugin).toBeDefined(); + expect(plugin.source.source).toBe("git-subdir"); + expect(plugin.source.url).toBe("https://github.com/FrkAk/mymir.git"); + expect(plugin.source.path).toBe("plugins/codex"); +}); + +test("Codex contributor marketplace is mymir-local sourcing ./codex", () => { + const mkt = readJson("plugins/.agents/plugins/marketplace.json"); + expect(mkt.name).toBe("mymir-local"); + const plugin = mkt.plugins.find((p: { name: string }) => p.name === "mymir"); + expect(plugin).toBeDefined(); + expect(plugin.source.path).toBe("./codex"); +}); + +test("Cursor root marketplace sources the cursor subdir", () => { + const mkt = readJson(".cursor-plugin/marketplace.json"); + expect(mkt.name).toBe("mymir"); + const plugin = mkt.plugins.find((p: { name: string }) => p.name === "mymir"); + expect(plugin).toBeDefined(); + expect(plugin.source).toBe("plugins/cursor"); +}); + +test("Cursor plugin manifest declares skills and mcp components", () => { + const p = readJson("plugins/cursor/.cursor-plugin/plugin.json"); + expect(p.skills).toBeDefined(); + expect(p.mcpServers).toBeDefined(); +}); + +test("Antigravity plugin marker exists and is named mymir", () => { + const p = readJson("plugins/antigravity/plugin.json"); + expect(p.name).toBe("mymir"); +}); + +test("Antigravity mcp_config uses serverUrl (never url/httpUrl) for both servers", () => { + const cfg = readJson("plugins/antigravity/mcp_config.json"); + const hosted = cfg.mcpServers.mymir; + const local = cfg.mcpServers["mymir-local"]; + expect(hosted.serverUrl).toContain("app.mymir.dev"); + expect(hosted.url).toBeUndefined(); + expect(hosted.httpUrl).toBeUndefined(); + expect(local.serverUrl).toContain("localhost:3000"); +}); + +test("Antigravity bundles every shared skill", () => { + for (const s of [ + "mymir", + "brainstorm", + "decompose", + "decompose-task", + "decompose-feature", + "manage", + "onboarding", + "review", + ]) { + expect( + existsSync(join(root, `plugins/antigravity/skills/${s}/SKILL.md`)), + ).toBe(true); + } +}); + +test.each([ + "plugins/claude-code/.mcp.json", + "plugins/codex/.mcp.json", + "plugins/cursor/mcp.json", +])("%s declares hosted mymir + local mymir-local", (path) => { + const cfg = readJson(path); + expect(cfg.mcpServers.mymir).toBeDefined(); + expect(cfg.mcpServers["mymir-local"]).toBeDefined(); + expect(JSON.stringify(cfg.mcpServers.mymir)).toContain("app.mymir.dev"); + expect(JSON.stringify(cfg.mcpServers["mymir-local"])).toContain( + "localhost:3000", + ); +}); diff --git a/tests/ui/get-started-modal.test.ts b/tests/ui/get-started-modal.test.ts new file mode 100644 index 00000000..84c627a5 --- /dev/null +++ b/tests/ui/get-started-modal.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test"; + +interface CliInstall { + name: string; + install: string; + setupNote: string; +} + +interface GetStartedModalModule { + getCliInstalls?: (deployTarget?: string) => readonly CliInstall[]; + getReadmeSetupUrl?: (deployTarget?: string) => string; +} + +/** + * Load the modal module through the public alias used by the app. + * + * @returns The install-data selectors exported by the modal module. + */ +async function loadGetStartedModalModule(): Promise<{ + getCliInstalls: NonNullable; + getReadmeSetupUrl: NonNullable; +}> { + const modal = (await import( + "@/components/home/GetStartedModal" + )) as GetStartedModalModule; + + expect(typeof modal.getCliInstalls).toBe("function"); + expect(typeof modal.getReadmeSetupUrl).toBe("function"); + return { + getCliInstalls: modal.getCliInstalls as NonNullable< + GetStartedModalModule["getCliInstalls"] + >, + getReadmeSetupUrl: modal.getReadmeSetupUrl as NonNullable< + GetStartedModalModule["getReadmeSetupUrl"] + >, + }; +} + +/** + * Flatten install snippets for substring assertions. + * + * @param installs - CLI install entries under test. + * @returns Combined command and setup-note text. + */ +function installText(installs: readonly CliInstall[]): string { + return installs.map((cli) => `${cli.install}\n${cli.setupNote}`).join("\n"); +} + +test("hosted deploy shows hosted setup snippets without local checkout paths", async () => { + const { getCliInstalls, getReadmeSetupUrl } = + await loadGetStartedModalModule(); + const installs = getCliInstalls("cloudflare"); + const text = installText(installs); + + expect(installs.map((cli) => cli.name)).toEqual([ + "Claude Code", + "Codex", + "Antigravity", + "Cursor", + ]); + expect(text).toContain("claude plugin marketplace add FrkAk/mymir"); + expect(text).toContain("claude plugin install mymir@mymir"); + expect(text).toContain("codex plugin marketplace add FrkAk/mymir"); + expect(text).toContain("https://app.mymir.dev/api/mcp"); + expect(text).toContain("cursor://anysphere.cursor-deeplink/mcp/install"); + expect(text).not.toContain("./plugins"); + expect(text).not.toContain("localhost"); + expect(text).not.toContain("mymir-local"); + expect(getReadmeSetupUrl("cloudflare")).toContain( + "#use-the-hosted-version-no-clone", + ); +}); + +test("self-host deploy keeps local plugin install commands", async () => { + const { getCliInstalls, getReadmeSetupUrl } = + await loadGetStartedModalModule(); + const installs = getCliInstalls(""); + const text = installText(installs); + + expect(text).toContain("./plugins/claude-code"); + expect(text).toContain("codex plugin marketplace add ./plugins"); + expect(text).toContain("./plugins/antigravity"); + expect(text).toContain("plugins/cursor"); + expect(text).toContain("mymir-local"); + expect(text).toContain("localhost"); + expect(text).not.toContain("FrkAk/mymir"); + expect(getReadmeSetupUrl("")).toContain("#self-host-contribute"); +}); diff --git a/tests/ui/oauth-client-name.test.ts b/tests/ui/oauth-client-name.test.ts index 41aadf7a..902e0784 100644 --- a/tests/ui/oauth-client-name.test.ts +++ b/tests/ui/oauth-client-name.test.ts @@ -7,6 +7,10 @@ test("formats supported OAuth client brand names consistently", () => { "Claude Code", ); expect(formatOAuthClientName("Cursor")).toBe("Cursor"); + expect(formatOAuthClientName("Antigravity")).toBe("Antigravity"); + expect(formatOAuthClientName("Google Antigravity (plugin:mymir:mymir)")).toBe( + "Antigravity", + ); expect(formatOAuthClientName("Gemini CLI")).toBe("Gemini"); });