- {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");
});