From 00eb8eaa948974f977670e06480c5541ea36d153 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Thu, 25 Jun 2026 00:36:46 -0700 Subject: [PATCH 1/2] chore: remove deprecated features Remove four deprecated surfaces ahead of a major release: - baseDirs alias: drop the --base-dir CLI flag, the baseDirs programmatic option and config field, and Config.getBaseDirs(); use outputRoots / --output-roots exclusively. - features object form: drop the deprecated per-target object form of features; the array form and the targets object form remain. - antigravity alias target: remove the legacy bare 'antigravity' target (use antigravity-ide / antigravity-cli); keep the shared frontmatter/strategy modules they depend on. - geminicli target: remove the geminicli target entirely (impl, processors, tuples, hooks events, e2e/matrix tests, docs, migration guide). Also delete the now-unused deprecation-warnings module and regenerate gitignore, supported-tools tables, JSON schema, and synced skill docs. --- .gitignore | 9 - README.md | 5 +- cspell.json | 1 - docs/.vitepress/config.ts | 4 - docs/api/programmatic-api.md | 1 - docs/faq.md | 2 +- docs/guide/configuration.md | 44 +- docs/guide/geminicli-to-antigravity-cli.md | 115 -- docs/reference/command-syntax.md | 45 +- docs/reference/file-formats.md | 25 +- docs/reference/supported-tools.md | 5 +- skills/rulesync/SKILL.md | 2 +- skills/rulesync/command-syntax.md | 45 +- skills/rulesync/configuration.md | 44 +- skills/rulesync/faq.md | 2 +- skills/rulesync/file-formats.md | 25 +- .../rulesync/geminicli-to-antigravity-cli.md | 115 -- skills/rulesync/programmatic-api.md | 1 - skills/rulesync/supported-tools.md | 5 +- src/cli/commands/generate.test.ts | 106 -- src/cli/commands/generate.ts | 83 +- src/cli/commands/gitignore-derive.ts | 6 +- src/cli/commands/gitignore-entries.test.ts | 158 +-- src/cli/commands/gitignore-entries.ts | 66 +- src/cli/commands/gitignore.test.ts | 7 +- src/cli/commands/gitignore.ts | 6 +- src/cli/commands/import.test.ts | 4 +- src/cli/commands/import.ts | 7 +- src/cli/index.ts | 5 - src/config/config-resolver.test.ts | 224 --- src/config/config-resolver.ts | 72 +- src/config/config.test.ts | 289 +--- src/config/config.ts | 72 +- src/config/deprecation-warnings.ts | 88 -- src/constants/antigravity-paths.ts | 10 - src/constants/geminicli-paths.ts | 13 - src/e2e/e2e-commands.spec.ts | 17 +- src/e2e/e2e-convert.spec.ts | 13 - src/e2e/e2e-hooks.spec.ts | 22 +- src/e2e/e2e-ignore.spec.ts | 3 - src/e2e/e2e-mcp.spec.ts | 9 +- src/e2e/e2e-permissions.spec.ts | 108 -- src/e2e/e2e-rules.spec.ts | 3 - src/e2e/e2e-skills.spec.ts | 14 - src/e2e/e2e-subagents.spec.ts | 7 - .../commands/antigravity-command.test.ts | 691 --------- src/features/commands/antigravity-command.ts | 268 ---- .../commands/commands-processor.test.ts | 78 - src/features/commands/commands-processor.ts | 28 - .../commands/geminicli-command.test.ts | 1256 ----------------- src/features/commands/geminicli-command.ts | 272 ---- src/features/hooks/geminicli-hooks.test.ts | 294 ---- src/features/hooks/geminicli-hooks.ts | 260 ---- src/features/hooks/hooks-processor.test.ts | 8 +- src/features/hooks/hooks-processor.ts | 12 - src/features/ignore/antigravity-cli-ignore.ts | 4 +- src/features/ignore/geminicli-ignore.test.ts | 502 ------- src/features/ignore/geminicli-ignore.ts | 72 - src/features/ignore/ignore-processor.test.ts | 18 - src/features/ignore/ignore-processor.ts | 2 - src/features/mcp/geminicli-mcp.test.ts | 1112 --------------- src/features/mcp/geminicli-mcp.ts | 122 -- src/features/mcp/mcp-processor.test.ts | 137 -- src/features/mcp/mcp-processor.ts | 13 - src/features/mcp/rulesync-mcp.test.ts | 2 +- .../permissions/geminicli-permissions.test.ts | 494 ------- .../permissions/geminicli-permissions.ts | 492 ------- .../permissions/permissions-processor.test.ts | 24 - .../permissions/permissions-processor.ts | 12 - src/features/rules/antigravity-cli-rule.ts | 2 +- src/features/rules/antigravity-ide-rule.ts | 9 +- src/features/rules/antigravity-rule.test.ts | 958 ------------- src/features/rules/antigravity-rule.ts | 276 ---- src/features/rules/geminicli-rule.test.ts | 649 --------- src/features/rules/geminicli-rule.ts | 154 -- src/features/rules/rules-processor.test.ts | 40 +- src/features/rules/rules-processor.ts | 34 +- src/features/rules/rulesync-rule.test.ts | 2 +- .../skills/antigravity-cli-skill.test.ts | 17 - .../skills/antigravity-ide-skill.test.ts | 17 - .../skills/antigravity-shared-skill.ts | 3 +- src/features/skills/antigravity-skill.test.ts | 396 ------ src/features/skills/antigravity-skill.ts | 211 --- src/features/skills/geminicli-skill.test.ts | 325 ----- src/features/skills/geminicli-skill.ts | 214 --- src/features/skills/skills-processor.test.ts | 10 - src/features/skills/skills-processor.ts | 16 - .../subagents/geminicli-subagent.test.ts | 610 -------- src/features/subagents/geminicli-subagent.ts | 192 --- .../subagents/subagents-processor.test.ts | 5 +- src/features/subagents/subagents-processor.ts | 8 - src/index.test.ts | 12 - src/index.ts | 7 - src/types/features.ts | 15 +- src/types/hooks.ts | 48 +- src/types/tool-display.ts | 2 - src/types/tool-target-tuples.ts | 11 - src/types/tool-targets.test.ts | 2 - 98 files changed, 85 insertions(+), 12240 deletions(-) delete mode 100644 docs/guide/geminicli-to-antigravity-cli.md delete mode 100644 skills/rulesync/geminicli-to-antigravity-cli.md delete mode 100644 src/config/deprecation-warnings.ts delete mode 100644 src/constants/geminicli-paths.ts delete mode 100644 src/features/commands/antigravity-command.test.ts delete mode 100644 src/features/commands/geminicli-command.test.ts delete mode 100644 src/features/commands/geminicli-command.ts delete mode 100644 src/features/hooks/geminicli-hooks.test.ts delete mode 100644 src/features/hooks/geminicli-hooks.ts delete mode 100644 src/features/ignore/geminicli-ignore.test.ts delete mode 100644 src/features/ignore/geminicli-ignore.ts delete mode 100644 src/features/mcp/geminicli-mcp.test.ts delete mode 100644 src/features/mcp/geminicli-mcp.ts delete mode 100644 src/features/permissions/geminicli-permissions.test.ts delete mode 100644 src/features/permissions/geminicli-permissions.ts delete mode 100644 src/features/rules/antigravity-rule.test.ts delete mode 100644 src/features/rules/geminicli-rule.test.ts delete mode 100644 src/features/rules/geminicli-rule.ts delete mode 100644 src/features/skills/antigravity-skill.test.ts delete mode 100644 src/features/skills/geminicli-skill.test.ts delete mode 100644 src/features/skills/geminicli-skill.ts delete mode 100644 src/features/subagents/geminicli-subagent.test.ts delete mode 100644 src/features/subagents/geminicli-subagent.ts diff --git a/.gitignore b/.gitignore index 2617a5005..ffcc7f17d 100644 --- a/.gitignore +++ b/.gitignore @@ -213,9 +213,6 @@ config-schema.json permissions-schema.json mcp-schema.json -**/.agent/rules/ -**/.agent/skills/ -**/.agent/workflows/ **/.playwright/ **/.playwright-cli/ docs/.vitepress/dist @@ -260,8 +257,6 @@ rulesync.local.jsonc **/.cursor/rules/ **/.deepagents/AGENTS.md **/.factory/rules/ -**/GEMINI.md -**/.gemini/memories/ **/.goosehints **/.hermes.md **/.junie/AGENTS.md @@ -287,7 +282,6 @@ rulesync.local.jsonc **/.github/prompts/ **/.cursor/commands/ **/.factory/commands/ -**/.gemini/commands/ **/.goose/recipes/ **/.junie/commands/ **/.kilo/commands/ @@ -305,7 +299,6 @@ rulesync.local.jsonc **/.cursor/skills/ **/.deepagents/skills/ **/.factory/skills/ -**/.gemini/skills/ **/.goose/skills/ **/.grok/skills/ **/.junie/skills/ @@ -330,7 +323,6 @@ rulesync.local.jsonc **/.deepagents/agents/ **/.devin/agents/ **/.factory/droids/ -**/.gemini/agents/ **/.goose/recipes/subagents/ **/.hermes/rulesync/subagents/ **/.grok/agents/ @@ -374,7 +366,6 @@ rulesync.local.jsonc **/.vibe/hooks.toml **/.cline/command-permissions.json **/.cursor/cli.json -**/.gemini/policies/rulesync.toml **/.aiignore **/.geminiignore **/.augmentignore diff --git a/README.md b/README.md index 85a8cb702..09e145b82 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,6 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Amp | ✅ | | ✅ | | | ✅ | | ✅ | | Claude Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Codex CLI | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Gemini CLI ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | GitHub Copilot | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | | GitHub Copilot CLI | ✅ | | ✅ | | ✅ | ✅ | ✅ | | | Goose | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | @@ -104,7 +103,6 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Kiro IDE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | Google Antigravity IDE | ✅ | | ✅ | ✅ | | ✅ | ✅ | ✅ | | Google Antigravity CLI | ✅ | ✅ | ✅ | | | ✅ | ✅ | ✅ | -| Google Antigravity ⚠️ | ✅ | | | ✅ | | ✅ | | | | JetBrains AI Assistant | ✅ | ✅ | | | | ✅ | | | | JetBrains Junie | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | AugmentCode | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | @@ -131,8 +129,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte ### Deprecation notes -- **Gemini CLI (`geminicli`)** — Google is retiring Gemini CLI on **June 18, 2026**, when it stops serving requests for Google AI Pro/Ultra and free Gemini Code Assist for individuals (Enterprise plans are unaffected). The successor is the **Antigravity CLI (`antigravity-cli`)**. `geminicli` is **not** removed from rulesync — Enterprise access continues and existing `GEMINI.md`/`.gemini/` repositories still rely on it — but new projects should prefer `antigravity-cli`. See the [Gemini CLI → Antigravity CLI migration guide](https://dyoshikawa.github.io/rulesync/guide/geminicli-to-antigravity-cli). -- **Google Antigravity (`antigravity`)** — Antigravity 2.0 splits into two products with separate global config trees: the desktop **`antigravity-ide`** and the **`antigravity-cli`** (`agy`). The legacy `antigravity` target is now a **deprecated alias for `antigravity-ide`** that keeps its original `.agent/` (singular) paths for backward compatibility. Migrate to `antigravity-ide` (desktop IDE) or `antigravity-cli` (CLI). For project-scope rules, **both `antigravity-ide` and `antigravity-cli`** emit the root rule as a plain cross-tool **`AGENTS.md`** at the project root (the Gemini-lineage discovery order is `AGENTS.md`, `CONTEXT.md`, `GEMINI.md`; the IDE has read `AGENTS.md` since v1.20.3) and non-root rules under `.agents/rules/`. +- **Google Antigravity (`antigravity-ide` / `antigravity-cli`)** — Antigravity 2.0 splits into two products with separate global config trees: the desktop **`antigravity-ide`** and the **`antigravity-cli`** (`agy`). For project-scope rules, **both `antigravity-ide` and `antigravity-cli`** emit the root rule as a plain cross-tool **`AGENTS.md`** at the project root (the Gemini-lineage discovery order is `AGENTS.md`, `CONTEXT.md`, `GEMINI.md`; the IDE has read `AGENTS.md` since v1.20.3) and non-root rules under `.agents/rules/`. - **Kiro (`kiro`)** — Kiro's IDE and CLI use diverging config formats (IDE: Markdown subagents `.kiro/agents/*.md` and `.kiro/hooks/*.kiro.hook`; CLI: JSON agent-config subagents `.kiro/agents/*.json` and hooks in `.kiro/agents/default.json`), so `kiro` is split into **`kiro-cli`** and **`kiro-ide`**. The legacy `kiro` target remains as a **deprecated alias** with its current behavior unchanged. The two targets share every surface except **subagents** (Markdown vs JSON); Kiro IDE multi-file `.kiro.hook` hooks are not yet supported, so use `kiro-cli` for agent hooks. Some features accept per-feature options (e.g., Claude Code's `ignore` feature supports `fileMode: "local"` to write to `settings.local.json` instead of `settings.json`). See [Configuration > Per-feature options](https://dyoshikawa.github.io/rulesync/guide/configuration#per-feature-options) for details. diff --git a/cspell.json b/cspell.json index 27ac1766f..0753905fb 100644 --- a/cspell.json +++ b/cspell.json @@ -131,7 +131,6 @@ "fseventsd", "fullwidth", "gemincli", - "geminicli", "geminiignore", "gibo", "giget", diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 29c4dbd5f..c2317906c 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -94,10 +94,6 @@ export default defineConfig({ link: "/guide/declarative-sources", }, { text: "Official Skills", link: "/guide/official-skills" }, - { - text: "Gemini CLI → Antigravity CLI", - link: "/guide/geminicli-to-antigravity-cli", - }, { text: "Dry Run", link: "/guide/dry-run" }, { text: "Case Studies", link: "/guide/case-studies" }, ], diff --git a/docs/api/programmatic-api.md b/docs/api/programmatic-api.md index 5932fe4d1..522231eb9 100644 --- a/docs/api/programmatic-api.md +++ b/docs/api/programmatic-api.md @@ -43,7 +43,6 @@ Generates configuration files for the specified targets and features. | `targets` | `ToolTarget[]` | from config file | Tools to generate configurations for | | `features` | `Feature[]` | from config file | Features to generate | | `outputRoots` | `string[]` | `[process.cwd()]` | Output root directories to generate files into | -| `baseDirs` | `string[]` | — | **Deprecated** alias of `outputRoots`. Still accepted for backward compatibility; emits a one-shot deprecation warning. Will be removed in a future major release. | | `inputRoot` | `string` | `process.cwd()` | Directory containing the `.rulesync/` source files. Output still goes to each `outputRoots` entry; only the input source root is redirected. Mirrors the CLI's `--input-root`. | | `configPath` | `string` | auto-detected | Path to `rulesync.jsonc` | | `verbose` | `boolean` | `false` | Enable verbose logging | diff --git a/docs/faq.md b/docs/faq.md index 9baed9248..24429c344 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -18,7 +18,7 @@ According to [the documentation](https://code.claude.com/docs/en/settings), this Google Antigravity has a known limitation where it won't load rules, workflows, and skills if the `.agents/rules/`, `.agents/workflows/`, and `.agents/skills/` directories are listed in `.gitignore`, even with "Agent Gitignore Access" enabled. -> **Note:** Antigravity 2.0 uses the plural `.agents/` directory by default (the `antigravity-ide` and `antigravity-cli` targets). The singular `.agent/` directory is the Antigravity 1.x layout, still read for backward compatibility by the deprecated `antigravity` alias target; apply the same workaround to those paths if you target the alias. +> **Note:** Antigravity 2.0 uses the plural `.agents/` directory by default (the `antigravity-ide` and `antigravity-cli` targets). **Workaround:** Instead of adding these directories to `.gitignore`, add them to `.git/info/exclude`: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 1039a3899..5c153f2ac 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -25,23 +25,14 @@ Example: "$schema": "https://github.com/dyoshikawa/rulesync/releases/latest/download/config-schema.json", // List of tools to generate configurations for. You can specify "*" to generate all tools. - "targets": ["cursor", "claudecode", "geminicli", "opencode", "codexcli"], + "targets": ["cursor", "claudecode", "opencode", "codexcli"], // Features to generate. You can specify "*" to generate all features. "features": ["rules", "ignore", "mcp", "commands", "subagents", "hooks"], - // Alternatively, you can use object format to specify features per target: - // "features": { - // "claudecode": ["rules", "commands"], - // "cursor": ["rules", "mcp"], - // }, - // Output root directories to generate files into. // Basically, you can specify `["."]` only. // However, for example, if your project is a monorepo and you have to launch the AI agent at each package directory, you can specify multiple output roots. - // - // The legacy field name `baseDirs` is still accepted as a deprecated alias - // and will be removed in a future major release. Migrate to `outputRoots`. "outputRoots": ["."], // Delete existing files before generating @@ -161,39 +152,6 @@ Priority is **more specific wins**: 3. root level 4. default (`"gitignore"`) -### Deprecated: object form under `features` - -Earlier versions of Rulesync accepted per-target configuration under the -top-level `features` field, paired with a `targets` array. That form is -still parsed for backward compatibility but emits a deprecation warning; -new configs should use the `targets` object form shown above. - -> **⚠️ Breaking change in v8.0.0:** mixing `targets` (array) with -> `features` (object) is no longer accepted — the config loader throws. -> If your config previously looked like -> `targets: ["claudecode"], features: { claudecode: {...} }`, migrate to -> the `targets` object form (recommended) or drop the `targets` array and -> keep only the `features` object (deprecated path). The migration is -> mechanical — see the example below. - -```jsonc -// ⚠️ Deprecated — still works, logs a warning -{ - "features": { - "claudecode": { "rules": true, "ignore": { "fileMode": "local" } }, - }, -} -``` - -To silence the deprecation warning (for example, in CI pipelines that -intentionally run on the deprecated form until the migration is scheduled), -set the `RULESYNC_SILENT_DEPRECATION` environment variable to any truthy -value: - -```bash -RULESYNC_SILENT_DEPRECATION=1 npx rulesync generate -``` - The current per-feature options are: | Target | Feature | Option | Values | Default | diff --git a/docs/guide/geminicli-to-antigravity-cli.md b/docs/guide/geminicli-to-antigravity-cli.md deleted file mode 100644 index e0231d6c9..000000000 --- a/docs/guide/geminicli-to-antigravity-cli.md +++ /dev/null @@ -1,115 +0,0 @@ -# Gemini CLI → Antigravity CLI Migration - -Google is **retiring Gemini CLI on June 18, 2026**. On that date, Gemini CLI and -the Gemini Code Assist IDE extensions stop serving requests for Google AI -Pro/Ultra and free Gemini Code Assist for individuals. **Enterprise** plans -(Gemini Code Assist Standard/Enterprise, and GCA for GitHub via Google Cloud) -are **unaffected**. - -The successor is the **Antigravity CLI** (`agy`), the Go-based CLI that shipped -with Antigravity 2.0 at Google I/O 2026. It carries over the most critical -features of Gemini CLI — Agent Skills, Hooks, MCP, and rules/context files — -though Google has stated there is **no 1:1 feature parity right out of the -gate**. - -This guide explains how to move a rulesync configuration from the `geminicli` -target to the new `antigravity-cli` target. - -## `geminicli` is not removed - -Rulesync keeps the `geminicli` target: - -- Enterprise access to Gemini CLI continues past June 18, 2026. -- Countless existing `GEMINI.md` / `.gemini/` repositories still depend on it. - -`geminicli` is simply **marked deprecated** in the -[supported tools matrix](../reference/supported-tools.md). New projects should -prefer `antigravity-cli`; existing projects can keep generating both during a -transition. - -## Feature mapping - -| Feature | `geminicli` | `antigravity-cli` | -| ----------- | -------------------------------------- | ------------------------------------------------------------------------------------------- | -| rules | root `GEMINI.md` + `.gemini/memories/` | root `AGENTS.md` + `.agents/rules/`; global `~/.gemini/GEMINI.md` | -| skills | `.gemini/skills/` | `.agents/skills/`; global `~/.gemini/antigravity-cli/skills/` | -| mcp | `.gemini/settings.json` (`mcpServers`) | `.agents/mcp_config.json`; global `~/.gemini/config/mcp_config.json` (shared config dir) | -| hooks | `.gemini/` (Gemini hook shape) | `.agents/hooks.json`; global `~/.gemini/config/hooks.json` (Claude-Code-like matcher shape) | -| permissions | `.gemini/settings.json` | global `~/.gemini/antigravity-cli/settings.json` (`permissions.allow`/`ask`/`deny`) | -| commands | `.gemini/commands/` (TOML) | **not yet supported** — Antigravity CLI exposes slash commands via skills | -| subagents | `.gemini/agents/` | **not yet supported** — CLI subagents are only definable via plugin bundles | - -### Notable differences - -- **Root rules file**: `antigravity-cli` emits the project root rule as the - cross-tool **`AGENTS.md`** (matching `antigravity-ide`), not `GEMINI.md`. The - CLI reads both, with the Gemini-lineage discovery order `AGENTS.md`, - `CONTEXT.md`, `GEMINI.md`. If you previously generated `antigravity-cli` output - and have a generated root `GEMINI.md`, rulesync no longer manages it — delete - the stale `GEMINI.md` manually after regenerating. Global scope is unchanged - (`~/.gemini/GEMINI.md`). -- **MCP**: Antigravity uses `serverUrl` (not `url`) for HTTP servers and honors - a `disabledTools` array. Rulesync emits the Antigravity-compatible shape - automatically. -- **Hooks**: Antigravity uses a Claude-Code-like `hooks.json` with a matcher - shape, **not** the Gemini CLI hook format. The event map is nested under a - generated `rulesync` hook name (`{ "rulesync": { "": [...] } }`). - Rulesync translates five events for Antigravity: `preToolUse`, `postToolUse`, - `preModelInvocation` (→ `PreInvocation`), `postModelInvocation` - (→ `PostInvocation`), and `stop`; the model-invocation events and `stop` are - matcher-less handler lists. -- **Permissions**: The Antigravity CLI reads permissions only from the global - `~/.gemini/antigravity-cli/settings.json`; there is no documented - workspace-scoped permissions file, so rulesync generates this file in - **global mode only**. The canonical `bash` tool maps to Antigravity's - `command` tool name. -- **Commands / subagents**: These are intentionally out of scope for - `antigravity-cli` today. Antigravity surfaces slash commands through skills. - CLI subagents are defined and managed at runtime (the interactive `/agents` - panel and the orchestrator's `invoke_subagent` / `define_subagent` tools), and - the only file-based way to ship one is bundled inside a plugin (a namespaced - package of skills + subagents + rules + MCP + hooks). There is no standalone, - declarative per-agent file (analogous to `geminicli`'s `.gemini/agents/`) that - rulesync could generate, and the plugin bundle does not map cleanly onto - rulesync's per-feature model. Keep generating subagents with `geminicli` if - you still rely on them. - -## Updating `rulesync.jsonc` - -Replace the `geminicli` target with `antigravity-cli`. For example: - -```jsonc -{ - "targets": { - // Before - // "geminicli": { "rules": true, "skills": true, "mcp": true, "hooks": true, "permissions": true }, - - // After - "antigravity-cli": { - "rules": true, - "skills": true, - "mcp": true, - "hooks": true, - "permissions": true, - }, - }, -} -``` - -To generate global-scope files (including the permissions file, which is -global-only), run with `--global`: - -```bash -rulesync generate --targets antigravity-cli --global -``` - -You can keep both targets enabled during a transition so the same `.rulesync/` -sources fan out to both `.gemini/` (Gemini CLI) and `.agents/` (Antigravity -CLI) trees. - -## See also - -- [Supported Tools and Features](../reference/supported-tools.md) -- [Global Mode](./global-mode.md) -- Google's official migration guide: -- Announcement: diff --git a/docs/reference/command-syntax.md b/docs/reference/command-syntax.md index 05abcfc55..2201cc1ec 100644 --- a/docs/reference/command-syntax.md +++ b/docs/reference/command-syntax.md @@ -19,7 +19,6 @@ The table below shows how each placeholder is translated for the supported tools | ----------------- | ---------------------- | --------------------------- | | Claude Code | pass-through | pass-through | | Codex CLI[^codex] | pass-through (literal) | pass-through (literal) | -| Gemini CLI | `{{args}}` | `!{cmd}` | | Pi | pass-through | pass-through (literal)[^pi] | | Other tools[^1] | pass-through (literal) | pass-through (literal) | @@ -29,7 +28,7 @@ The table below shows how each placeholder is translated for the supported tools [^pi]: Pi natively expands `$ARGUMENTS` (along with `$1`, `$2`, `$@`), so `$ARGUMENTS` is a real pass-through there. rulesync still emits `` !`cmd` `` verbatim for Pi, but does not assume Pi expands inline shell snippets — treat that placeholder as literal text on Pi's side. -The translation also runs in reverse when you import an existing tool command file via `rulesync import`, so e.g. a Gemini CLI command containing `{{args}}` becomes `$ARGUMENTS` in the generated `.rulesync/commands/*.md`. +The translation also runs in reverse when you import an existing tool command file via `rulesync import`, so a tool-native placeholder is rewritten back to the universal form in the generated `.rulesync/commands/*.md`. ## Example @@ -37,7 +36,7 @@ Given the following rulesync command: ```md --- -targets: ["geminicli"] +targets: ["claudecode"] description: "Summarize git diff" --- @@ -47,42 +46,10 @@ Summarize the diff: Focus on $ARGUMENTS. ``` -rulesync generates `.gemini/commands/summarize.toml`: - -```toml -description = "Summarize git diff" -prompt = """ -Summarize the diff: -!{git diff} - -Focus on {{args}}. -""" -``` +rulesync generates `.claude/commands/summarize.md`, passing the placeholders through verbatim because Claude Code already understands the universal form. ## Notes -- If you author a command with explicit tool-specific syntax (e.g. you write `{{args}}` directly in a rulesync command body), rulesync does **not** re-translate the already-tool-native form. Stick to the universal placeholders to keep commands portable across tools. -- The translation is purely textual and is applied to the entire body. It does not skip fenced or inline code blocks, so ` ```js\n$ARGUMENTS\n``` ` in a rulesync body will still be rewritten when generating Gemini CLI output. There is **no escape syntax** for the universal placeholders — backslashes are not consumed by the regex, so `\$ARGUMENTS` becomes `\{{args}}`, not a literal `$ARGUMENTS`. If you need a literal `$ARGUMENTS` or `` !`cmd` `` in the emitted Gemini CLI prompt, your options are: - 1. Hand-author the Gemini-native body via the per-tool `geminicli.prompt` override (see below) — this skips translation entirely for Gemini CLI. - 2. Drop `geminicli` from the command's `targets:` so no Gemini CLI file is generated. - -- The shell expansion regex matches a single backtick-delimited segment without embedded backticks or newlines (`` !`...` ``). Multi-line shell snippets are not supported, and a backtick inside the command body is not allowed — for that case, hand-author the Gemini-native form via `geminicli.prompt`. -- Gemini CLI accepts both `{{args}}` and `{{ args }}` (with whitespace). rulesync canonicalizes the imported form to `$ARGUMENTS`. -- The reverse rewrite (Gemini CLI → rulesync, on import) is **not** symmetric with the forward direction: a rulesync body that already contains Gemini-native forms (`{{args}}`, `!{cmd}`) is preserved on export, but a Gemini CLI file containing those forms is always rewritten back to the universal syntax on import. In practice this is what users want, but it does mean that an import will canonicalize away any literal `{{args}}` / `!{cmd}` text that you intended as documentation. - -### Per-tool override (Gemini CLI) - -If you need to bypass the translation entirely — for example, to embed a literal backtick inside a Gemini-native shell expansion — you can author the prompt directly in the `geminicli` section of the rulesync frontmatter: - -```md ---- -targets: ["geminicli"] -description: "Hand-authored prompt" -geminicli: - prompt: "Run !{echo `hello`}." ---- - -Body content here is ignored only for the Gemini CLI output when geminicli.prompt is set. -``` - -When `geminicli.prompt` is present, rulesync uses it verbatim as the Gemini CLI command body and skips the universal-syntax translation entirely. Note that this override is **scoped to the Gemini CLI output only**: if `targets:` lists other tools (e.g. `["geminicli", "claudecode"]`), the rulesync command body is still used as the source for those other tools — `geminicli.prompt` does not replace the body for them. +- If you author a command with explicit tool-specific syntax (e.g. you write a tool-native placeholder directly in a rulesync command body), rulesync does **not** re-translate the already-tool-native form. Stick to the universal placeholders to keep commands portable across tools. +- The translation is purely textual and is applied to the entire body. It does not skip fenced or inline code blocks, so ` ```js\n$ARGUMENTS\n``` ` in a rulesync body will still be rewritten when generating tool output. There is **no escape syntax** for the universal placeholders — backslashes are not consumed by the regex, so `\$ARGUMENTS` is rewritten alongside the placeholder rather than producing a literal `$ARGUMENTS`. +- The shell expansion regex matches a single backtick-delimited segment without embedded backticks or newlines (`` !`...` ``). Multi-line shell snippets are not supported, and a backtick inside the command body is not allowed. diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index b62cbf0f5..f995ca93f 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -103,11 +103,6 @@ Example: "hooks": { "afterError": [{ "command": ".rulesync/hooks/report-error.sh" }] } - }, - "geminicli": { - "hooks": { - "beforeToolSelection": [{ "command": ".rulesync/hooks/before-tool.sh" }] - } } } ``` @@ -116,7 +111,7 @@ Example: - `version`: Schema version (currently `1`). - `hooks`: Map of canonical event names to an array of hook entries. These are dispatched to every tool that supports the given event. -- `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `geminicli.hooks`, `codexcli.hooks`, `goose.hooks`, `deepagents.hooks`, `kiro.hooks`, `qwencode.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block. +- `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `codexcli.hooks`, `goose.hooks`, `deepagents.hooks`, `kiro.hooks`, `qwencode.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block. **Hook entry keys:** @@ -359,8 +354,6 @@ Based on the user's instruction, create a plan while analyzing the related files Attention, again, you are just the planner, so though you can read any files and run any commands for analysis, please don't write any code. ``` -> **Gemini CLI note (as of 2026-04-01):** Subagents are generated to `.gemini/agents/`. To enable the agents feature, set `"experimental": { "enableAgents": true }` in your `.gemini/settings.json`. - > **Qwen Code note:** Subagents are emitted as Markdown + YAML frontmatter under `.qwen/agents/` (project) and `~/.qwen/agents/` (user/global, via `--global`); the body is the subagent's system prompt. Besides the shared `name`/`description`, the `qwencode:` block accepts these optional fields (all preserved on round-trip): `model`, `approvalMode` (`default` | `plan` | `auto-edit` | `yolo` | `bubble`), `tools` (allowlist), `disallowedTools` (denylist), `maxTurns`, `color`, `mcpServers` (per-agent MCP overrides), and `hooks` (per-agent hook registrations). See the [Qwen Code sub-agents docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/sub-agents.md). > **Cline note:** Cline file-based agents are emitted as YAML files (`.yaml`) into `.cline/agents/` (project) and `~/.cline/agents/` (global, via `--global`). The file is a YAML frontmatter block (`name` required, `description`) followed by the system prompt body, matching Cline's agent config loader. @@ -727,8 +720,7 @@ credentials/ Most tools get a dedicated ignore file (for example `.cursorignore`, `.geminiignore`, `.clineignore`). Antigravity CLI is built on the same engine -as Gemini CLI, so it shares the `.geminiignore` file (enabling either target -writes it). Claude Code is the exception: it does not +as Gemini CLI, so it reads the project-root `.geminiignore` file. Claude Code is the exception: it does not read a separate ignore file, so Rulesync writes the deny list into Claude Code's settings file as `permissions.deny` entries (`Read()`). @@ -834,19 +826,6 @@ Rulesync always emits `":minimal" = "read"` in the generated filesystem table. T `network.mode`, `network.unix_sockets`, and `description` have no equivalent in Rulesync's canonical permissions model and are not generated. If an existing `.codex/config.toml` already contains these fields on the `rulesync` profile, Rulesync preserves them on regeneration. Note that `filesystem`, `network.enabled`, `network.domains`, and `extends` are always managed by Rulesync (derived from `edit`/`write`/`webfetch` rules), so hand-authored values in those fields will be replaced on regeneration. -For Gemini CLI, this generates a Policy Engine file at `.gemini/policies/rulesync.toml` (project mode) or `~/.gemini/policies/rulesync.toml` (global mode). Gemini CLI auto-discovers any `*.toml` file under the `policies/` directory, so no `settings.json` modification is required. **Only `--global` output is effective:** Gemini CLI's Policy Engine documents the Workspace tier (project-level `.gemini/policies/`) as **non-functional** — policies placed there have no effect, and only the User tier (`~/.gemini/policies/`, written with `--global`) is honored. Rulesync still emits the project-scope file but logs a warning that it is inert; generate with `--global` for an effective policy. - -- `allow` / `deny` / `ask` rules are converted into Policy Engine `decision` values `allow` / `deny` / `ask_user` -- Rule `priority` is assigned per decision so that `deny` (999) beats `ask_user` (500) beats `allow` (1) in the engine's first-match ordering. This matches intuitive allow-with-narrow-deny authoring (e.g. `bash: { "git *": "allow", "git push --force *": "deny" }`) without relying on array order. The values stay within the Policy Engine's documented `0`–`999` per-rule priority range, with `deny` at the top of the band so a hand-authored rule in a sibling `.toml` under `policies/` is unlikely to outrank a rulesync-managed deny by accident. -- `bash` rules are generated with `toolName = "run_shell_command"`. When the pattern ends with a trailing ` *` (or has no glob metacharacters), the rule uses `commandPrefix` with the trailing ` *` stripped, so `"git *"` and `"git"` both serialize as `commandPrefix = "git"`. The reverse import canonicalizes these to `" *"`. -- When a `bash` pattern contains interior glob metacharacters (anything other than a trailing ` *`, e.g. `"rm -rf /tmp/*"`), rulesync emits `argsPattern` with a `"command":"` JSON-anchor instead of `commandPrefix`, because Gemini CLI treats `commandPrefix` as a literal string prefix. -- Non-`bash` rules are generated with `toolName` + `argsPattern`. The pattern is anchored at both ends of the JSON string value (leading `"` and trailing `\"`) so a match cannot leak across JSON fields. Glob translation: `*` → `[^/\"]*` (single segment), `**` → `[^\"]*` (cross segment but still inside the string), `?` → `[^/\"]` (single non-separator character). Glob character classes (e.g. `[abc]`) are emitted as regex literals (the brackets themselves become `\[` / `\]`), because a translated class such as `[^a]` or `[!-~]` can bypass the JSON-boundary guard. -- Patterns that contain an unescaped `"` or `\` are skipped with a warning, because smol-toml escaping would let the pattern hijack the surrounding regex anchor and silently disable deny rules. -- Empty patterns (`""`) are skipped with a warning, since they would match every invocation on `bash` and never match anything on other tools. -- `bash` patterns `*` and `**` are skipped when paired with `allow` or `deny`, because either would silently grant or revoke permission for every shell command. They are still honored with `ask` (interactive prompt on every invocation). -- Imported policy rules whose `toolName` maps to a reserved JavaScript object key (`__proto__`, `constructor`, `prototype`) are skipped with a warning to prevent prototype pollution when round-tripping untrusted TOML. -- Tool categories are mapped as: `bash` → `run_shell_command`, `read` → `read_file`, `edit` → `replace`, `write` → `write_file`, `webfetch` → `web_fetch` - For Kiro, this generates tool permission settings in `.kiro/agents/default.json` (project mode): - `bash` maps to `toolsSettings.shell.allowedCommands` / `toolsSettings.shell.deniedCommands` diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index f5e625c02..7193074eb 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -11,7 +11,6 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Amp | amp | ✅ 🌏 | | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Gemini CLI ⚠️ | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | Goose | goose | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | 🌏 | @@ -34,7 +33,6 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Kiro IDE | kiro-ide | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ | ✅ | ✅ | | ✅ | | Google Antigravity IDE | antigravity-ide | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ | | Google Antigravity CLI | antigravity-cli | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | | | ✅ 🌏 | ✅ 🌏 | 🌏 | -| Google Antigravity ⚠️ | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | | | JetBrains AI Assistant | aiassistant | ✅ | ✅ | | | | ✅ | | | | JetBrains Junie | junie | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | 🌏 | | | AugmentCode | augmentcode | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | @@ -54,6 +52,5 @@ Rulesync supports both **generation** and **import** for All of the major AI cod ## Deprecation notes -- **Gemini CLI (`geminicli`)** — Google is retiring Gemini CLI on **June 18, 2026**, when it stops serving requests for Google AI Pro/Ultra and free Gemini Code Assist for individuals (Enterprise plans are unaffected). The successor is the **Antigravity CLI (`antigravity-cli`)**. `geminicli` is **not** removed from rulesync — Enterprise access continues and existing `GEMINI.md`/`.gemini/` repositories still rely on it — but new projects should prefer `antigravity-cli`. See the [Gemini CLI → Antigravity CLI migration guide](../guide/geminicli-to-antigravity-cli.md). -- **Google Antigravity (`antigravity`)** — Antigravity 2.0 splits into two products: the desktop **`antigravity-ide`** and the **`antigravity-cli`** (`agy`). The legacy `antigravity` target is now a **deprecated alias for `antigravity-ide`** that keeps its original `.agent/` (singular) paths for backward compatibility. Migrate to `antigravity-ide` (desktop IDE) or `antigravity-cli` (CLI). As of Antigravity 2.0 the IDE reads its global MCP config and skills from the shared `~/.gemini/config/` tree — `~/.gemini/config/mcp_config.json` and `~/.gemini/config/skills/`, matching the current [MCP](https://antigravity.google/docs/mcp) and [Skills](https://antigravity.google/docs/skills) docs. The `antigravity-cli` global MCP config also lives in the shared `~/.gemini/config/mcp_config.json`, while the CLI keeps its own global skills tree at `~/.gemini/antigravity-cli/skills/`. Both targets also intentionally **share** the global rule file `~/.gemini/GEMINI.md` and the global hooks file `~/.gemini/config/hooks.json` — enabling both targets in `--global` mode writes those shared files once. For project-scope rules, **both `antigravity-ide` and `antigravity-cli`** emit the root rule as a plain cross-tool **`AGENTS.md`** at the project root (the Gemini-lineage discovery order is `AGENTS.md`, `CONTEXT.md`, `GEMINI.md`; the IDE has read `AGENTS.md` since v1.20.3) and non-root rules under `.agents/rules/` (the IDE adds trigger frontmatter to non-root rules; the CLI keeps them as plain markdown). +- **Google Antigravity (`antigravity-ide` / `antigravity-cli`)** — Antigravity 2.0 splits into two products: the desktop **`antigravity-ide`** and the **`antigravity-cli`** (`agy`). As of Antigravity 2.0 the IDE reads its global MCP config and skills from the shared `~/.gemini/config/` tree — `~/.gemini/config/mcp_config.json` and `~/.gemini/config/skills/`, matching the current [MCP](https://antigravity.google/docs/mcp) and [Skills](https://antigravity.google/docs/skills) docs. The `antigravity-cli` global MCP config also lives in the shared `~/.gemini/config/mcp_config.json`, while the CLI keeps its own global skills tree at `~/.gemini/antigravity-cli/skills/`. Both targets also intentionally **share** the global rule file `~/.gemini/GEMINI.md` and the global hooks file `~/.gemini/config/hooks.json` — enabling both targets in `--global` mode writes those shared files once. For project-scope rules, **both `antigravity-ide` and `antigravity-cli`** emit the root rule as a plain cross-tool **`AGENTS.md`** at the project root (the Gemini-lineage discovery order is `AGENTS.md`, `CONTEXT.md`, `GEMINI.md`; the IDE has read `AGENTS.md` since v1.20.3) and non-root rules under `.agents/rules/` (the IDE adds trigger frontmatter to non-root rules; the CLI keeps them as plain markdown). - **Kiro (`kiro`)** — Kiro ships as two products with diverging config formats: the **Kiro IDE** reads Markdown subagents (`.kiro/agents/*.md`) and `.kiro/hooks/*.kiro.hook` agent hooks, while the **Kiro CLI** reads JSON agent-config subagents (`.kiro/agents/*.json`) and agent hooks in `.kiro/agents/default.json`. A single target cannot emit both faithfully, so `kiro` is split into **`kiro-cli`** and **`kiro-ide`**. The legacy `kiro` target is kept as a **deprecated alias** (its current mixed output is unchanged for backward compatibility). Shared surfaces (steering rules with `inclusion`, `.kiro/settings/mcp.json`, `.kiro/prompts/` commands, `.kiro/skills/`, `.kiroignore`, permissions) are identical between the two; they differ only in **subagents** (`.md` vs `.json`). Kiro IDE **hooks** (`.kiro/hooks/*.kiro.hook`) require multi-file output that the current single-file hooks pipeline does not yet emit, so `kiro-ide` does not support `hooks` (use `kiro-cli` for agent hooks). diff --git a/skills/rulesync/SKILL.md b/skills/rulesync/SKILL.md index 63159ff65..1827fb694 100644 --- a/skills/rulesync/SKILL.md +++ b/skills/rulesync/SKILL.md @@ -52,7 +52,7 @@ rulesync generate --targets "*" --features "*" ## Detailed Reference - [Installation](./installation.md), [Quick Start](./quick-start.md) -- [Why Rulesync?](./why-rulesync.md), [Configuration](./configuration.md), [Global Mode](./global-mode.md), [Separate Input Root](./separate-input-root.md), [Simulated Features](./simulated-features.md), [Declarative Sources](./declarative-sources.md), [Official Skills](./official-skills.md), [Gemini CLI → Antigravity CLI](./geminicli-to-antigravity-cli.md), [Dry Run](./dry-run.md), [Case Studies](./case-studies.md) +- [Why Rulesync?](./why-rulesync.md), [Configuration](./configuration.md), [Global Mode](./global-mode.md), [Separate Input Root](./separate-input-root.md), [Simulated Features](./simulated-features.md), [Declarative Sources](./declarative-sources.md), [Official Skills](./official-skills.md), [Dry Run](./dry-run.md), [Case Studies](./case-studies.md) - [Supported Tools](./supported-tools.md), [CLI Commands](./cli-commands.md), [File Formats](./file-formats.md), [Command Syntax](./command-syntax.md), [MCP Server](./mcp-server.md) - [Programmatic API](./programmatic-api.md) - [FAQ](./faq.md) diff --git a/skills/rulesync/command-syntax.md b/skills/rulesync/command-syntax.md index 05abcfc55..2201cc1ec 100644 --- a/skills/rulesync/command-syntax.md +++ b/skills/rulesync/command-syntax.md @@ -19,7 +19,6 @@ The table below shows how each placeholder is translated for the supported tools | ----------------- | ---------------------- | --------------------------- | | Claude Code | pass-through | pass-through | | Codex CLI[^codex] | pass-through (literal) | pass-through (literal) | -| Gemini CLI | `{{args}}` | `!{cmd}` | | Pi | pass-through | pass-through (literal)[^pi] | | Other tools[^1] | pass-through (literal) | pass-through (literal) | @@ -29,7 +28,7 @@ The table below shows how each placeholder is translated for the supported tools [^pi]: Pi natively expands `$ARGUMENTS` (along with `$1`, `$2`, `$@`), so `$ARGUMENTS` is a real pass-through there. rulesync still emits `` !`cmd` `` verbatim for Pi, but does not assume Pi expands inline shell snippets — treat that placeholder as literal text on Pi's side. -The translation also runs in reverse when you import an existing tool command file via `rulesync import`, so e.g. a Gemini CLI command containing `{{args}}` becomes `$ARGUMENTS` in the generated `.rulesync/commands/*.md`. +The translation also runs in reverse when you import an existing tool command file via `rulesync import`, so a tool-native placeholder is rewritten back to the universal form in the generated `.rulesync/commands/*.md`. ## Example @@ -37,7 +36,7 @@ Given the following rulesync command: ```md --- -targets: ["geminicli"] +targets: ["claudecode"] description: "Summarize git diff" --- @@ -47,42 +46,10 @@ Summarize the diff: Focus on $ARGUMENTS. ``` -rulesync generates `.gemini/commands/summarize.toml`: - -```toml -description = "Summarize git diff" -prompt = """ -Summarize the diff: -!{git diff} - -Focus on {{args}}. -""" -``` +rulesync generates `.claude/commands/summarize.md`, passing the placeholders through verbatim because Claude Code already understands the universal form. ## Notes -- If you author a command with explicit tool-specific syntax (e.g. you write `{{args}}` directly in a rulesync command body), rulesync does **not** re-translate the already-tool-native form. Stick to the universal placeholders to keep commands portable across tools. -- The translation is purely textual and is applied to the entire body. It does not skip fenced or inline code blocks, so ` ```js\n$ARGUMENTS\n``` ` in a rulesync body will still be rewritten when generating Gemini CLI output. There is **no escape syntax** for the universal placeholders — backslashes are not consumed by the regex, so `\$ARGUMENTS` becomes `\{{args}}`, not a literal `$ARGUMENTS`. If you need a literal `$ARGUMENTS` or `` !`cmd` `` in the emitted Gemini CLI prompt, your options are: - 1. Hand-author the Gemini-native body via the per-tool `geminicli.prompt` override (see below) — this skips translation entirely for Gemini CLI. - 2. Drop `geminicli` from the command's `targets:` so no Gemini CLI file is generated. - -- The shell expansion regex matches a single backtick-delimited segment without embedded backticks or newlines (`` !`...` ``). Multi-line shell snippets are not supported, and a backtick inside the command body is not allowed — for that case, hand-author the Gemini-native form via `geminicli.prompt`. -- Gemini CLI accepts both `{{args}}` and `{{ args }}` (with whitespace). rulesync canonicalizes the imported form to `$ARGUMENTS`. -- The reverse rewrite (Gemini CLI → rulesync, on import) is **not** symmetric with the forward direction: a rulesync body that already contains Gemini-native forms (`{{args}}`, `!{cmd}`) is preserved on export, but a Gemini CLI file containing those forms is always rewritten back to the universal syntax on import. In practice this is what users want, but it does mean that an import will canonicalize away any literal `{{args}}` / `!{cmd}` text that you intended as documentation. - -### Per-tool override (Gemini CLI) - -If you need to bypass the translation entirely — for example, to embed a literal backtick inside a Gemini-native shell expansion — you can author the prompt directly in the `geminicli` section of the rulesync frontmatter: - -```md ---- -targets: ["geminicli"] -description: "Hand-authored prompt" -geminicli: - prompt: "Run !{echo `hello`}." ---- - -Body content here is ignored only for the Gemini CLI output when geminicli.prompt is set. -``` - -When `geminicli.prompt` is present, rulesync uses it verbatim as the Gemini CLI command body and skips the universal-syntax translation entirely. Note that this override is **scoped to the Gemini CLI output only**: if `targets:` lists other tools (e.g. `["geminicli", "claudecode"]`), the rulesync command body is still used as the source for those other tools — `geminicli.prompt` does not replace the body for them. +- If you author a command with explicit tool-specific syntax (e.g. you write a tool-native placeholder directly in a rulesync command body), rulesync does **not** re-translate the already-tool-native form. Stick to the universal placeholders to keep commands portable across tools. +- The translation is purely textual and is applied to the entire body. It does not skip fenced or inline code blocks, so ` ```js\n$ARGUMENTS\n``` ` in a rulesync body will still be rewritten when generating tool output. There is **no escape syntax** for the universal placeholders — backslashes are not consumed by the regex, so `\$ARGUMENTS` is rewritten alongside the placeholder rather than producing a literal `$ARGUMENTS`. +- The shell expansion regex matches a single backtick-delimited segment without embedded backticks or newlines (`` !`...` ``). Multi-line shell snippets are not supported, and a backtick inside the command body is not allowed. diff --git a/skills/rulesync/configuration.md b/skills/rulesync/configuration.md index 1039a3899..5c153f2ac 100644 --- a/skills/rulesync/configuration.md +++ b/skills/rulesync/configuration.md @@ -25,23 +25,14 @@ Example: "$schema": "https://github.com/dyoshikawa/rulesync/releases/latest/download/config-schema.json", // List of tools to generate configurations for. You can specify "*" to generate all tools. - "targets": ["cursor", "claudecode", "geminicli", "opencode", "codexcli"], + "targets": ["cursor", "claudecode", "opencode", "codexcli"], // Features to generate. You can specify "*" to generate all features. "features": ["rules", "ignore", "mcp", "commands", "subagents", "hooks"], - // Alternatively, you can use object format to specify features per target: - // "features": { - // "claudecode": ["rules", "commands"], - // "cursor": ["rules", "mcp"], - // }, - // Output root directories to generate files into. // Basically, you can specify `["."]` only. // However, for example, if your project is a monorepo and you have to launch the AI agent at each package directory, you can specify multiple output roots. - // - // The legacy field name `baseDirs` is still accepted as a deprecated alias - // and will be removed in a future major release. Migrate to `outputRoots`. "outputRoots": ["."], // Delete existing files before generating @@ -161,39 +152,6 @@ Priority is **more specific wins**: 3. root level 4. default (`"gitignore"`) -### Deprecated: object form under `features` - -Earlier versions of Rulesync accepted per-target configuration under the -top-level `features` field, paired with a `targets` array. That form is -still parsed for backward compatibility but emits a deprecation warning; -new configs should use the `targets` object form shown above. - -> **⚠️ Breaking change in v8.0.0:** mixing `targets` (array) with -> `features` (object) is no longer accepted — the config loader throws. -> If your config previously looked like -> `targets: ["claudecode"], features: { claudecode: {...} }`, migrate to -> the `targets` object form (recommended) or drop the `targets` array and -> keep only the `features` object (deprecated path). The migration is -> mechanical — see the example below. - -```jsonc -// ⚠️ Deprecated — still works, logs a warning -{ - "features": { - "claudecode": { "rules": true, "ignore": { "fileMode": "local" } }, - }, -} -``` - -To silence the deprecation warning (for example, in CI pipelines that -intentionally run on the deprecated form until the migration is scheduled), -set the `RULESYNC_SILENT_DEPRECATION` environment variable to any truthy -value: - -```bash -RULESYNC_SILENT_DEPRECATION=1 npx rulesync generate -``` - The current per-feature options are: | Target | Feature | Option | Values | Default | diff --git a/skills/rulesync/faq.md b/skills/rulesync/faq.md index 9baed9248..24429c344 100644 --- a/skills/rulesync/faq.md +++ b/skills/rulesync/faq.md @@ -18,7 +18,7 @@ According to [the documentation](https://code.claude.com/docs/en/settings), this Google Antigravity has a known limitation where it won't load rules, workflows, and skills if the `.agents/rules/`, `.agents/workflows/`, and `.agents/skills/` directories are listed in `.gitignore`, even with "Agent Gitignore Access" enabled. -> **Note:** Antigravity 2.0 uses the plural `.agents/` directory by default (the `antigravity-ide` and `antigravity-cli` targets). The singular `.agent/` directory is the Antigravity 1.x layout, still read for backward compatibility by the deprecated `antigravity` alias target; apply the same workaround to those paths if you target the alias. +> **Note:** Antigravity 2.0 uses the plural `.agents/` directory by default (the `antigravity-ide` and `antigravity-cli` targets). **Workaround:** Instead of adding these directories to `.gitignore`, add them to `.git/info/exclude`: diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index b62cbf0f5..f995ca93f 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -103,11 +103,6 @@ Example: "hooks": { "afterError": [{ "command": ".rulesync/hooks/report-error.sh" }] } - }, - "geminicli": { - "hooks": { - "beforeToolSelection": [{ "command": ".rulesync/hooks/before-tool.sh" }] - } } } ``` @@ -116,7 +111,7 @@ Example: - `version`: Schema version (currently `1`). - `hooks`: Map of canonical event names to an array of hook entries. These are dispatched to every tool that supports the given event. -- `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `geminicli.hooks`, `codexcli.hooks`, `goose.hooks`, `deepagents.hooks`, `kiro.hooks`, `qwencode.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block. +- `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `codexcli.hooks`, `goose.hooks`, `deepagents.hooks`, `kiro.hooks`, `qwencode.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block. **Hook entry keys:** @@ -359,8 +354,6 @@ Based on the user's instruction, create a plan while analyzing the related files Attention, again, you are just the planner, so though you can read any files and run any commands for analysis, please don't write any code. ``` -> **Gemini CLI note (as of 2026-04-01):** Subagents are generated to `.gemini/agents/`. To enable the agents feature, set `"experimental": { "enableAgents": true }` in your `.gemini/settings.json`. - > **Qwen Code note:** Subagents are emitted as Markdown + YAML frontmatter under `.qwen/agents/` (project) and `~/.qwen/agents/` (user/global, via `--global`); the body is the subagent's system prompt. Besides the shared `name`/`description`, the `qwencode:` block accepts these optional fields (all preserved on round-trip): `model`, `approvalMode` (`default` | `plan` | `auto-edit` | `yolo` | `bubble`), `tools` (allowlist), `disallowedTools` (denylist), `maxTurns`, `color`, `mcpServers` (per-agent MCP overrides), and `hooks` (per-agent hook registrations). See the [Qwen Code sub-agents docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/sub-agents.md). > **Cline note:** Cline file-based agents are emitted as YAML files (`.yaml`) into `.cline/agents/` (project) and `~/.cline/agents/` (global, via `--global`). The file is a YAML frontmatter block (`name` required, `description`) followed by the system prompt body, matching Cline's agent config loader. @@ -727,8 +720,7 @@ credentials/ Most tools get a dedicated ignore file (for example `.cursorignore`, `.geminiignore`, `.clineignore`). Antigravity CLI is built on the same engine -as Gemini CLI, so it shares the `.geminiignore` file (enabling either target -writes it). Claude Code is the exception: it does not +as Gemini CLI, so it reads the project-root `.geminiignore` file. Claude Code is the exception: it does not read a separate ignore file, so Rulesync writes the deny list into Claude Code's settings file as `permissions.deny` entries (`Read()`). @@ -834,19 +826,6 @@ Rulesync always emits `":minimal" = "read"` in the generated filesystem table. T `network.mode`, `network.unix_sockets`, and `description` have no equivalent in Rulesync's canonical permissions model and are not generated. If an existing `.codex/config.toml` already contains these fields on the `rulesync` profile, Rulesync preserves them on regeneration. Note that `filesystem`, `network.enabled`, `network.domains`, and `extends` are always managed by Rulesync (derived from `edit`/`write`/`webfetch` rules), so hand-authored values in those fields will be replaced on regeneration. -For Gemini CLI, this generates a Policy Engine file at `.gemini/policies/rulesync.toml` (project mode) or `~/.gemini/policies/rulesync.toml` (global mode). Gemini CLI auto-discovers any `*.toml` file under the `policies/` directory, so no `settings.json` modification is required. **Only `--global` output is effective:** Gemini CLI's Policy Engine documents the Workspace tier (project-level `.gemini/policies/`) as **non-functional** — policies placed there have no effect, and only the User tier (`~/.gemini/policies/`, written with `--global`) is honored. Rulesync still emits the project-scope file but logs a warning that it is inert; generate with `--global` for an effective policy. - -- `allow` / `deny` / `ask` rules are converted into Policy Engine `decision` values `allow` / `deny` / `ask_user` -- Rule `priority` is assigned per decision so that `deny` (999) beats `ask_user` (500) beats `allow` (1) in the engine's first-match ordering. This matches intuitive allow-with-narrow-deny authoring (e.g. `bash: { "git *": "allow", "git push --force *": "deny" }`) without relying on array order. The values stay within the Policy Engine's documented `0`–`999` per-rule priority range, with `deny` at the top of the band so a hand-authored rule in a sibling `.toml` under `policies/` is unlikely to outrank a rulesync-managed deny by accident. -- `bash` rules are generated with `toolName = "run_shell_command"`. When the pattern ends with a trailing ` *` (or has no glob metacharacters), the rule uses `commandPrefix` with the trailing ` *` stripped, so `"git *"` and `"git"` both serialize as `commandPrefix = "git"`. The reverse import canonicalizes these to `" *"`. -- When a `bash` pattern contains interior glob metacharacters (anything other than a trailing ` *`, e.g. `"rm -rf /tmp/*"`), rulesync emits `argsPattern` with a `"command":"` JSON-anchor instead of `commandPrefix`, because Gemini CLI treats `commandPrefix` as a literal string prefix. -- Non-`bash` rules are generated with `toolName` + `argsPattern`. The pattern is anchored at both ends of the JSON string value (leading `"` and trailing `\"`) so a match cannot leak across JSON fields. Glob translation: `*` → `[^/\"]*` (single segment), `**` → `[^\"]*` (cross segment but still inside the string), `?` → `[^/\"]` (single non-separator character). Glob character classes (e.g. `[abc]`) are emitted as regex literals (the brackets themselves become `\[` / `\]`), because a translated class such as `[^a]` or `[!-~]` can bypass the JSON-boundary guard. -- Patterns that contain an unescaped `"` or `\` are skipped with a warning, because smol-toml escaping would let the pattern hijack the surrounding regex anchor and silently disable deny rules. -- Empty patterns (`""`) are skipped with a warning, since they would match every invocation on `bash` and never match anything on other tools. -- `bash` patterns `*` and `**` are skipped when paired with `allow` or `deny`, because either would silently grant or revoke permission for every shell command. They are still honored with `ask` (interactive prompt on every invocation). -- Imported policy rules whose `toolName` maps to a reserved JavaScript object key (`__proto__`, `constructor`, `prototype`) are skipped with a warning to prevent prototype pollution when round-tripping untrusted TOML. -- Tool categories are mapped as: `bash` → `run_shell_command`, `read` → `read_file`, `edit` → `replace`, `write` → `write_file`, `webfetch` → `web_fetch` - For Kiro, this generates tool permission settings in `.kiro/agents/default.json` (project mode): - `bash` maps to `toolsSettings.shell.allowedCommands` / `toolsSettings.shell.deniedCommands` diff --git a/skills/rulesync/geminicli-to-antigravity-cli.md b/skills/rulesync/geminicli-to-antigravity-cli.md deleted file mode 100644 index e0231d6c9..000000000 --- a/skills/rulesync/geminicli-to-antigravity-cli.md +++ /dev/null @@ -1,115 +0,0 @@ -# Gemini CLI → Antigravity CLI Migration - -Google is **retiring Gemini CLI on June 18, 2026**. On that date, Gemini CLI and -the Gemini Code Assist IDE extensions stop serving requests for Google AI -Pro/Ultra and free Gemini Code Assist for individuals. **Enterprise** plans -(Gemini Code Assist Standard/Enterprise, and GCA for GitHub via Google Cloud) -are **unaffected**. - -The successor is the **Antigravity CLI** (`agy`), the Go-based CLI that shipped -with Antigravity 2.0 at Google I/O 2026. It carries over the most critical -features of Gemini CLI — Agent Skills, Hooks, MCP, and rules/context files — -though Google has stated there is **no 1:1 feature parity right out of the -gate**. - -This guide explains how to move a rulesync configuration from the `geminicli` -target to the new `antigravity-cli` target. - -## `geminicli` is not removed - -Rulesync keeps the `geminicli` target: - -- Enterprise access to Gemini CLI continues past June 18, 2026. -- Countless existing `GEMINI.md` / `.gemini/` repositories still depend on it. - -`geminicli` is simply **marked deprecated** in the -[supported tools matrix](../reference/supported-tools.md). New projects should -prefer `antigravity-cli`; existing projects can keep generating both during a -transition. - -## Feature mapping - -| Feature | `geminicli` | `antigravity-cli` | -| ----------- | -------------------------------------- | ------------------------------------------------------------------------------------------- | -| rules | root `GEMINI.md` + `.gemini/memories/` | root `AGENTS.md` + `.agents/rules/`; global `~/.gemini/GEMINI.md` | -| skills | `.gemini/skills/` | `.agents/skills/`; global `~/.gemini/antigravity-cli/skills/` | -| mcp | `.gemini/settings.json` (`mcpServers`) | `.agents/mcp_config.json`; global `~/.gemini/config/mcp_config.json` (shared config dir) | -| hooks | `.gemini/` (Gemini hook shape) | `.agents/hooks.json`; global `~/.gemini/config/hooks.json` (Claude-Code-like matcher shape) | -| permissions | `.gemini/settings.json` | global `~/.gemini/antigravity-cli/settings.json` (`permissions.allow`/`ask`/`deny`) | -| commands | `.gemini/commands/` (TOML) | **not yet supported** — Antigravity CLI exposes slash commands via skills | -| subagents | `.gemini/agents/` | **not yet supported** — CLI subagents are only definable via plugin bundles | - -### Notable differences - -- **Root rules file**: `antigravity-cli` emits the project root rule as the - cross-tool **`AGENTS.md`** (matching `antigravity-ide`), not `GEMINI.md`. The - CLI reads both, with the Gemini-lineage discovery order `AGENTS.md`, - `CONTEXT.md`, `GEMINI.md`. If you previously generated `antigravity-cli` output - and have a generated root `GEMINI.md`, rulesync no longer manages it — delete - the stale `GEMINI.md` manually after regenerating. Global scope is unchanged - (`~/.gemini/GEMINI.md`). -- **MCP**: Antigravity uses `serverUrl` (not `url`) for HTTP servers and honors - a `disabledTools` array. Rulesync emits the Antigravity-compatible shape - automatically. -- **Hooks**: Antigravity uses a Claude-Code-like `hooks.json` with a matcher - shape, **not** the Gemini CLI hook format. The event map is nested under a - generated `rulesync` hook name (`{ "rulesync": { "": [...] } }`). - Rulesync translates five events for Antigravity: `preToolUse`, `postToolUse`, - `preModelInvocation` (→ `PreInvocation`), `postModelInvocation` - (→ `PostInvocation`), and `stop`; the model-invocation events and `stop` are - matcher-less handler lists. -- **Permissions**: The Antigravity CLI reads permissions only from the global - `~/.gemini/antigravity-cli/settings.json`; there is no documented - workspace-scoped permissions file, so rulesync generates this file in - **global mode only**. The canonical `bash` tool maps to Antigravity's - `command` tool name. -- **Commands / subagents**: These are intentionally out of scope for - `antigravity-cli` today. Antigravity surfaces slash commands through skills. - CLI subagents are defined and managed at runtime (the interactive `/agents` - panel and the orchestrator's `invoke_subagent` / `define_subagent` tools), and - the only file-based way to ship one is bundled inside a plugin (a namespaced - package of skills + subagents + rules + MCP + hooks). There is no standalone, - declarative per-agent file (analogous to `geminicli`'s `.gemini/agents/`) that - rulesync could generate, and the plugin bundle does not map cleanly onto - rulesync's per-feature model. Keep generating subagents with `geminicli` if - you still rely on them. - -## Updating `rulesync.jsonc` - -Replace the `geminicli` target with `antigravity-cli`. For example: - -```jsonc -{ - "targets": { - // Before - // "geminicli": { "rules": true, "skills": true, "mcp": true, "hooks": true, "permissions": true }, - - // After - "antigravity-cli": { - "rules": true, - "skills": true, - "mcp": true, - "hooks": true, - "permissions": true, - }, - }, -} -``` - -To generate global-scope files (including the permissions file, which is -global-only), run with `--global`: - -```bash -rulesync generate --targets antigravity-cli --global -``` - -You can keep both targets enabled during a transition so the same `.rulesync/` -sources fan out to both `.gemini/` (Gemini CLI) and `.agents/` (Antigravity -CLI) trees. - -## See also - -- [Supported Tools and Features](../reference/supported-tools.md) -- [Global Mode](./global-mode.md) -- Google's official migration guide: -- Announcement: diff --git a/skills/rulesync/programmatic-api.md b/skills/rulesync/programmatic-api.md index 5932fe4d1..522231eb9 100644 --- a/skills/rulesync/programmatic-api.md +++ b/skills/rulesync/programmatic-api.md @@ -43,7 +43,6 @@ Generates configuration files for the specified targets and features. | `targets` | `ToolTarget[]` | from config file | Tools to generate configurations for | | `features` | `Feature[]` | from config file | Features to generate | | `outputRoots` | `string[]` | `[process.cwd()]` | Output root directories to generate files into | -| `baseDirs` | `string[]` | — | **Deprecated** alias of `outputRoots`. Still accepted for backward compatibility; emits a one-shot deprecation warning. Will be removed in a future major release. | | `inputRoot` | `string` | `process.cwd()` | Directory containing the `.rulesync/` source files. Output still goes to each `outputRoots` entry; only the input source root is redirected. Mirrors the CLI's `--input-root`. | | `configPath` | `string` | auto-detected | Path to `rulesync.jsonc` | | `verbose` | `boolean` | `false` | Enable verbose logging | diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index f5e625c02..7193074eb 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -11,7 +11,6 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Amp | amp | ✅ 🌏 | | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Gemini CLI ⚠️ | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | Goose | goose | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | 🌏 | @@ -34,7 +33,6 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Kiro IDE | kiro-ide | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ | ✅ | ✅ | | ✅ | | Google Antigravity IDE | antigravity-ide | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ | | Google Antigravity CLI | antigravity-cli | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | | | ✅ 🌏 | ✅ 🌏 | 🌏 | -| Google Antigravity ⚠️ | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | | | JetBrains AI Assistant | aiassistant | ✅ | ✅ | | | | ✅ | | | | JetBrains Junie | junie | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | 🌏 | | | AugmentCode | augmentcode | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | @@ -54,6 +52,5 @@ Rulesync supports both **generation** and **import** for All of the major AI cod ## Deprecation notes -- **Gemini CLI (`geminicli`)** — Google is retiring Gemini CLI on **June 18, 2026**, when it stops serving requests for Google AI Pro/Ultra and free Gemini Code Assist for individuals (Enterprise plans are unaffected). The successor is the **Antigravity CLI (`antigravity-cli`)**. `geminicli` is **not** removed from rulesync — Enterprise access continues and existing `GEMINI.md`/`.gemini/` repositories still rely on it — but new projects should prefer `antigravity-cli`. See the [Gemini CLI → Antigravity CLI migration guide](../guide/geminicli-to-antigravity-cli.md). -- **Google Antigravity (`antigravity`)** — Antigravity 2.0 splits into two products: the desktop **`antigravity-ide`** and the **`antigravity-cli`** (`agy`). The legacy `antigravity` target is now a **deprecated alias for `antigravity-ide`** that keeps its original `.agent/` (singular) paths for backward compatibility. Migrate to `antigravity-ide` (desktop IDE) or `antigravity-cli` (CLI). As of Antigravity 2.0 the IDE reads its global MCP config and skills from the shared `~/.gemini/config/` tree — `~/.gemini/config/mcp_config.json` and `~/.gemini/config/skills/`, matching the current [MCP](https://antigravity.google/docs/mcp) and [Skills](https://antigravity.google/docs/skills) docs. The `antigravity-cli` global MCP config also lives in the shared `~/.gemini/config/mcp_config.json`, while the CLI keeps its own global skills tree at `~/.gemini/antigravity-cli/skills/`. Both targets also intentionally **share** the global rule file `~/.gemini/GEMINI.md` and the global hooks file `~/.gemini/config/hooks.json` — enabling both targets in `--global` mode writes those shared files once. For project-scope rules, **both `antigravity-ide` and `antigravity-cli`** emit the root rule as a plain cross-tool **`AGENTS.md`** at the project root (the Gemini-lineage discovery order is `AGENTS.md`, `CONTEXT.md`, `GEMINI.md`; the IDE has read `AGENTS.md` since v1.20.3) and non-root rules under `.agents/rules/` (the IDE adds trigger frontmatter to non-root rules; the CLI keeps them as plain markdown). +- **Google Antigravity (`antigravity-ide` / `antigravity-cli`)** — Antigravity 2.0 splits into two products: the desktop **`antigravity-ide`** and the **`antigravity-cli`** (`agy`). As of Antigravity 2.0 the IDE reads its global MCP config and skills from the shared `~/.gemini/config/` tree — `~/.gemini/config/mcp_config.json` and `~/.gemini/config/skills/`, matching the current [MCP](https://antigravity.google/docs/mcp) and [Skills](https://antigravity.google/docs/skills) docs. The `antigravity-cli` global MCP config also lives in the shared `~/.gemini/config/mcp_config.json`, while the CLI keeps its own global skills tree at `~/.gemini/antigravity-cli/skills/`. Both targets also intentionally **share** the global rule file `~/.gemini/GEMINI.md` and the global hooks file `~/.gemini/config/hooks.json` — enabling both targets in `--global` mode writes those shared files once. For project-scope rules, **both `antigravity-ide` and `antigravity-cli`** emit the root rule as a plain cross-tool **`AGENTS.md`** at the project root (the Gemini-lineage discovery order is `AGENTS.md`, `CONTEXT.md`, `GEMINI.md`; the IDE has read `AGENTS.md` since v1.20.3) and non-root rules under `.agents/rules/` (the IDE adds trigger frontmatter to non-root rules; the CLI keeps them as plain markdown). - **Kiro (`kiro`)** — Kiro ships as two products with diverging config formats: the **Kiro IDE** reads Markdown subagents (`.kiro/agents/*.md`) and `.kiro/hooks/*.kiro.hook` agent hooks, while the **Kiro CLI** reads JSON agent-config subagents (`.kiro/agents/*.json`) and agent hooks in `.kiro/agents/default.json`. A single target cannot emit both faithfully, so `kiro` is split into **`kiro-cli`** and **`kiro-ide`**. The legacy `kiro` target is kept as a **deprecated alias** (its current mixed output is unchanged for backward compatibility). Shared surfaces (steering rules with `inclusion`, `.kiro/settings/mcp.json`, `.kiro/prompts/` commands, `.kiro/skills/`, `.kiroignore`, permissions) are identical between the two; they differ only in **subagents** (`.md` vs `.json`). Kiro IDE **hooks** (`.kiro/hooks/*.kiro.hook`) require multi-file output that the current single-file hooks pipeline does not yet emit, so `kiro-ide` does not support `hooks` (use `kiro-cli` for agent hooks). diff --git a/src/cli/commands/generate.test.ts b/src/cli/commands/generate.test.ts index 8980b75a3..ac855e351 100644 --- a/src/cli/commands/generate.test.ts +++ b/src/cli/commands/generate.test.ts @@ -152,112 +152,6 @@ describe("generateCommand", () => { expect(mockLogger.debug).toHaveBeenCalledWith("Generating files..."); }); - - it("should map deprecated --base-dir (singular) to outputRoots on the resolver call", async () => { - const options: GenerateOptions = { baseDir: ["a", "b"] }; - - await generateCommand(mockLogger, options); - - expect(ConfigResolver.resolve).toHaveBeenCalledWith( - expect.objectContaining({ outputRoots: ["a", "b"] }), - { logger: mockLogger }, - ); - }); - - it("should warn that --base-dir is deprecated whenever it is supplied", async () => { - const options: GenerateOptions = { baseDir: ["a"] }; - - await generateCommand(mockLogger, options); - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining("--base-dir is deprecated"), - ); - }); - - it("should prefer outputRoots over deprecated baseDir when both are provided", async () => { - const options: GenerateOptions = { baseDir: ["a"], outputRoots: ["b"] }; - - await generateCommand(mockLogger, options); - - expect(ConfigResolver.resolve).toHaveBeenCalledWith( - expect.objectContaining({ outputRoots: ["b"] }), - { logger: mockLogger }, - ); - }); - - it("should warn when --base-dir and --output-roots disagree on non-empty values", async () => { - const options: GenerateOptions = { baseDir: ["a"], outputRoots: ["b"] }; - - await generateCommand(mockLogger, options); - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining("Both '--output-roots' and '--base-dir'"), - ); - }); - - it("should not warn about override when --base-dir and --output-roots match exactly", async () => { - const options: GenerateOptions = { baseDir: ["a", "b"], outputRoots: ["a", "b"] }; - - await generateCommand(mockLogger, options); - - expect(mockLogger.warn).not.toHaveBeenCalledWith( - expect.stringContaining("Both '--output-roots' and '--base-dir'"), - ); - }); - - it("should not warn about override when --base-dir and --output-roots match as a set (different order)", async () => { - // Order is irrelevant — `outputRoots: ["a","b"]` and `baseDir: ["b","a"]` - // describe the same set of output roots, so no override is happening. - const options: GenerateOptions = { baseDir: ["b", "a"], outputRoots: ["a", "b"] }; - - await generateCommand(mockLogger, options); - - expect(mockLogger.warn).not.toHaveBeenCalledWith( - expect.stringContaining("Both '--output-roots' and '--base-dir'"), - ); - }); - - it("should warn about override when --base-dir and --output-roots differ as sets", async () => { - // Different sets — `["a"]` vs `["a","b"]` — must trigger the override - // warning even though one is a subset of the other. - const options: GenerateOptions = { baseDir: ["a"], outputRoots: ["a", "b"] }; - - await generateCommand(mockLogger, options); - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining("Both '--output-roots' and '--base-dir'"), - ); - }); - - it("should not warn about override when only deprecated baseDir is provided", async () => { - const options: GenerateOptions = { baseDir: ["a"] }; - - await generateCommand(mockLogger, options); - - expect(mockLogger.warn).not.toHaveBeenCalledWith( - expect.stringContaining("Both '--output-roots' and '--base-dir'"), - ); - }); - - it("should fall back to deprecated baseDir when outputRoots is an empty array", async () => { - // A programmatic caller passing `outputRoots: []` together with a - // populated `baseDir` must not silently lose the alias's values to - // the empty array. The empty array is treated as "not provided" so - // the deprecated alias still flows through to the resolver. - const options: GenerateOptions = { baseDir: ["a", "b"], outputRoots: [] }; - - await generateCommand(mockLogger, options); - - expect(ConfigResolver.resolve).toHaveBeenCalledWith( - expect.objectContaining({ outputRoots: ["a", "b"] }), - { logger: mockLogger }, - ); - // The override warning must NOT fire here because the empty array is - // treated as "not provided" — there is nothing to disagree about. - expect(mockLogger.warn).not.toHaveBeenCalledWith( - expect.stringContaining("Both '--output-roots' and '--base-dir'"), - ); - }); }); describe("rulesync directory check", () => { diff --git a/src/cli/commands/generate.ts b/src/cli/commands/generate.ts index 1a39ca434..cab7a49ca 100644 --- a/src/cli/commands/generate.ts +++ b/src/cli/commands/generate.ts @@ -4,30 +4,7 @@ import { CLIError, ErrorCodes } from "../../types/json-output.js"; import type { Logger } from "../../utils/logger.js"; import { calculateTotalCount } from "../../utils/result.js"; -export type GenerateOptions = ConfigResolverResolveParams & { - // Commander maps `--base-dir ` to `baseDir` (camelCase, singular) - // while the resolver canonical field is `outputRoots` (plural). The CLI - // flag is a deprecated alias retained for backward compatibility — accept - // the CLI shape here and normalize at the command boundary, emitting a - // one-shot deprecation warning when it is used. - baseDir?: string[]; -}; - -/** - * Compares two output-root lists as sets — order-insensitive and - * duplicate-insensitive. Used to decide whether `--base-dir` and - * `--output-roots` actually differ. Identical sets like `["a", "b"]` vs - * `["b", "a"]` should NOT trigger the override warning. - */ -function sameDirSets(a: readonly string[], b: readonly string[]): boolean { - const aSet = new Set(a); - const bSet = new Set(b); - if (aSet.size !== bSet.size) return false; - for (const v of aSet) { - if (!bSet.has(v)) return false; - } - return true; -} +export type GenerateOptions = ConfigResolverResolveParams; /** * Log feature generation result with appropriate prefix based on dry run mode. @@ -55,55 +32,6 @@ function logFeatureResult( } } -/** - * Resolve the effective output roots from the canonical `--output-roots` and - * the deprecated `--base-dir` alias, emitting the relevant deprecation / - * override warnings as a side effect. Returns the resolved output-root list. - */ -function resolveOutputRoots({ - logger, - baseDir, - outputRoots, -}: { - logger: Logger; - baseDir: string[] | undefined; - outputRoots: string[] | undefined; -}): string[] | undefined { - // The deprecated `--base-dir` CLI flag is accepted as an alias of - // `--output-roots`. Emit a deprecation warning whenever it is used so the - // user sees a clear migration prompt at the call site. When both are - // supplied with non-empty, differing values, prefer `--output-roots` but - // also surface the override to the user. Identical (set-equal, order- - // insensitive) or empty inputs are silently merged. - if (baseDir !== undefined) { - logger.warn( - "--base-dir is deprecated; use --output-roots instead. " + - "It will be removed in a future major release.", - ); - } - // Treat `outputRoots: []` as "not provided" so a programmatic caller - // passing an empty array does not silently win over a non-empty `baseDir`. - // Without this, `outputRoots ?? baseDir` would resolve to `[]` and override - // the deprecated alias even when the alias carried real values. - const outputRootsResolved = - outputRoots !== undefined && outputRoots.length > 0 ? outputRoots : baseDir; - if ( - baseDir !== undefined && - outputRoots !== undefined && - baseDir.length > 0 && - outputRoots.length > 0 && - !sameDirSets(outputRoots, baseDir) - ) { - logger.warn( - `Both '--output-roots' and '--base-dir' were provided with differing ` + - `values; using '--output-roots' (${JSON.stringify(outputRoots)}) and ` + - `ignoring '--base-dir' (${JSON.stringify(baseDir)}).`, - ); - } - - return outputRootsResolved; -} - const FEATURE_DEBUG_MESSAGES: Record = { ignore: "Generating ignore files...", mcp: "Generating MCP files...", @@ -159,14 +87,7 @@ function buildSummaryParts(result: GenerateResult): string[] { } export async function generateCommand(logger: Logger, options: GenerateOptions): Promise { - const { baseDir, outputRoots, ...rest } = options; - - const outputRootsResolved = resolveOutputRoots({ logger, baseDir, outputRoots }); - - const config = await ConfigResolver.resolve( - { ...rest, outputRoots: outputRootsResolved }, - { logger }, - ); + const config = await ConfigResolver.resolve(options, { logger }); const check = config.getCheck(); diff --git a/src/cli/commands/gitignore-derive.ts b/src/cli/commands/gitignore-derive.ts index db260c3f7..ef949d3ea 100644 --- a/src/cli/commands/gitignore-derive.ts +++ b/src/cli/commands/gitignore-derive.ts @@ -11,12 +11,10 @@ export type GitignoreEntryTag = { }; // Targets excluded from derivation: they don't generate project files -// (agentsskills), are deprecated aliases whose outputs are covered elsewhere -// (augmentcode-legacy → augmentcode, claudecode-legacy → claudecode), or — for -// `antigravity` (legacy) — write to a `.agent/` tree rulesync does not generate. +// (agentsskills) or are deprecated aliases whose outputs are covered elsewhere +// (augmentcode-legacy → augmentcode, claudecode-legacy → claudecode). const TARGETS_NOT_DERIVED: ReadonlySet = new Set([ "agentsskills", - "antigravity", "augmentcode-legacy", "claudecode-legacy", ]); diff --git a/src/cli/commands/gitignore-entries.test.ts b/src/cli/commands/gitignore-entries.test.ts index 09140ba25..cc2723eaf 100644 --- a/src/cli/commands/gitignore-entries.test.ts +++ b/src/cli/commands/gitignore-entries.test.ts @@ -20,7 +20,6 @@ const logger = createMockLogger(); // lands in the user-owned `.amp/settings.{json,jsonc}`, which is not gitignored. const TARGETS_WITHOUT_GITIGNORE_ENTRIES = new Set([ "agentsskills", - "antigravity", "augmentcode-legacy", "claudecode-legacy", ]); @@ -201,7 +200,7 @@ describe("filterGitignoreEntries", () => { // non-root rules under .agents/rules/. expect(result).toContain("**/AGENTS.md"); expect(result).toContain("**/.agents/rules/"); - // GEMINI.md (a geminicli-only entry) must NOT be included for this target. + // GEMINI.md must NOT be included for this target. expect(result).not.toContain("**/GEMINI.md"); }); @@ -212,7 +211,7 @@ describe("filterGitignoreEntries", () => { // antigravity-ide), not GEMINI.md, plus non-root rules under .agents/rules/. expect(result).toContain("**/AGENTS.md"); expect(result).toContain("**/.agents/rules/"); - // The project root is no longer GEMINI.md (that is a geminicli-only entry). + // The project root is AGENTS.md, not GEMINI.md. expect(result).not.toContain("**/GEMINI.md"); }); @@ -303,124 +302,6 @@ describe("filterGitignoreEntries", () => { }); }); - describe("features as object format (per-target)", () => { - it("should apply per-target feature filtering", () => { - const result = filterGitignoreEntries({ - features: { - claudecode: ["rules"], - copilot: ["commands"], - }, - }); - - // claudecode rules - expect(result).toContain("**/CLAUDE.md"); - expect(result).toContain("**/.claude/rules/"); - - // copilot commands - expect(result).toContain("**/.github/prompts/"); - - // Shared copilot/copilotcli rule entries stay included because copilotcli - // is not restricted in this per-target feature map. - expect(result).toContain("**/.github/copilot-instructions.md"); - - // claudecode commands should NOT be included - expect(result).not.toContain("**/.claude/commands/"); - - // Targets not in the object should include all features - expect(result).toContain("**/.cursor/rules/"); - expect(result).toContain("**/.cursorignore"); - }); - - it("should support wildcard in per-target features", () => { - const result = filterGitignoreEntries({ - features: { - claudecode: ["*"], - copilot: ["rules"], - }, - }); - - // claudecode all features - expect(result).toContain("**/CLAUDE.md"); - expect(result).toContain("**/.claude/commands/"); - expect(result).toContain("**/.mcp.json"); - - // copilot rules only - expect(result).toContain("**/.github/copilot-instructions.md"); - expect(result).not.toContain("**/.github/prompts/"); - }); - - it("should combine with target filtering", () => { - const result = filterGitignoreEntries({ - targets: ["claudecode", "copilot"], - features: { - claudecode: ["rules"], - copilot: ["commands"], - }, - }); - - expect(result).toContain("**/CLAUDE.md"); - expect(result).not.toContain("**/.claude/commands/"); - expect(result).toContain("**/.github/prompts/"); - expect(result).not.toContain("**/.github/copilot-instructions.md"); - expect(result).not.toContain("**/.cursor/"); - }); - - it("should include shared entries when copilotcli enables matching features", () => { - const result = filterGitignoreEntries({ - features: { - copilot: ["commands"], - copilotcli: ["rules"], - }, - }); - - expect(result).toContain("**/.github/copilot-instructions.md"); - expect(result).toContain("**/.github/instructions/"); - expect(result).toContain("**/.github/prompts/"); - }); - }); - - describe("features as per-feature object format (per-target)", () => { - it("should apply per-feature object form filtering", () => { - const result = filterGitignoreEntries({ - features: { - claudecode: { - rules: true, - mcp: { someOption: true }, - commands: false, - }, - }, - }); - - // claudecode rules enabled (boolean true) - expect(result).toContain("**/CLAUDE.md"); - expect(result).toContain("**/.claude/rules/"); - - // claudecode mcp enabled (via options object) - expect(result).toContain("**/.mcp.json"); - - // claudecode commands disabled (boolean false) - expect(result).not.toContain("**/.claude/commands/"); - }); - - it("should support wildcard in per-feature object form", () => { - const result = filterGitignoreEntries({ - features: { - claudecode: { "*": true }, - copilot: { rules: true }, - }, - }); - - // claudecode all features - expect(result).toContain("**/CLAUDE.md"); - expect(result).toContain("**/.claude/commands/"); - expect(result).toContain("**/.mcp.json"); - - // copilot rules only - expect(result).toContain("**/.github/copilot-instructions.md"); - expect(result).not.toContain("**/.github/prompts/"); - }); - }); - describe("validation warnings", () => { let warnSpy: ReturnType; @@ -458,45 +339,10 @@ describe("filterGitignoreEntries", () => { ); }); - it("should warn when an invalid feature is provided (object format)", () => { - filterGitignoreEntries({ - logger, - features: { claudecode: ["rules", "unknown-feat" as any] }, - }); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("Unknown feature 'unknown-feat'"), - ); - }); - it("should not warn for valid features", () => { filterGitignoreEntries({ logger, features: ["rules", "commands", "*"] }); expect(warnSpy).not.toHaveBeenCalled(); }); - - it("should deduplicate warnings for the same invalid feature across targets (object format)", () => { - filterGitignoreEntries({ - logger, - features: { - claudecode: ["bogus" as any], - copilot: ["bogus" as any], - }, - }); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown feature 'bogus'")); - }); - - it("should warn when an invalid feature key is provided (per-feature object format)", () => { - filterGitignoreEntries({ - logger, - features: { - claudecode: { rules: true, "unknown-feat": true } as any, - }, - }); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("Unknown feature 'unknown-feat'"), - ); - }); }); describe("deduplication", () => { diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index adcd872ae..9d71546a3 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -1,4 +1,3 @@ -import { GITIGNORE_DESTINATION_KEY } from "../../config/config.js"; import { CLAUDECODE_DIR, CLAUDECODE_LOCAL_RULE_FILE_NAME, @@ -9,10 +8,9 @@ import { RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rules import { ALL_FEATURES_WITH_WILDCARD, type Feature, - isFeatureValueEnabled, type RulesyncFeatures, } from "../../types/features.js"; -import { ALL_TOOL_TARGETS_WITH_WILDCARD, type ToolTarget } from "../../types/tool-targets.js"; +import { ALL_TOOL_TARGETS_WITH_WILDCARD } from "../../types/tool-targets.js"; import type { Logger } from "../../utils/logger.js"; import { deriveAllGitignoreEntries, @@ -156,46 +154,15 @@ const getSelectedGitignoreEntryTargets = ( return targets.filter((candidate) => selectedTargets.includes(candidate)); }; -const isFeatureSelectedForTarget = ( +const isFeatureSelected = ( feature: Feature | "general", - target: ToolTarget | "common", features: RulesyncFeatures | undefined, ): boolean => { if (feature === "general") return true; if (!features) return true; - - if (Array.isArray(features)) { - if (features.length === 0) return true; - if (features.includes("*")) return true; - return features.includes(feature); - } - - // Object format: per-target features - // NOTE: Unlike Config.getFeatures(target) which returns [] for missing keys, - // gitignore intentionally treats missing keys as "no restriction" (include all features). - // This is because gitignore filtering is additive — users specify which targets to restrict, - // and unmentioned targets should default to including all their entries. - if (target === "common") return true; - const targetFeatures = features[target]; - if (!targetFeatures) return true; - if (Array.isArray(targetFeatures)) { - if (targetFeatures.includes("*")) return true; - return targetFeatures.includes(feature); - } - // Per-feature object form: feature is enabled when its value is truthy - // (true or an options object). A wildcard "*" key enables all features. - if (isFeatureValueEnabled(targetFeatures["*"])) return true; - return isFeatureValueEnabled(targetFeatures[feature]); -}; - -const isFeatureSelected = ( - feature: Feature | "general", - target: GitignoreEntryTag["target"], - features: RulesyncFeatures | undefined, -): boolean => { - return normalizeGitignoreEntryTargets(target).some((candidate) => - isFeatureSelectedForTarget(feature, candidate, features), - ); + if (features.length === 0) return true; + if (features.includes("*")) return true; + return features.includes(feature); }; const warnInvalidTargets = (targets: ReadonlyArray, logger?: Logger): void => { @@ -212,32 +179,13 @@ const warnInvalidTargets = (targets: ReadonlyArray, logger?: Logger): vo const warnInvalidFeatures = (features: RulesyncFeatures, logger?: Logger): void => { const validFeatures = new Set(ALL_FEATURES_WITH_WILDCARD); const warned = new Set(); - const warnOnce = (feature: string): void => { + for (const feature of features) { if (!validFeatures.has(feature) && !warned.has(feature)) { warned.add(feature); logger?.warn( `Unknown feature '${feature}'. Valid features: ${ALL_FEATURES_WITH_WILDCARD.join(", ")}`, ); } - }; - if (Array.isArray(features)) { - for (const feature of features) { - warnOnce(feature); - } - } else { - for (const targetFeatures of Object.values(features)) { - if (!targetFeatures) continue; - if (Array.isArray(targetFeatures)) { - for (const feature of targetFeatures) { - warnOnce(feature); - } - } else { - for (const feature of Object.keys(targetFeatures)) { - if (feature === GITIGNORE_DESTINATION_KEY) continue; - warnOnce(feature); - } - } - } } }; @@ -265,7 +213,7 @@ export const resolveGitignoreEntries = ( for (const tag of GITIGNORE_ENTRY_REGISTRY) { if (!isTargetSelected(tag.target, targets)) continue; const selectedTagTargets = getSelectedGitignoreEntryTargets(tag.target, targets); - if (!isFeatureSelected(tag.feature, selectedTagTargets, features)) continue; + if (!isFeatureSelected(tag.feature, features)) continue; if (seen.has(tag.entry)) continue; seen.add(tag.entry); result.push({ diff --git a/src/cli/commands/gitignore.test.ts b/src/cli/commands/gitignore.test.ts index 1f069f8d4..5bb724039 100644 --- a/src/cli/commands/gitignore.test.ts +++ b/src/cli/commands/gitignore.test.ts @@ -76,7 +76,6 @@ describe("gitignoreCommand", () => { expect(content).toContain("**/.clinerules/workflows/"); expect(content).toContain("**/CLAUDE.md"); expect(content).toContain("**/.opencode/agents/"); - expect(content).toContain("**/.gemini/memories/"); expect(content).toContain("**/.roo/rules/"); expect(content).toContain("**/.kilo/skills/"); expect(content).toContain("**/.kilo/rules/"); @@ -295,9 +294,9 @@ dist/`; expect(mockLogger.info).toHaveBeenCalledWith( " You can add the following to .git/info/exclude instead:", ); - expect(mockLogger.info).toHaveBeenCalledWith(" **/.agent/rules/"); - expect(mockLogger.info).toHaveBeenCalledWith(" **/.agent/workflows/"); - expect(mockLogger.info).toHaveBeenCalledWith(" **/.agent/skills/"); + expect(mockLogger.info).toHaveBeenCalledWith(" **/.agents/rules/"); + expect(mockLogger.info).toHaveBeenCalledWith(" **/.agents/workflows/"); + expect(mockLogger.info).toHaveBeenCalledWith(" **/.agents/skills/"); expect(mockLogger.info).toHaveBeenCalledWith( " For more details: https://github.com/dyoshikawa/rulesync/issues/981", ); diff --git a/src/cli/commands/gitignore.ts b/src/cli/commands/gitignore.ts index 1957fc038..fc2a124eb 100644 --- a/src/cli/commands/gitignore.ts +++ b/src/cli/commands/gitignore.ts @@ -243,8 +243,8 @@ export const gitignoreCommand = async ( "💡 If you're using Google Antigravity, note that rules, workflows, and skills won't load if they're gitignored.", ); logger.info(" You can add the following to .git/info/exclude instead:"); - logger.info(" **/.agent/rules/"); - logger.info(" **/.agent/workflows/"); - logger.info(" **/.agent/skills/"); + logger.info(" **/.agents/rules/"); + logger.info(" **/.agents/workflows/"); + logger.info(" **/.agents/skills/"); logger.info(" For more details: https://github.com/dyoshikawa/rulesync/issues/981"); }; diff --git a/src/cli/commands/import.test.ts b/src/cli/commands/import.test.ts index e9e47834e..0f4880770 100644 --- a/src/cli/commands/import.test.ts +++ b/src/cli/commands/import.test.ts @@ -40,8 +40,8 @@ describe("importCommand", () => { mockLogger = createMockLogger(); // Setup processor mocks with default return values - vi.mocked(RulesProcessor.getToolTargets).mockReturnValue(["claudecode", "roo", "geminicli"]); - vi.mocked(IgnoreProcessor.getToolTargets).mockReturnValue(["claudecode", "roo", "geminicli"]); + vi.mocked(RulesProcessor.getToolTargets).mockReturnValue(["claudecode", "roo"]); + vi.mocked(IgnoreProcessor.getToolTargets).mockReturnValue(["claudecode", "roo"]); vi.mocked(McpProcessor.getToolTargets).mockReturnValue(["claudecode"]); vi.mocked(SubagentsProcessor.getToolTargets).mockReturnValue(["claudecode"]); vi.mocked(CommandsProcessor.getToolTargets).mockReturnValue(["claudecode", "roo"]); diff --git a/src/cli/commands/import.ts b/src/cli/commands/import.ts index b2d571309..17b02d610 100644 --- a/src/cli/commands/import.ts +++ b/src/cli/commands/import.ts @@ -12,14 +12,9 @@ import { calculateTotalCount } from "../../utils/result.js"; // still see the warning because `ConfigResolver.resolve` reads `configByFile` // regardless of this `Omit`. That residual warning is actionable — it tells // the user their config-file `inputRoot` is being ignored during `import`. -// -// The deprecated `baseDirs` programmatic alias is also excluded so that the -// CLI's import options never expose a deprecated field — `outputRoots` is the -// only canonical name imports were ever supposed to omit, and `baseDirs` is -// exclusively a backwards-compat input on the resolver boundary. export type ImportOptions = Omit< ConfigResolverResolveParams, - "delete" | "outputRoots" | "baseDirs" | "inputRoot" + "delete" | "outputRoots" | "inputRoot" >; export async function importCommand(logger: Logger, options: ImportOptions): Promise { diff --git a/src/cli/index.ts b/src/cli/index.ts index 5273b881c..8001a551b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -222,11 +222,6 @@ const main = async () => { "Output root directories to generate files into (comma-separated for multiple paths)", parseCommaSeparatedList, ) - .option( - "-b, --base-dir ", - "[Deprecated] Use --output-roots instead. Output root directories (comma-separated for multiple paths)", - parseCommaSeparatedList, - ) .option("-V, --verbose", "Verbose output") .option("-s, --silent", "Suppress all output") .option("-c, --config ", "Path to configuration file") diff --git a/src/config/config-resolver.test.ts b/src/config/config-resolver.test.ts index 47ba148ea..e6d0ff773 100644 --- a/src/config/config-resolver.test.ts +++ b/src/config/config-resolver.test.ts @@ -6,7 +6,6 @@ import { setupTestDirectory } from "../test-utils/test-directories.js"; import { writeFileContent } from "../utils/file.js"; import type { Logger } from "../utils/logger.js"; import { ConfigResolver } from "./config-resolver.js"; -import { resetDeprecationWarningForTests } from "./deprecation-warnings.js"; const { getHomeDirectoryMock } = vi.hoisted(() => { return { @@ -176,7 +175,6 @@ describe("config-resolver", () => { expect(configFileTargets).toContain("codexcli"); // Legacy targets are excluded from wildcard expansion (must be explicit). expect(configFileTargets).not.toContain("claudecode-legacy"); - expect(configFileTargets).not.toContain("antigravity"); }); it("keeps the full config-file target list even when CLI -t selects one target", async () => { @@ -727,226 +725,4 @@ describe("config-resolver", () => { ).rejects.toThrow(/outputRoot must not be the filesystem root/); }); }); - - describe("deprecation warning for object-form features", () => { - let warnSpy: ReturnType; - - beforeEach(() => { - resetDeprecationWarningForTests(); - warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - delete process.env.RULESYNC_SILENT_DEPRECATION; - }); - - it("emits the deprecation warning once when features is in object form", async () => { - const configContent = JSON.stringify({ - outputRoots: ["./"], - features: { claudecode: ["rules"] }, - }); - await writeFileContent(join(testDir, "rulesync.jsonc"), configContent); - - await ConfigResolver.resolve({ configPath: join(testDir, "rulesync.jsonc") }); - await ConfigResolver.resolve({ configPath: join(testDir, "rulesync.jsonc") }); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: 'features' object form"), - ); - expect(deprecationCalls).toHaveLength(1); - }); - - it("does not emit the warning when features is in array form", async () => { - const configContent = JSON.stringify({ - outputRoots: ["./"], - targets: ["claudecode"], - features: ["rules"], - }); - await writeFileContent(join(testDir, "rulesync.jsonc"), configContent); - - await ConfigResolver.resolve({ configPath: join(testDir, "rulesync.jsonc") }); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: 'features' object form"), - ); - expect(deprecationCalls).toHaveLength(0); - }); - - it("suppresses the warning when RULESYNC_SILENT_DEPRECATION is set", async () => { - process.env.RULESYNC_SILENT_DEPRECATION = "1"; - const configContent = JSON.stringify({ - outputRoots: ["./"], - features: { claudecode: ["rules"] }, - }); - await writeFileContent(join(testDir, "rulesync.jsonc"), configContent); - - await ConfigResolver.resolve({ configPath: join(testDir, "rulesync.jsonc") }); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: 'features' object form"), - ); - expect(deprecationCalls).toHaveLength(0); - }); - }); - - describe("deprecation alias: 'baseDirs' config field", () => { - let warnSpy: ReturnType; - - beforeEach(() => { - resetDeprecationWarningForTests(); - warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - delete process.env.RULESYNC_SILENT_DEPRECATION; - }); - - it("maps 'baseDirs' from a config file to outputRoots and emits a warning", async () => { - const configContent = JSON.stringify({ - baseDirs: [testDir], - targets: ["claudecode"], - features: ["rules"], - }); - await writeFileContent(join(testDir, "rulesync.jsonc"), configContent); - - const config = await ConfigResolver.resolve({ - configPath: join(testDir, "rulesync.jsonc"), - }); - - expect(config.getOutputRoots()).toEqual([testDir]); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: 'baseDirs' config field"), - ); - expect(deprecationCalls).toHaveLength(1); - }); - - it("prefers 'outputRoots' over 'baseDirs' when both are present in a config file", async () => { - const otherDir = join(testDir, "other"); - const configContent = JSON.stringify({ - baseDirs: [otherDir], - outputRoots: [testDir], - targets: ["claudecode"], - features: ["rules"], - }); - await writeFileContent(join(testDir, "rulesync.jsonc"), configContent); - - const config = await ConfigResolver.resolve({ - configPath: join(testDir, "rulesync.jsonc"), - }); - - expect(config.getOutputRoots()).toEqual([testDir]); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: 'baseDirs' config field"), - ); - expect(deprecationCalls).toHaveLength(1); - }); - - it("maps programmatic 'baseDirs' resolver param to outputRoots and emits a warning", async () => { - const config = await ConfigResolver.resolve({ - baseDirs: [testDir], - targets: ["claudecode"], - features: ["rules"], - }); - - expect(config.getOutputRoots()).toEqual([testDir]); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: 'baseDirs' config field"), - ); - expect(deprecationCalls).toHaveLength(1); - }); - - it("does not emit the warning when only 'outputRoots' is used", async () => { - const configContent = JSON.stringify({ - outputRoots: [testDir], - targets: ["claudecode"], - features: ["rules"], - }); - await writeFileContent(join(testDir, "rulesync.jsonc"), configContent); - - await ConfigResolver.resolve({ configPath: join(testDir, "rulesync.jsonc") }); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: 'baseDirs' config field"), - ); - expect(deprecationCalls).toHaveLength(0); - }); - - it("suppresses the warning when RULESYNC_SILENT_DEPRECATION is set", async () => { - process.env.RULESYNC_SILENT_DEPRECATION = "1"; - const configContent = JSON.stringify({ - baseDirs: [testDir], - targets: ["claudecode"], - features: ["rules"], - }); - await writeFileContent(join(testDir, "rulesync.jsonc"), configContent); - - await ConfigResolver.resolve({ configPath: join(testDir, "rulesync.jsonc") }); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: 'baseDirs' config field"), - ); - expect(deprecationCalls).toHaveLength(0); - }); - }); - - describe("deprecation alias: 'antigravity' target", () => { - let warnSpy: ReturnType; - - beforeEach(() => { - resetDeprecationWarningForTests(); - warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - delete process.env.RULESYNC_SILENT_DEPRECATION; - }); - - it("emits the deprecation warning once when the 'antigravity' target is selected", async () => { - await ConfigResolver.resolve({ - targets: ["antigravity"], - features: ["rules"], - }); - await ConfigResolver.resolve({ - targets: ["antigravity"], - features: ["rules"], - }); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: the 'antigravity' target"), - ); - expect(deprecationCalls).toHaveLength(1); - }); - - it("does not emit the warning for the split 'antigravity-ide' / 'antigravity-cli' targets", async () => { - await ConfigResolver.resolve({ - targets: ["antigravity-ide", "antigravity-cli"], - features: ["rules"], - }); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: the 'antigravity' target"), - ); - expect(deprecationCalls).toHaveLength(0); - }); - - it("suppresses the warning when RULESYNC_SILENT_DEPRECATION is set", async () => { - process.env.RULESYNC_SILENT_DEPRECATION = "1"; - - await ConfigResolver.resolve({ - targets: ["antigravity"], - features: ["rules"], - }); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: the 'antigravity' target"), - ); - expect(deprecationCalls).toHaveLength(0); - }); - }); }); diff --git a/src/config/config-resolver.ts b/src/config/config-resolver.ts index 6e5abf2ac..ef2f413eb 100644 --- a/src/config/config-resolver.ts +++ b/src/config/config-resolver.ts @@ -30,25 +30,13 @@ import { PartialConfigParams, RequiredConfigParams, } from "./config.js"; -import { - emitAntigravityAliasDeprecationWarning, - emitBaseDirsConfigFieldDeprecationWarning, - emitFeaturesObjectFormDeprecationWarning, -} from "./deprecation-warnings.js"; /** * CLI-resolvable params exclude `sources` — sources are config-file-only. - * - * `baseDirs` is a deprecated alias for `outputRoots` accepted at the resolver - * boundary for backward compatibility. When provided, the resolver emits a - * one-shot deprecation warning and maps it to `outputRoots`. If both are - * present, `outputRoots` wins. Will be removed in a future major release. */ export type ConfigResolverResolveParams = Partial< Omit & { configPath: string; - /** @deprecated Use `outputRoots` instead. */ - baseDirs: string[]; } >; @@ -90,17 +78,7 @@ const loadConfigFromFile = async (filePath: string): Promise({ return cli ?? file ?? fallback; } -/** - * Map the deprecated `baseDirs` alias onto `outputRoots`. If both are supplied, - * `outputRoots` wins; either way emit a one-shot deprecation warning so callers - * know to migrate. Returns the effective `outputRoots`. - */ -function applyDeprecatedBaseDirs({ - outputRoots, - deprecatedBaseDirs, -}: { - outputRoots: string[] | undefined; - deprecatedBaseDirs: string[] | undefined; -}): string[] | undefined { - if (deprecatedBaseDirs === undefined) { - return outputRoots; - } - emitBaseDirsConfigFieldDeprecationWarning(); - return outputRoots ?? deprecatedBaseDirs; -} - /** * Re-validate `targets`/`features` mutual-exclusivity after the base and local * config files have been merged. A base file and local file can each be valid @@ -245,9 +197,6 @@ function resolveGlobal({ * - When the user provides `targets` in object form, `features` must stay * undefined (the per-target feature config lives inside the `targets` * object); skip the `features` default. - * - When the user provides `features` in object form without `targets`, leave - * `targets` undefined so `Config.getTargets` can derive the target list from - * the `features` object keys; skip the `targets` default. * - Otherwise fall through to the array-form defaults. */ function resolveFeaturesAndTargets({ @@ -265,15 +214,9 @@ function resolveFeaturesAndTargets({ const userProvidedFeatures = features ?? configByFile.features; const userProvidedTargets = targets ?? configByFile.targets; const targetsIsObject = userProvidedTargets !== undefined && !Array.isArray(userProvidedTargets); - const featuresIsObject = - userProvidedFeatures !== undefined && !Array.isArray(userProvidedFeatures); - if (featuresIsObject) { - emitFeaturesObjectFormDeprecationWarning(); - } const resolvedFeatures = userProvidedFeatures ?? (targetsIsObject ? undefined : getDefaults().features); - const resolvedTargets = - userProvidedTargets ?? (featuresIsObject ? undefined : getDefaults().targets); + const resolvedTargets = userProvidedTargets ?? getDefaults().targets; return { resolvedFeatures, resolvedTargets }; } @@ -286,7 +229,6 @@ export class ConfigResolver { verbose, delete: isDelete, outputRoots, - baseDirs: deprecatedBaseDirs, configPath = getDefaults().configPath, global, silent, @@ -301,10 +243,6 @@ export class ConfigResolver { }: ConfigResolverResolveParams, { logger }: { logger?: Logger } = {}, ): Promise { - // Map the deprecated programmatic `baseDirs` alias to `outputRoots`. - // If both are supplied, `outputRoots` wins; either way emit a one-shot - // deprecation warning so callers know to migrate. - outputRoots = applyDeprecatedBaseDirs({ outputRoots, deprecatedBaseDirs }); // Capture cwd once at the entry point so the resolved config is // deterministic and independent of any later `process.chdir()` calls. const cwd = resolve(process.cwd()); @@ -426,12 +364,6 @@ export class ConfigResolver { configFileTargets: extractConfigFileTargets(configByFile.targets), }; const config = new Config(configParams); - // The legacy `antigravity` target is never produced by wildcard expansion - // (it lives in LEGACY_TARGETS), so its presence here means the user - // explicitly selected it. Warn that it is now an alias for `antigravity-ide`. - if (config.getTargets().includes("antigravity")) { - emitAntigravityAliasDeprecationWarning(); - } return config; } } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 0e07db78f..06e452ae0 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1,11 +1,10 @@ import { isAbsolute, resolve } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ALL_FEATURES } from "../types/features.js"; import { ALL_TOOL_TARGETS } from "../types/tool-targets.js"; import { assertTargetsFeaturesExclusive, Config, type ConfigParams } from "./config.js"; -import { resetDeprecationWarningForTests } from "./deprecation-warnings.js"; describe("Config", () => { const defaultConfig: ConfigParams = { @@ -89,7 +88,7 @@ describe("Config", () => { const targets = config.getTargets(); const expectedTargets = ALL_TOOL_TARGETS.filter( - (t) => t !== "claudecode-legacy" && t !== "augmentcode-legacy" && t !== "antigravity", + (t) => t !== "claudecode-legacy" && t !== "augmentcode-legacy", ); expect(targets).toEqual(expectedTargets); @@ -134,210 +133,6 @@ describe("Config", () => { }); }); - describe("per-target features configuration", () => { - it("should return all features when using array format with wildcard", () => { - const config = createConfig({ features: ["*"] }); - const features = config.getFeatures(); - - expect(features).toContain("rules"); - expect(features).toContain("ignore"); - expect(features).toContain("mcp"); - expect(features).toContain("commands"); - expect(features).toContain("subagents"); - expect(features).toContain("skills"); - expect(features).toContain("hooks"); - }); - - it("should return target-specific features when using object format", () => { - const config = createConfig({ - features: { - copilot: ["commands"], - agentsmd: ["rules", "mcp"], - }, - }); - - expect(config.getFeatures("copilot")).toEqual(["commands"]); - expect(config.getFeatures("agentsmd")).toEqual(["rules", "mcp"]); - }); - - it("should return empty array for target not in per-target features", () => { - const config = createConfig({ - features: { - copilot: ["commands"], - }, - }); - - expect(config.getFeatures("cursor")).toEqual([]); - }); - - it("should handle wildcard in per-target features", () => { - const config = createConfig({ - features: { - copilot: ["*"], - agentsmd: ["rules"], - }, - }); - - const copilotFeatures = config.getFeatures("copilot"); - expect(copilotFeatures).toContain("rules"); - expect(copilotFeatures).toContain("ignore"); - expect(copilotFeatures).toContain("mcp"); - expect(copilotFeatures).toContain("commands"); - expect(copilotFeatures).toContain("subagents"); - expect(copilotFeatures).toContain("skills"); - expect(copilotFeatures).toContain("hooks"); - - expect(config.getFeatures("agentsmd")).toEqual(["rules"]); - }); - - it("should collect all unique features when calling getFeatures() without target in object mode", () => { - const config = createConfig({ - features: { - copilot: ["commands", "rules"], - agentsmd: ["rules", "mcp"], - }, - }); - - const features = config.getFeatures(); - expect(features).toContain("commands"); - expect(features).toContain("rules"); - expect(features).toContain("mcp"); - expect(features).not.toContain("*"); - }); - - it("should return all features when per-target has wildcard and getFeatures() is called without target", () => { - const config = createConfig({ - features: { - copilot: ["commands"], - agentsmd: ["*"], - }, - }); - - const features = config.getFeatures(); - expect(features).toContain("rules"); - expect(features).toContain("ignore"); - expect(features).toContain("mcp"); - expect(features).toContain("commands"); - expect(features).toContain("subagents"); - expect(features).toContain("skills"); - expect(features).toContain("hooks"); - }); - - it("should correctly identify per-target features configuration", () => { - const arrayConfig = createConfig({ features: ["rules", "commands"] }); - expect(arrayConfig.hasPerTargetFeatures()).toBe(false); - - const objectConfig = createConfig({ - features: { - copilot: ["commands"], - }, - }); - expect(objectConfig.hasPerTargetFeatures()).toBe(true); - }); - }); - - describe("per-feature options object form", () => { - it("should treat truthy per-feature values as enabled features", () => { - const config = createConfig({ - features: { - claudecode: { - rules: true, - ignore: { fileMode: "local" }, - mcp: false, - }, - }, - }); - - const features = config.getFeatures("claudecode"); - expect(features).toContain("rules"); - expect(features).toContain("ignore"); - expect(features).not.toContain("mcp"); - }); - - it("should expose per-feature options via getFeatureOptions", () => { - const config = createConfig({ - features: { - claudecode: { - ignore: { fileMode: "local" }, - }, - }, - }); - - expect(config.getFeatureOptions("claudecode", "ignore")).toEqual({ - fileMode: "local", - }); - }); - - it("should return undefined for getFeatureOptions when feature is enabled with bare boolean", () => { - const config = createConfig({ - features: { - claudecode: { ignore: true }, - }, - }); - - expect(config.getFeatureOptions("claudecode", "ignore")).toBeUndefined(); - }); - - it("should return undefined for getFeatureOptions when features is array form", () => { - const config = createConfig({ - targets: ["claudecode"], - features: ["ignore"], - }); - - expect(config.getFeatureOptions("claudecode", "ignore")).toBeUndefined(); - }); - - it("should expand wildcard inside per-feature object form", () => { - const config = createConfig({ - features: { - claudecode: { "*": true }, - }, - }); - - const features = config.getFeatures("claudecode"); - expect(features).toContain("rules"); - expect(features).toContain("ignore"); - expect(features).toContain("mcp"); - expect(features).toContain("commands"); - expect(features).toContain("subagents"); - expect(features).toContain("skills"); - expect(features).toContain("hooks"); - expect(features).toContain("permissions"); - expect(features).toHaveLength(ALL_FEATURES.length); - }); - - it("should return undefined for getFeatureOptions when wildcard enables all features", () => { - const config = createConfig({ - features: { - claudecode: { "*": true }, - }, - }); - - // Wildcard `true` is a boolean, not an options object, so individual - // features should not inherit options from it. - expect(config.getFeatureOptions("claudecode", "ignore")).toBeUndefined(); - expect(config.getFeatureOptions("claudecode", "rules")).toBeUndefined(); - }); - - it("should return specific options even when wildcard is also present", () => { - const config = createConfig({ - features: { - claudecode: { - "*": true, - ignore: { fileMode: "local" }, - }, - }, - }); - - // Explicitly provided options should still be returned - expect(config.getFeatureOptions("claudecode", "ignore")).toEqual({ - fileMode: "local", - }); - // Other features enabled via wildcard have no options - expect(config.getFeatureOptions("claudecode", "rules")).toBeUndefined(); - }); - }); - describe("gitignoreTargetsOnly", () => { it("should default to true when not specified", () => { const config = createConfig(); @@ -492,7 +287,6 @@ describe("Config", () => { targets: { claudecode: ["rules"] }, }); expect(config.hasPerTargetFeatures()).toBe(true); - expect(config.hasDeprecatedFeaturesObjectForm()).toBe(false); }); it("should detect conflicting targets within the object form keys", () => { @@ -549,24 +343,6 @@ describe("Config", () => { ).toThrow(/when 'targets' is in object form, 'features' must be omitted/); }); - it("rejects object-form targets combined with object-form features", () => { - expect(() => - assertTargetsFeaturesExclusive({ - targets: { claudecode: ["rules"] }, - features: { claudecode: ["rules"] }, - }), - ).toThrow(/when 'targets' is in object form, 'features' must be omitted/); - }); - - it("rejects object-form features combined with array-form targets", () => { - expect(() => - assertTargetsFeaturesExclusive({ - targets: ["claudecode"], - features: { claudecode: ["rules"] }, - }), - ).toThrow(/when 'features' is in object form, 'targets' must be omitted/); - }); - it("accepts object-form targets alone", () => { expect(() => assertTargetsFeaturesExclusive({ @@ -575,14 +351,6 @@ describe("Config", () => { ).not.toThrow(); }); - it("accepts object-form features alone (still supported, deprecated)", () => { - expect(() => - assertTargetsFeaturesExclusive({ - features: { claudecode: ["rules"] }, - }), - ).not.toThrow(); - }); - it("accepts array-form targets with array-form features", () => { expect(() => assertTargetsFeaturesExclusive({ @@ -647,57 +415,4 @@ describe("Config", () => { expect(config.getInputRoot()).toBe(expected); }); }); - - describe("getBaseDirs (deprecated alias for getOutputRoots)", () => { - let warnSpy: ReturnType; - - beforeEach(() => { - resetDeprecationWarningForTests(); - warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - delete process.env.RULESYNC_SILENT_DEPRECATION; - }); - - it("returns the same array as getOutputRoots()", () => { - const config = createConfig({ outputRoots: ["./out-a", "./out-b"] }); - expect(config.getBaseDirs()).toEqual(config.getOutputRoots()); - expect(config.getBaseDirs()).toEqual(["./out-a", "./out-b"]); - }); - - it("emits the deprecation warning the first time it is called per process", () => { - const config = createConfig({ outputRoots: ["./out"] }); - config.getBaseDirs(); - config.getBaseDirs(); - config.getBaseDirs(); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: Config.getBaseDirs()"), - ); - expect(deprecationCalls).toHaveLength(1); - }); - - it("does not emit the warning when getOutputRoots() is called instead", () => { - const config = createConfig({ outputRoots: ["./out"] }); - config.getOutputRoots(); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: Config.getBaseDirs()"), - ); - expect(deprecationCalls).toHaveLength(0); - }); - - it("suppresses the warning when RULESYNC_SILENT_DEPRECATION is set", () => { - process.env.RULESYNC_SILENT_DEPRECATION = "1"; - const config = createConfig({ outputRoots: ["./out"] }); - config.getBaseDirs(); - - const deprecationCalls = warnSpy.mock.calls.filter((call: unknown[]) => - String(call[0]).includes("DEPRECATED: Config.getBaseDirs()"), - ); - expect(deprecationCalls).toHaveLength(0); - }); - }); }); diff --git a/src/config/config.ts b/src/config/config.ts index eaf5c746e..228849267 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -11,7 +11,6 @@ import { GitignoreDestinationSchema, isFeatureValueEnabled, PerFeatureConfig, - PerTargetFeatures, PerTargetFeaturesValue, RulesyncFeatures, RulesyncFeaturesSchema, @@ -27,7 +26,6 @@ import { ToolTargets, } from "../types/tool-targets.js"; import { hasControlCharacters } from "../utils/validation.js"; -import { emitGetBaseDirsDeprecationWarning } from "./deprecation-warnings.js"; export const GITIGNORE_DESTINATION_KEY = "gitignoreDestination"; @@ -116,16 +114,9 @@ export type PartialConfigParams = Omit; export type ConfigFile = Omit & { @@ -152,7 +143,7 @@ const CONFLICTING_TARGET_PAIRS: Array<[string, string]> = [ * Legacy targets that should NOT be included in wildcard (*) expansion. * These targets must be explicitly specified. */ -export const LEGACY_TARGETS = ["augmentcode-legacy", "claudecode-legacy", "antigravity"] as const; +export const LEGACY_TARGETS = ["augmentcode-legacy", "claudecode-legacy"] as const; /** * Expand the wildcard target (`*`) to every non-legacy tool target. Legacy @@ -167,11 +158,10 @@ export function expandWildcardTargets(): ToolTarget[] { /** * Validates that the user-authored config does not double-define the - * target set in both `targets` and `features` object forms. + * target set by combining the object form of `targets` with `features`. * - * Rules: - * - If `targets` is in object form, `features` must be omitted. - * - If `features` is in object form, `targets` must be omitted. + * Rule: if `targets` is in object form, `features` must be omitted (the + * per-target feature config lives inside the `targets` object). * * This is called on *user-authored* config (a file load or an explicit * programmatic construction) before defaults are merged in — the defaults @@ -187,7 +177,6 @@ export const assertTargetsFeaturesExclusive = ({ features?: RulesyncFeatures; }): void => { const targetsIsObject = targets !== undefined && !Array.isArray(targets); - const featuresIsObject = features !== undefined && !Array.isArray(features); if (targetsIsObject && features !== undefined) { throw new Error( @@ -195,12 +184,6 @@ export const assertTargetsFeaturesExclusive = ({ "Declare per-target features inside the 'targets' object instead.", ); } - if (featuresIsObject && targets !== undefined) { - throw new Error( - "Invalid config: when 'features' is in object form, 'targets' must be omitted. " + - "Migrate to the 'targets' object form, e.g. `targets: { claudecode: [...] }`.", - ); - } }; /** @@ -388,16 +371,6 @@ export class Config { return this.outputRoots; } - /** - * @deprecated Use {@link Config.getOutputRoots} instead. This alias remains - * for backward compatibility and will be removed in a future major release. - * Calling it emits a one-shot deprecation warning per process. - */ - public getBaseDirs(): string[] { - emitGetBaseDirsDeprecationWarning(); - return this.outputRoots; - } - /** * Filter an arbitrary string-key list down to the known `ToolTarget` set, * skipping `*` (which is only meaningful as an array element, not a key). @@ -424,14 +397,6 @@ export class Config { // object form was handled above). const arrayTargets: RulesyncTargets = Array.isArray(this.targets) ? this.targets : []; - // Object form on `features` (legacy / deprecated path): derive the - // target list from the `features` object keys. `assertTargetsFeaturesExclusive` - // guarantees that when `features` is an object, the user cannot also have - // provided `targets`, so there is no intersection to compute here. - if (!Array.isArray(this.features)) { - return Config.filterValidToolTargets(Object.keys(this.features)); - } - if (arrayTargets.includes("*")) { return expandWildcardTargets(); } @@ -457,19 +422,6 @@ export class Config { return Config.collectAllFeatures(Object.values(this.targets)); } - // Legacy object form on `features` (deprecated). - if (!Array.isArray(this.features)) { - const perTargetFeatures: PerTargetFeatures = this.features; - if (target) { - const targetFeatures = perTargetFeatures[target]; - if (!targetFeatures) { - return []; - } - return Config.normalizeTargetFeatures(targetFeatures); - } - return Config.collectAllFeatures(Object.values(perTargetFeatures)); - } - // Array format - traditional behavior if (this.features.includes("*")) { return [...ALL_FEATURES]; @@ -530,11 +482,7 @@ export class Config { * feature is not enabled for the given target. */ public getFeatureOptions(target: ToolTarget, feature: Feature): FeatureOptions | undefined { - const value = isRulesyncConfigTargetsObject(this.targets) - ? this.targets[target] - : !Array.isArray(this.features) - ? this.features[target] - : undefined; + const value = isRulesyncConfigTargetsObject(this.targets) ? this.targets[target] : undefined; if (!value || Array.isArray(value)) { return undefined; } @@ -583,15 +531,7 @@ export class Config { * Check if per-target features configuration is being used. */ public hasPerTargetFeatures(): boolean { - return isRulesyncConfigTargetsObject(this.targets) || !Array.isArray(this.features); - } - - /** - * Returns true if the deprecated object form under `features` is in use. - * Callers can use this to emit a migration warning. - */ - public hasDeprecatedFeaturesObjectForm(): boolean { - return !Array.isArray(this.features); + return isRulesyncConfigTargetsObject(this.targets); } public getVerbose(): boolean { diff --git a/src/config/deprecation-warnings.ts b/src/config/deprecation-warnings.ts deleted file mode 100644 index e762de840..000000000 --- a/src/config/deprecation-warnings.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * One-shot deprecation warnings. - * - * Each guard is emitted at most once per Node.js process to avoid repeat logs - * when the resolver is invoked from multiple commands within the same run, or - * when a programmatic `new Config(...)` call triggers the same warning path. - * - * NOTE: Vitest runs all tests within a single Node process and does not - * reset module state between test files by default, so the "once" guard - * would carry across tests. Tests that need to assert on the warning must - * call `resetDeprecationWarningForTests()` in a `beforeEach` hook. - * - * Warnings can be silenced by setting the `RULESYNC_SILENT_DEPRECATION` - * environment variable so CI pipelines that intentionally run on the - * deprecated form can opt out. - */ -let featuresObjectFormWarningEmitted = false; -let getBaseDirsWarningEmitted = false; -let baseDirsConfigFieldWarningEmitted = false; -let antigravityAliasWarningEmitted = false; - -export const emitFeaturesObjectFormDeprecationWarning = (): void => { - if (featuresObjectFormWarningEmitted) return; - if (process.env.RULESYNC_SILENT_DEPRECATION) return; - featuresObjectFormWarningEmitted = true; - // oxlint-disable-next-line no-console - console.warn( - "[rulesync] DEPRECATED: 'features' object form is deprecated. " + - "Use the new 'targets' object form instead: " + - "`targets: { claudecode: { rules: true, ignore: { fileMode: 'local' } } }`. " + - "See https://github.com/dyoshikawa/rulesync/blob/main/docs/guide/configuration.md " + - "for the migration guide.", - ); -}; - -export const emitGetBaseDirsDeprecationWarning = (): void => { - if (getBaseDirsWarningEmitted) return; - if (process.env.RULESYNC_SILENT_DEPRECATION) return; - getBaseDirsWarningEmitted = true; - // oxlint-disable-next-line no-console - console.warn( - "[rulesync] DEPRECATED: Config.getBaseDirs() is deprecated; " + - "use Config.getOutputRoots() instead. " + - "It will be removed in a future major release.", - ); -}; - -export const emitBaseDirsConfigFieldDeprecationWarning = (): void => { - if (baseDirsConfigFieldWarningEmitted) return; - if (process.env.RULESYNC_SILENT_DEPRECATION) return; - baseDirsConfigFieldWarningEmitted = true; - // oxlint-disable-next-line no-console - console.warn( - "[rulesync] DEPRECATED: 'baseDirs' config field is deprecated; " + - "use 'outputRoots' instead. " + - "It will be removed in a future major release.", - ); -}; - -export const emitAntigravityAliasDeprecationWarning = (): void => { - if (antigravityAliasWarningEmitted) return; - if (process.env.RULESYNC_SILENT_DEPRECATION) return; - antigravityAliasWarningEmitted = true; - // oxlint-disable-next-line no-console - console.warn( - "[rulesync] DEPRECATED: the 'antigravity' target is deprecated; " + - "it is now an alias for 'antigravity-ide'. " + - "Use 'antigravity-ide' (desktop IDE) or 'antigravity-cli' (the `agy` CLI) instead. " + - "It will be removed in a future major release.", - ); -}; - -/** - * Test-only helper to reset the one-shot emission guards between test runs. - * Throws in non-test environments so production bundles cannot accidentally - * reset the guards. - */ -export const resetDeprecationWarningForTests = (): void => { - if (process.env.NODE_ENV !== "test") { - throw new Error( - "resetDeprecationWarningForTests is a test-only helper; do not call it outside NODE_ENV=test.", - ); - } - featuresObjectFormWarningEmitted = false; - getBaseDirsWarningEmitted = false; - baseDirsConfigFieldWarningEmitted = false; - antigravityAliasWarningEmitted = false; -}; diff --git a/src/constants/antigravity-paths.ts b/src/constants/antigravity-paths.ts index 1f29a9f28..0a7695683 100644 --- a/src/constants/antigravity-paths.ts +++ b/src/constants/antigravity-paths.ts @@ -1,10 +1,5 @@ import { join } from "node:path"; -export const ANTIGRAVITY_LEGACY_DIR = ".agent"; -export const ANTIGRAVITY_LEGACY_RULES_DIR_PATH = join(ANTIGRAVITY_LEGACY_DIR, "rules"); -export const ANTIGRAVITY_LEGACY_COMMANDS_DIR_PATH = join(ANTIGRAVITY_LEGACY_DIR, "workflows"); -export const ANTIGRAVITY_LEGACY_SKILLS_DIR_PATH = join(ANTIGRAVITY_LEGACY_DIR, "skills"); - export const ANTIGRAVITY_DIR = ".agents"; export const ANTIGRAVITY_SKILLS_DIR_PATH = join(ANTIGRAVITY_DIR, "skills"); export const ANTIGRAVITY_MCP_FILE_NAME = "mcp_config.json"; @@ -29,8 +24,3 @@ export const ANTIGRAVITY_GLOBAL_CONFIG_DIR_PATH = join( ANTIGRAVITY_GEMINI_DIR, ANTIGRAVITY_GLOBAL_CONFIG_SUBDIR, ); -export const ANTIGRAVITY_GLOBAL_SKILLS_LEGACY_PATH = join( - ANTIGRAVITY_GEMINI_DIR, - "antigravity", - "skills", -); diff --git a/src/constants/geminicli-paths.ts b/src/constants/geminicli-paths.ts deleted file mode 100644 index bec93d851..000000000 --- a/src/constants/geminicli-paths.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { join } from "node:path"; - -export const GEMINICLI_DIR = ".gemini"; -export const GEMINICLI_MEMORIES_DIR_NAME = "memories"; -export const GEMINICLI_COMMANDS_DIR_PATH = join(GEMINICLI_DIR, "commands"); -export const GEMINICLI_SKILLS_DIR_PATH = join(GEMINICLI_DIR, "skills"); -export const GEMINICLI_AGENTS_DIR_PATH = join(GEMINICLI_DIR, "agents"); -export const GEMINICLI_POLICIES_DIR_PATH = join(GEMINICLI_DIR, "policies"); -export const GEMINICLI_MCP_FILE_NAME = "settings.json"; -export const GEMINICLI_HOOKS_FILE_NAME = "settings.json"; -export const GEMINICLI_IGNORE_FILE_NAME = ".geminiignore"; -export const GEMINICLI_RULE_FILE_NAME = "GEMINI.md"; -export const GEMINICLI_PERMISSIONS_FILE_NAME = "rulesync.toml"; diff --git a/src/e2e/e2e-commands.spec.ts b/src/e2e/e2e-commands.spec.ts index f430d76ab..9079b1d3a 100644 --- a/src/e2e/e2e-commands.spec.ts +++ b/src/e2e/e2e-commands.spec.ts @@ -18,7 +18,6 @@ describe("E2E: commands", () => { { target: "claudecode", outputPath: join(".claude", "commands", "review-pr.md") }, { target: "cursor", outputPath: join(".cursor", "commands", "review-pr.md") }, { target: "augmentcode", outputPath: join(".augment", "commands", "review-pr.md") }, - { target: "geminicli", outputPath: join(".gemini", "commands", "review-pr.toml") }, { target: "copilot", outputPath: join(".github", "prompts", "review-pr.prompt.md") }, { target: "opencode", outputPath: join(".opencode", "commands", "review-pr.md") }, { target: "cline", outputPath: join(".clinerules", "workflows", "review-pr.md") }, @@ -54,12 +53,7 @@ Check the PR diff and provide feedback. // Verify that the expected output file was generated const generatedContent = await readFileContent(join(testDir, outputPath)); - if (target === "geminicli") { - // Gemini CLI uses TOML format - expect(generatedContent).toContain('description = "Review a pull request"'); - } else { - expect(generatedContent).toContain("Check the PR diff and provide feedback."); - } + expect(generatedContent).toContain("Check the PR diff and provide feedback."); }); it.each([{ target: "agentsmd", outputPath: join(".agents", "commands", "review-pr.md") }])( @@ -89,7 +83,6 @@ Check the PR diff and provide feedback. { target: "claudecode", orphanPath: join(".claude", "commands", "orphan.md") }, { target: "cursor", orphanPath: join(".cursor", "commands", "orphan.md") }, { target: "augmentcode", orphanPath: join(".augment", "commands", "orphan.md") }, - { target: "geminicli", orphanPath: join(".gemini", "commands", "orphan.toml") }, { target: "copilot", orphanPath: join(".github", "prompts", "orphan.prompt.md") }, { target: "opencode", orphanPath: join(".opencode", "commands", "orphan.md") }, { target: "cline", orphanPath: join(".clinerules", "workflows", "orphan.md") }, @@ -192,7 +185,6 @@ describe("E2E: commands (global mode)", () => { { target: "cursor", outputPath: join(".cursor", "commands", "review-pr.md") }, { target: "augmentcode", outputPath: join(".augment", "commands", "review-pr.md") }, { target: "opencode", outputPath: join(".config", "opencode", "commands", "review-pr.md") }, - { target: "geminicli", outputPath: join(".gemini", "commands", "review-pr.toml") }, { target: "codexcli", outputPath: join(".codex", "prompts", "review-pr.md") }, { target: "cline", outputPath: join("Documents", "Cline", "Workflows", "review-pr.md") }, { target: "kilo", outputPath: join(".config", "kilo", "commands", "review-pr.md") }, @@ -243,12 +235,7 @@ Check the PR diff and provide feedback. // Verify that the expected output file was generated const generatedContent = await readFileContent(join(homeDir, outputPath)); - if (target === "geminicli") { - // Gemini CLI uses TOML format - expect(generatedContent).toContain('description = "Review a pull request"'); - } else { - expect(generatedContent).toContain("Check the PR diff and provide feedback."); - } + expect(generatedContent).toContain("Check the PR diff and provide feedback."); }); it("should ignore non-root commands in global mode", async () => { diff --git a/src/e2e/e2e-convert.spec.ts b/src/e2e/e2e-convert.spec.ts index d840659ec..ab4932593 100644 --- a/src/e2e/e2e-convert.spec.ts +++ b/src/e2e/e2e-convert.spec.ts @@ -256,19 +256,6 @@ const scenarios: ConvertScenario[] = [ }, // --- ignore --- - { - feature: "ignore", - from: "cursor", - to: "geminicli", - setup: async (dir) => { - await writeFileContent(join(dir, ".cursorignore"), "tmp/\ncredentials/\n*.secret\n"); - }, - verify: async (dir) => { - const content = await readFileContent(join(dir, ".geminiignore")); - expect(content).toContain("tmp/"); - expect(content).toContain("credentials/"); - }, - }, { feature: "ignore", from: "roo", diff --git a/src/e2e/e2e-hooks.spec.ts b/src/e2e/e2e-hooks.spec.ts index e420cce01..e94da78f9 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -14,9 +14,8 @@ import { /** * Verify that a parsed hooks config preserves the canonical command paths * configured in the rulesync source. Event-name casing/mapping varies per tool - * (e.g. claudecode uses PascalCase `Stop`, geminicli maps `stop` to - * `AfterAgent`), so checking command paths inside the serialized hooks block - * is the most tool-agnostic assertion. + * (e.g. claudecode uses PascalCase `Stop`), so checking command paths inside + * the serialized hooks block is the most tool-agnostic assertion. */ function assertHookCommandsPreserved(parsed: { hooks?: unknown }): void { expect(parsed.hooks).toBeDefined(); @@ -33,7 +32,6 @@ describe("E2E: hooks", () => { { target: "cursor", outputPath: join(".cursor", "hooks.json") }, { target: "opencode", outputPath: join(".opencode", "plugins", "rulesync-hooks.js") }, { target: "codexcli", outputPath: join(".codex", "hooks.json") }, - { target: "geminicli", outputPath: join(".gemini", "settings.json") }, { target: "qwencode", outputPath: join(".qwen", "settings.json") }, { target: "goose", @@ -134,7 +132,7 @@ describe("E2E: hooks", () => { expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh"); expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/audit.sh"); } else { - // codexcli, geminicli, factorydroid, goose: event-name casing/mapping + // codexcli, factorydroid, goose: event-name casing/mapping // varies per tool, so verify the configured hook command paths are preserved. assertHookCommandsPreserved(parsed); } @@ -254,7 +252,7 @@ describe("E2E: hooks", () => { }); it.each([ - // claudecode, geminicli, kiro use shared config files (isDeletable=false) — excluded. + // claudecode, kiro use shared config files (isDeletable=false) — excluded. // factorydroid now writes a dedicated .factory/hooks.json (isDeletable=true). { target: "cursor", orphanPath: join(".cursor", "hooks.json") }, { target: "opencode", orphanPath: join(".opencode", "plugins", "rulesync-hooks.js") }, @@ -367,17 +365,6 @@ describe("E2E: hooks (import)", () => { }, }, }, - { - target: "geminicli", - sourcePath: join(".gemini", "settings.json"), - sourceContent: { - hooks: { - sessionStart: [ - { matcher: "", hooks: [{ type: "command", command: "echo session started" }] }, - ], - }, - }, - }, { target: "factorydroid", sourcePath: join(".factory", "hooks.json"), @@ -481,7 +468,6 @@ describe("E2E: hooks (global mode)", () => { it.each([ { target: "claudecode", outputPath: join(".claude", "settings.json") }, { target: "codexcli", outputPath: join(".codex", "hooks.json") }, - { target: "geminicli", outputPath: join(".gemini", "settings.json") }, { target: "qwencode", outputPath: join(".qwen", "settings.json") }, { target: "goose", diff --git a/src/e2e/e2e-ignore.spec.ts b/src/e2e/e2e-ignore.spec.ts index ce388cdec..954f0f4db 100644 --- a/src/e2e/e2e-ignore.spec.ts +++ b/src/e2e/e2e-ignore.spec.ts @@ -17,7 +17,6 @@ describe("E2E: ignore", () => { outputPath: join(".claude", "settings.json"), format: "json" as const, }, - { target: "geminicli", outputPath: ".geminiignore", format: "plaintext" as const }, { target: "antigravity-cli", outputPath: ".geminiignore", format: "plaintext" as const }, { target: "goose", outputPath: ".gooseignore", format: "plaintext" as const }, { target: "cline", outputPath: ".clineignore", format: "plaintext" as const }, @@ -76,7 +75,6 @@ credentials/ it.each([ { target: "cursor", orphanPath: ".cursorignore" }, // claudecode uses settings.json (isDeletable=false) — excluded - { target: "geminicli", orphanPath: ".geminiignore" }, { target: "antigravity-cli", orphanPath: ".geminiignore" }, { target: "goose", orphanPath: ".gooseignore" }, { target: "cline", orphanPath: ".clineignore" }, @@ -145,7 +143,6 @@ describe("E2E: ignore (import)", () => { it.each([ { target: "cursor", sourcePath: ".cursorignore" }, - { target: "geminicli", sourcePath: ".geminiignore" }, { target: "antigravity-cli", sourcePath: ".geminiignore" }, { target: "goose", sourcePath: ".gooseignore" }, { target: "cline", sourcePath: ".clineignore" }, diff --git a/src/e2e/e2e-mcp.spec.ts b/src/e2e/e2e-mcp.spec.ts index 4aa6921f5..ecd104b1a 100644 --- a/src/e2e/e2e-mcp.spec.ts +++ b/src/e2e/e2e-mcp.spec.ts @@ -26,7 +26,6 @@ describe("E2E: mcp", () => { { target: "amp", outputPath: join(".amp", "settings.json") }, { target: "claudecode", outputPath: ".mcp.json" }, { target: "cursor", outputPath: join(".cursor", "mcp.json") }, - { target: "geminicli", outputPath: join(".gemini", "settings.json") }, { target: "qwencode", outputPath: join(".qwen", "settings.json") }, { target: "codexcli", outputPath: join(".codex", "config.toml") }, { target: "grokcli", outputPath: join(".grok", "config.toml") }, @@ -108,7 +107,7 @@ describe("E2E: mcp", () => { }); it.each([ - // amp, geminicli, codexcli, grokcli, opencode, kilo use merged config files (isDeletable=false) — excluded + // amp, codexcli, grokcli, opencode, kilo use merged config files (isDeletable=false) — excluded { target: "claudecode", orphanPath: ".mcp.json" }, { target: "cursor", orphanPath: join(".cursor", "mcp.json") }, { target: "copilot", orphanPath: join(".vscode", "mcp.json") }, @@ -161,11 +160,6 @@ describe("E2E: mcp", () => { outputPath: join(".amp", "settings.json"), content: JSON.stringify({ "amp.dangerouslyAllowAll": false, "amp.mcpServers": {} }, null, 2), }, - { - target: "geminicli", - outputPath: join(".gemini", "settings.json"), - content: JSON.stringify({ theme: "dark", mcpServers: {} }, null, 2), - }, { target: "codexcli", outputPath: join(".codex", "config.toml"), @@ -468,7 +462,6 @@ describe("E2E: mcp (global mode)", () => { { target: "augmentcode", outputPath: join(".augment", "settings.json") }, { target: "claudecode", outputPath: ".claude.json" }, { target: "cursor", outputPath: join(".cursor", "mcp.json") }, - { target: "geminicli", outputPath: join(".gemini", "settings.json") }, { target: "qwencode", outputPath: join(".qwen", "settings.json") }, { target: "goose", outputPath: join(".config", "goose", "config.yaml") }, { target: "hermesagent", outputPath: join(".hermes", "config.yaml") }, diff --git a/src/e2e/e2e-permissions.spec.ts b/src/e2e/e2e-permissions.spec.ts index 8397aac8d..e345ed69d 100644 --- a/src/e2e/e2e-permissions.spec.ts +++ b/src/e2e/e2e-permissions.spec.ts @@ -148,35 +148,6 @@ describe("E2E: permissions", () => { expect(rulesContent).toContain('decision = "forbidden"'); }); - it("should generate geminicli permissions into .gemini/policies/rulesync.toml", async () => { - const testDir = getTestDir(); - - await writeFileContent( - join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH), - JSON.stringify( - { - permission: { - bash: { "git *": "allow", "rm *": "deny" }, - read: { "src/**": "allow" }, - webfetch: { "example.com": "deny" }, - }, - }, - null, - 2, - ), - ); - - await runGenerate({ target: "geminicli", features: "permissions" }); - - const policyContent = await readFileContent( - join(testDir, ".gemini", "policies", "rulesync.toml"), - ); - expect(policyContent).toContain('toolName = "run_shell_command"'); - expect(policyContent).toContain('commandPrefix = "git"'); - expect(policyContent).toContain('decision = "deny"'); - expect(policyContent).toContain('toolName = "web_fetch"'); - }); - it("should generate cursor permissions into .cursor/cli.json", async () => { const testDir = getTestDir(); @@ -688,50 +659,6 @@ enabled = true expect(content.permission.webfetch["example.com"]).toBe("deny"); }); - it("should import geminicli permissions into .rulesync/permissions.json", async () => { - const testDir = getTestDir(); - - await writeFileContent( - join(testDir, ".gemini", "policies", "rulesync.toml"), - [ - "[[rule]]", - 'toolName = "run_shell_command"', - 'decision = "allow"', - 'commandPrefix = "git"', - "priority = 100", - "", - "[[rule]]", - 'toolName = "read_file"', - 'decision = "allow"', - 'argsPattern = "src/.*"', - "priority = 100", - "", - "[[rule]]", - 'toolName = "run_shell_command"', - 'decision = "deny"', - 'commandPrefix = "rm"', - "priority = 100", - "", - "[[rule]]", - 'toolName = "web_fetch"', - 'decision = "deny"', - 'argsPattern = "example\\\\.com"', - "priority = 100", - "", - ].join("\n"), - ); - - await runImport({ target: "geminicli", features: "permissions" }); - - const content = JSON.parse( - await readFileContent(join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH)), - ); - expect(content.permission.bash["git *"]).toBe("allow"); - expect(content.permission.read["src/**"]).toBe("allow"); - expect(content.permission.bash["rm *"]).toBe("deny"); - expect(content.permission.webfetch["example.com"]).toBe("deny"); - }); - it("should import kilo permissions into .rulesync/permissions.json", async () => { const testDir = getTestDir(); @@ -1037,41 +964,6 @@ describe("E2E: permissions (global mode)", () => { expect(rulesContent).toContain('decision = "allow"'); }); - it("should generate geminicli permissions in home directory with --global", async () => { - const projectDir = getProjectDir(); - const homeDir = getHomeDir(); - - await writeFileContent( - join(projectDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH), - JSON.stringify( - { - permission: { - bash: { "git status *": "allow" }, - read: { "src/**": "deny" }, - }, - }, - null, - 2, - ), - ); - - await runGenerate({ - target: "geminicli", - features: "permissions", - global: true, - env: { HOME_DIR: homeDir }, - }); - - const policyContent = await readFileContent( - join(homeDir, ".gemini", "policies", "rulesync.toml"), - ); - expect(policyContent).toContain('toolName = "run_shell_command"'); - expect(policyContent).toContain('commandPrefix = "git status"'); - expect(policyContent).toContain('decision = "allow"'); - expect(policyContent).toContain('toolName = "read_file"'); - expect(policyContent).toContain('decision = "deny"'); - }); - it("should generate cursor permissions in home directory with --global", async () => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index 101837aee..262f6c32c 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -30,7 +30,6 @@ describe("E2E: rules", () => { { target: "hermesagent", outputPath: ".hermes.md" }, { target: "copilot", outputPath: join(".github", "copilot-instructions.md") }, { target: "opencode", outputPath: "AGENTS.md" }, - { target: "geminicli", outputPath: "GEMINI.md" }, { target: "antigravity-cli", outputPath: "AGENTS.md" }, { target: "antigravity-ide", outputPath: "AGENTS.md" }, { target: "goose", outputPath: ".goosehints" }, @@ -554,7 +553,6 @@ describe("E2E: rules (import)", () => { importedFileName: "copilot-instructions.md", }, { target: "opencode", sourcePath: "AGENTS.md", importedFileName: "overview.md" }, - { target: "geminicli", sourcePath: "GEMINI.md", importedFileName: "overview.md" }, { target: "goose", sourcePath: ".goosehints", importedFileName: "overview.md" }, { target: "copilotcli", @@ -663,7 +661,6 @@ describe("E2E: rules (global mode)", () => { { target: "grokcli", outputPath: join(".grok", "AGENTS.md") }, { target: "amp", outputPath: join(".config", "amp", "AGENTS.md") }, { target: "cline", outputPath: join(".agents", "AGENTS.md") }, - { target: "geminicli", outputPath: join(".gemini", "GEMINI.md") }, { target: "antigravity-ide", outputPath: join(".gemini", "GEMINI.md") }, { target: "antigravity-cli", outputPath: join(".gemini", "GEMINI.md") }, { target: "goose", outputPath: join(".config", "goose", ".goosehints") }, diff --git a/src/e2e/e2e-skills.spec.ts b/src/e2e/e2e-skills.spec.ts index be0404e7d..58d4daa4e 100644 --- a/src/e2e/e2e-skills.spec.ts +++ b/src/e2e/e2e-skills.spec.ts @@ -39,10 +39,6 @@ describe("E2E: skills", () => { target: "goose", outputPath: join(".goose", "skills", "test-skill", "SKILL.md"), }, - { - target: "geminicli", - outputPath: join(".gemini", "skills", "test-skill", "SKILL.md"), - }, { target: "qwencode", outputPath: join(".qwen", "skills", "test-skill", "SKILL.md"), @@ -193,7 +189,6 @@ This is the test skill body content. { target: "claudecode", orphanPath: join(".claude", "skills", "orphan-skill", "SKILL.md") }, { target: "cursor", orphanPath: join(".cursor", "skills", "orphan-skill", "SKILL.md") }, { target: "codexcli", orphanPath: join(".agents", "skills", "orphan-skill", "SKILL.md") }, - { target: "geminicli", orphanPath: join(".gemini", "skills", "orphan-skill", "SKILL.md") }, { target: "copilot", orphanPath: join(".github", "skills", "orphan-skill", "SKILL.md") }, { target: "deepagents", orphanPath: join(".deepagents", "skills", "orphan-skill", "SKILL.md") }, { target: "cline", orphanPath: join(".cline", "skills", "orphan-skill", "SKILL.md") }, @@ -255,7 +250,6 @@ describe("E2E: skills (import)", () => { { target: "claudecode", sourcePath: join(".claude", "skills", "test-skill", "SKILL.md") }, { target: "cursor", sourcePath: join(".cursor", "skills", "test-skill", "SKILL.md") }, { target: "codexcli", sourcePath: join(".agents", "skills", "test-skill", "SKILL.md") }, - { target: "geminicli", sourcePath: join(".gemini", "skills", "test-skill", "SKILL.md") }, { target: "copilot", sourcePath: join(".github", "skills", "test-skill", "SKILL.md") }, { target: "opencode", sourcePath: join(".opencode", "skill", "test-skill", "SKILL.md") }, { target: "deepagents", sourcePath: join(".deepagents", "skills", "test-skill", "SKILL.md") }, @@ -360,10 +354,6 @@ describe("E2E: skills (global mode)", () => { target: "grokcli", outputPath: join(".grok", "skills", "test-skill", "SKILL.md"), }, - { - target: "geminicli", - outputPath: join(".gemini", "skills", "test-skill", "SKILL.md"), - }, { target: "qwencode", outputPath: join(".qwen", "skills", "test-skill", "SKILL.md"), @@ -551,10 +541,6 @@ This is the scheduled task body content. target: "cursor", excludedPath: join(".cursor", "skills", "weekly-review", "SKILL.md"), }, - { - target: "geminicli", - excludedPath: join(".gemini", "skills", "weekly-review", "SKILL.md"), - }, { target: "copilot", excludedPath: join(".github", "skills", "weekly-review", "SKILL.md"), diff --git a/src/e2e/e2e-subagents.spec.ts b/src/e2e/e2e-subagents.spec.ts index e8c9791f7..6ce3775c2 100644 --- a/src/e2e/e2e-subagents.spec.ts +++ b/src/e2e/e2e-subagents.spec.ts @@ -27,10 +27,6 @@ describe("E2E: subagents", () => { target: "cursor", outputPath: join(".cursor", "agents", "planner.md"), }, - { - target: "geminicli", - outputPath: join(".gemini", "agents", "planner.md"), - }, { target: "grokcli", outputPath: join(".grok", "agents", "planner.md"), @@ -241,7 +237,6 @@ You are a subagent-only helper. it.each([ { target: "claudecode", orphanPath: join(".claude", "agents", "orphan.md") }, { target: "cursor", orphanPath: join(".cursor", "agents", "orphan.md") }, - { target: "geminicli", orphanPath: join(".gemini", "agents", "orphan.md") }, { target: "grokcli", orphanPath: join(".grok", "agents", "orphan.md") }, { target: "codexcli", orphanPath: join(".codex", "agents", "orphan.toml") }, { target: "copilot", orphanPath: join(".github", "agents", "orphan.md") }, @@ -289,7 +284,6 @@ describe("E2E: subagents (import)", () => { it.each([ { target: "claudecode", sourcePath: join(".claude", "agents", "planner.md") }, { target: "cursor", sourcePath: join(".cursor", "agents", "planner.md") }, - { target: "geminicli", sourcePath: join(".gemini", "agents", "planner.md") }, { target: "copilot", sourcePath: join(".github", "agents", "planner.md") }, { target: "opencode", sourcePath: join(".opencode", "agents", "planner.md") }, { target: "deepagents", sourcePath: join(".deepagents", "agents", "planner", "AGENTS.md") }, @@ -448,7 +442,6 @@ describe("E2E: subagents (global mode)", () => { { target: "copilot", outputPath: join(".copilot", "agents", "planner.agent.md") }, { target: "copilotcli", outputPath: join(".copilot", "agents", "planner.agent.md") }, { target: "cursor", outputPath: join(".cursor", "agents", "planner.md") }, - { target: "geminicli", outputPath: join(".gemini", "agents", "planner.md") }, { target: "grokcli", outputPath: join(".grok", "agents", "planner.md") }, { target: "qwencode", outputPath: join(".qwen", "agents", "planner.md") }, { target: "junie", outputPath: join(".junie", "agents", "planner.md") }, diff --git a/src/features/commands/antigravity-command.test.ts b/src/features/commands/antigravity-command.test.ts deleted file mode 100644 index a76ddd7d9..000000000 --- a/src/features/commands/antigravity-command.test.ts +++ /dev/null @@ -1,691 +0,0 @@ -import { join } from "node:path"; - -import { describe, expect, it, vi } from "vitest"; - -import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; -import { setupTestDirectory } from "../../test-utils/test-directories.js"; -import { writeFileContent } from "../../utils/file.js"; -import { stringifyFrontmatter } from "../../utils/frontmatter.js"; -import { AntigravityCommand, AntigravityCommandFrontmatter } from "./antigravity-command.js"; -import { RulesyncCommand, RulesyncCommandFrontmatter } from "./rulesync-command.js"; - -describe("AntigravityCommand", () => { - describe("constructor", () => { - it("should create an AntigravityCommand with valid frontmatter", () => { - const frontmatter: AntigravityCommandFrontmatter = { - description: "Test workflow", - }; - const body = "This is a test workflow body"; - - const command = new AntigravityCommand({ - outputRoot: ".", - relativeDirPath: ".agent/workflows", - relativeFilePath: "test.md", - frontmatter, - body, - fileContent: stringifyFrontmatter(body, frontmatter), - }); - - expect(command.getBody()).toBe(body); - expect(command.getFrontmatter()).toEqual(frontmatter); - expect(command.getRelativeDirPath()).toBe(".agent/workflows"); - expect(command.getRelativeFilePath()).toBe("test.md"); - }); - - it("should validate frontmatter when validation is enabled", () => { - const invalidFrontmatter = { - description: 123, // Invalid: should be string - }; - - expect(() => { - new AntigravityCommand({ - outputRoot: ".", - relativeDirPath: ".agent/workflows", - relativeFilePath: "test.md", - frontmatter: invalidFrontmatter as any, - body: "test body", - fileContent: "test content", - validate: true, - }); - }).toThrow(); - }); - - it("should skip validation when validation is disabled", () => { - const invalidFrontmatter = { - description: 123, // Invalid: should be string - }; - - expect(() => { - new AntigravityCommand({ - outputRoot: ".", - relativeDirPath: ".agent/workflows", - relativeFilePath: "test.md", - frontmatter: invalidFrontmatter as any, - body: "test body", - fileContent: "test content", - validate: false, - }); - }).not.toThrow(); - }); - }); - - describe("validate", () => { - it("should return success for valid frontmatter", () => { - const frontmatter: AntigravityCommandFrontmatter = { - description: "Valid workflow", - }; - - const command = new AntigravityCommand({ - outputRoot: ".", - relativeDirPath: ".agent/workflows", - relativeFilePath: "test.md", - frontmatter, - body: "test body", - fileContent: stringifyFrontmatter("test body", frontmatter), - }); - - const result = command.validate(); - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - }); - - it("should return error for invalid frontmatter", () => { - const command = new AntigravityCommand({ - outputRoot: ".", - relativeDirPath: ".agent/workflows", - relativeFilePath: "test.md", - frontmatter: { description: 123 } as any, - body: "test body", - fileContent: "test content", - validate: false, // Skip validation in constructor - }); - - const result = command.validate(); - expect(result.success).toBe(false); - expect(result.error).toBeDefined(); - }); - - it("should return success when frontmatter is undefined", () => { - const command = new AntigravityCommand({ - outputRoot: ".", - relativeDirPath: ".agent/workflows", - relativeFilePath: "test.md", - frontmatter: { description: "test" }, - body: "test body", - fileContent: "test content", - validate: false, - }); - - // Set frontmatter to undefined via type assertion for testing - (command as any).frontmatter = undefined; - - const result = command.validate(); - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - }); - }); - - describe("toRulesyncCommand", () => { - it("should convert AntigravityCommand to RulesyncCommand", () => { - const frontmatter: AntigravityCommandFrontmatter = { - description: "Test workflow for conversion", - }; - const body = "This workflow will be converted"; - - const antigravityCommand = new AntigravityCommand({ - outputRoot: "/test/base", - relativeDirPath: ".agent/workflows", - relativeFilePath: "convert-test.md", - frontmatter, - body, - fileContent: stringifyFrontmatter(body, frontmatter), - }); - - const rulesyncCommand = antigravityCommand.toRulesyncCommand(); - - expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); - expect(rulesyncCommand.getBody()).toBe(body); - expect(rulesyncCommand.getFrontmatter()).toEqual({ - targets: ["antigravity"], - description: frontmatter.description, - }); - expect(rulesyncCommand.getRelativeDirPath()).toBe(RULESYNC_COMMANDS_RELATIVE_DIR_PATH); - expect(rulesyncCommand.getRelativeFilePath()).toBe("convert-test.md"); - expect(rulesyncCommand.getOutputRoot()).toBe("."); - }); - - it("should preserve trigger and turbo fields in antigravity section", () => { - const frontmatter: AntigravityCommandFrontmatter = { - description: "Workflow with trigger and turbo", - trigger: "/my-workflow", - turbo: true, - }; - const body = "# Workflow: /my-workflow\n\nWorkflow content\n\n// turbo"; - - const antigravityCommand = new AntigravityCommand({ - outputRoot: "/test/base", - relativeDirPath: ".agent/workflows", - relativeFilePath: "my-workflow.md", - frontmatter, - body, - fileContent: stringifyFrontmatter(body, frontmatter), - }); - - const rulesyncCommand = antigravityCommand.toRulesyncCommand(); - - expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); - expect(rulesyncCommand.getFrontmatter()).toEqual({ - targets: ["antigravity"], - description: frontmatter.description, - antigravity: { - trigger: "/my-workflow", - turbo: true, - }, - }); - }); - - it("should not include antigravity section when no extra fields exist", () => { - const frontmatter: AntigravityCommandFrontmatter = { - description: "Simple workflow without extra fields", - }; - const body = "Simple workflow content"; - - const antigravityCommand = new AntigravityCommand({ - outputRoot: "/test/base", - relativeDirPath: ".agent/workflows", - relativeFilePath: "simple.md", - frontmatter, - body, - fileContent: stringifyFrontmatter(body, frontmatter), - }); - - const rulesyncCommand = antigravityCommand.toRulesyncCommand(); - - expect(rulesyncCommand.getFrontmatter()).toEqual({ - targets: ["antigravity"], - description: frontmatter.description, - }); - expect(rulesyncCommand.getFrontmatter()).not.toHaveProperty("antigravity"); - }); - }); - - describe("fromRulesyncCommand", () => { - it("should create AntigravityCommand from RulesyncCommand", () => { - const rulesyncFrontmatter = { - targets: ["antigravity" as const], - description: "Converted from rulesync", - }; - const body = "Workflow converted from rulesync"; - - const rulesyncCommand = new RulesyncCommand({ - outputRoot: "/test/base", - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "from-rulesync.md", - frontmatter: rulesyncFrontmatter, - body, - fileContent: stringifyFrontmatter(body, rulesyncFrontmatter), - }); - - const antigravityCommand = AntigravityCommand.fromRulesyncCommand({ - outputRoot: "/converted/base", - rulesyncCommand, - validate: true, - }); - - expect(antigravityCommand).toBeInstanceOf(AntigravityCommand); - expect(antigravityCommand.getBody()).toContain(body); - expect(antigravityCommand.getBody()).toContain("# Workflow:"); - - expect(antigravityCommand.getFrontmatter()).toEqual({ - description: rulesyncFrontmatter.description, - trigger: "/from-rulesync", - turbo: true, - }); - expect(antigravityCommand.getRelativeDirPath()).toBe(".agent/workflows"); - expect(antigravityCommand.getRelativeFilePath()).toBe("from-rulesync.md"); - }); - - it("should use default outputRoot when not provided", () => { - const rulesyncFrontmatter = { - targets: ["antigravity" as const], - description: "Default outputRoot test", - }; - const body = "Test workflow"; - - const rulesyncCommand = new RulesyncCommand({ - outputRoot: "/test/base", - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "default-base.md", - frontmatter: rulesyncFrontmatter, - body, - fileContent: stringifyFrontmatter(body, rulesyncFrontmatter), - }); - - const antigravityCommand = AntigravityCommand.fromRulesyncCommand({ - rulesyncCommand, - }); - - expect(antigravityCommand.getOutputRoot()).toBe(process.cwd()); - }); - - it("should handle validation parameter", () => { - const rulesyncFrontmatter = { - targets: ["antigravity" as const], - description: 123, // Invalid: should be string - }; - const body = "Test workflow with validation"; - - // Testing runtime validation: force invalid type through TS - const invalidCommandParams = { - outputRoot: "/test/base", - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "validation.md", - frontmatter: rulesyncFrontmatter as unknown as RulesyncCommandFrontmatter, - body, - fileContent: stringifyFrontmatter( - body, - rulesyncFrontmatter as unknown as RulesyncCommandFrontmatter, - ), - }; - - const rulesyncCommand = new RulesyncCommand(invalidCommandParams); - - // Should fail when validate is true (default) - expect(() => { - AntigravityCommand.fromRulesyncCommand({ - rulesyncCommand, - validate: true, - }); - }).toThrow(); - - const withoutValidation = AntigravityCommand.fromRulesyncCommand({ - rulesyncCommand, - validate: false, - }); - - expect(withoutValidation.getBody()).toContain(body); - // Should succeed when validate is false - expect(() => { - AntigravityCommand.fromRulesyncCommand({ - rulesyncCommand, - validate: false, - }); - }).not.toThrow(); - }); - - it("should transform RulesyncCommand into Workflow when trigger is present", () => { - const rulesyncFrontmatter = { - targets: ["antigravity" as const], - description: "Test Workflow", - antigravity: { - trigger: "/test-workflow", - turbo: true, - }, - }; - const body = "Step 1: Do something"; - - const rulesyncCommand = new RulesyncCommand({ - outputRoot: "/test/base", - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "original-file.md", - frontmatter: rulesyncFrontmatter, - body, - fileContent: stringifyFrontmatter(body, rulesyncFrontmatter), - }); - - const antigravityCommand = AntigravityCommand.fromRulesyncCommand({ - rulesyncCommand, - }); - - // 1. Check filename renaming (based on trigger) - expect(antigravityCommand.getRelativeFilePath()).toBe("test-workflow.md"); - - // 2. Check content wrapping - const content = antigravityCommand.getBody(); - expect(content).toContain("# Workflow: /test-workflow"); - expect(content).toContain("Step 1: Do something"); - - // 3. Check Turbo mode - expect(content).toContain("// turbo"); - - // 4. Verify Frontmatter description - expect(antigravityCommand.getFrontmatter()).toEqual({ - description: "Test Workflow", - trigger: "/test-workflow", - turbo: true, - }); - }); - - it("should support root level trigger as fallback", () => { - const rulesyncFrontmatter = { - targets: ["antigravity" as const], - description: "Root Trigger Workflow", - trigger: "/root-trigger", - }; - const body = "Simple body"; - - const rulesyncCommand = new RulesyncCommand({ - outputRoot: "/test/base", - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "root.md", - frontmatter: rulesyncFrontmatter, - body, - fileContent: stringifyFrontmatter(body, rulesyncFrontmatter), - }); - - const antigravityCommand = AntigravityCommand.fromRulesyncCommand({ - rulesyncCommand, - }); - - expect(antigravityCommand.getRelativeFilePath()).toBe("root-trigger.md"); - expect(antigravityCommand.getBody()).toContain("# Workflow: /root-trigger"); - }); - - it("should use filename as default trigger if none provided", () => { - const rulesyncFrontmatter = { - targets: ["antigravity" as const], - description: "Standard Command", - }; - const body = "Just a command"; - - const rulesyncCommand = new RulesyncCommand({ - outputRoot: "/test/base", - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "standard.md", - frontmatter: rulesyncFrontmatter, - body, - fileContent: stringifyFrontmatter(body, rulesyncFrontmatter), - }); - - const antigravityCommand = AntigravityCommand.fromRulesyncCommand({ - rulesyncCommand, - }); - - // Should use filename as trigger name (standard.md -> /standard) - expect(antigravityCommand.getRelativeFilePath()).toBe("standard.md"); - - // Should HAVE workflow header with default trigger - expect(antigravityCommand.getBody()).toContain("# Workflow: /standard"); - expect(antigravityCommand.getBody()).toContain("// turbo"); // Default is true - - expect(antigravityCommand.getFrontmatter()).toEqual({ - description: "Standard Command", - trigger: "/standard", - turbo: true, - }); - }); - - it("should omit turbo directive when turbo is explicitly false", () => { - const rulesyncFrontmatter = { - targets: ["antigravity" as const], - description: "No Turbo Workflow", - antigravity: { - trigger: "/no-turbo", - turbo: false, - }, - }; - const body = "Workflow without auto-execution"; - - const rulesyncCommand = new RulesyncCommand({ - outputRoot: "/test/base", - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "no-turbo.md", - frontmatter: rulesyncFrontmatter, - body, - fileContent: stringifyFrontmatter(body, rulesyncFrontmatter), - }); - - const antigravityCommand = AntigravityCommand.fromRulesyncCommand({ - rulesyncCommand, - }); - - // Should have workflow header - expect(antigravityCommand.getBody()).toContain("# Workflow: /no-turbo"); - expect(antigravityCommand.getBody()).toContain("Workflow without auto-execution"); - - // Should NOT contain turbo directive - expect(antigravityCommand.getBody()).not.toContain("// turbo"); - - expect(antigravityCommand.getFrontmatter()).toEqual({ - description: "No Turbo Workflow", - trigger: "/no-turbo", - turbo: false, - }); - }); - - it("should strip existing frontmatter from body if present (Double Frontmatter Fix)", () => { - const dirtyBody = `--- -description: Old Description -targets: ["*"] ---- -Actual command content`; - - const rulesyncFrontmatter = { - targets: ["antigravity" as const], - description: "New Description", - antigravity: { - trigger: "/clean-workflow", - }, - }; - - const rulesyncCommand = new RulesyncCommand({ - outputRoot: "/test/base", - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "dirty.md", - frontmatter: rulesyncFrontmatter, - body: dirtyBody, - fileContent: stringifyFrontmatter(dirtyBody, rulesyncFrontmatter), - }); - - const antigravityCommand = AntigravityCommand.fromRulesyncCommand({ - rulesyncCommand, - }); - - const body = antigravityCommand.getBody(); - - // Should NOT contain the old frontmatter delimiters - expect(body).not.toContain("description: Old Description"); - expect(body).not.toContain("---"); - - expect(body).toContain("# Workflow: /clean-workflow"); - expect(body).toContain("Actual command content"); - }); - - it("should strip frontmatter with Windows line endings (CRLF)", () => { - const dirtyBody = "---\r\ndescription: Old\r\n---\r\nActual content"; - - const rulesyncFrontmatter = { - targets: ["antigravity" as const], - description: "CRLF Test", - antigravity: { trigger: "/crlf-test" }, - }; - - const rulesyncCommand = new RulesyncCommand({ - outputRoot: "/test/base", - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "crlf.md", - frontmatter: rulesyncFrontmatter, - body: dirtyBody, - fileContent: stringifyFrontmatter(dirtyBody, rulesyncFrontmatter), - }); - - const antigravityCommand = AntigravityCommand.fromRulesyncCommand({ rulesyncCommand }); - const body = antigravityCommand.getBody(); - - expect(body).not.toContain("description: Old"); - expect(body).toContain("Actual content"); - }); - - it("should sanitize trigger to prevent path traversal", () => { - const rulesyncFrontmatter = { - targets: ["antigravity" as const], - description: "Security Test", - antigravity: { - trigger: "/../evil-workflow", // Potentially malicious trigger - }, - }; - - const rulesyncCommand = new RulesyncCommand({ - outputRoot: "/test/base", - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "evil.md", - frontmatter: rulesyncFrontmatter, - body: "Malicious payload", - fileContent: stringifyFrontmatter("Malicious payload", rulesyncFrontmatter), - }); - - const antigravityCommand = AntigravityCommand.fromRulesyncCommand({ - rulesyncCommand, - }); - - // Should be sanitized to safe characters only - // /../evil-workflow -> -evil-workflow - expect(antigravityCommand.getRelativeFilePath()).not.toContain(".."); - expect(antigravityCommand.getRelativeFilePath()).not.toContain("/"); - expect(antigravityCommand.getRelativeFilePath()).toMatch(/^[a-zA-Z0-9-_]+\.md$/); - }); - - it("should throw error when sanitization results in empty string", () => { - const rulesyncFrontmatter = { - targets: ["antigravity" as const], - description: "Empty Trigger Test", - antigravity: { - trigger: "/../../../", // All characters will be sanitized away - }, - }; - - const rulesyncCommand = new RulesyncCommand({ - outputRoot: "/test/base", - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "empty.md", - frontmatter: rulesyncFrontmatter, - body: "Test body", - fileContent: stringifyFrontmatter("Test body", rulesyncFrontmatter), - }); - - expect(() => { - AntigravityCommand.fromRulesyncCommand({ rulesyncCommand }); - }).toThrow(/sanitization resulted in empty string/); - }); - }); - describe("fromFile", () => { - it("should create AntigravityCommand from file", async () => { - const { testDir, cleanup } = await setupTestDirectory(); - vi.spyOn(process, "cwd").mockReturnValue(testDir); - - try { - const frontmatter: AntigravityCommandFrontmatter = { - description: "Test workflow from file", - }; - const body = "Workflow body from file"; - const fileContent = stringifyFrontmatter(body, frontmatter); - - const workflowsDir = join(testDir, ".agent/workflows"); - await writeFileContent(join(workflowsDir, "test-file.md"), fileContent); - - const command = await AntigravityCommand.fromFile({ - outputRoot: testDir, - relativeFilePath: "test-file.md", - }); - - expect(command.getBody()).toBe(body); - expect(command.getFrontmatter()).toEqual(frontmatter); - expect(command.getRelativeDirPath()).toBe(".agent/workflows"); - expect(command.getRelativeFilePath()).toBe("test-file.md"); - } finally { - await cleanup(); - vi.restoreAllMocks(); - } - }); - - it("should throw error when file does not exist", async () => { - const { testDir, cleanup } = await setupTestDirectory(); - - try { - await expect( - AntigravityCommand.fromFile({ - outputRoot: testDir, - relativeFilePath: "nonexistent.md", - }), - ).rejects.toThrow(); - } finally { - await cleanup(); - } - }); - - it("should throw error when frontmatter is invalid", async () => { - const { testDir, cleanup } = await setupTestDirectory(); - - try { - const invalidContent = "---\ndescription: 123\n---\nBody content"; - const workflowsDir = join(testDir, ".agent/workflows"); - await writeFileContent(join(workflowsDir, "invalid.md"), invalidContent); - - await expect( - AntigravityCommand.fromFile({ - outputRoot: testDir, - relativeFilePath: "invalid.md", - validate: true, - }), - ).rejects.toThrow(); - } finally { - await cleanup(); - } - }); - }); - - describe("isTargetedByRulesyncCommand", () => { - it("should return true for wildcard target", () => { - const rulesyncCommand = new RulesyncCommand({ - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "test.md", - frontmatter: { - targets: ["*"], - description: "Test", - }, - body: "Body", - fileContent: "", - }); - - expect(AntigravityCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); - }); - - it("should return true for antigravity target", () => { - const rulesyncCommand = new RulesyncCommand({ - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "test.md", - frontmatter: { - targets: ["antigravity"], - description: "Test", - }, - body: "Body", - fileContent: "", - }); - - expect(AntigravityCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(true); - }); - - it("should return false for other specific targets", () => { - const rulesyncCommand = new RulesyncCommand({ - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "test.md", - frontmatter: { - targets: ["cursor"], - description: "Test", - }, - body: "Body", - fileContent: "", - }); - - expect(AntigravityCommand.isTargetedByRulesyncCommand(rulesyncCommand)).toBe(false); - }); - }); - - describe("getSettablePaths", () => { - it("should return correct workflows path", () => { - const paths = AntigravityCommand.getSettablePaths(); - - expect(paths.relativeDirPath).toBe(".agent/workflows"); - }); - }); -}); diff --git a/src/features/commands/antigravity-command.ts b/src/features/commands/antigravity-command.ts index 6bf1e7da4..0249669f1 100644 --- a/src/features/commands/antigravity-command.ts +++ b/src/features/commands/antigravity-command.ts @@ -1,21 +1,5 @@ -import { basename, join } from "node:path"; - import { z } from "zod/mini"; -import { ANTIGRAVITY_LEGACY_COMMANDS_DIR_PATH } from "../../constants/antigravity-paths.js"; -import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; -import { formatError } from "../../utils/error.js"; -import { readFileContent } from "../../utils/file.js"; -import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; -import { isRecord } from "../../utils/type-guards.js"; -import { RulesyncCommand, RulesyncCommandFrontmatter } from "./rulesync-command.js"; -import { - ToolCommand, - ToolCommandForDeletionParams, - ToolCommandFromFileParams, - ToolCommandFromRulesyncCommandParams, -} from "./tool-command.js"; - // looseObject preserves unknown keys during parsing (like passthrough in Zod 3) const AntigravityWorkflowFrontmatterSchema = z.looseObject({ trigger: z.optional(z.string()), @@ -30,255 +14,3 @@ export const AntigravityCommandFrontmatterSchema = z.looseObject({ }); export type AntigravityCommandFrontmatter = z.infer; - -export type AntigravityCommandParams = { - frontmatter: AntigravityCommandFrontmatter; - body: string; -} & AiFileParams; - -export type AntigravityCommandSettablePaths = { - relativeDirPath: string; -}; - -/** - * Command generator for Google Antigravity IDE - * - * Generates workflow files for Antigravity's .agent/workflows/ directory. - */ -export class AntigravityCommand extends ToolCommand { - private readonly frontmatter: AntigravityCommandFrontmatter; - private readonly body: string; - - static getSettablePaths(): AntigravityCommandSettablePaths { - return { - relativeDirPath: ANTIGRAVITY_LEGACY_COMMANDS_DIR_PATH, - }; - } - - constructor({ frontmatter, body, ...rest }: AntigravityCommandParams) { - // Validate frontmatter before calling super to avoid validation order issues - if (rest.validate) { - const result = AntigravityCommandFrontmatterSchema.safeParse(frontmatter); - if (!result.success) { - throw new Error( - `Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, - ); - } - } - - super({ - ...rest, - fileContent: stringifyFrontmatter(body, frontmatter), - }); - - this.frontmatter = frontmatter; - this.body = body; - } - - getBody(): string { - return this.body; - } - - getFrontmatter(): Record { - return this.frontmatter; - } - - toRulesyncCommand(): RulesyncCommand { - const { description, ...restFields } = this.frontmatter; - - const rulesyncFrontmatter: RulesyncCommandFrontmatter = { - targets: ["antigravity"], - description, - // Preserve extra fields in antigravity section - ...(Object.keys(restFields).length > 0 && { antigravity: restFields }), - }; - - // Generate proper file content with Rulesync specific frontmatter - const fileContent = stringifyFrontmatter(this.body, rulesyncFrontmatter); - - return new RulesyncCommand({ - outputRoot: ".", // RulesyncCommand outputRoot is always the project root directory - frontmatter: rulesyncFrontmatter, - body: this.body, - relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath, - relativeFilePath: this.relativeFilePath, - fileContent, - validate: true, - }); - } - - private static extractAntigravityConfig( - rulesyncCommand: RulesyncCommand, - ): Record | undefined { - const antigravity = rulesyncCommand.getFrontmatter().antigravity; - return isRecord(antigravity) ? antigravity : undefined; - } - - static fromRulesyncCommand({ - outputRoot = process.cwd(), - rulesyncCommand, - validate = true, - }: ToolCommandFromRulesyncCommandParams): AntigravityCommand { - const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); - const antigravityConfig = this.extractAntigravityConfig(rulesyncCommand); - - const trigger = this.resolveTrigger(rulesyncCommand, antigravityConfig); - - // Default to true unless explicitly set to false - const turbo = typeof antigravityConfig?.turbo === "boolean" ? antigravityConfig.turbo : true; - - let relativeFilePath = rulesyncCommand.getRelativeFilePath(); - - // Fix: Clean up body if it contains frontmatter (prevent double frontmatter) - // This handles cases where body incorrectly includes the original frontmatter block - let body = rulesyncCommand - .getBody() - .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "") - .trim(); - - // Transform into a Workflow - // Note: resolveTrigger always returns a string (fallback to filename-based trigger) - - // 1. Rename file based on trigger (e.g. /my-workflow -> my-workflow.md) - // Security: Sanitize trigger to prevent path traversal (e.g. /../evil) - const sanitizedTrigger = trigger.replace(/[^a-zA-Z0-9-_]/g, "-").replace(/^-+|-+$/g, ""); - if (!sanitizedTrigger) { - throw new Error(`Invalid trigger: sanitization resulted in empty string from "${trigger}"`); - } - const validFilename = sanitizedTrigger + ".md"; - relativeFilePath = validFilename; - - // 2. Wrap content with Workflow header and turbo directive - const turboDirective = turbo ? "\n\n// turbo" : ""; - - // We don't need to duplicate the frontmatter in the body string for the file content - // because stringifyFrontmatter will handle it. - // But we DO need to update the body to include the specific workflow header. - body = `# Workflow: ${trigger}\n\n${body}${turboDirective}`; - - const description = rulesyncFrontmatter.description; - - const antigravityFrontmatter: AntigravityCommandFrontmatter = { - description, - trigger, - turbo, - }; - - // Generate proper file content with Antigravity specific frontmatter - const fileContent = stringifyFrontmatter(body, antigravityFrontmatter); - - return new AntigravityCommand({ - outputRoot: outputRoot, - frontmatter: antigravityFrontmatter, - body, - relativeDirPath: AntigravityCommand.getSettablePaths().relativeDirPath, - relativeFilePath, - fileContent: fileContent, - validate, - }); - } - - private static resolveTrigger( - rulesyncCommand: RulesyncCommand, - antigravityConfig: Record | undefined, - ): string { - const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); - - // Strategy 1: Look for explicit antigravity config in frontmatter (passed as parameter) - const antigravityTrigger = - antigravityConfig && typeof antigravityConfig.trigger === "string" - ? antigravityConfig.trigger - : undefined; - - // Strategy 2: Look for root level trigger (fallback) - const rootTrigger = - typeof rulesyncFrontmatter.trigger === "string" ? rulesyncFrontmatter.trigger : undefined; - - // Strategy 3: Look for trigger in body regex (Legacy support) - // Support triggers with hyphens (e.g., /my-workflow) - const bodyTriggerMatch = rulesyncCommand.getBody().match(/trigger:\s*(\/[\w-]+)/); - - // Strategy 4: Fallback to filename as trigger (e.g. add-tests.md -> /add-tests) - const filenameTrigger = `/${basename(rulesyncCommand.getRelativeFilePath(), ".md")}`; - - return ( - antigravityTrigger || - rootTrigger || - (bodyTriggerMatch ? bodyTriggerMatch[1] : undefined) || - filenameTrigger - ); - } - - validate(): ValidationResult { - // Check if frontmatter is set (may be undefined during construction) - if (!this.frontmatter) { - return { success: true, error: null }; - } - - const result = AntigravityCommandFrontmatterSchema.safeParse(this.frontmatter); - if (result.success) { - return { success: true, error: null }; - } else { - return { - success: false, - error: new Error( - `Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`, - ), - }; - } - } - - static isTargetedByRulesyncCommand(rulesyncCommand: RulesyncCommand): boolean { - return this.isTargetedByRulesyncCommandDefault({ - rulesyncCommand, - toolTarget: "antigravity", - }); - } - - static async fromFile({ - outputRoot = process.cwd(), - relativeFilePath, - validate = true, - }: ToolCommandFromFileParams): Promise { - const filePath = join( - outputRoot, - AntigravityCommand.getSettablePaths().relativeDirPath, - relativeFilePath, - ); - // Read file content - const fileContent = await readFileContent(filePath); - const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); - - // Validate frontmatter using AntigravityCommandFrontmatterSchema - const result = AntigravityCommandFrontmatterSchema.safeParse(frontmatter); - if (!result.success) { - throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); - } - - return new AntigravityCommand({ - outputRoot: outputRoot, - relativeDirPath: AntigravityCommand.getSettablePaths().relativeDirPath, - relativeFilePath, - frontmatter: result.data, - body: content.trim(), - fileContent, - validate, - }); - } - - static forDeletion({ - outputRoot = process.cwd(), - relativeDirPath, - relativeFilePath, - }: ToolCommandForDeletionParams): AntigravityCommand { - return new AntigravityCommand({ - outputRoot, - relativeDirPath, - relativeFilePath, - frontmatter: { description: "" }, - body: "", - fileContent: "", - validate: false, - }); - } -} diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index 9c8a7d079..e7e0a2ce6 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -10,7 +10,6 @@ import { ClaudecodeCommand } from "./claudecode-command.js"; import { ClineCommand } from "./cline-command.js"; import { CommandsProcessor, CommandsProcessorToolTarget } from "./commands-processor.js"; import { CursorCommand } from "./cursor-command.js"; -import { GeminiCliCommand } from "./geminicli-command.js"; import { JunieCommand } from "./junie-command.js"; import { KiloCommand } from "./kilo-command.js"; import { OpenCodeCommand } from "./opencode-command.js"; @@ -43,11 +42,6 @@ vi.mock("./claudecode-command.js", () => ({ return { ...config, isDeletable: () => true }; }), })); -vi.mock("./geminicli-command.js", () => ({ - GeminiCliCommand: vi.fn().mockImplementation(function (config) { - return { ...config, isDeletable: () => true }; - }), -})); vi.mock("./junie-command.js", () => ({ JunieCommand: vi.fn().mockImplementation(function (config) { return { ...config, isDeletable: () => true }; @@ -128,19 +122,6 @@ vi.mocked(JunieCommand).forDeletion = vi.fn().mockImplementation((params) => ({ getRelativeFilePath: () => params.relativeFilePath, })); -// Set up static methods after mocking -vi.mocked(GeminiCliCommand).fromFile = vi.fn(); -vi.mocked(GeminiCliCommand).fromRulesyncCommand = vi.fn(); -vi.mocked(GeminiCliCommand).isTargetedByRulesyncCommand = vi.fn().mockReturnValue(true); -vi.mocked(GeminiCliCommand).getSettablePaths = vi - .fn() - .mockReturnValue({ relativeDirPath: join(".gemini", "commands") }); -vi.mocked(GeminiCliCommand).forDeletion = vi.fn().mockImplementation((params) => ({ - ...params, - isDeletable: () => true, - getRelativeFilePath: () => params.relativeFilePath, -})); - // Set up static methods after mocking vi.mocked(KiloCommand).fromFile = vi.fn(); vi.mocked(KiloCommand).fromRulesyncCommand = vi.fn(); @@ -353,40 +334,6 @@ describe("CommandsProcessor", () => { }); }); - it("should convert rulesync commands to geminicli commands", async () => { - processor = new CommandsProcessor({ logger, outputRoot: testDir, toolTarget: "geminicli" }); - - const mockRulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "test.md", - fileContent: "test content", - frontmatter: { - targets: ["geminicli"], - description: "test description", - }, - body: "test content", - }); - - const mockGeminiCliCommand = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "commands"), - relativeFilePath: "test.md", - fileContent: `description = "test description"\nprompt = """\nconverted content\n"""`, - }); - - vi.mocked(GeminiCliCommand.fromRulesyncCommand).mockReturnValue(mockGeminiCliCommand); - - const result = await processor.convertRulesyncFilesToToolFiles([mockRulesyncCommand]); - - expect(GeminiCliCommand.fromRulesyncCommand).toHaveBeenCalledWith({ - outputRoot: expect.any(String), - rulesyncCommand: mockRulesyncCommand, - global: false, - }); - expect(result).toEqual([mockGeminiCliCommand]); - }); - it("should convert rulesync commands to roo commands", async () => { processor = new CommandsProcessor({ logger, outputRoot: testDir, toolTarget: "roo" }); @@ -884,25 +831,6 @@ describe("CommandsProcessor", () => { expect(result).toEqual([mockCommand]); }); - it("should load geminicli commands with correct parameters", async () => { - processor = new CommandsProcessor({ logger, outputRoot: testDir, toolTarget: "geminicli" }); - - const mockPaths = [join(testDir, ".gemini", "commands", "test.toml")]; - const mockCommand = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "commands"), - relativeFilePath: "test.toml", - fileContent: `description = "test description"\nprompt = """\ncontent\n"""`, - }); - - mockFindFilesByGlobs.mockResolvedValue(mockPaths); - vi.mocked(GeminiCliCommand.fromFile).mockResolvedValue(mockCommand); - - const result = await processor.loadToolFiles(); - - expect(result).toEqual([mockCommand]); - }); - it("should load roo commands with correct parameters", async () => { processor = new CommandsProcessor({ logger, outputRoot: testDir, toolTarget: "roo" }); @@ -1134,7 +1062,6 @@ describe("CommandsProcessor", () => { const targets = CommandsProcessor.getToolTargets(); expect(new Set(targets)).toEqual( new Set([ - "antigravity", "antigravity-ide", "augmentcode", "claudecode", @@ -1143,7 +1070,6 @@ describe("CommandsProcessor", () => { "copilot", "cursor", "factorydroid", - "geminicli", "goose", "junie", "kilo", @@ -1165,7 +1091,6 @@ describe("CommandsProcessor", () => { expect(new Set(targets)).toEqual( new Set([ "agentsmd", - "antigravity", "antigravity-ide", "augmentcode", "claudecode", @@ -1174,7 +1099,6 @@ describe("CommandsProcessor", () => { "copilot", "cursor", "factorydroid", - "geminicli", "goose", "junie", "kilo", @@ -1205,7 +1129,6 @@ describe("CommandsProcessor", () => { "codexcli", "cursor", "factorydroid", - "geminicli", "goose", "hermesagent", "junie", @@ -1243,7 +1166,6 @@ describe("CommandsProcessor", () => { "claudecode", "claudecode-legacy", "cline", - "geminicli", "junie", "kilo", "roo", diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index 4baa566b7..4fcd53689 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -12,7 +12,6 @@ import { formatError } from "../../utils/error.js"; import { checkPathTraversal, findFilesByGlobs } from "../../utils/file.js"; import type { Logger } from "../../utils/logger.js"; import { AgentsmdCommand } from "./agentsmd-command.js"; -import { AntigravityCommand } from "./antigravity-command.js"; import { AntigravityIdeCommand } from "./antigravity-ide-command.js"; import { AugmentcodeCommand } from "./augmentcode-command.js"; import { ClaudecodeCommand } from "./claudecode-command.js"; @@ -22,7 +21,6 @@ import { CopilotCommand } from "./copilot-command.js"; import { CursorCommand } from "./cursor-command.js"; import { DevinCommand } from "./devin-command.js"; import { FactorydroidCommand } from "./factorydroid-command.js"; -import { GeminiCliCommand } from "./geminicli-command.js"; import { GooseCommand } from "./goose-command.js"; import { HermesagentCommand } from "./hermesagent-command.js"; import { JunieCommand } from "./junie-command.js"; @@ -108,19 +106,6 @@ export const toolCommandFactories = new Map { - let testDir: string; - let cleanup: () => Promise; - - const validTomlContent = `description = "Test command description" -prompt = """ -This is a test prompt for the command. -It can be multiline. -"""`; - - const validTomlWithoutDescription = `prompt = """ -This is a test prompt without description. -"""`; - - const invalidTomlContent = `description = "Test description" -# Missing required prompt field`; - - const malformedTomlContent = `description = "Test description" -prompt = "Unclosed string`; - - beforeEach(async () => { - const testSetup = await setupTestDirectory(); - testDir = testSetup.testDir; - cleanup = testSetup.cleanup; - vi.spyOn(process, "cwd").mockReturnValue(testDir); - }); - - afterEach(async () => { - await cleanup(); - vi.restoreAllMocks(); - }); - - describe("constructor", () => { - it("should create instance with valid TOML content", () => { - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "test-command.toml", - fileContent: validTomlContent, - validate: true, - }); - - expect(command).toBeInstanceOf(GeminiCliCommand); - expect(command.getBody()).toBe( - "This is a test prompt for the command.\nIt can be multiline.\n", - ); - expect(command.getFrontmatter()).toEqual({ - description: "Test command description", - prompt: "This is a test prompt for the command.\nIt can be multiline.\n", - }); - }); - - it("should create instance with TOML content without description", () => { - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "test-command.toml", - fileContent: validTomlWithoutDescription, - validate: true, - }); - - expect(command.getBody()).toBe("This is a test prompt without description.\n"); - expect(command.getFrontmatter()).toEqual({ - description: undefined, - prompt: "This is a test prompt without description.\n", - }); - }); - - it("should throw error for invalid TOML content", () => { - expect( - () => - new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "invalid-command.toml", - fileContent: invalidTomlContent, - validate: true, - }), - ).toThrow("Failed to parse TOML command file"); - }); - - it("should throw error for malformed TOML content", () => { - expect( - () => - new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "malformed-command.toml", - fileContent: malformedTomlContent, - validate: true, - }), - ).toThrow("Failed to parse TOML command file"); - }); - }); - - describe("parseTomlContent", () => { - it("should parse valid TOML with all fields", () => { - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "test-command.toml", - fileContent: validTomlContent, - validate: true, - }); - - const frontmatter = command.getFrontmatter() as GeminiCliCommandFrontmatter; - expect(frontmatter.description).toBe("Test command description"); - expect(frontmatter.prompt).toBe( - "This is a test prompt for the command.\nIt can be multiline.\n", - ); - }); - - it("should parse TOML with optional description missing", () => { - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "test-command.toml", - fileContent: validTomlWithoutDescription, - validate: true, - }); - - const frontmatter = command.getFrontmatter() as GeminiCliCommandFrontmatter; - expect(frontmatter.description).toBeUndefined(); - expect(frontmatter.prompt).toBe("This is a test prompt without description.\n"); - }); - - it("should throw error for TOML without required prompt field", () => { - expect( - () => - new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "invalid-command.toml", - fileContent: `description = "Test description"`, - validate: true, - }), - ).toThrow("Failed to parse TOML command file"); - }); - }); - - describe("getBody", () => { - it("should return the prompt content as body", () => { - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "test-command.toml", - fileContent: validTomlContent, - validate: true, - }); - - expect(command.getBody()).toBe( - "This is a test prompt for the command.\nIt can be multiline.\n", - ); - }); - }); - - describe("getFrontmatter", () => { - it("should return frontmatter with description and prompt", () => { - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "test-command.toml", - fileContent: validTomlContent, - validate: true, - }); - - const frontmatter = command.getFrontmatter(); - expect(frontmatter).toEqual({ - description: "Test command description", - prompt: "This is a test prompt for the command.\nIt can be multiline.\n", - }); - }); - }); - - describe("toRulesyncCommand", () => { - it("should convert to RulesyncCommand with correct frontmatter", () => { - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "test-command.toml", - fileContent: validTomlContent, - validate: true, - }); - - const rulesyncCommand = command.toRulesyncCommand(); - - expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); - expect(rulesyncCommand.getFrontmatter()).toEqual({ - targets: ["geminicli"], - description: "Test command description", - }); - expect(rulesyncCommand.getBody()).toBe( - "This is a test prompt for the command.\nIt can be multiline.\n", - ); - expect(rulesyncCommand.getRelativeDirPath()).toBe(RULESYNC_COMMANDS_RELATIVE_DIR_PATH); - expect(rulesyncCommand.getRelativeFilePath()).toBe("test-command.toml"); - }); - - it("should translate {{args}} back to $ARGUMENTS", () => { - const tomlContent = `description = "Args" -prompt = """ -Focus on {{args}}. -"""`; - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "args.toml", - fileContent: tomlContent, - validate: true, - }); - - const rulesyncCommand = command.toRulesyncCommand(); - - expect(rulesyncCommand.getBody()).toBe("Focus on $ARGUMENTS.\n"); - }); - - it("should translate !{cmd} back to !`cmd`", () => { - const tomlContent = `description = "Shell" -prompt = """ -Run !{git status} and report. -"""`; - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "shell.toml", - fileContent: tomlContent, - validate: true, - }); - - const rulesyncCommand = command.toRulesyncCommand(); - - expect(rulesyncCommand.getBody()).toBe("Run !`git status` and report.\n"); - }); - - it("should round-trip rulesync -> gemini -> rulesync preserving syntax", () => { - const originalBody = "Diff: !`git diff`\nFocus on $ARGUMENTS."; - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "round-trip.md", - frontmatter: { targets: ["geminicli"], description: "Round trip" }, - body: originalBody, - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - const restored = geminiCommand.toRulesyncCommand(); - - expect(restored.getBody().trimEnd()).toBe(originalBody); - }); - - it("should round-trip nested !`echo $ARGUMENTS` shell expansion", () => { - // Regression test for the previously-greedy reverse regex that ate the - // wrapping `!{...}` when the body contained a nested `{{args}}`. - const originalBody = "Run !`echo $ARGUMENTS` now."; - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "nested-round-trip.md", - frontmatter: { targets: ["geminicli"], description: "Nested" }, - body: originalBody, - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - // Forward direction - expect(geminiCommand.getBody().trimEnd()).toBe("Run !{echo {{args}}} now."); - - // Reverse direction - const restored = geminiCommand.toRulesyncCommand(); - expect(restored.getBody().trimEnd()).toBe(originalBody); - }); - - it("should convert to RulesyncCommand with empty description", () => { - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "test-command.toml", - fileContent: validTomlWithoutDescription, - validate: true, - }); - - const rulesyncCommand = command.toRulesyncCommand(); - - expect(rulesyncCommand.getFrontmatter()).toEqual({ - targets: ["geminicli"], - description: undefined, - }); - }); - }); - - describe("fromRulesyncCommand", () => { - it("should create GeminiCliCommand from RulesyncCommand", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "test-command.md", - frontmatter: { - targets: ["geminicli"], - description: "Test description from rulesync", - }, - body: "Test prompt content", - fileContent: "", // Will be generated - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand).toBeInstanceOf(GeminiCliCommand); - expect(geminiCommand.getBody()).toBe("Test prompt content\n"); - expect(geminiCommand.getFrontmatter()).toEqual({ - description: "Test description from rulesync", - prompt: "Test prompt content\n", - }); - expect(geminiCommand.getRelativeFilePath()).toBe("test-command.toml"); - expect(geminiCommand.getRelativeDirPath()).toBe(".gemini/commands"); - }); - - it("should handle RulesyncCommand with .md extension replacement", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "complex-command.md", - frontmatter: { - targets: ["geminicli"], - description: "Complex command", - }, - body: "Complex prompt", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getRelativeFilePath()).toBe("complex-command.toml"); - }); - - it("should translate $ARGUMENTS to {{args}}", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "args.md", - frontmatter: { targets: ["geminicli"], description: "Args" }, - body: "Focus on $ARGUMENTS.", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getBody()).toBe("Focus on {{args}}.\n"); - expect(geminiCommand.getFileContent()).toContain("Focus on {{args}}."); - }); - - it("should translate multiple occurrences of $ARGUMENTS", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "args-multi.md", - frontmatter: { targets: ["geminicli"], description: "Args multi" }, - body: "First: $ARGUMENTS. Second: $ARGUMENTS.", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getBody()).toBe("First: {{args}}. Second: {{args}}.\n"); - }); - - it("should translate !`cmd` shell expansion to !{cmd}", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "shell.md", - frontmatter: { targets: ["geminicli"], description: "Shell" }, - body: "Run !`git status` and report.", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getBody()).toBe("Run !{git status} and report.\n"); - }); - - it("should translate combined shell expansion and arguments", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "combined.md", - frontmatter: { targets: ["geminicli"], description: "Combined" }, - body: "Diff: !`git diff`\nFocus on $ARGUMENTS.", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getBody()).toBe("Diff: !{git diff}\nFocus on {{args}}.\n"); - }); - - it("should not re-translate already-Gemini-native syntax (idempotency)", () => { - // A rulesync body that already contains Gemini-native forms should be - // emitted verbatim — see docs/reference/command-syntax.md. - const nativeBody = "Already native: {{args}} and !{git diff}"; - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "idempotent.md", - frontmatter: { targets: ["geminicli"], description: "Idempotent" }, - body: nativeBody, - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getBody()).toBe(`${nativeBody}\n`); - }); - - it("should not translate $ARGUMENTS_FOO (underscore is a word char)", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "word-boundary-underscore.md", - frontmatter: { targets: ["geminicli"], description: "Word boundary" }, - body: "Use $ARGUMENTS_FOO here.", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getBody()).toBe("Use $ARGUMENTS_FOO here.\n"); - }); - - it("should not translate $ARGUMENTSx (letter is a word char)", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "word-boundary-letter.md", - frontmatter: { targets: ["geminicli"], description: "Word boundary" }, - body: "Token $ARGUMENTSx remains.", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getBody()).toBe("Token $ARGUMENTSx remains.\n"); - }); - - it("should translate $ARGUMENTS-foo (hyphen is not a word char)", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "word-boundary-hyphen.md", - frontmatter: { targets: ["geminicli"], description: "Word boundary" }, - body: "Token $ARGUMENTS-foo here.", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getBody()).toBe("Token {{args}}-foo here.\n"); - }); - - it("should translate nested shell expansion containing $ARGUMENTS", () => { - // Pin current behavior: !`echo $ARGUMENTS` becomes !{echo {{args}}} - // because the shell-expansion replacement runs first. - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "nested.md", - frontmatter: { targets: ["geminicli"], description: "Nested" }, - body: "Run !`echo $ARGUMENTS` now.", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getBody()).toBe("Run !{echo {{args}}} now.\n"); - }); - - it("should respect explicit geminicli.prompt override without translation", () => { - // When the user provides geminicli.prompt in rulesync frontmatter, it is - // assumed to be hand-authored Gemini syntax and emitted verbatim. - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "override.md", - frontmatter: { - targets: ["geminicli"], - description: "Override", - geminicli: { - prompt: "Hand-written {{args}} body", - }, - }, - body: "Body containing $ARGUMENTS that should be ignored.", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getBody()).toBe("Hand-written {{args}} body\n"); - }); - }); - - describe("toRulesyncCommand whitespace and inverse round-trip", () => { - it("should canonicalize {{ args }} (with whitespace) to $ARGUMENTS", () => { - const tomlContent = `description = "Whitespace" -prompt = """ -Focus on {{ args }}. -"""`; - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "whitespace.toml", - fileContent: tomlContent, - validate: true, - }); - - const rulesyncCommand = command.toRulesyncCommand(); - - expect(rulesyncCommand.getBody()).toBe("Focus on $ARGUMENTS.\n"); - }); - - it("should round-trip gemini -> rulesync -> gemini preserving syntax", () => { - // The TOML serializer normalizes trailing whitespace on round-trip, so - // we compare with `.trimEnd()` to focus on meaningful body content. - const tomlContent = `description = "Inverse" -prompt = """ -Diff: !{git diff} -Focus on {{args}}. -"""`; - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "inverse-round-trip.toml", - fileContent: tomlContent, - validate: true, - }); - - const rulesyncCommand = command.toRulesyncCommand(); - const restored = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(restored.getBody().trimEnd()).toBe("Diff: !{git diff}\nFocus on {{args}}."); - }); - - it("should canonicalize multiple {{args}} occurrences on import", () => { - const tomlContent = `description = "Args multi" -prompt = """ -First: {{args}}. Second: {{args}}. -"""`; - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "args-multi.toml", - fileContent: tomlContent, - validate: true, - }); - - const rulesyncCommand = command.toRulesyncCommand(); - - expect(rulesyncCommand.getBody().trimEnd()).toBe("First: $ARGUMENTS. Second: $ARGUMENTS."); - }); - - it("should canonicalize multi-line bodies with both placeholders on import", () => { - const tomlContent = `description = "Combined" -prompt = """ -Diff: !{git diff} -Focus on {{args}}. -"""`; - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "combined-import.toml", - fileContent: tomlContent, - validate: true, - }); - - const rulesyncCommand = command.toRulesyncCommand(); - - expect(rulesyncCommand.getBody().trimEnd()).toBe("Diff: !`git diff`\nFocus on $ARGUMENTS."); - }); - - it("should canonicalize {{args}} adjacent to alphanumeric characters", () => { - const tomlContent = `description = "Adjacent" -prompt = """ -Token {{args}}-foo and prefix{{args}} here. -"""`; - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "adjacent.toml", - fileContent: tomlContent, - validate: true, - }); - - const rulesyncCommand = command.toRulesyncCommand(); - - expect(rulesyncCommand.getBody().trimEnd()).toBe( - "Token $ARGUMENTS-foo and prefix$ARGUMENTS here.", - ); - }); - }); - - describe("TOML escaping in fromRulesyncCommand", () => { - it("should escape double quotes in description without breaking TOML", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "quotes-desc.md", - frontmatter: { - targets: ["geminicli"], - description: 'Title with "quoted" word', - }, - body: "body", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getFrontmatter()).toMatchObject({ - description: 'Title with "quoted" word', - }); - // The TOML must be re-parseable (i.e. quotes were escaped, not raw). - expect(geminiCommand.validate().success).toBe(true); - }); - - it("should escape backslashes in description without breaking TOML", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "backslash-desc.md", - frontmatter: { - targets: ["geminicli"], - description: "Path C:\\foo\\bar", - }, - body: "body", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getFrontmatter()).toMatchObject({ - description: "Path C:\\foo\\bar", - }); - expect(geminiCommand.validate().success).toBe(true); - }); - - it("should preserve newlines in description across TOML round-trip", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "newline-desc.md", - frontmatter: { - targets: ["geminicli"], - description: "Line 1\nLine 2", - }, - body: "body", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getFrontmatter()).toMatchObject({ - description: "Line 1\nLine 2", - }); - }); - - it("should preserve embedded triple-quote sequence in prompt body", () => { - // Bodies that contain `"""` would have broken the previous - // multi-line literal serializer. The smol-toml stringify path encodes - // them as `\"\"\"` so they round-trip cleanly. - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "triple-quote.md", - frontmatter: { targets: ["geminicli"], description: "Triple" }, - body: 'Body with """ inside it.', - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getBody().trimEnd()).toBe('Body with """ inside it.'); - expect(geminiCommand.validate().success).toBe(true); - }); - - it("should preserve a backtick inside !{...} on forward translation", () => { - // The forward regex requires no backtick inside !`...`, so users who - // need a literal backtick in a Gemini-native shell expansion must - // hand-author it via the geminicli.prompt override. This test pins - // that the override path still emits the body verbatim. - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "backtick-shell.md", - frontmatter: { - targets: ["geminicli"], - description: "Backtick shell", - geminicli: { prompt: "Run !{echo `hello`}." }, - }, - body: "ignored", - fileContent: "", - validate: true, - }); - - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - - expect(geminiCommand.getBody().trimEnd()).toBe("Run !{echo `hello`}."); - - // The on-disk TOML must parse cleanly (the basic-string serializer - // escapes the embedded backtick as part of the prompt value) and must - // not contain an unescaped `"""` triple-quote — a malformed serializer - // could otherwise break out of a multi-line literal. - const fileContent = geminiCommand.getFileContent(); - expect(fileContent).not.toContain('"""'); - expect(() => parseToml(fileContent)).not.toThrow(); - const parsed = parseToml(fileContent) as { prompt: string }; - expect(parsed.prompt).toContain("Run !{echo `hello`}."); - }); - }); - - describe("forward translation edge cases (rulesync → Gemini CLI)", () => { - const translateBody = (body: string): string => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "edge.md", - frontmatter: { targets: ["geminicli"], description: "edge" }, - body, - fileContent: "", - validate: true, - }); - const geminiCommand = GeminiCliCommand.fromRulesyncCommand({ - outputRoot: testDir, - rulesyncCommand, - validate: true, - }); - // The serializer appends a trailing \n; strip it for comparison so the - // assertions focus on the translation output itself. - return geminiCommand.getBody().replace(/\n$/, ""); - }; - - it("translates $ARGUMENTS to {{args}}", () => { - expect(translateBody("Focus on $ARGUMENTS.")).toBe("Focus on {{args}}."); - }); - - it("translates !`cmd` to !{cmd}", () => { - expect(translateBody("Run !`git status`.")).toBe("Run !{git status}."); - }); - - it("preserves $ARGUMENTSx (no leading or trailing word boundary mismatch)", () => { - expect(translateBody("$ARGUMENTSx remains")).toBe("$ARGUMENTSx remains"); - }); - - it("translates $ARGUMENTS-foo (hyphen is not a word char)", () => { - expect(translateBody("$ARGUMENTS-foo")).toBe("{{args}}-foo"); - }); - - it("translates a leading $ARGUMENTS at start of input", () => { - expect(translateBody("$ARGUMENTS go")).toBe("{{args}} go"); - }); - - it("translates a trailing $ARGUMENTS at end of input", () => { - expect(translateBody("go $ARGUMENTS")).toBe("go {{args}}"); - }); - - it("translates prefix$ARGUMENTS (no leading boundary anchor)", () => { - expect(translateBody("prefix$ARGUMENTS")).toBe("prefix{{args}}"); - }); - - it("preserves $ARGUMENTS_FOO (underscore is a word char)", () => { - expect(translateBody("$ARGUMENTS_FOO")).toBe("$ARGUMENTS_FOO"); - }); - - it("rewrites nested !`echo $ARGUMENTS` to !{echo {{args}}}", () => { - expect(translateBody("Run !`echo $ARGUMENTS`.")).toBe("Run !{echo {{args}}}."); - }); - }); - - describe("reverse translation edge cases (Gemini CLI → rulesync)", () => { - const translateBody = (body: string): string => { - const tomlContent = `description = "edge"\nprompt = ${JSON.stringify(body)}\n`; - const geminiCommand = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "commands"), - relativeFilePath: "edge.toml", - fileContent: tomlContent, - validate: true, - }); - return geminiCommand.toRulesyncCommand().getBody(); - }; - - it("translates {{args}} to $ARGUMENTS", () => { - expect(translateBody("Focus on {{args}}.")).toBe("Focus on $ARGUMENTS."); - }); - - it("translates {{ args }} (with whitespace) to $ARGUMENTS", () => { - expect(translateBody("Focus on {{ args }}.")).toBe("Focus on $ARGUMENTS."); - }); - - it("translates !{cmd} to !`cmd`", () => { - expect(translateBody("Run !{git status}.")).toBe("Run !`git status`."); - }); - - it("translates multiple !{...} occurrences", () => { - expect(translateBody("A !{cmd1} B !{cmd2} C")).toBe("A !`cmd1` B !`cmd2` C"); - }); - - it("translates nested !{echo {{args}}} to !`echo $ARGUMENTS` in one pass", () => { - expect(translateBody("Run !{echo {{args}}} now.")).toBe("Run !`echo $ARGUMENTS` now."); - }); - - it("handles multi-line bodies", () => { - expect(translateBody("Line1 !{a}\nLine2 with {{args}}\nLine3 !{b}")).toBe( - "Line1 !`a`\nLine2 with $ARGUMENTS\nLine3 !`b`", - ); - }); - - it("does not match across newlines for !{...}", () => { - // The non-greedy [^}\n]+? still excludes newlines, matching the - // forward direction's `!\`[^\`\n]+\`` anchor. - expect(translateBody("!{abc\ndef}")).toBe("!{abc\ndef}"); - }); - }); - - describe("fromFile", () => { - it("should load GeminiCliCommand from file", async () => { - const commandsDir = join(testDir, ".gemini", "commands"); - const filePath = join(commandsDir, "test-file-command.toml"); - - await writeFileContent(filePath, validTomlContent); - - const command = await GeminiCliCommand.fromFile({ - outputRoot: testDir, - relativeFilePath: "test-file-command.toml", - validate: true, - }); - - expect(command).toBeInstanceOf(GeminiCliCommand); - expect(command.getBody()).toBe( - "This is a test prompt for the command.\nIt can be multiline.\n", - ); - expect(command.getFrontmatter()).toEqual({ - description: "Test command description", - prompt: "This is a test prompt for the command.\nIt can be multiline.\n", - }); - expect(command.getRelativeFilePath()).toBe("test-file-command.toml"); - }); - - it("should handle file path with subdirectories", async () => { - const commandsDir = join(testDir, ".gemini", "commands", "subdir"); - const filePath = join(commandsDir, "nested-command.toml"); - - await writeFileContent(filePath, validTomlContent); - - const command = await GeminiCliCommand.fromFile({ - outputRoot: testDir, - relativeFilePath: "subdir/nested-command.toml", - validate: true, - }); - - expect(command.getRelativeFilePath()).toBe("subdir/nested-command.toml"); - }); - - it("should throw error when file does not exist", async () => { - await expect( - GeminiCliCommand.fromFile({ - outputRoot: testDir, - relativeFilePath: "non-existent-command.toml", - validate: true, - }), - ).rejects.toThrow(); - }); - - it("should throw error when file contains invalid TOML", async () => { - const commandsDir = join(testDir, ".gemini", "commands"); - const filePath = join(commandsDir, "invalid-command.toml"); - - await writeFileContent(filePath, invalidTomlContent); - - await expect( - GeminiCliCommand.fromFile({ - outputRoot: testDir, - relativeFilePath: "invalid-command.toml", - validate: true, - }), - ).rejects.toThrow("Failed to parse TOML command file"); - }); - }); - - describe("validate", () => { - it("should return success for valid TOML content", () => { - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "valid-command.toml", - fileContent: validTomlContent, - validate: false, // Skip validation in constructor to test validate method - }); - - const result = command.validate(); - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - }); - - it("should return error for invalid TOML content", () => { - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "invalid-command.toml", - fileContent: `prompt = """ -Valid prompt content -"""`, - validate: false, - }); - - // Manually set invalid content to test validation - (command as any).fileContent = invalidTomlContent; - - const result = command.validate(); - expect(result.success).toBe(false); - expect(result.error).toBeInstanceOf(Error); - expect(result.error?.message).toContain("Failed to parse TOML command file"); - }); - - it("should return error for malformed TOML content", () => { - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "malformed-command.toml", - fileContent: validTomlContent, - validate: false, - }); - - // Manually set malformed content to test validation - (command as any).fileContent = malformedTomlContent; - - const result = command.validate(); - expect(result.success).toBe(false); - expect(result.error).toBeInstanceOf(Error); - }); - }); - - describe("GeminiCliCommandFrontmatterSchema", () => { - it("should validate valid frontmatter with description", () => { - const validFrontmatter = { - description: "Test description", - prompt: "Test prompt", - }; - - const result = GeminiCliCommandFrontmatterSchema.parse(validFrontmatter); - expect(result).toEqual(validFrontmatter); - }); - - it("should validate valid frontmatter without description", () => { - const frontmatterWithoutDescription = { - prompt: "Test prompt", - }; - - const result = GeminiCliCommandFrontmatterSchema.parse(frontmatterWithoutDescription); - expect(result).toEqual({ - prompt: "Test prompt", - }); - }); - - it("should throw error for frontmatter without prompt", () => { - const invalidFrontmatter = { - description: "Test description", - }; - - expect(() => GeminiCliCommandFrontmatterSchema.parse(invalidFrontmatter)).toThrow(); - }); - - it("should throw error for frontmatter with invalid types", () => { - const invalidFrontmatter = { - description: 123, // Should be string - prompt: true, // Should be string - }; - - expect(() => GeminiCliCommandFrontmatterSchema.parse(invalidFrontmatter)).toThrow(); - }); - }); - - describe("edge cases", () => { - it("should handle empty prompt content", () => { - const emptyPromptToml = `description = "Empty prompt test" -prompt = ""`; - - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "empty-prompt.toml", - fileContent: emptyPromptToml, - validate: true, - }); - - expect(command.getBody()).toBe(""); - expect(command.getFrontmatter()).toEqual({ - description: "Empty prompt test", - prompt: "", - }); - }); - - it("should handle special characters in prompt", () => { - const specialCharToml = `description = "Special characters test" -prompt = """ -This prompt contains special characters: @#$%^&*() -And unicode: 你好世界 🌍 -And escaped quotes: "Hello "World"" -"""`; - - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "special-char.toml", - fileContent: specialCharToml, - validate: true, - }); - - expect(command.getBody()).toContain("@#$%^&*()"); - expect(command.getBody()).toContain("你好世界 🌍"); - expect(command.getBody()).toContain('Hello "World"'); - }); - - it("should handle very long prompt content", () => { - const longPrompt = "A".repeat(10000); - const longPromptToml = `description = "Long prompt test" -prompt = """ -${longPrompt} -"""`; - - const command = new GeminiCliCommand({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "long-prompt.toml", - fileContent: longPromptToml, - validate: true, - }); - - expect(command.getBody()).toBe(longPrompt + "\n"); - expect(command.getBody().length).toBe(10001); - }); - }); - - describe("isTargetedByRulesyncCommand", () => { - it("should return true for rulesync command with wildcard target", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "test.md", - frontmatter: { targets: ["*"], description: "Test" }, - body: "Body", - fileContent: "", - }); - - const result = GeminiCliCommand.isTargetedByRulesyncCommand(rulesyncCommand); - expect(result).toBe(true); - }); - - it("should return true for rulesync command with geminicli target", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "test.md", - frontmatter: { targets: ["geminicli"], description: "Test" }, - body: "Body", - fileContent: "", - }); - - const result = GeminiCliCommand.isTargetedByRulesyncCommand(rulesyncCommand); - expect(result).toBe(true); - }); - - it("should return true for rulesync command with geminicli and other targets", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "test.md", - frontmatter: { targets: ["cursor", "geminicli", "cline"], description: "Test" }, - body: "Body", - fileContent: "", - }); - - const result = GeminiCliCommand.isTargetedByRulesyncCommand(rulesyncCommand); - expect(result).toBe(true); - }); - - it("should return false for rulesync command with different target", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "test.md", - frontmatter: { targets: ["cursor"], description: "Test" }, - body: "Body", - fileContent: "", - }); - - const result = GeminiCliCommand.isTargetedByRulesyncCommand(rulesyncCommand); - expect(result).toBe(false); - }); - - it("should return true for rulesync command with no targets specified", () => { - const rulesyncCommand = new RulesyncCommand({ - outputRoot: testDir, - relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, - relativeFilePath: "test.md", - frontmatter: { targets: undefined, description: "Test" } as any, - body: "Body", - fileContent: "", - }); - - const result = GeminiCliCommand.isTargetedByRulesyncCommand(rulesyncCommand); - expect(result).toBe(true); - }); - }); - - describe("forDeletion", () => { - it("should create instance for deletion with empty content", () => { - const command = GeminiCliCommand.forDeletion({ - outputRoot: testDir, - relativeDirPath: ".gemini/commands", - relativeFilePath: "to-delete.toml", - }); - - expect(command).toBeInstanceOf(GeminiCliCommand); - expect(command.getBody()).toBe(""); - expect(command.getFrontmatter()).toEqual({ - description: "", - prompt: "", - }); - expect(command.getRelativeDirPath()).toBe(".gemini/commands"); - expect(command.getRelativeFilePath()).toBe("to-delete.toml"); - }); - - it("should use process.cwd() as default outputRoot", () => { - const command = GeminiCliCommand.forDeletion({ - relativeDirPath: ".gemini/commands", - relativeFilePath: "to-delete.toml", - }); - - expect(command).toBeInstanceOf(GeminiCliCommand); - expect(command.getOutputRoot()).toBe(testDir); // testDir is mocked as process.cwd() - }); - }); -}); diff --git a/src/features/commands/geminicli-command.ts b/src/features/commands/geminicli-command.ts deleted file mode 100644 index 28e1e2a75..000000000 --- a/src/features/commands/geminicli-command.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { join } from "node:path"; - -import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; -import { z } from "zod/mini"; - -import { GEMINICLI_COMMANDS_DIR_PATH } from "../../constants/geminicli-paths.js"; -import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; -import { formatError } from "../../utils/error.js"; -import { readFileContent } from "../../utils/file.js"; -import { stringifyFrontmatter } from "../../utils/frontmatter.js"; -import { RulesyncCommand, RulesyncCommandFrontmatter } from "./rulesync-command.js"; -import { - ToolCommand, - ToolCommandForDeletionParams, - ToolCommandFromFileParams, - ToolCommandFromRulesyncCommandParams, - ToolCommandSettablePaths, -} from "./tool-command.js"; - -// looseObject preserves unknown keys during parsing (like passthrough in Zod 3) -export const GeminiCliCommandFrontmatterSchema = z.looseObject({ - description: z.optional(z.string()), - prompt: z.string(), -}); - -/** - * Translate rulesync universal command syntax (Claude Code compatible) into - * Gemini CLI's native syntax. See docs/reference/command-syntax.md. - * - * Replacement order: - * 1. `` !`cmd` `` → `!{cmd}` (backtick shell expansion → brace form) - * 2. `$ARGUMENTS` → `{{args}}` (handled after step 1 so that - * `` !`echo $ARGUMENTS` `` survives as `!{echo {{args}}}` rather than - * requiring two passes). - * - * `$ARGUMENTS\b` uses a trailing word boundary so `$ARGUMENTSx` and - * `$ARGUMENTS_FOO` are left alone, while `$ARGUMENTS-foo` (hyphen is not a - * word char) is rewritten. There is no leading anchor, so `prefix$ARGUMENTS` - * is rewritten to `prefix{{args}}`. - * - * Bodies that already contain Gemini-native forms (`{{args}}` or `!{cmd}`) - * are left untouched, which gives us the documented "we do not re-translate - * already-Gemini-native forms" property. - */ -function translateRulesyncBodyToGemini(body: string): string { - return body.replace(/!`([^`\n]+)`/g, "!{$1}").replace(/\$ARGUMENTS\b/g, "{{args}}"); -} - -/** - * Inverse of {@link translateRulesyncBodyToGemini}, used when importing a - * Gemini CLI command file back into rulesync's universal syntax. - * - * Replacement order is intentionally inverted from the forward direction: - * 1. `{{args}}` → `$ARGUMENTS` (handled first) - * 2. `!{cmd}` → `` !`cmd` `` (handled second, with a non-greedy body - * `[^}\n]+?`) - * - * Doing `{{args}}` first ensures that nested forms like - * `!{echo {{args}}}` round-trip back to `` !`echo $ARGUMENTS` `` in a single - * pass: the inner `{{args}}` is rewritten to `$ARGUMENTS`, and then the - * non-greedy `!{...}` match consumes the smallest possible body. - */ -function translateGeminiBodyToRulesync(body: string): string { - return body.replace(/\{\{\s*args\s*\}\}/g, "$ARGUMENTS").replace(/!\{([^}\n]+?)\}/g, "!`$1`"); -} - -export type GeminiCliCommandFrontmatter = z.infer; - -export class GeminiCliCommand extends ToolCommand { - private readonly frontmatter: GeminiCliCommandFrontmatter; - private readonly body: string; - - constructor(params: AiFileParams) { - super(params); - const parsed = this.parseTomlContent(this.fileContent); - this.frontmatter = parsed; - this.body = parsed.prompt; - } - - static getSettablePaths(_options: { global?: boolean } = {}): ToolCommandSettablePaths { - return { - relativeDirPath: GEMINICLI_COMMANDS_DIR_PATH, - }; - } - - private parseTomlContent(content: string): GeminiCliCommandFrontmatter { - try { - const parsed = parseToml(content); - const result = GeminiCliCommandFrontmatterSchema.safeParse(parsed); - if (!result.success) { - throw new Error( - `Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`, - ); - } - // Preserve all fields including unknown ones (looseObject passthrough) - return { - ...result.data, - description: result.data.description, - }; - } catch (error) { - throw new Error( - `Failed to parse TOML command file (${join(this.relativeDirPath, this.relativeFilePath)}): ${formatError(error)}`, - { cause: error }, - ); - } - } - - getBody(): string { - return this.body; - } - - getFrontmatter(): Record { - return { - description: this.frontmatter.description, - prompt: this.frontmatter.prompt, - }; - } - - toRulesyncCommand(): RulesyncCommand { - const { description, prompt: _prompt, ...restFields } = this.frontmatter; - - const rulesyncFrontmatter: RulesyncCommandFrontmatter = { - targets: ["geminicli"], - description: description, - // Preserve extra fields in geminicli section (excluding prompt which is the body) - ...(Object.keys(restFields).length > 0 && { geminicli: restFields }), - }; - - const universalBody = translateGeminiBodyToRulesync(this.body); - - // Generate proper file content with Rulesync specific frontmatter. The - // `body` and `fileContent` fields below are derived from the same - // `universalBody` source string, so they stay in sync — `body` is the - // raw markdown content while `fileContent` is the same content wrapped - // with YAML frontmatter for on-disk serialization. - const fileContent = stringifyFrontmatter(universalBody, rulesyncFrontmatter); - - return new RulesyncCommand({ - outputRoot: process.cwd(), // RulesyncCommand outputRoot is always the project root directory - frontmatter: rulesyncFrontmatter, - body: universalBody, - relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath, - relativeFilePath: this.relativeFilePath, - fileContent, - validate: true, - }); - } - - static fromRulesyncCommand({ - outputRoot = process.cwd(), - rulesyncCommand, - validate = true, - global = false, - }: ToolCommandFromRulesyncCommandParams): GeminiCliCommand { - const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); - - // Merge geminicli-specific fields from rulesync frontmatter - const geminicliFields = rulesyncFrontmatter.geminicli ?? {}; - - // Translate universal command syntax to Gemini CLI's native syntax — - // unless an explicit `geminicli.prompt` override is present, in which - // case the user is hand-authoring the Gemini-native body and we skip - // translation entirely. Short-circuiting here avoids running the regex - // pipeline only to discard its result via the spread below. - const hasPromptOverride = typeof geminicliFields.prompt === "string"; - const translatedPrompt = hasPromptOverride - ? "" - : translateRulesyncBodyToGemini(rulesyncCommand.getBody()); - - const geminiFrontmatter: GeminiCliCommandFrontmatter = { - description: rulesyncFrontmatter.description, - prompt: translatedPrompt, - ...geminicliFields, - }; - - // Serialize via smol-toml's stringify so that special characters in the - // description / prompt (`"`, `\`, control chars, embedded `"""`, etc.) - // are properly escaped instead of breaking out of the TOML literal. The - // serializer emits each value as a basic string with JSON-style escaping - // — multi-line bodies are encoded with `\n` escape sequences, which - // round-trip cleanly through `parseToml`. - const tomlObject: Record = {}; - if (geminiFrontmatter.description !== undefined) { - tomlObject.description = geminiFrontmatter.description; - } - // Preserve the historical trailing-newline behavior of the prompt body. - // - // Before the migration to `stringifyToml`, the serializer wrote - // `prompt = """\n${body}\n"""` — a multi-line basic string in which the - // surrounding literal newlines are real bytes on disk, parsed back into - // a single trailing `\n` by `parseToml`. Downstream code, snapshots, and - // round-trip tests rely on that trailing newline being present in - // `parsed.prompt`. - // - // The new `stringifyToml`-based serializer emits a basic single-line - // string with `\n` escape sequences instead. The on-disk *shape* is - // therefore different (no surrounding `"""`, escaped `\n` in place of - // raw newline bytes), but the parsed-string equivalence is preserved by - // unconditionally ensuring the in-memory value ends with `\n` before - // serialization. This keeps the externally-observable contract stable. - tomlObject.prompt = geminiFrontmatter.prompt.endsWith("\n") - ? geminiFrontmatter.prompt - : `${geminiFrontmatter.prompt}\n`; - // Note: TOML output only carries description and prompt. Extra fields - // from the `geminicli` rulesync section are intentionally not serialized. - const tomlContent = stringifyToml(tomlObject); - - const paths = this.getSettablePaths({ global }); - - return new GeminiCliCommand({ - outputRoot: outputRoot, - relativeDirPath: paths.relativeDirPath, - relativeFilePath: rulesyncCommand.getRelativeFilePath().replace(".md", ".toml"), - fileContent: tomlContent, - validate, - }); - } - - static async fromFile({ - outputRoot = process.cwd(), - relativeFilePath, - validate = true, - global = false, - }: ToolCommandFromFileParams): Promise { - const paths = this.getSettablePaths({ global }); - const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath); - // Read file content - const fileContent = await readFileContent(filePath); - - return new GeminiCliCommand({ - outputRoot: outputRoot, - relativeDirPath: paths.relativeDirPath, - relativeFilePath, - fileContent, - validate, - }); - } - - validate(): ValidationResult { - try { - this.parseTomlContent(this.fileContent); - return { success: true, error: null }; - } catch (error) { - return { success: false, error: error instanceof Error ? error : new Error(String(error)) }; - } - } - - static isTargetedByRulesyncCommand(rulesyncCommand: RulesyncCommand): boolean { - return this.isTargetedByRulesyncCommandDefault({ - rulesyncCommand, - toolTarget: "geminicli", - }); - } - - static forDeletion({ - outputRoot = process.cwd(), - relativeDirPath, - relativeFilePath, - }: ToolCommandForDeletionParams): GeminiCliCommand { - // Provide minimal valid TOML to pass constructor parsing. - // The constructor always calls parseTomlContent(), so we need valid TOML even for deletion. - const placeholderToml = `description = "" -prompt = ""`; - return new GeminiCliCommand({ - outputRoot, - relativeDirPath, - relativeFilePath, - fileContent: placeholderToml, - validate: false, - }); - } -} diff --git a/src/features/hooks/geminicli-hooks.test.ts b/src/features/hooks/geminicli-hooks.test.ts deleted file mode 100644 index 59e1c2dab..000000000 --- a/src/features/hooks/geminicli-hooks.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { join } from "node:path"; - -import { describe, expect, it, beforeEach, afterEach } from "vitest"; - -import { setupTestDirectory } from "../../test-utils/test-directories.js"; -import { readOrInitializeFileContent, ensureDir, writeFileContent } from "../../utils/file.js"; -import { GeminicliHooks } from "./geminicli-hooks.js"; -import { RulesyncHooks } from "./rulesync-hooks.js"; - -function createMockAiFileParams( - override: Partial[0]> = {}, -) { - return { - outputRoot: "/mock", - relativeDirPath: ".rulesync", - relativeFilePath: "hooks.json", - fileContent: "{}", - ...override, - }; -} - -describe("GeminicliHooks", () => { - let testDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ testDir, cleanup } = await setupTestDirectory()); - }); - - afterEach(async () => { - await cleanup(); - }); - - describe("fromRulesyncHooks", () => { - it("should filter unsupported events and convert to Gemini CLI format", async () => { - const rulesyncHooks = new RulesyncHooks( - createMockAiFileParams({ - fileContent: JSON.stringify({ - hooks: { - sessionStart: [ - { command: "echo start", name: "Start Hook", description: "Runs on start" }, - ], - unsupportedEvent: [{ command: "echo ignored" }], - }, - }), - }), - ); - - const geminiHooks = await GeminicliHooks.fromRulesyncHooks({ - outputRoot: testDir, - rulesyncHooks, - validate: true, - }); - - const parsed = JSON.parse(geminiHooks.getFileContent()); - expect(parsed.hooks).toBeDefined(); - expect(parsed.hooks.SessionStart).toBeDefined(); - expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("echo start"); - expect(parsed.hooks.SessionStart[0].hooks[0].name).toBe("Start Hook"); - expect(parsed.hooks.SessionStart[0].hooks[0].description).toBe("Runs on start"); - expect(parsed.hooks.UnsupportedEvent).toBeUndefined(); - }); - - it("should prefix dot-relative commands with $GEMINI_PROJECT_DIR", async () => { - const rulesyncHooks = new RulesyncHooks( - createMockAiFileParams({ - fileContent: JSON.stringify({ - hooks: { - sessionStart: [{ command: "./hooks/start.sh" }], - }, - }), - }), - ); - - const geminiHooks = await GeminicliHooks.fromRulesyncHooks({ - outputRoot: testDir, - rulesyncHooks, - validate: true, - }); - - const parsed = JSON.parse(geminiHooks.getFileContent()); - expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe( - "$GEMINI_PROJECT_DIR/hooks/start.sh", - ); - }); - - it("should merge with existing settings.json", async () => { - const mockSettings = { - theme: "dark", - hooks: { - OldEvent: [{ hooks: [{ command: "old" }] }], - }, - }; - - const settingsPath = join(testDir, ".gemini", "settings.json"); - await ensureDir(join(testDir, ".gemini")); - await readOrInitializeFileContent(settingsPath, JSON.stringify(mockSettings)); - - const rulesyncHooks = new RulesyncHooks( - createMockAiFileParams({ - fileContent: JSON.stringify({ - hooks: { - sessionStart: [{ command: "echo start" }], - }, - }), - }), - ); - - const geminiHooks = await GeminicliHooks.fromRulesyncHooks({ - outputRoot: testDir, - rulesyncHooks, - validate: true, - }); - - const parsed = JSON.parse(geminiHooks.getFileContent()); - expect(parsed.theme).toBe("dark"); - expect(parsed.hooks.SessionStart).toBeDefined(); - expect(parsed.hooks.OldEvent).toBeUndefined(); // Existing hooks are overwritten by Rulesync - }); - - it("should process overrides", async () => { - const rulesyncHooks = new RulesyncHooks( - createMockAiFileParams({ - fileContent: JSON.stringify({ - hooks: { - sessionStart: [{ command: "echo start" }], - }, - geminicli: { - hooks: { - sessionStart: [{ command: "echo override" }], - sessionEnd: [{ command: "echo end" }], - }, - }, - }), - }), - ); - - const geminiHooks = await GeminicliHooks.fromRulesyncHooks({ - outputRoot: testDir, - rulesyncHooks, - validate: true, - }); - - const parsed = JSON.parse(geminiHooks.getFileContent()); - expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("echo override"); - expect(parsed.hooks.SessionEnd[0].hooks[0].command).toBe("echo end"); - }); - }); - - describe("toRulesyncHooks", () => { - it("should convert Gemini CLI format to canonical format", () => { - const geminiHooks = new GeminicliHooks( - createMockAiFileParams({ - fileContent: JSON.stringify({ - hooks: { - SessionStart: [ - { - matcher: "init", - hooks: [ - { - type: "command", - command: "$GEMINI_PROJECT_DIR/echo start", - timeout: 1000, - name: "Start Hook", - description: "Runs on start", - }, - ], - }, - ], - }, - }), - }), - ); - - const rulesyncHooks = geminiHooks.toRulesyncHooks(); - const parsed = rulesyncHooks.getJson(); - - expect(parsed.hooks.sessionStart).toBeDefined(); - expect(parsed.hooks.sessionStart?.[0]).toEqual({ - type: "command", - command: "./echo start", - timeout: 1000, - matcher: "init", - name: "Start Hook", - description: "Runs on start", - }); - }); - - it("should handle missing optional fields", () => { - const geminiHooks = new GeminicliHooks( - createMockAiFileParams({ - fileContent: JSON.stringify({ - hooks: { - SessionEnd: [ - { - hooks: [ - { - command: "echo end", - }, - ], - }, - ], - }, - }), - }), - ); - - const rulesyncHooks = geminiHooks.toRulesyncHooks(); - const parsed = rulesyncHooks.getJson(); - - expect(parsed.hooks.sessionEnd).toBeDefined(); - expect(parsed.hooks.sessionEnd?.[0]).toEqual({ - type: "command", - command: "echo end", - }); - }); - - it("should ignore invalid entries", () => { - const geminiHooks = new GeminicliHooks( - createMockAiFileParams({ - fileContent: JSON.stringify({ - hooks: { - SessionStart: "invalid", // Not an array - SessionEnd: [ - "invalid", // Not an object - { hooks: "invalid" }, // hooks is not an array - ], - }, - }), - }), - ); - - const rulesyncHooks = geminiHooks.toRulesyncHooks(); - const parsed = rulesyncHooks.getJson(); - - expect(parsed.hooks.sessionStart).toBeUndefined(); - expect(parsed.hooks.sessionEnd).toBeUndefined(); - }); - }); - - describe("fromFile", () => { - it("should load from .gemini/settings.json when it exists", async () => { - await ensureDir(join(testDir, ".gemini")); - await writeFileContent( - join(testDir, ".gemini", "settings.json"), - JSON.stringify({ - hooks: { - SessionStart: [ - { hooks: [{ type: "command", command: "$GEMINI_PROJECT_DIR/echo start" }] }, - ], - }, - }), - ); - - const geminiHooks = await GeminicliHooks.fromFile({ - outputRoot: testDir, - validate: false, - }); - expect(geminiHooks).toBeInstanceOf(GeminicliHooks); - const content = geminiHooks.getFileContent(); - const parsed = JSON.parse(content); - expect(parsed.hooks.SessionStart).toHaveLength(1); - }); - - it("should initialize empty hooks when settings.json does not exist", async () => { - const geminiHooks = await GeminicliHooks.fromFile({ - outputRoot: testDir, - validate: false, - }); - expect(geminiHooks).toBeInstanceOf(GeminicliHooks); - const content = geminiHooks.getFileContent(); - const parsed = JSON.parse(content); - expect(parsed.hooks).toEqual({}); - }); - }); - - describe("isDeletable", () => { - it("should return false", () => { - const hooks = new GeminicliHooks(createMockAiFileParams()); - expect(hooks.isDeletable()).toBe(false); - }); - }); - - describe("forDeletion", () => { - it("should create instance with empty hooks", () => { - const hooks = GeminicliHooks.forDeletion({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - }); - const parsed = JSON.parse(hooks.getFileContent()); - expect(parsed.hooks).toEqual({}); - }); - }); -}); diff --git a/src/features/hooks/geminicli-hooks.ts b/src/features/hooks/geminicli-hooks.ts deleted file mode 100644 index c81465d14..000000000 --- a/src/features/hooks/geminicli-hooks.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { join } from "node:path"; - -import { z } from "zod/mini"; - -import { GEMINICLI_DIR, GEMINICLI_HOOKS_FILE_NAME } from "../../constants/geminicli-paths.js"; -import type { AiFileParams } from "../../types/ai-file.js"; -import type { ValidationResult } from "../../types/ai-file.js"; -import type { HooksConfig } from "../../types/hooks.js"; -import { - GEMINICLI_HOOK_EVENTS, - GEMINICLI_TO_CANONICAL_EVENT_NAMES, - CANONICAL_TO_GEMINICLI_EVENT_NAMES, -} from "../../types/hooks.js"; -import { formatError } from "../../utils/error.js"; -import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js"; -import type { RulesyncHooks } from "./rulesync-hooks.js"; -import { - ToolHooks, - type ToolHooksForDeletionParams, - type ToolHooksFromFileParams, - type ToolHooksFromRulesyncHooksParams, - type ToolHooksSettablePaths, -} from "./tool-hooks.js"; - -/** - * Convert canonical hooks config to Gemini CLI format. - * Filters shared hooks to GEMINICLI_HOOK_EVENTS, merges config.geminicli?.hooks, - * then converts to PascalCase and Gemini CLI matcher/hooks structure. - */ -function canonicalToGeminicliHooks(config: HooksConfig): Record { - const geminiSupported: Set = new Set(GEMINICLI_HOOK_EVENTS); - const sharedHooks: HooksConfig["hooks"] = {}; - for (const [event, defs] of Object.entries(config.hooks)) { - if (geminiSupported.has(event)) { - sharedHooks[event] = defs; - } - } - const effectiveHooks: HooksConfig["hooks"] = { - ...sharedHooks, - ...config.geminicli?.hooks, - }; - const gemini: Record = {}; - for (const [eventName, definitions] of Object.entries(effectiveHooks)) { - const geminiEventName = CANONICAL_TO_GEMINICLI_EVENT_NAMES[eventName] ?? eventName; - const byMatcher = new Map(); - for (const def of definitions) { - const key = def.matcher ?? ""; - const list = byMatcher.get(key); - if (list) list.push(def); - else byMatcher.set(key, [def]); - } - const entries: unknown[] = []; - for (const [matcherKey, defs] of byMatcher) { - const hooks = defs.map((def) => { - const commandText = def.command; - const trimmedCommand = - typeof commandText === "string" ? commandText.trimStart() : undefined; - const shouldPrefix = - typeof trimmedCommand === "string" && - !trimmedCommand.startsWith("$") && - trimmedCommand.startsWith("."); - - const command = - shouldPrefix && typeof trimmedCommand === "string" - ? `$GEMINI_PROJECT_DIR/${trimmedCommand.replace(/^\.\//, "")}` - : def.command; - return { - type: def.type ?? "command", - ...(command !== undefined && command !== null && { command }), - ...(def.timeout !== undefined && def.timeout !== null && { timeout: def.timeout }), - ...(def.name !== undefined && def.name !== null && { name: def.name }), - ...(def.description !== undefined && - def.description !== null && { description: def.description }), - }; - }); - entries.push(matcherKey ? { matcher: matcherKey, hooks } : { hooks }); - } - gemini[geminiEventName] = entries; - } - return gemini; -} - -/** - * Gemini CLI hook entry as stored in each matcher group's `hooks` array. - * Uses `z.looseObject` so that unknown fields added by future Gemini CLI - * versions are accepted and silently ignored during import. - */ -const GeminiHookEntrySchema = z.looseObject({ - type: z.optional(z.string()), - command: z.optional(z.string()), - timeout: z.optional(z.number()), - name: z.optional(z.string()), - description: z.optional(z.string()), -}); - -/** - * A matcher group entry in a Gemini CLI event array. - * Each event maps to an array of these groups. - */ -const GeminiMatcherEntrySchema = z.looseObject({ - matcher: z.optional(z.string()), - hooks: z.optional(z.array(GeminiHookEntrySchema)), -}); - -/** - * Convert a single parsed Gemini CLI matcher group into canonical hook definitions. - */ -function geminiMatcherEntryToCanonical( - entry: z.infer, -): HooksConfig["hooks"][string] { - const defs: HooksConfig["hooks"][string] = []; - const hooks = entry.hooks ?? []; - for (const h of hooks) { - const cmd = h.command; - const command = - typeof cmd === "string" && cmd.startsWith("$GEMINI_PROJECT_DIR/") - ? cmd.replace(/^\$GEMINI_PROJECT_DIR\/?/, "./") - : cmd; - const hookType = h.type === "command" || h.type === "prompt" ? h.type : "command"; - defs.push({ - type: hookType, - ...(command !== undefined && command !== null && { command }), - ...(h.timeout !== undefined && h.timeout !== null && { timeout: h.timeout }), - ...(h.name !== undefined && h.name !== null && { name: h.name }), - ...(h.description !== undefined && h.description !== null && { description: h.description }), - ...(entry.matcher !== undefined && - entry.matcher !== null && - entry.matcher !== "" && { matcher: entry.matcher }), - }); - } - return defs; -} - -/** - * Extract hooks from Gemini CLI settings.json into canonical format. - */ -function geminiHooksToCanonical(geminiHooks: unknown): HooksConfig["hooks"] { - if (geminiHooks === null || geminiHooks === undefined || typeof geminiHooks !== "object") { - return {}; - } - const canonical: HooksConfig["hooks"] = {}; - for (const [geminiEventName, matcherEntries] of Object.entries(geminiHooks)) { - const eventName = GEMINICLI_TO_CANONICAL_EVENT_NAMES[geminiEventName] ?? geminiEventName; - if (!Array.isArray(matcherEntries)) continue; - const defs: HooksConfig["hooks"][string] = []; - for (const rawEntry of matcherEntries) { - const parseResult = GeminiMatcherEntrySchema.safeParse(rawEntry); - if (!parseResult.success) continue; - defs.push(...geminiMatcherEntryToCanonical(parseResult.data)); - } - if (defs.length > 0) { - canonical[eventName] = defs; - } - } - return canonical; -} - -export class GeminicliHooks extends ToolHooks { - constructor(params: AiFileParams) { - super({ - ...params, - fileContent: params.fileContent ?? "{}", - }); - } - - override isDeletable(): boolean { - return false; - } - - static getSettablePaths(_options: { global?: boolean } = {}): ToolHooksSettablePaths { - return { relativeDirPath: GEMINICLI_DIR, relativeFilePath: GEMINICLI_HOOKS_FILE_NAME }; - } - - static async fromFile({ - outputRoot = process.cwd(), - validate = true, - global = false, - }: ToolHooksFromFileParams): Promise { - const paths = GeminicliHooks.getSettablePaths({ global }); - const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); - const fileContent = (await readFileContentOrNull(filePath)) ?? '{"hooks":{}}'; - return new GeminicliHooks({ - outputRoot, - relativeDirPath: paths.relativeDirPath, - relativeFilePath: paths.relativeFilePath, - fileContent, - validate, - }); - } - - static async fromRulesyncHooks({ - outputRoot = process.cwd(), - rulesyncHooks, - validate = true, - global = false, - }: ToolHooksFromRulesyncHooksParams & { global?: boolean }): Promise { - const paths = GeminicliHooks.getSettablePaths({ global }); - const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); - const existingContent = await readOrInitializeFileContent( - filePath, - JSON.stringify({}, null, 2), - ); - let settings: Record; - try { - settings = JSON.parse(existingContent); - } catch (error) { - throw new Error( - `Failed to parse existing Gemini CLI settings at ${filePath}: ${formatError(error)}`, - { cause: error }, - ); - } - const config = rulesyncHooks.getJson(); - const geminiHooks = canonicalToGeminicliHooks(config); - const merged = { ...settings, hooks: geminiHooks }; - const fileContent = JSON.stringify(merged, null, 2); - return new GeminicliHooks({ - outputRoot, - relativeDirPath: paths.relativeDirPath, - relativeFilePath: paths.relativeFilePath, - fileContent, - validate, - }); - } - - toRulesyncHooks(): RulesyncHooks { - let settings: { hooks?: unknown }; - try { - settings = JSON.parse(this.getFileContent()); - } catch (error) { - throw new Error( - `Failed to parse Gemini CLI hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, - { - cause: error, - }, - ); - } - const hooks = geminiHooksToCanonical(settings.hooks); - return this.toRulesyncHooksDefault({ - fileContent: JSON.stringify({ version: 1, hooks }, null, 2), - }); - } - - validate(): ValidationResult { - return { success: true, error: null }; - } - - static forDeletion({ - outputRoot = process.cwd(), - relativeDirPath, - relativeFilePath, - }: ToolHooksForDeletionParams): GeminicliHooks { - return new GeminicliHooks({ - outputRoot, - relativeDirPath, - relativeFilePath, - fileContent: JSON.stringify({ hooks: {} }, null, 2), - validate: false, - }); - } -} diff --git a/src/features/hooks/hooks-processor.test.ts b/src/features/hooks/hooks-processor.test.ts index 05dc8cdb4..72da6ca03 100644 --- a/src/features/hooks/hooks-processor.test.ts +++ b/src/features/hooks/hooks-processor.test.ts @@ -481,7 +481,7 @@ describe("HooksProcessor", () => { }); describe("getToolTargets", () => { - it("should return cursor, claudecode, copilot, copilotcli, opencode, kilo, factorydroid, geminicli, and kiro for project mode", () => { + it("should return cursor, claudecode, copilot, copilotcli, opencode, kilo, factorydroid, and kiro for project mode", () => { const targets = HooksProcessor.getToolTargets({ global: false }); expect(targets).toEqual([ "antigravity-cli", @@ -494,7 +494,6 @@ describe("HooksProcessor", () => { "kilo", "opencode", "factorydroid", - "geminicli", "goose", "kiro", "kiro-cli", @@ -505,7 +504,7 @@ describe("HooksProcessor", () => { ]); }); - it("should return cursor, claudecode, copilotcli, opencode, kilo, factorydroid, and geminicli for global mode", () => { + it("should return cursor, claudecode, copilotcli, opencode, kilo, and factorydroid for global mode", () => { const targets = HooksProcessor.getToolTargets({ global: true }); expect(targets).toEqual([ "antigravity-cli", @@ -517,7 +516,6 @@ describe("HooksProcessor", () => { "kilo", "opencode", "factorydroid", - "geminicli", "goose", "hermesagent", "deepagents", @@ -540,7 +538,6 @@ describe("HooksProcessor", () => { "copilot", "copilotcli", "factorydroid", - "geminicli", "goose", "kiro", "kiro-cli", @@ -561,7 +558,6 @@ describe("HooksProcessor", () => { "codexcli", "copilotcli", "factorydroid", - "geminicli", "goose", "hermesagent", "deepagents", diff --git a/src/features/hooks/hooks-processor.ts b/src/features/hooks/hooks-processor.ts index 760e69432..d36995d36 100644 --- a/src/features/hooks/hooks-processor.ts +++ b/src/features/hooks/hooks-processor.ts @@ -12,7 +12,6 @@ import { CURSOR_HOOK_EVENTS, DEEPAGENTS_HOOK_EVENTS, FACTORYDROID_HOOK_EVENTS, - GEMINICLI_HOOK_EVENTS, GOOSE_HOOK_EVENTS, JUNIE_HOOK_EVENTS, KILO_HOOK_EVENTS, @@ -39,7 +38,6 @@ import { CursorHooks } from "./cursor-hooks.js"; import { DeepagentsHooks } from "./deepagents-hooks.js"; import { DEVIN_HOOK_EVENTS, DevinHooks } from "./devin-hooks.js"; import { FactorydroidHooks } from "./factorydroid-hooks.js"; -import { GeminicliHooks } from "./geminicli-hooks.js"; import { GooseHooks } from "./goose-hooks.js"; import { HermesagentHooks } from "./hermesagent-hooks.js"; import { JunieHooks } from "./junie-hooks.js"; @@ -233,16 +231,6 @@ export const toolHooksFactories = new Map { - let testDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ testDir, cleanup } = await setupTestDirectory()); - vi.spyOn(process, "cwd").mockReturnValue(testDir); - }); - - afterEach(async () => { - await cleanup(); - vi.restoreAllMocks(); - }); - - describe("constructor", () => { - it("should create instance with default parameters", () => { - const geminiCliIgnore = new GeminiCliIgnore({ - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent: "*.log\nnode_modules/", - }); - - expect(geminiCliIgnore).toBeInstanceOf(GeminiCliIgnore); - expect(geminiCliIgnore.getRelativeDirPath()).toBe("."); - expect(geminiCliIgnore.getRelativeFilePath()).toBe(".geminiignore"); - expect(geminiCliIgnore.getFileContent()).toBe("*.log\nnode_modules/"); - }); - - it("should create instance with custom outputRoot", () => { - const geminiCliIgnore = new GeminiCliIgnore({ - outputRoot: "/custom/path", - relativeDirPath: "subdir", - relativeFilePath: ".geminiignore", - fileContent: "*.tmp", - }); - - expect(geminiCliIgnore.getFilePath()).toBe("/custom/path/subdir/.geminiignore"); - }); - - it("should validate content by default", () => { - expect(() => { - const _instance = new GeminiCliIgnore({ - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent: "", // empty content should be valid - }); - }).not.toThrow(); - }); - - it("should skip validation when validate=false", () => { - expect(() => { - const _instance = new GeminiCliIgnore({ - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent: "any content", - validate: false, - }); - }).not.toThrow(); - }); - }); - - describe("toRulesyncIgnore", () => { - it("should convert to RulesyncIgnore with same content", () => { - const fileContent = "*.log\nnode_modules/\n.env"; - const geminiCliIgnore = new GeminiCliIgnore({ - outputRoot: testDir, - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent, - }); - - const rulesyncIgnore = geminiCliIgnore.toRulesyncIgnore(); - - expect(rulesyncIgnore).toBeInstanceOf(RulesyncIgnore); - expect(rulesyncIgnore.getFileContent()).toBe(fileContent); - expect(rulesyncIgnore.getRelativeDirPath()).toBe(RULESYNC_RELATIVE_DIR_PATH); - expect(rulesyncIgnore.getRelativeFilePath()).toBe(RULESYNC_AIIGNORE_FILE_NAME); - }); - - it("should handle empty content", () => { - const geminiCliIgnore = new GeminiCliIgnore({ - outputRoot: testDir, - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent: "", - }); - - const rulesyncIgnore = geminiCliIgnore.toRulesyncIgnore(); - - expect(rulesyncIgnore.getFileContent()).toBe(""); - }); - - it("should preserve patterns and formatting", () => { - const fileContent = "# Generated files\n*.log\n*.tmp\n\n# Dependencies\nnode_modules/\n.env*"; - const geminiCliIgnore = new GeminiCliIgnore({ - outputRoot: testDir, - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent, - }); - - const rulesyncIgnore = geminiCliIgnore.toRulesyncIgnore(); - - expect(rulesyncIgnore.getFileContent()).toBe(fileContent); - }); - }); - - describe("fromRulesyncIgnore", () => { - it("should create GeminiCliIgnore from RulesyncIgnore with default outputRoot", () => { - const fileContent = "*.log\nnode_modules/\n.env"; - const rulesyncIgnore = new RulesyncIgnore({ - relativeDirPath: ".rulesync", - relativeFilePath: ".rulesignore", - fileContent, - }); - - const geminiCliIgnore = GeminiCliIgnore.fromRulesyncIgnore({ - rulesyncIgnore, - }); - - expect(geminiCliIgnore).toBeInstanceOf(GeminiCliIgnore); - expect(geminiCliIgnore.getOutputRoot()).toBe(testDir); - expect(geminiCliIgnore.getRelativeDirPath()).toBe("."); - expect(geminiCliIgnore.getRelativeFilePath()).toBe(".geminiignore"); - expect(geminiCliIgnore.getFileContent()).toBe(fileContent); - }); - - it("should create GeminiCliIgnore from RulesyncIgnore with custom outputRoot", () => { - const fileContent = "*.tmp\nbuild/"; - const rulesyncIgnore = new RulesyncIgnore({ - relativeDirPath: ".rulesync", - relativeFilePath: ".rulesignore", - fileContent, - }); - - const geminiCliIgnore = GeminiCliIgnore.fromRulesyncIgnore({ - outputRoot: "/custom/base", - rulesyncIgnore, - }); - - expect(geminiCliIgnore.getOutputRoot()).toBe("/custom/base"); - expect(geminiCliIgnore.getFilePath()).toBe("/custom/base/.geminiignore"); - expect(geminiCliIgnore.getFileContent()).toBe(fileContent); - }); - - it("should handle empty content", () => { - const rulesyncIgnore = new RulesyncIgnore({ - relativeDirPath: ".rulesync", - relativeFilePath: ".rulesignore", - fileContent: "", - }); - - const geminiCliIgnore = GeminiCliIgnore.fromRulesyncIgnore({ - rulesyncIgnore, - }); - - expect(geminiCliIgnore.getFileContent()).toBe(""); - }); - - it("should preserve complex patterns", () => { - const fileContent = "# Comments\n*.log\n**/*.tmp\n!important.tmp\nnode_modules/\n.env*"; - const rulesyncIgnore = new RulesyncIgnore({ - relativeDirPath: ".rulesync", - relativeFilePath: ".rulesignore", - fileContent, - }); - - const geminiCliIgnore = GeminiCliIgnore.fromRulesyncIgnore({ - rulesyncIgnore, - }); - - expect(geminiCliIgnore.getFileContent()).toBe(fileContent); - }); - }); - - describe("fromFile", () => { - it("should read .geminiignore file from outputRoot with default outputRoot", async () => { - const fileContent = "*.log\nnode_modules/\n.env"; - const aiexcludePath = join(testDir, ".geminiignore"); - await writeFileContent(aiexcludePath, fileContent); - - const geminiCliIgnore = await GeminiCliIgnore.fromFile({ - outputRoot: testDir, - }); - - expect(geminiCliIgnore).toBeInstanceOf(GeminiCliIgnore); - expect(geminiCliIgnore.getOutputRoot()).toBe(testDir); - expect(geminiCliIgnore.getRelativeDirPath()).toBe("."); - expect(geminiCliIgnore.getRelativeFilePath()).toBe(".geminiignore"); - expect(geminiCliIgnore.getFileContent()).toBe(fileContent); - }); - - it("should read .geminiignore file with validation enabled by default", async () => { - const fileContent = "*.log\nnode_modules/"; - const aiexcludePath = join(testDir, ".geminiignore"); - await writeFileContent(aiexcludePath, fileContent); - - const geminiCliIgnore = await GeminiCliIgnore.fromFile({ - outputRoot: testDir, - }); - - expect(geminiCliIgnore.getFileContent()).toBe(fileContent); - }); - - it("should read .geminiignore file with validation disabled", async () => { - const fileContent = "*.log\nnode_modules/"; - const aiexcludePath = join(testDir, ".geminiignore"); - await writeFileContent(aiexcludePath, fileContent); - - const geminiCliIgnore = await GeminiCliIgnore.fromFile({ - outputRoot: testDir, - validate: false, - }); - - expect(geminiCliIgnore.getFileContent()).toBe(fileContent); - }); - - it("should handle empty .geminiignore file", async () => { - const aiexcludePath = join(testDir, ".geminiignore"); - await writeFileContent(aiexcludePath, ""); - - const geminiCliIgnore = await GeminiCliIgnore.fromFile({ - outputRoot: testDir, - }); - - expect(geminiCliIgnore.getFileContent()).toBe(""); - }); - - it("should handle .geminiignore file with complex patterns", async () => { - const fileContent = `# Build outputs -build/ -dist/ -*.map - -# Dependencies -node_modules/ -.pnpm-store/ - -# Environment files -.env* -!.env.example - -# IDE files -.vscode/ -.idea/ - -# Logs -*.log -logs/ - -# Cache -.cache/ -*.tmp -*.temp - -# OS generated files -.DS_Store -Thumbs.db`; - - const aiexcludePath = join(testDir, ".geminiignore"); - await writeFileContent(aiexcludePath, fileContent); - - const geminiCliIgnore = await GeminiCliIgnore.fromFile({ - outputRoot: testDir, - }); - - expect(geminiCliIgnore.getFileContent()).toBe(fileContent); - }); - - it("should default outputRoot to process.cwd() when not provided", async () => { - // process.cwd() is already mocked to return testDir in beforeEach - const fileContent = "*.log\nnode_modules/"; - const aiexcludePath = join(testDir, ".geminiignore"); - await writeFileContent(aiexcludePath, fileContent); - - const geminiCliIgnore = await GeminiCliIgnore.fromFile({}); - - expect(geminiCliIgnore.getOutputRoot()).toBe(testDir); - expect(geminiCliIgnore.getFileContent()).toBe(fileContent); - }); - - it("should throw error when .geminiignore file does not exist", async () => { - await expect( - GeminiCliIgnore.fromFile({ - outputRoot: testDir, - }), - ).rejects.toThrow(); - }); - - it("should handle file with Windows line endings", async () => { - const fileContent = "*.log\r\nnode_modules/\r\n.env"; - const aiexcludePath = join(testDir, ".geminiignore"); - await writeFileContent(aiexcludePath, fileContent); - - const geminiCliIgnore = await GeminiCliIgnore.fromFile({ - outputRoot: testDir, - }); - - expect(geminiCliIgnore.getFileContent()).toBe(fileContent); - }); - }); - - describe("inheritance from ToolIgnore", () => { - it("should inherit getPatterns method", () => { - const fileContent = "*.log\nnode_modules/\n.env"; - const geminiCliIgnore = new GeminiCliIgnore({ - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent, - }); - - const patterns = geminiCliIgnore.getPatterns(); - - expect(Array.isArray(patterns)).toBe(true); - expect(patterns).toEqual(["*.log", "node_modules/", ".env"]); - }); - - it("should inherit validation method", () => { - const geminiCliIgnore = new GeminiCliIgnore({ - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent: "*.log\nnode_modules/", - }); - - const result = geminiCliIgnore.validate(); - - expect(result.success).toBe(true); - expect(result.error).toBe(null); - }); - - it("should inherit file path methods from ToolFile", () => { - const geminiCliIgnore = new GeminiCliIgnore({ - outputRoot: "/test/base", - relativeDirPath: "subdir", - relativeFilePath: ".geminiignore", - fileContent: "*.log", - }); - - expect(geminiCliIgnore.getOutputRoot()).toBe("/test/base"); - expect(geminiCliIgnore.getRelativeDirPath()).toBe("subdir"); - expect(geminiCliIgnore.getRelativeFilePath()).toBe(".geminiignore"); - expect(geminiCliIgnore.getFilePath()).toBe("/test/base/subdir/.geminiignore"); - expect(geminiCliIgnore.getFileContent()).toBe("*.log"); - }); - }); - - describe("round-trip conversion", () => { - it("should maintain content integrity in round-trip conversion", () => { - const originalContent = `# Gemini CLI ignore patterns -*.log -node_modules/ -.env* -build/ -dist/ -*.tmp`; - - // GeminiCliIgnore -> RulesyncIgnore -> GeminiCliIgnore - const originalGeminiCliIgnore = new GeminiCliIgnore({ - outputRoot: testDir, - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent: originalContent, - }); - - const rulesyncIgnore = originalGeminiCliIgnore.toRulesyncIgnore(); - const roundTripGeminiCliIgnore = GeminiCliIgnore.fromRulesyncIgnore({ - outputRoot: testDir, - rulesyncIgnore, - }); - - expect(roundTripGeminiCliIgnore.getFileContent()).toBe(originalContent); - expect(roundTripGeminiCliIgnore.getOutputRoot()).toBe(testDir); - expect(roundTripGeminiCliIgnore.getRelativeDirPath()).toBe("."); - expect(roundTripGeminiCliIgnore.getRelativeFilePath()).toBe(".geminiignore"); - }); - - it("should maintain patterns in round-trip conversion", () => { - const patterns = ["*.log", "node_modules/", ".env", "build/", "*.tmp"]; - const originalContent = patterns.join("\n"); - - const originalGeminiCliIgnore = new GeminiCliIgnore({ - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent: originalContent, - }); - - const rulesyncIgnore = originalGeminiCliIgnore.toRulesyncIgnore(); - const roundTripGeminiCliIgnore = GeminiCliIgnore.fromRulesyncIgnore({ - rulesyncIgnore, - }); - - expect(roundTripGeminiCliIgnore.getPatterns()).toEqual(patterns); - }); - }); - - describe("edge cases", () => { - it("should handle file content with only whitespace", () => { - const geminiCliIgnore = new GeminiCliIgnore({ - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent: " \n\t\n ", - }); - - expect(geminiCliIgnore.getFileContent()).toBe(" \n\t\n "); - // Patterns are trimmed and empty lines are filtered out - expect(geminiCliIgnore.getPatterns()).toEqual([]); - }); - - it("should handle file content with mixed line endings", () => { - const fileContent = "*.log\r\nnode_modules/\n.env\r\nbuild/"; - const geminiCliIgnore = new GeminiCliIgnore({ - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent, - }); - - expect(geminiCliIgnore.getFileContent()).toBe(fileContent); - }); - - it("should handle very long patterns", () => { - const longPattern = "a".repeat(1000); - const geminiCliIgnore = new GeminiCliIgnore({ - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent: longPattern, - }); - - expect(geminiCliIgnore.getFileContent()).toBe(longPattern); - expect(geminiCliIgnore.getPatterns()).toEqual([longPattern]); - }); - - it("should handle unicode characters in patterns", () => { - const unicodeContent = "*.log\n節点模块/\n環境.env\n🏗️build/"; - const geminiCliIgnore = new GeminiCliIgnore({ - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent: unicodeContent, - }); - - expect(geminiCliIgnore.getFileContent()).toBe(unicodeContent); - expect(geminiCliIgnore.getPatterns()).toEqual(["*.log", "節点模块/", "環境.env", "🏗️build/"]); - }); - }); - - describe("file integration", () => { - it("should write and read file correctly", async () => { - const fileContent = "*.log\nnode_modules/\n.env"; - const geminiCliIgnore = new GeminiCliIgnore({ - outputRoot: testDir, - relativeDirPath: ".", - relativeFilePath: ".geminiignore", - fileContent, - }); - - // Write file using writeFileContent utility - await writeFileContent(geminiCliIgnore.getFilePath(), geminiCliIgnore.getFileContent()); - - // Read file back - const readGeminiCliIgnore = await GeminiCliIgnore.fromFile({ - outputRoot: testDir, - }); - - expect(readGeminiCliIgnore.getFileContent()).toBe(fileContent); - expect(readGeminiCliIgnore.getPatterns()).toEqual(["*.log", "node_modules/", ".env"]); - }); - - it("should handle subdirectory placement", async () => { - const subDir = join(testDir, "project", "config"); - await ensureDir(subDir); - - const fileContent = "*.log\nbuild/"; - const geminiCliIgnore = new GeminiCliIgnore({ - outputRoot: testDir, - relativeDirPath: "project/config", - relativeFilePath: ".geminiignore", - fileContent, - }); - - // Write file using writeFileContent utility - await writeFileContent(geminiCliIgnore.getFilePath(), geminiCliIgnore.getFileContent()); - - const readGeminiCliIgnore = await GeminiCliIgnore.fromFile({ - outputRoot: join(testDir, "project/config"), - }); - - expect(readGeminiCliIgnore.getFileContent()).toBe(fileContent); - }); - }); -}); diff --git a/src/features/ignore/geminicli-ignore.ts b/src/features/ignore/geminicli-ignore.ts deleted file mode 100644 index f7745eaa3..000000000 --- a/src/features/ignore/geminicli-ignore.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { join } from "node:path"; - -import { GEMINICLI_IGNORE_FILE_NAME } from "../../constants/geminicli-paths.js"; -import { readFileContent } from "../../utils/file.js"; -import { RulesyncIgnore } from "./rulesync-ignore.js"; -import type { - ToolIgnoreForDeletionParams, - ToolIgnoreFromFileParams, - ToolIgnoreFromRulesyncIgnoreParams, - ToolIgnoreSettablePaths, -} from "./tool-ignore.js"; -import { ToolIgnore } from "./tool-ignore.js"; - -export class GeminiCliIgnore extends ToolIgnore { - static getSettablePaths(): ToolIgnoreSettablePaths { - return { - relativeDirPath: ".", - relativeFilePath: GEMINICLI_IGNORE_FILE_NAME, - }; - } - - toRulesyncIgnore(): RulesyncIgnore { - return this.toRulesyncIgnoreDefault(); - } - - static fromRulesyncIgnore({ - outputRoot = process.cwd(), - rulesyncIgnore, - }: ToolIgnoreFromRulesyncIgnoreParams): GeminiCliIgnore { - return new GeminiCliIgnore({ - outputRoot, - relativeDirPath: this.getSettablePaths().relativeDirPath, - relativeFilePath: this.getSettablePaths().relativeFilePath, - fileContent: rulesyncIgnore.getFileContent(), - }); - } - - static async fromFile({ - outputRoot = process.cwd(), - validate = true, - }: ToolIgnoreFromFileParams): Promise { - const fileContent = await readFileContent( - join( - outputRoot, - this.getSettablePaths().relativeDirPath, - this.getSettablePaths().relativeFilePath, - ), - ); - - return new GeminiCliIgnore({ - outputRoot, - relativeDirPath: this.getSettablePaths().relativeDirPath, - relativeFilePath: this.getSettablePaths().relativeFilePath, - fileContent, - validate, - }); - } - - static forDeletion({ - outputRoot = process.cwd(), - relativeDirPath, - relativeFilePath, - }: ToolIgnoreForDeletionParams): GeminiCliIgnore { - return new GeminiCliIgnore({ - outputRoot, - relativeDirPath, - relativeFilePath, - fileContent: "", - validate: false, - }); - } -} diff --git a/src/features/ignore/ignore-processor.test.ts b/src/features/ignore/ignore-processor.test.ts index 116e7e1ed..560d53ae4 100644 --- a/src/features/ignore/ignore-processor.test.ts +++ b/src/features/ignore/ignore-processor.test.ts @@ -14,7 +14,6 @@ import { ClaudecodeIgnore } from "./claudecode-ignore.js"; import { ClineIgnore } from "./cline-ignore.js"; import { CursorIgnore } from "./cursor-ignore.js"; import { DevinIgnore } from "./devin-ignore.js"; -import { GeminiCliIgnore } from "./geminicli-ignore.js"; import { IgnoreProcessor } from "./ignore-processor.js"; import { JunieIgnore } from "./junie-ignore.js"; import { KiroIgnore } from "./kiro-ignore.js"; @@ -83,7 +82,6 @@ describe("IgnoreProcessor", () => { "claudecode-legacy", "cline", "cursor", - "geminicli", "junie", "kiro", "qwencode", @@ -220,20 +218,6 @@ describe("IgnoreProcessor", () => { expect(ignores[0]).toBeInstanceOf(CursorIgnore); }); - it("should load GeminiCliIgnore for geminicli target", async () => { - await writeFileContent(join(testDir, ".geminiignore"), "*.log\nnode_modules/"); - - const processor = new IgnoreProcessor({ - logger, - outputRoot: testDir, - toolTarget: "geminicli", - }); - - const ignores = await processor.loadToolIgnores(); - expect(ignores).toHaveLength(1); - expect(ignores[0]).toBeInstanceOf(GeminiCliIgnore); - }); - it("should load JunieIgnore for junie target", async () => { await writeFileContent(join(testDir, ".aiignore"), "*.log\nnode_modules/"); @@ -320,7 +304,6 @@ describe("IgnoreProcessor", () => { "augmentcode", "cline", "cursor", - "geminicli", "junie", "kiro", "qwencode", @@ -435,7 +418,6 @@ describe("IgnoreProcessor", () => { "claudecode-legacy", "cline", "cursor", - "geminicli", "goose", "junie", "kilo", diff --git a/src/features/ignore/ignore-processor.ts b/src/features/ignore/ignore-processor.ts index 9a61a061c..d5da1f00b 100644 --- a/src/features/ignore/ignore-processor.ts +++ b/src/features/ignore/ignore-processor.ts @@ -16,7 +16,6 @@ import { ClaudecodeIgnore } from "./claudecode-ignore.js"; import { ClineIgnore } from "./cline-ignore.js"; import { CursorIgnore } from "./cursor-ignore.js"; import { DevinIgnore } from "./devin-ignore.js"; -import { GeminiCliIgnore } from "./geminicli-ignore.js"; import { GooseIgnore } from "./goose-ignore.js"; import { JunieIgnore } from "./junie-ignore.js"; import { KiloIgnore } from "./kilo-ignore.js"; @@ -59,7 +58,6 @@ export const toolIgnoreFactories = new Map { - let testDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ testDir, cleanup } = await setupTestDirectory()); - vi.spyOn(process, "cwd").mockReturnValue(testDir); - }); - - afterEach(async () => { - await cleanup(); - vi.restoreAllMocks(); - }); - - describe("getSettablePaths", () => { - it("should return correct paths for local mode", () => { - const paths = GeminiCliMcp.getSettablePaths(); - - expect(paths.relativeDirPath).toBe(".gemini"); - expect(paths.relativeFilePath).toBe("settings.json"); - }); - - it("should return correct paths for global mode", () => { - const paths = GeminiCliMcp.getSettablePaths({ global: true }); - - expect(paths.relativeDirPath).toBe(".gemini"); - expect(paths.relativeFilePath).toBe("settings.json"); - }); - }); - - describe("isDeletable", () => { - it("should always return false because settings.json may contain other settings", () => { - const localMcp = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify({ mcpServers: {} }), - global: false, - }); - - expect(localMcp.isDeletable()).toBe(false); - - const globalMcp = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify({ mcpServers: {} }), - global: true, - }); - - expect(globalMcp.isDeletable()).toBe(false); - }); - - it("should return false when created via forDeletion with global: true", () => { - const geminiCliMcp = GeminiCliMcp.forDeletion({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - global: true, - }); - - expect(geminiCliMcp.isDeletable()).toBe(false); - }); - }); - - describe("constructor", () => { - it("should create instance with default parameters", () => { - const validJsonContent = JSON.stringify({ - mcpServers: { - "@modelcontextprotocol/server-filesystem": { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], - }, - }, - }); - - const geminiCliMcp = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: validJsonContent, - }); - - expect(geminiCliMcp).toBeInstanceOf(GeminiCliMcp); - expect(geminiCliMcp.getRelativeDirPath()).toBe(".gemini"); - expect(geminiCliMcp.getRelativeFilePath()).toBe("settings.json"); - expect(geminiCliMcp.getFileContent()).toBe(validJsonContent); - }); - - it("should create instance with custom outputRoot", () => { - const validJsonContent = JSON.stringify({ - mcpServers: {}, - }); - - const geminiCliMcp = new GeminiCliMcp({ - outputRoot: "/custom/path", - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: validJsonContent, - }); - - expect(geminiCliMcp.getFilePath()).toBe("/custom/path/.gemini/settings.json"); - }); - - it("should parse JSON content correctly", () => { - const jsonData = { - mcpServers: { - "test-server": { - command: "node", - args: ["server.js"], - env: { - NODE_ENV: "development", - }, - }, - }, - }; - const validJsonContent = JSON.stringify(jsonData); - - const geminiCliMcp = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: validJsonContent, - }); - - expect(geminiCliMcp.getJson()).toEqual(jsonData); - }); - - it("should handle empty JSON object", () => { - const emptyJsonContent = JSON.stringify({}); - - const geminiCliMcp = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: emptyJsonContent, - }); - - expect(geminiCliMcp.getJson()).toEqual({}); - }); - - it("should validate content by default", () => { - const validJsonContent = JSON.stringify({ - mcpServers: {}, - }); - - expect(() => { - const _instance = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: validJsonContent, - }); - }).not.toThrow(); - }); - - it("should skip validation when validate is false", () => { - const validJsonContent = JSON.stringify({ - mcpServers: {}, - }); - - expect(() => { - const _instance = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: validJsonContent, - validate: false, - }); - }).not.toThrow(); - }); - - it("should throw error for invalid JSON content", () => { - const invalidJsonContent = "{ invalid json }"; - - expect(() => { - const _instance = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: invalidJsonContent, - }); - }).toThrow(); - }); - }); - - describe("fromFile", () => { - it("should create instance from file with default parameters", async () => { - const jsonData = { - mcpServers: { - filesystem: { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", testDir], - }, - }, - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent( - join(testDir, ".gemini/settings.json"), - JSON.stringify(jsonData, null, 2), - ); - - const geminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - }); - - expect(geminiCliMcp).toBeInstanceOf(GeminiCliMcp); - expect(geminiCliMcp.getJson()).toEqual(jsonData); - expect(geminiCliMcp.getFilePath()).toBe(join(testDir, ".gemini/settings.json")); - }); - - it("should initialize empty mcpServers if file does not exist", async () => { - const geminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - }); - - expect(geminiCliMcp).toBeInstanceOf(GeminiCliMcp); - expect(geminiCliMcp.getJson()).toEqual({ mcpServers: {} }); - expect(geminiCliMcp.getFilePath()).toBe(join(testDir, ".gemini/settings.json")); - }); - - it("should initialize mcpServers if missing in existing file", async () => { - const jsonData = { - customConfig: { - setting: "value", - }, - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent(join(testDir, ".gemini/settings.json"), JSON.stringify(jsonData)); - - const geminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - }); - - expect(geminiCliMcp.getJson()).toEqual({ - customConfig: { - setting: "value", - }, - mcpServers: {}, - }); - }); - - it("should create instance from file with custom outputRoot", async () => { - const customDir = join(testDir, "custom"); - await ensureDir(join(customDir, ".gemini")); - - const jsonData = { - mcpServers: { - git: { - command: "node", - args: ["git-server.js"], - }, - }, - }; - await writeFileContent(join(customDir, ".gemini/settings.json"), JSON.stringify(jsonData)); - - const geminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: customDir, - }); - - expect(geminiCliMcp.getFilePath()).toBe(join(customDir, ".gemini/settings.json")); - expect(geminiCliMcp.getJson()).toEqual(jsonData); - }); - - it("should handle validation when validate is true", async () => { - const jsonData = { - mcpServers: { - "valid-server": { - command: "node", - args: ["server.js"], - }, - }, - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent(join(testDir, ".gemini/settings.json"), JSON.stringify(jsonData)); - - const geminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - validate: true, - }); - - expect(geminiCliMcp.getJson()).toEqual(jsonData); - }); - - it("should skip validation when validate is false", async () => { - const jsonData = { - mcpServers: {}, - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent(join(testDir, ".gemini/settings.json"), JSON.stringify(jsonData)); - - const geminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - validate: false, - }); - - expect(geminiCliMcp.getJson()).toEqual(jsonData); - }); - - it("should create instance from file in global mode", async () => { - const jsonData = { - mcpServers: { - filesystem: { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", testDir], - }, - }, - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent( - join(testDir, ".gemini/settings.json"), - JSON.stringify(jsonData, null, 2), - ); - - const geminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - global: true, - }); - - expect(geminiCliMcp).toBeInstanceOf(GeminiCliMcp); - expect(geminiCliMcp.getJson()).toEqual(jsonData); - expect(geminiCliMcp.getFilePath()).toBe(join(testDir, ".gemini/settings.json")); - }); - - it("should create instance from file in local mode (default)", async () => { - const jsonData = { - mcpServers: { - git: { - command: "node", - args: ["git-server.js"], - }, - }, - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent(join(testDir, ".gemini/settings.json"), JSON.stringify(jsonData)); - - const geminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - global: false, - }); - - expect(geminiCliMcp.getFilePath()).toBe(join(testDir, ".gemini/settings.json")); - expect(geminiCliMcp.getJson()).toEqual(jsonData); - }); - - it("should initialize global config file if it does not exist", async () => { - const geminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - global: true, - }); - - expect(geminiCliMcp).toBeInstanceOf(GeminiCliMcp); - expect(geminiCliMcp.getJson()).toEqual({ mcpServers: {} }); - expect(geminiCliMcp.getFilePath()).toBe(join(testDir, ".gemini/settings.json")); - }); - - it("should preserve non-mcpServers properties in global mode", async () => { - const existingGlobalConfig = { - mcpServers: { - "old-server": { - command: "node", - args: ["old-server.js"], - }, - }, - userSettings: { - theme: "dark", - fontSize: 14, - }, - version: "1.0.0", - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent( - join(testDir, ".gemini/settings.json"), - JSON.stringify(existingGlobalConfig, null, 2), - ); - - const geminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - global: true, - }); - - const json = geminiCliMcp.getJson(); - expect(json.mcpServers).toEqual({ - "old-server": { - command: "node", - args: ["old-server.js"], - }, - }); - expect((json as any).userSettings).toEqual({ - theme: "dark", - fontSize: 14, - }); - expect((json as any).version).toBe("1.0.0"); - }); - }); - - describe("fromRulesyncMcp", () => { - it("should strip codex-only envVars from gemini output", async () => { - // Regression test: prior to migrating fromRulesyncMcp to - // `getMcpServers()`, the gemini generator read mcpServers from - // `rulesyncMcp.getJson()` (unfiltered), causing codex-only fields like - // `envVars` to leak into ~/.gemini/settings.json. Strip must apply - // here so the field is absent from gemini output. - const jsonData = { - mcpServers: { - pal: { - type: "stdio", - command: "uvx", - args: ["pal-mcp-server"], - envVars: ["OPENAI_API_KEY"], - }, - }, - }; - const rulesyncMcp = new RulesyncMcp({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: ".mcp.json", - fileContent: JSON.stringify(jsonData), - }); - - const geminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({ - outputRoot: testDir, - rulesyncMcp, - }); - - const json = geminiCliMcp.getJson() as any; - expect(json.mcpServers.pal).toBeDefined(); - expect(json.mcpServers.pal.envVars).toBeUndefined(); - // Defensive: other fields survive. - expect(json.mcpServers.pal.command).toBe("uvx"); - expect(json.mcpServers.pal.args).toEqual(["pal-mcp-server"]); - }); - - it("should create instance from RulesyncMcp with default parameters", async () => { - const jsonData = { - mcpServers: { - "test-server": { - command: "node", - args: ["test-server.js"], - }, - }, - }; - const rulesyncMcp = new RulesyncMcp({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: ".mcp.json", - fileContent: JSON.stringify(jsonData), - }); - - const geminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({ - outputRoot: testDir, - rulesyncMcp, - }); - - expect(geminiCliMcp).toBeInstanceOf(GeminiCliMcp); - expect(geminiCliMcp.getJson()).toEqual(jsonData); - expect(geminiCliMcp.getRelativeDirPath()).toBe(".gemini"); - expect(geminiCliMcp.getRelativeFilePath()).toBe("settings.json"); - }); - - it("should create instance from RulesyncMcp with custom outputRoot", async () => { - const jsonData = { - mcpServers: { - "custom-server": { - command: "python", - args: ["server.py"], - env: { - PYTHONPATH: "/custom/path", - }, - }, - }, - }; - const rulesyncMcp = new RulesyncMcp({ - outputRoot: "/custom/base", - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: ".mcp.json", - fileContent: JSON.stringify(jsonData), - }); - - const customDir = join(testDir, "target"); - await ensureDir(customDir); - const geminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({ - outputRoot: customDir, - rulesyncMcp, - }); - - expect(geminiCliMcp.getFilePath()).toBe(join(customDir, ".gemini/settings.json")); - expect(geminiCliMcp.getJson()).toEqual(jsonData); - }); - - it("should handle validation when validate is true", async () => { - const jsonData = { - mcpServers: { - "validated-server": { - command: "node", - args: ["validated-server.js"], - }, - }, - }; - const rulesyncMcp = new RulesyncMcp({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: ".mcp.json", - fileContent: JSON.stringify(jsonData), - }); - - const geminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({ - outputRoot: testDir, - rulesyncMcp, - validate: true, - }); - - expect(geminiCliMcp.getJson()).toEqual(jsonData); - }); - - it("should skip validation when validate is false", async () => { - const jsonData = { - mcpServers: {}, - }; - const rulesyncMcp = new RulesyncMcp({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: ".mcp.json", - fileContent: JSON.stringify(jsonData), - }); - - const geminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({ - outputRoot: testDir, - rulesyncMcp, - validate: false, - }); - - expect(geminiCliMcp.getJson()).toEqual(jsonData); - }); - - it("should handle empty mcpServers object", async () => { - const jsonData = { - mcpServers: {}, - }; - const rulesyncMcp = new RulesyncMcp({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: ".mcp.json", - fileContent: JSON.stringify(jsonData), - }); - - const geminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({ - outputRoot: testDir, - rulesyncMcp, - }); - - expect(geminiCliMcp.getJson()).toEqual(jsonData); - }); - - it("should create instance from RulesyncMcp in global mode", async () => { - const jsonData = { - mcpServers: { - "global-server": { - command: "node", - args: ["global-server.js"], - }, - }, - }; - const rulesyncMcp = new RulesyncMcp({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: ".mcp.json", - fileContent: JSON.stringify(jsonData), - }); - - const geminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({ - outputRoot: testDir, - rulesyncMcp, - global: true, - }); - - expect(geminiCliMcp).toBeInstanceOf(GeminiCliMcp); - expect(geminiCliMcp.getJson()).toEqual(jsonData); - expect(geminiCliMcp.getRelativeDirPath()).toBe(".gemini"); - expect(geminiCliMcp.getRelativeFilePath()).toBe("settings.json"); - }); - - it("should create instance from RulesyncMcp in local mode (default)", async () => { - const jsonData = { - mcpServers: { - "local-server": { - command: "python", - args: ["local-server.py"], - }, - }, - }; - const rulesyncMcp = new RulesyncMcp({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: ".mcp.json", - fileContent: JSON.stringify(jsonData), - }); - - const geminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({ - outputRoot: testDir, - rulesyncMcp, - global: false, - }); - - expect(geminiCliMcp.getFilePath()).toBe(join(testDir, ".gemini/settings.json")); - expect(geminiCliMcp.getJson()).toEqual(jsonData); - expect(geminiCliMcp.getRelativeDirPath()).toBe(".gemini"); - expect(geminiCliMcp.getRelativeFilePath()).toBe("settings.json"); - }); - - it("should preserve non-mcpServers properties when updating global config", async () => { - const existingGlobalConfig = { - mcpServers: { - "old-server": { - command: "node", - args: ["old-server.js"], - }, - }, - userSettings: { - theme: "dark", - }, - version: "1.0.0", - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent( - join(testDir, ".gemini/settings.json"), - JSON.stringify(existingGlobalConfig, null, 2), - ); - - const newMcpServers = { - mcpServers: { - "new-server": { - command: "python", - args: ["new-server.py"], - }, - }, - }; - const rulesyncMcp = new RulesyncMcp({ - relativeDirPath: ".rulesync", - relativeFilePath: ".mcp.json", - fileContent: JSON.stringify(newMcpServers), - }); - - const geminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({ - outputRoot: testDir, - rulesyncMcp, - global: true, - }); - - const json = geminiCliMcp.getJson(); - expect(json.mcpServers).toEqual({ - "new-server": { - command: "python", - args: ["new-server.py"], - }, - }); - expect((json as any).userSettings).toEqual({ - theme: "dark", - }); - expect((json as any).version).toBe("1.0.0"); - }); - - it("should merge mcpServers when updating global config", async () => { - const existingGlobalConfig = { - mcpServers: { - "existing-server": { - command: "node", - args: ["existing-server.js"], - }, - }, - customProperty: "value", - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent( - join(testDir, ".gemini/settings.json"), - JSON.stringify(existingGlobalConfig, null, 2), - ); - - const newMcpConfig = { - mcpServers: { - "new-server": { - command: "python", - args: ["new-server.py"], - }, - "another-server": { - command: "node", - args: ["another.js"], - }, - }, - }; - const rulesyncMcp = new RulesyncMcp({ - relativeDirPath: ".rulesync", - relativeFilePath: ".mcp.json", - fileContent: JSON.stringify(newMcpConfig), - }); - - const geminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({ - outputRoot: testDir, - rulesyncMcp, - global: true, - }); - - const json = geminiCliMcp.getJson(); - // Should replace mcpServers entirely, not merge individual servers - expect(json.mcpServers).toEqual({ - "new-server": { - command: "python", - args: ["new-server.py"], - }, - "another-server": { - command: "node", - args: ["another.js"], - }, - }); - expect((json as any).customProperty).toBe("value"); - }); - }); - - describe("toRulesyncMcp", () => { - it("should convert to RulesyncMcp with default configuration", () => { - const jsonData = { - mcpServers: { - filesystem: { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], - }, - }, - }; - const geminiCliMcp = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify(jsonData), - }); - - const rulesyncMcp = geminiCliMcp.toRulesyncMcp(); - - expect(rulesyncMcp).toBeInstanceOf(RulesyncMcp); - expect(rulesyncMcp.getFileContent()).toBe( - JSON.stringify( - { - $schema: RULESYNC_MCP_SCHEMA_URL, - ...jsonData, - }, - null, - 2, - ), - ); - expect(rulesyncMcp.getRelativeDirPath()).toBe(RULESYNC_RELATIVE_DIR_PATH); - expect(rulesyncMcp.getRelativeFilePath()).toBe("mcp.json"); - }); - - it("should preserve file content when converting to RulesyncMcp", () => { - const jsonData = { - mcpServers: { - "complex-server": { - command: "node", - args: ["complex-server.js", "--port", "3000"], - env: { - NODE_ENV: "production", - DEBUG: "mcp:*", - }, - }, - "another-server": { - command: "python", - args: ["another-server.py"], - }, - }, - }; - const geminiCliMcp = new GeminiCliMcp({ - outputRoot: "/test/dir", - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify(jsonData), - }); - - const rulesyncMcp = geminiCliMcp.toRulesyncMcp(); - - expect(rulesyncMcp.getOutputRoot()).toBe("/test/dir"); - expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ - $schema: RULESYNC_MCP_SCHEMA_URL, - ...jsonData, - }); - }); - - it("should handle empty mcpServers object when converting", () => { - const jsonData = { - mcpServers: {}, - }; - const geminiCliMcp = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify(jsonData), - }); - - const rulesyncMcp = geminiCliMcp.toRulesyncMcp(); - - expect(JSON.parse(rulesyncMcp.getFileContent())).toEqual({ - $schema: RULESYNC_MCP_SCHEMA_URL, - ...jsonData, - }); - }); - - it("should extract only mcpServers when converting to RulesyncMcp", () => { - const jsonData = { - mcpServers: { - "test-server": { - command: "node", - args: ["server.js"], - }, - }, - userSettings: { - theme: "light", - }, - version: "2.0.0", - }; - const geminiCliMcp = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify(jsonData), - }); - - const rulesyncMcp = geminiCliMcp.toRulesyncMcp(); - - const exportedJson = JSON.parse(rulesyncMcp.getFileContent()); - expect(exportedJson).toEqual({ - mcpServers: { - "test-server": { - command: "node", - args: ["server.js"], - }, - }, - $schema: RULESYNC_MCP_SCHEMA_URL, - }); - expect((exportedJson as any).userSettings).toBeUndefined(); - expect((exportedJson as any).version).toBeUndefined(); - }); - }); - - describe("validate", () => { - it("should return successful validation result", () => { - const jsonData = { - mcpServers: { - "test-server": { - command: "node", - args: ["server.js"], - }, - }, - }; - const geminiCliMcp = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify(jsonData), - validate: false, // Skip validation in constructor to test method directly - }); - - const result = geminiCliMcp.validate(); - - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - }); - - it("should always return success (no validation logic implemented)", () => { - const jsonData = { - mcpServers: {}, - }; - const geminiCliMcp = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify(jsonData), - validate: false, - }); - - const result = geminiCliMcp.validate(); - - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - }); - - it("should return success for complex MCP configuration", () => { - const jsonData = { - mcpServers: { - filesystem: { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], - env: { - NODE_ENV: "development", - }, - }, - git: { - command: "node", - args: ["git-server.js"], - }, - sqlite: { - command: "python", - args: ["sqlite-server.py", "--database", "/path/to/db.sqlite"], - env: { - PYTHONPATH: "/custom/path", - DEBUG: "true", - }, - }, - }, - globalSettings: { - timeout: 30000, - retries: 3, - }, - }; - const geminiCliMcp = new GeminiCliMcp({ - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify(jsonData), - validate: false, - }); - - const result = geminiCliMcp.validate(); - - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - }); - }); - - describe("integration", () => { - it("should handle complete workflow: fromFile -> toRulesyncMcp -> fromRulesyncMcp", async () => { - const originalJsonData = { - mcpServers: { - "workflow-server": { - command: "node", - args: ["workflow-server.js", "--config", "config.json"], - env: { - NODE_ENV: "test", - }, - }, - }, - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent( - join(testDir, ".gemini/settings.json"), - JSON.stringify(originalJsonData, null, 2), - ); - - // Step 1: Load from file - const originalGeminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - }); - - // Step 2: Convert to RulesyncMcp - const rulesyncMcp = originalGeminiCliMcp.toRulesyncMcp(); - - // Step 3: Create new GeminiCliMcp from RulesyncMcp - const newGeminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({ - outputRoot: testDir, - rulesyncMcp, - }); - - // Verify data integrity - expect(newGeminiCliMcp.getJson()).toEqual(originalJsonData); - expect(newGeminiCliMcp.getFilePath()).toBe(join(testDir, ".gemini/settings.json")); - }); - - it("should maintain data consistency across transformations", async () => { - const complexJsonData = { - mcpServers: { - "primary-server": { - command: "node", - args: ["primary.js", "--mode", "production"], - env: { - NODE_ENV: "production", - LOG_LEVEL: "info", - API_KEY: "secret", - }, - }, - "secondary-server": { - command: "python", - args: ["secondary.py", "--workers", "4"], - env: { - PYTHONPATH: "/app/lib", - }, - }, - }, - config: { - timeout: 60000, - maxRetries: 5, - logLevel: "debug", - }, - }; - - // Create GeminiCliMcp - const geminiCliMcp = new GeminiCliMcp({ - outputRoot: testDir, - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify(complexJsonData), - }); - - // Convert to RulesyncMcp - const rulesyncMcp = geminiCliMcp.toRulesyncMcp(); - - // Verify only mcpServers is in exported data - const exportedJson = JSON.parse(rulesyncMcp.getFileContent()); - expect(exportedJson.mcpServers).toBeDefined(); - expect((exportedJson as any).config).toBeUndefined(); - }); - - it("should handle complete workflow in global mode", async () => { - const originalJsonData = { - mcpServers: { - "global-workflow-server": { - command: "node", - args: ["global-server.js"], - }, - }, - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent( - join(testDir, ".gemini/settings.json"), - JSON.stringify(originalJsonData, null, 2), - ); - - // Step 1: Load from global config - const originalGeminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - global: true, - }); - - // Step 2: Convert to RulesyncMcp - const rulesyncMcp = originalGeminiCliMcp.toRulesyncMcp(); - - // Step 3: Create new GeminiCliMcp from RulesyncMcp in global mode - const newGeminiCliMcp = await GeminiCliMcp.fromRulesyncMcp({ - outputRoot: testDir, - rulesyncMcp, - global: true, - }); - - // Verify data integrity - expect(newGeminiCliMcp.getJson()).toEqual(originalJsonData); - expect(newGeminiCliMcp.getFilePath()).toBe(join(testDir, ".gemini/settings.json")); - }); - }); - - describe("error handling", () => { - it("should handle malformed JSON in existing file gracefully", async () => { - await ensureDir(join(testDir, ".gemini")); - await writeFileContent(join(testDir, ".gemini/settings.json"), "{ invalid json }"); - - await expect( - GeminiCliMcp.fromFile({ - outputRoot: testDir, - }), - ).rejects.toThrow(); - }); - - it("should handle malformed JSON in global config gracefully", async () => { - await ensureDir(join(testDir, ".gemini")); - await writeFileContent(join(testDir, ".gemini/settings.json"), "{ invalid: json }"); - - await expect( - GeminiCliMcp.fromFile({ - outputRoot: testDir, - global: true, - }), - ).rejects.toThrow(); - }); - - it("should handle null mcpServers in existing file", async () => { - const jsonData = { - mcpServers: null, - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent(join(testDir, ".gemini/settings.json"), JSON.stringify(jsonData)); - - const geminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - }); - - expect(geminiCliMcp.getJson().mcpServers).toEqual({}); - }); - - it("should handle undefined mcpServers in existing file", async () => { - const jsonData = { - otherProperty: "value", - }; - await ensureDir(join(testDir, ".gemini")); - await writeFileContent(join(testDir, ".gemini/settings.json"), JSON.stringify(jsonData)); - - const geminiCliMcp = await GeminiCliMcp.fromFile({ - outputRoot: testDir, - }); - - expect(geminiCliMcp.getJson().mcpServers).toEqual({}); - expect((geminiCliMcp.getJson() as any).otherProperty).toBe("value"); - }); - - it("should handle empty file", async () => { - await ensureDir(join(testDir, ".gemini")); - await writeFileContent(join(testDir, ".gemini/settings.json"), ""); - - await expect( - GeminiCliMcp.fromFile({ - outputRoot: testDir, - }), - ).rejects.toThrow(); - }); - - it("should handle file with only whitespace", async () => { - await ensureDir(join(testDir, ".gemini")); - await writeFileContent(join(testDir, ".gemini/settings.json"), " \n\t "); - - await expect( - GeminiCliMcp.fromFile({ - outputRoot: testDir, - }), - ).rejects.toThrow(); - }); - }); -}); diff --git a/src/features/mcp/geminicli-mcp.ts b/src/features/mcp/geminicli-mcp.ts deleted file mode 100644 index 9022fb2de..000000000 --- a/src/features/mcp/geminicli-mcp.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { join } from "node:path"; - -import { GEMINICLI_DIR, GEMINICLI_MCP_FILE_NAME } from "../../constants/geminicli-paths.js"; -import { ValidationResult } from "../../types/ai-file.js"; -import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js"; -import { RulesyncMcp } from "./rulesync-mcp.js"; -import { - ToolMcp, - ToolMcpForDeletionParams, - ToolMcpFromFileParams, - ToolMcpFromRulesyncMcpParams, - ToolMcpParams, - ToolMcpSettablePaths, -} from "./tool-mcp.js"; - -export class GeminiCliMcp extends ToolMcp { - private readonly json: Record; - - constructor(params: ToolMcpParams) { - super(params); - this.json = JSON.parse(this.fileContent || "{}"); - } - - getJson(): Record { - return this.json; - } - - static getSettablePaths({ global }: { global?: boolean } = {}): ToolMcpSettablePaths { - if (global) { - return { - relativeDirPath: GEMINICLI_DIR, - relativeFilePath: GEMINICLI_MCP_FILE_NAME, - }; - } - return { - relativeDirPath: GEMINICLI_DIR, - relativeFilePath: GEMINICLI_MCP_FILE_NAME, - }; - } - - static async fromFile({ - outputRoot = process.cwd(), - validate = true, - global = false, - }: ToolMcpFromFileParams): Promise { - const paths = this.getSettablePaths({ global }); - const fileContent = - (await readFileContentOrNull( - join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), - )) ?? '{"mcpServers":{}}'; - const json = JSON.parse(fileContent); - const newJson = { ...json, mcpServers: json.mcpServers ?? {} }; - - return new GeminiCliMcp({ - outputRoot, - relativeDirPath: paths.relativeDirPath, - relativeFilePath: paths.relativeFilePath, - fileContent: JSON.stringify(newJson, null, 2), - validate, - }); - } - - static async fromRulesyncMcp({ - outputRoot = process.cwd(), - rulesyncMcp, - validate = true, - global = false, - }: ToolMcpFromRulesyncMcpParams): Promise { - const paths = this.getSettablePaths({ global }); - - const fileContent = await readOrInitializeFileContent( - join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), - JSON.stringify({ mcpServers: {} }, null, 2), - ); - const json = JSON.parse(fileContent); - // Use getMcpServers() (not getJson()) so rulesync-only fields and - // codex-only fields (`envVars`) are stripped before writing the - // gemini settings file. - const newJson = { ...json, mcpServers: rulesyncMcp.getMcpServers() }; - - return new GeminiCliMcp({ - outputRoot, - relativeDirPath: paths.relativeDirPath, - relativeFilePath: paths.relativeFilePath, - fileContent: JSON.stringify(newJson, null, 2), - validate, - }); - } - - toRulesyncMcp(): RulesyncMcp { - return this.toRulesyncMcpDefault({ - fileContent: JSON.stringify({ mcpServers: this.json.mcpServers }, null, 2), - }); - } - - validate(): ValidationResult { - return { success: true, error: null }; - } - - /** - * settings.json may contain other settings, so it should not be deleted. - */ - override isDeletable(): boolean { - return false; - } - - static forDeletion({ - outputRoot = process.cwd(), - relativeDirPath, - relativeFilePath, - global = false, - }: ToolMcpForDeletionParams): GeminiCliMcp { - return new GeminiCliMcp({ - outputRoot, - relativeDirPath, - relativeFilePath, - fileContent: "{}", - validate: false, - global, - }); - } -} diff --git a/src/features/mcp/mcp-processor.test.ts b/src/features/mcp/mcp-processor.test.ts index cdac4f469..8f9d17ed2 100644 --- a/src/features/mcp/mcp-processor.test.ts +++ b/src/features/mcp/mcp-processor.test.ts @@ -12,7 +12,6 @@ import { CodexcliMcp } from "./codexcli-mcp.js"; import { CopilotMcp } from "./copilot-mcp.js"; import { CopilotcliMcp } from "./copilotcli-mcp.js"; import { CursorMcp } from "./cursor-mcp.js"; -import { GeminiCliMcp } from "./geminicli-mcp.js"; import { McpProcessor, type McpProcessorToolTarget, @@ -29,7 +28,6 @@ vi.mock("./codexcli-mcp.js"); vi.mock("./copilot-mcp.js"); vi.mock("./copilotcli-mcp.js"); vi.mock("./cursor-mcp.js"); -vi.mock("./geminicli-mcp.js"); vi.mock("./opencode-mcp.js"); vi.mock("./roo-mcp.js"); vi.mock("./rulesync-mcp.js"); @@ -97,13 +95,6 @@ describe("McpProcessor", () => { isDeletable: () => true, getRelativeFilePath: () => params.relativeFilePath, })); - (GeminiCliMcp as any).fromFile = vi.fn(); - (GeminiCliMcp as any).fromRulesyncMcp = vi.fn(); - (GeminiCliMcp as any).forDeletion = vi.fn().mockImplementation((params) => ({ - ...params, - isDeletable: () => true, - getRelativeFilePath: () => params.relativeFilePath, - })); (OpencodeMcp as any).fromFile = vi.fn(); (OpencodeMcp as any).fromRulesyncMcp = vi.fn(); (OpencodeMcp as any).forDeletion = vi.fn().mockImplementation((params) => ({ @@ -432,65 +423,6 @@ describe("McpProcessor", () => { }); }); - describe("geminicli", () => { - it("should load GeminiCliMcp files", async () => { - const mockMcp = new GeminiCliMcp({ - outputRoot: testDir, - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify({ mcpServers: {} }), - }); - - vi.mocked(GeminiCliMcp.fromFile).mockReturnValue(Promise.resolve(mockMcp)); - - const processor = new McpProcessor({ - logger: createMockLogger(), - outputRoot: testDir, - toolTarget: "geminicli", - }); - - const files = await processor.loadToolFiles(); - - expect(files).toHaveLength(1); - expect(files[0]).toBe(mockMcp); - expect(GeminiCliMcp.fromFile).toHaveBeenCalledWith({ - outputRoot: testDir, - validate: true, - global: false, - logger: expect.any(Object), - }); - }); - - it("should load GeminiCliMcp files in global mode", async () => { - const mockMcp = new GeminiCliMcp({ - outputRoot: testDir, - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify({ mcpServers: {} }), - }); - - vi.mocked(GeminiCliMcp.fromFile).mockReturnValue(Promise.resolve(mockMcp)); - - const processor = new McpProcessor({ - logger: createMockLogger(), - outputRoot: testDir, - toolTarget: "geminicli", - global: true, - }); - - const files = await processor.loadToolFiles(); - - expect(files).toHaveLength(1); - expect(files[0]).toBe(mockMcp); - expect(GeminiCliMcp.fromFile).toHaveBeenCalledWith({ - outputRoot: testDir, - validate: true, - global: true, - logger: expect.any(Object), - }); - }); - }); - describe("codexcli", () => { it("should load CodexcliMcp files in global mode", async () => { const mockMcp = { @@ -840,75 +772,6 @@ describe("McpProcessor", () => { }); }); - it("should convert rulesync files to geminicli tool files", async () => { - const rulesyncMcp = new RulesyncMcp({ - outputRoot: testDir, - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: ".mcp.json", - fileContent: JSON.stringify({ mcpServers: {} }), - }); - - const mockToolMcp = new GeminiCliMcp({ - outputRoot: testDir, - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify({ mcpServers: {} }), - }); - - vi.mocked(GeminiCliMcp.fromRulesyncMcp).mockReturnValue(Promise.resolve(mockToolMcp)); - - const processor = new McpProcessor({ - logger: createMockLogger(), - outputRoot: testDir, - toolTarget: "geminicli", - }); - - const toolFiles = await processor.convertRulesyncFilesToToolFiles([rulesyncMcp]); - - expect(toolFiles).toHaveLength(1); - expect(toolFiles[0]).toBe(mockToolMcp); - expect(GeminiCliMcp.fromRulesyncMcp).toHaveBeenCalledWith({ - outputRoot: testDir, - rulesyncMcp, - global: false, - }); - }); - - it("should convert rulesync files to geminicli tool files in global mode", async () => { - const rulesyncMcp = new RulesyncMcp({ - outputRoot: testDir, - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: ".mcp.json", - fileContent: JSON.stringify({ mcpServers: {} }), - }); - - const mockToolMcp = new GeminiCliMcp({ - outputRoot: testDir, - relativeDirPath: ".gemini", - relativeFilePath: "settings.json", - fileContent: JSON.stringify({ mcpServers: {} }), - }); - - vi.mocked(GeminiCliMcp.fromRulesyncMcp).mockReturnValue(Promise.resolve(mockToolMcp)); - - const processor = new McpProcessor({ - logger: createMockLogger(), - outputRoot: testDir, - toolTarget: "geminicli", - global: true, - }); - - const toolFiles = await processor.convertRulesyncFilesToToolFiles([rulesyncMcp]); - - expect(toolFiles).toHaveLength(1); - expect(toolFiles[0]).toBe(mockToolMcp); - expect(GeminiCliMcp.fromRulesyncMcp).toHaveBeenCalledWith({ - outputRoot: testDir, - rulesyncMcp, - global: true, - }); - }); - it("should convert rulesync files to codexcli tool files in global mode", async () => { const rulesyncMcp = new RulesyncMcp({ outputRoot: testDir, diff --git a/src/features/mcp/mcp-processor.ts b/src/features/mcp/mcp-processor.ts index d956473ca..32926f8f0 100644 --- a/src/features/mcp/mcp-processor.ts +++ b/src/features/mcp/mcp-processor.ts @@ -21,7 +21,6 @@ import { CursorMcp } from "./cursor-mcp.js"; import { DeepagentsMcp } from "./deepagents-mcp.js"; import { DevinMcp } from "./devin-mcp.js"; import { FactorydroidMcp } from "./factorydroid-mcp.js"; -import { GeminiCliMcp } from "./geminicli-mcp.js"; import { GooseMcp } from "./goose-mcp.js"; import { GrokcliMcp } from "./grokcli-mcp.js"; import { HermesagentMcp } from "./hermesagent-mcp.js"; @@ -249,18 +248,6 @@ export const toolMcpFactories = new Map( }, }, ], - [ - "geminicli", - { - class: GeminiCliMcp, - meta: { - supportsProject: true, - supportsGlobal: true, - supportsEnabledTools: false, - supportsDisabledTools: false, - }, - }, - ], [ "goose", { diff --git a/src/features/mcp/rulesync-mcp.test.ts b/src/features/mcp/rulesync-mcp.test.ts index 11198d912..d02b53d3a 100644 --- a/src/features/mcp/rulesync-mcp.test.ts +++ b/src/features/mcp/rulesync-mcp.test.ts @@ -989,7 +989,7 @@ describe("RulesyncMcp", () => { describe("getMcpServers field stripping", () => { it("should strip codex-specific envVars from getMcpServers output", () => { // envVars is codex-only; it must NOT leak into other tools' generated - // configs (claudecode, opencode, kilo, geminicli, etc.) which all + // configs (claudecode, opencode, kilo, etc.) which all // consume getMcpServers(). The codex generator reads envVars directly // from getJson() instead. const rulesyncMcp = new RulesyncMcp({ diff --git a/src/features/permissions/geminicli-permissions.test.ts b/src/features/permissions/geminicli-permissions.test.ts deleted file mode 100644 index 8293c8948..000000000 --- a/src/features/permissions/geminicli-permissions.test.ts +++ /dev/null @@ -1,494 +0,0 @@ -import { join } from "node:path"; - -import * as smolToml from "smol-toml"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { createMockLogger } from "../../test-utils/mock-logger.js"; -import { setupTestDirectory } from "../../test-utils/test-directories.js"; -import { ensureDir, writeFileContent } from "../../utils/file.js"; -import { GeminicliPermissions } from "./geminicli-permissions.js"; -import { RulesyncPermissions } from "./rulesync-permissions.js"; - -type ParsedRule = { - toolName?: string; - decision?: string; - commandPrefix?: string; - argsPattern?: string; - priority?: number; -}; - -function parseRules(content: string): ParsedRule[] { - const parsed = smolToml.parse(content) as { rule?: ParsedRule[] }; - return parsed.rule ?? []; -} - -describe("GeminicliPermissions", () => { - let testDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ testDir, cleanup } = await setupTestDirectory()); - vi.spyOn(process, "cwd").mockReturnValue(testDir); - }); - - afterEach(async () => { - await cleanup(); - vi.restoreAllMocks(); - }); - - it("should emit a Gemini CLI policy TOML at .gemini/policies/rulesync.toml", async () => { - const rulesyncPermissions = new RulesyncPermissions({ - outputRoot: testDir, - relativeDirPath: ".rulesync", - relativeFilePath: "permissions.json", - fileContent: JSON.stringify({ - permission: { - bash: { "git *": "allow", "rm *": "deny", "*": "ask" }, - read: { "src/**": "allow" }, - }, - }), - }); - - const geminiPermissions = await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions, - }); - - expect(geminiPermissions.getRelativeDirPath()).toBe(join(".gemini", "policies")); - expect(geminiPermissions.getRelativeFilePath()).toBe("rulesync.toml"); - const rules = parseRules(geminiPermissions.getFileContent()); - expect(rules).toContainEqual( - expect.objectContaining({ - toolName: "run_shell_command", - decision: "allow", - commandPrefix: "git", - }), - ); - expect(rules).toContainEqual( - expect.objectContaining({ toolName: "run_shell_command", decision: "deny" }), - ); - expect(rules).toContainEqual( - expect.objectContaining({ toolName: "run_shell_command", decision: "ask_user" }), - ); - // Non-bash argsPattern must be anchored at both ends of the JSON string value to - // prevent cross-field matching. - expect(rules).toContainEqual( - expect.objectContaining({ - toolName: "read_file", - decision: "allow", - argsPattern: '"src/[^\\"]*\\"', - }), - ); - }); - - it("should assign higher priority to deny rules than ask or allow rules", async () => { - const rulesyncPermissions = new RulesyncPermissions({ - outputRoot: testDir, - relativeDirPath: ".rulesync", - relativeFilePath: "permissions.json", - fileContent: JSON.stringify({ - permission: { - bash: { "git *": "allow", "git push --force *": "deny", "*": "ask" }, - }, - }), - }); - - const geminiPermissions = await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions, - }); - - const content = geminiPermissions.getFileContent(); - const denyIndex = content.indexOf('decision = "deny"'); - const askIndex = content.indexOf('decision = "ask_user"'); - const allowIndex = content.indexOf('decision = "allow"'); - expect(denyIndex).toBeGreaterThanOrEqual(0); - expect(askIndex).toBeGreaterThanOrEqual(0); - expect(allowIndex).toBeGreaterThanOrEqual(0); - // Deny must appear before allow so that first-match wins goes to deny. - expect(denyIndex).toBeLessThan(allowIndex); - expect(askIndex).toBeLessThan(allowIndex); - // Priorities stay within the Policy Engine's documented 0-999 range, with deny - // at the top of the band so deny > ask > allow in first-match order. - const priorities = parseRules(content) - .map((rule) => rule.priority) - .filter((priority): priority is number => typeof priority === "number"); - expect(priorities).toContain(999); - expect(priorities).toContain(500); - expect(priorities).toContain(1); - expect(Math.max(...priorities)).toBeLessThanOrEqual(999); - }); - - it("should warn that project-scope output is inert, but not in global mode", async () => { - const rulesyncPermissions = new RulesyncPermissions({ - outputRoot: testDir, - relativeDirPath: ".rulesync", - relativeFilePath: "permissions.json", - fileContent: JSON.stringify({ - permission: { bash: { "git *": "allow" } }, - }), - }); - - const projectLogger = createMockLogger(); - await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions, - logger: projectLogger, - global: false, - }); - expect(projectLogger.warn).toHaveBeenCalledWith( - expect.stringContaining("Workspace policy tier is non-functional"), - ); - - const globalLogger = createMockLogger(); - await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions, - logger: globalLogger, - global: true, - }); - const globalWarnings = globalLogger.warn.mock.calls.filter((call) => - String(call[0]).includes("Workspace policy tier"), - ); - expect(globalWarnings).toHaveLength(0); - }); - - it("should emit argsPattern for bash patterns with interior glob metacharacters", async () => { - const rulesyncPermissions = new RulesyncPermissions({ - outputRoot: testDir, - relativeDirPath: ".rulesync", - relativeFilePath: "permissions.json", - fileContent: JSON.stringify({ - permission: { - bash: { "rm -rf /tmp/*": "deny" }, - }, - }), - }); - - const geminiPermissions = await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions, - }); - - const rules = parseRules(geminiPermissions.getFileContent()); - const rule = rules.find((entry) => entry.toolName === "run_shell_command"); - expect(rule?.argsPattern).toBe('"command":"rm -rf /tmp/[^/\\"]*'); - expect(rule?.commandPrefix).toBeUndefined(); - }); - - it("should convert Gemini CLI policy TOML back to rulesync format", () => { - const geminiPermissions = new GeminicliPermissions({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "policies"), - relativeFilePath: "rulesync.toml", - fileContent: [ - "[[rule]]", - 'toolName = "run_shell_command"', - 'decision = "allow"', - 'commandPrefix = "git"', - "priority = 100", - "", - "[[rule]]", - 'toolName = "run_shell_command"', - 'decision = "deny"', - 'commandPrefix = "rm"', - "priority = 300", - "", - "[[rule]]", - 'toolName = "read_file"', - 'decision = "allow"', - 'argsPattern = "\\"src/.*"', - "priority = 100", - "", - ].join("\n"), - }); - - const rulesyncPermissions = geminiPermissions.toRulesyncPermissions(); - const json = rulesyncPermissions.getJson(); - - expect(json.permission.bash?.["git *"]).toBe("allow"); - expect(json.permission.bash?.["rm *"]).toBe("deny"); - expect(json.permission.read?.["src/**"]).toBe("allow"); - }); - - it("should accept legacy unanchored argsPattern encoding for backward compatibility", () => { - const geminiPermissions = new GeminicliPermissions({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "policies"), - relativeFilePath: "rulesync.toml", - fileContent: [ - "[[rule]]", - 'toolName = "read_file"', - 'decision = "allow"', - 'argsPattern = "src/.*"', - "priority = 100", - "", - ].join("\n"), - }); - - const json = geminiPermissions.toRulesyncPermissions().getJson(); - expect(json.permission.read?.["src/**"]).toBe("allow"); - }); - - it("should load existing .gemini/policies/rulesync.toml", async () => { - const policyDir = join(testDir, ".gemini", "policies"); - await ensureDir(policyDir); - await writeFileContent( - join(policyDir, "rulesync.toml"), - '[[rule]]\ntoolName = "run_shell_command"\ndecision = "allow"\ncommandPrefix = "git"\npriority = 100\n', - ); - - const loaded = await GeminicliPermissions.fromFile({ outputRoot: testDir }); - expect(loaded).toBeInstanceOf(GeminicliPermissions); - expect(loaded.getFileContent()).toContain('commandPrefix = "git"'); - }); - - it("should return empty permissions when TOML file is missing", async () => { - const loaded = await GeminicliPermissions.fromFile({ outputRoot: testDir }); - const json = loaded.toRulesyncPermissions().getJson(); - expect(json.permission).toEqual({}); - }); - - it("should throw a descriptive error on malformed TOML", () => { - const geminiPermissions = new GeminicliPermissions({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "policies"), - relativeFilePath: "rulesync.toml", - fileContent: "[[rule]]\ninvalid !!! :: broken", - }); - - expect(() => geminiPermissions.toRulesyncPermissions()).toThrow( - /Failed to parse Gemini CLI policy TOML/, - ); - }); - - it("should translate ? as a single-char wildcard that respects path segments", async () => { - const rulesyncPermissions = new RulesyncPermissions({ - outputRoot: testDir, - relativeDirPath: ".rulesync", - relativeFilePath: "permissions.json", - fileContent: JSON.stringify({ - permission: { read: { "file?.ts": "allow" } }, - }), - }); - - const geminiPermissions = await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions, - }); - - const rule = parseRules(geminiPermissions.getFileContent()).find( - (entry) => entry.toolName === "read_file", - ); - expect(rule?.argsPattern).toBe('"file[^/\\"]\\.ts\\"'); - }); - - it("should round-trip ** patterns back to rulesync", async () => { - const source = new RulesyncPermissions({ - outputRoot: testDir, - relativeDirPath: ".rulesync", - relativeFilePath: "permissions.json", - fileContent: JSON.stringify({ - permission: { read: { "src/**": "allow" } }, - }), - }); - const emitted = await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions: source, - }); - const reloaded = new GeminicliPermissions({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "policies"), - relativeFilePath: "rulesync.toml", - fileContent: emitted.getFileContent(), - }); - - const json = reloaded.toRulesyncPermissions().getJson(); - expect(json.permission.read?.["src/**"]).toBe("allow"); - }); - - it("should skip patterns containing a quote to avoid regex-anchor hijack", async () => { - const rulesyncPermissions = new RulesyncPermissions({ - outputRoot: testDir, - relativeDirPath: ".rulesync", - relativeFilePath: "permissions.json", - fileContent: JSON.stringify({ - permission: { bash: { '"x":"*"': "allow", "git *": "allow" } }, - }), - }); - - const geminiPermissions = await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions, - }); - - const rules = parseRules(geminiPermissions.getFileContent()); - expect(rules).toHaveLength(1); - expect(rules[0]?.commandPrefix).toBe("git"); - }); - - it("should not allow a malicious pattern to inject a second [[rule]] block", async () => { - const rulesyncPermissions = new RulesyncPermissions({ - outputRoot: testDir, - relativeDirPath: ".rulesync", - relativeFilePath: "permissions.json", - fileContent: JSON.stringify({ - permission: { - // Newline-based injection attempt: no " or \ so it passes hasUnsafeAnchorChar, - // but smol-toml must escape the newline and prevent spawning a second rule. - read: { "safe\n[[rule]]\ndecision = ask_user\npriority = 9999": "deny" }, - }, - }), - }); - - const geminiPermissions = await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions, - }); - - const rules = parseRules(geminiPermissions.getFileContent()); - expect(rules).toHaveLength(1); - expect(rules[0]?.decision).toBe("deny"); - expect(rules.find((rule) => rule.decision === "ask_user")).toBeUndefined(); - }); - - it("should not pollute Object.prototype when importing a rule with toolName __proto__", () => { - const geminiPermissions = new GeminicliPermissions({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "policies"), - relativeFilePath: "rulesync.toml", - fileContent: [ - "[[rule]]", - 'toolName = "__proto__"', - 'decision = "allow"', - 'argsPattern = "pwned"', - "priority = 999999", - "", - "[[rule]]", - 'toolName = "constructor"', - 'decision = "allow"', - 'argsPattern = "pwned2"', - "priority = 999999", - "", - ].join("\n"), - }); - - const json = geminiPermissions.toRulesyncPermissions().getJson(); - - expect(json.permission).not.toHaveProperty("__proto__"); - expect(json.permission).not.toHaveProperty("constructor"); - // Smoke-check: a freshly-created object must not inherit a polluted "pwned" / "pwned2" key. - const probe: Record = {}; - expect(probe.pwned).toBeUndefined(); - expect(probe.pwned2).toBeUndefined(); - }); - - it("should treat glob character classes as regex literals to prevent JSON-boundary bypass", async () => { - const rulesyncPermissions = new RulesyncPermissions({ - outputRoot: testDir, - relativeDirPath: ".rulesync", - relativeFilePath: "permissions.json", - fileContent: JSON.stringify({ - permission: { - // Negated class `[^a]` would match `"` (34) and could cross JSON-field boundaries. - // After the fix, `[` and `]` are treated as literals and the `^` is also literal, - // so the resulting regex does not span fields. - read: { "[^a]*.ts": "allow" }, - }, - }), - }); - - const geminiPermissions = await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions, - }); - - const rule = parseRules(geminiPermissions.getFileContent()).find( - (entry) => entry.toolName === "read_file", - ); - expect(rule?.argsPattern).toBe('"\\[\\^a\\][^/\\"]*\\.ts\\"'); - // The emitted regex must still match literal "[^a]..." only, not exploit field hops. - const regex = new RegExp(rule?.argsPattern ?? ""); - expect(regex.test('{"path":"[^a]foo.ts"}')).toBe(true); - expect(regex.test('{"path":"other","hop":"x"}')).toBe(false); - }); - - it("should skip bash match-all patterns with allow or deny decisions", async () => { - const rulesyncPermissions = new RulesyncPermissions({ - outputRoot: testDir, - relativeDirPath: ".rulesync", - relativeFilePath: "permissions.json", - fileContent: JSON.stringify({ - permission: { - bash: { - "": "allow", - "*": "allow", - "**": "deny", - "git *": "allow", - }, - }, - }), - }); - - const geminiPermissions = await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions, - }); - - const rules = parseRules(geminiPermissions.getFileContent()); - expect(rules).toHaveLength(1); - expect(rules[0]?.commandPrefix).toBe("git"); - }); - - it("should still allow bash catch-all with ask decision", async () => { - const rulesyncPermissions = new RulesyncPermissions({ - outputRoot: testDir, - relativeDirPath: ".rulesync", - relativeFilePath: "permissions.json", - fileContent: JSON.stringify({ - permission: { bash: { "*": "ask" } }, - }), - }); - - const geminiPermissions = await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions, - }); - - const rules = parseRules(geminiPermissions.getFileContent()); - expect(rules).toHaveLength(1); - expect(rules[0]?.decision).toBe("ask_user"); - expect(rules[0]?.commandPrefix).toBeUndefined(); - expect(rules[0]?.argsPattern).toBeUndefined(); - }); - - it("should skip empty pattern for non-bash tools", async () => { - const rulesyncPermissions = new RulesyncPermissions({ - outputRoot: testDir, - relativeDirPath: ".rulesync", - relativeFilePath: "permissions.json", - fileContent: JSON.stringify({ - permission: { read: { "": "deny", "src/**": "allow" } }, - }), - }); - - const geminiPermissions = await GeminicliPermissions.fromRulesyncPermissions({ - outputRoot: testDir, - rulesyncPermissions, - }); - - const rules = parseRules(geminiPermissions.getFileContent()); - expect(rules).toHaveLength(1); - expect(rules[0]?.decision).toBe("allow"); - }); - - it("should be deletable because rulesync.toml is exclusively owned by rulesync", async () => { - const geminiPermissions = GeminicliPermissions.forDeletion({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "policies"), - relativeFilePath: "rulesync.toml", - }); - - expect(geminiPermissions.isDeletable()).toBe(true); - }); -}); diff --git a/src/features/permissions/geminicli-permissions.ts b/src/features/permissions/geminicli-permissions.ts deleted file mode 100644 index cde5fb733..000000000 --- a/src/features/permissions/geminicli-permissions.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { join } from "node:path"; - -import * as smolToml from "smol-toml"; -import { z } from "zod/mini"; - -import { - GEMINICLI_POLICIES_DIR_PATH, - GEMINICLI_PERMISSIONS_FILE_NAME, -} from "../../constants/geminicli-paths.js"; -import type { ValidationResult } from "../../types/ai-file.js"; -import type { PermissionsConfig } from "../../types/permissions.js"; -import { formatError } from "../../utils/error.js"; -import { readFileContentOrNull } from "../../utils/file.js"; -import { ConsoleLogger, type Logger } from "../../utils/logger.js"; -import { RulesyncPermissions } from "./rulesync-permissions.js"; -import { - ToolPermissions, - type ToolPermissionsForDeletionParams, - type ToolPermissionsFromFileParams, - type ToolPermissionsFromRulesyncPermissionsParams, - type ToolPermissionsSettablePaths, -} from "./tool-permissions.js"; - -const GEMINICLI_POLICY_RELATIVE_DIR_PATH = GEMINICLI_POLICIES_DIR_PATH; -const GEMINICLI_POLICY_FILE_NAME = GEMINICLI_PERMISSIONS_FILE_NAME; - -const RULESYNC_TO_GEMINICLI_TOOL_NAME: Record = { - bash: "run_shell_command", - read: "read_file", - edit: "replace", - write: "write_file", - webfetch: "web_fetch", -}; - -const GEMINICLI_TO_RULESYNC_TOOL_NAME: Record = Object.fromEntries( - Object.entries(RULESYNC_TO_GEMINICLI_TOOL_NAME).map(([k, v]) => [v, k]), -); - -// Priority values chosen so `deny` beats `ask` beats `allow` in first-match order, which -// the Gemini CLI Policy Engine does not otherwise enforce. The Policy Engine documents a -// valid per-rule priority range of 0-999 (combined as `tier_base + toml_priority / 1000`), -// so the values must stay within that range. `deny` is placed at the top of the band and -// `allow` at the bottom, leaving headroom for hand-authored rules in sibling `.toml` files. -// https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/policy-engine.md -const PRIORITY_DENY = 999; -const PRIORITY_ASK = 500; -const PRIORITY_ALLOW = 1; - -// Regex fragments emitted for glob wildcards. Both exclude `"` so the pattern cannot leak -// across a JSON string boundary when the Policy Engine matches argsPattern against a -// JSON-stringified args object. `*` additionally excludes `/` to stay within a single path -// segment; `**` spans segments but still stops at the closing string quote. -const SINGLE_STAR_REGEX = '[^/\\"]*'; -const DOUBLE_STAR_REGEX = '[^\\"]*'; -const SINGLE_CHAR_REGEX = '[^/\\"]'; -// Legacy encodings accepted on import for backward compatibility with earlier iterations -// of this PR that emitted un-segmented or un-bounded wildcards. -const LEGACY_SINGLE_STAR_REGEX = '[^\\"]*'; -const LEGACY_DOUBLE_STAR_REGEX = ".*"; -const COMMAND_ARGS_ANCHOR = '"command":"'; -const VALUE_END_ANCHOR = '\\"'; - -// Reserved JavaScript object keys that would either alias the prototype chain (prototype -// pollution) or be silently swallowed when used as a plain-object key. We reject these on -// both the `category` (tool name) and `pattern` sides when importing. -const RESERVED_OBJECT_KEYS: ReadonlySet = new Set([ - "__proto__", - "constructor", - "prototype", -]); - -const moduleLogger: Logger = new ConsoleLogger(); - -export class GeminicliPermissions extends ToolPermissions { - static getSettablePaths(_options: { global?: boolean } = {}): ToolPermissionsSettablePaths { - return { - relativeDirPath: GEMINICLI_POLICY_RELATIVE_DIR_PATH, - relativeFilePath: GEMINICLI_POLICY_FILE_NAME, - }; - } - - static async fromFile({ - outputRoot = process.cwd(), - validate = true, - global = false, - }: ToolPermissionsFromFileParams): Promise { - const paths = this.getSettablePaths({ global }); - const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); - const fileContent = (await readFileContentOrNull(filePath)) ?? ""; - return new GeminicliPermissions({ - outputRoot, - relativeDirPath: paths.relativeDirPath, - relativeFilePath: paths.relativeFilePath, - fileContent, - validate, - }); - } - - static fromRulesyncPermissions({ - outputRoot = process.cwd(), - rulesyncPermissions, - validate = true, - global = false, - logger = moduleLogger, - }: ToolPermissionsFromRulesyncPermissionsParams): GeminicliPermissions { - const paths = this.getSettablePaths({ global }); - if (!global) { - // Gemini CLI's Policy Engine documents the Workspace tier (project-level - // `.gemini/policies/`) as non-functional: policies placed there have no - // effect. Only the User tier (`~/.gemini/policies/`, written with - // `--global`) is honored. Warn so the inert project-scope output is not - // mistaken for an active permission set. - // https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/policy-engine.md - logger.warn( - `Gemini CLI permissions written to the project-scope ${join(GEMINICLI_POLICY_RELATIVE_DIR_PATH, GEMINICLI_POLICY_FILE_NAME)} are inert: Gemini CLI's Workspace policy tier is non-functional. Generate with --global to write the effective User-tier policy at ~/.gemini/policies/.`, - ); - } - const fileContent = buildGeminicliPolicyContent(rulesyncPermissions.getJson(), logger); - - return new GeminicliPermissions({ - outputRoot, - relativeDirPath: paths.relativeDirPath, - relativeFilePath: paths.relativeFilePath, - fileContent, - validate, - }); - } - - toRulesyncPermissions(): RulesyncPermissions { - const permission: PermissionsConfig["permission"] = {}; - - const fileContent = this.getFileContent(); - if (fileContent.trim().length > 0) { - let parsed: unknown; - try { - parsed = smolToml.parse(fileContent); - } catch (error) { - throw new Error( - `Failed to parse Gemini CLI policy TOML in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, - { cause: error }, - ); - } - - const rules = extractRules(parsed, moduleLogger); - for (const [index, rule] of rules.entries()) { - // Use Object.hasOwn to avoid inheriting accessors (e.g., `__proto__`) from the - // mapping object; falling back to the raw toolName preserves behavior for tools - // we don't know about yet. - const mappedCategory = Object.hasOwn(GEMINICLI_TO_RULESYNC_TOOL_NAME, rule.toolName) - ? GEMINICLI_TO_RULESYNC_TOOL_NAME[rule.toolName] - : undefined; - const category = mappedCategory ?? rule.toolName; - if (RESERVED_OBJECT_KEYS.has(category)) { - moduleLogger.warn( - `Skipping rule #${index} in ${this.getRelativeFilePath()}: toolName "${rule.toolName}" maps to a reserved object key ("${category}") and would risk prototype pollution.`, - ); - continue; - } - const action = mapFromGeminicliDecision(rule.decision); - if (!action) { - moduleLogger.warn( - `Skipping rule #${index} (toolName="${rule.toolName}", commandPrefix=${JSON.stringify(rule.commandPrefix)}, argsPattern=${JSON.stringify(rule.argsPattern)}) in ${this.getRelativeFilePath()}: unknown decision ${JSON.stringify(rule.decision)}`, - ); - continue; - } - if ( - rule.toolName === "run_shell_command" && - rule.commandPrefix !== undefined && - rule.argsPattern !== undefined - ) { - moduleLogger.warn( - `Rule #${index} in ${this.getRelativeFilePath()} sets both commandPrefix and argsPattern; rulesync will honor argsPattern and ignore commandPrefix=${JSON.stringify(rule.commandPrefix)}.`, - ); - } - const pattern = extractPattern(rule); - if (RESERVED_OBJECT_KEYS.has(pattern)) { - moduleLogger.warn( - `Skipping rule #${index} in ${this.getRelativeFilePath()}: pattern "${pattern}" is a reserved object key.`, - ); - continue; - } - // Use Object.hasOwn to avoid touching inherited accessors like `__proto__` when - // the Set guard above is ever bypassed (e.g., future tool-name mapping changes). - const existing = Object.hasOwn(permission, category) ? permission[category] : undefined; - const target = existing ?? {}; - if (existing === undefined) { - permission[category] = target; - } - target[pattern] = action; - } - } - - return this.toRulesyncPermissionsDefault({ - fileContent: JSON.stringify({ permission }, null, 2), - }); - } - - validate(): ValidationResult { - return { success: true, error: null }; - } - - static forDeletion({ - outputRoot = process.cwd(), - relativeDirPath, - relativeFilePath, - }: ToolPermissionsForDeletionParams): GeminicliPermissions { - return new GeminicliPermissions({ - outputRoot, - relativeDirPath, - relativeFilePath, - fileContent: "", - validate: false, - }); - } -} - -function buildGeminicliPolicyContent(config: PermissionsConfig, logger: Logger): string { - const rules: { rule: Record; order: number }[] = []; - let order = 0; - for (const [toolName, entries] of Object.entries(config.permission)) { - const mappedToolName = RULESYNC_TO_GEMINICLI_TOOL_NAME[toolName] ?? toolName; - for (const [pattern, action] of Object.entries(entries)) { - if (pattern === "") { - logger.warn( - `Skipping rule "${toolName}: "": empty pattern is not a valid permission target and would silently match every invocation (bash) or nothing (other tools).`, - ); - continue; - } - if (hasUnsafeAnchorChar(pattern)) { - logger.warn( - `Skipping rule "${toolName}: ${pattern}": pattern contains a character (" or \\) that would break JSON-anchor matching in the Gemini CLI Policy Engine.`, - ); - continue; - } - const decision = mapToGeminicliDecision(action); - if ( - mappedToolName === "run_shell_command" && - (pattern === "*" || pattern === "**") && - decision !== "ask_user" - ) { - // `*` / `**` on bash would emit a rule with no commandPrefix / argsPattern, which - // the Policy Engine treats as "match every shell command". At PRIORITY_DENY this is - // a global shell lockout; at PRIORITY_ALLOW it silently opens arbitrary execution. - // Catch-all is only meaningful at `ask_user` (interactive prompting). - logger.warn( - `Skipping rule "${toolName}: ${pattern}" with decision ${decision}: bash match-all patterns are only supported with "ask" because they would otherwise affect every shell command.`, - ); - continue; - } - const currentRule: Record = { - toolName: mappedToolName, - decision, - priority: priorityForDecision(decision), - }; - if (mappedToolName === "run_shell_command") { - applyShellPattern({ rule: currentRule, pattern, toolName, logger }); - } else if (pattern !== "*") { - currentRule.argsPattern = buildNonShellArgsPattern(pattern); - } - rules.push({ rule: currentRule, order: order++ }); - } - } - // Sort by priority descending; preserve input order within the same priority band by - // using the explicit secondary key rather than relying on Array.prototype.sort stability. - rules.sort((a, b) => { - const diff = toNumber(b.rule.priority) - toNumber(a.rule.priority); - return diff !== 0 ? diff : a.order - b.order; - }); - return smolToml.stringify({ rule: rules.map((entry) => entry.rule) }); -} - -function buildNonShellArgsPattern(pattern: string): string { - return `"${globPatternToRegex(pattern)}${VALUE_END_ANCHOR}`; -} - -function hasUnsafeAnchorChar(pattern: string): boolean { - return pattern.includes('"') || pattern.includes("\\"); -} - -function toNumber(value: unknown): number { - return typeof value === "number" ? value : 0; -} - -function applyShellPattern({ - rule, - pattern, - toolName, - logger, -}: { - rule: Record; - pattern: string; - toolName: string; - logger: Logger; -}): void { - if (pattern === "*") { - return; - } - const trailingWildcardStripped = pattern.endsWith(" *") ? pattern.slice(0, -2) : pattern; - if (hasGlobMetacharacter(trailingWildcardStripped)) { - // Interior wildcards are meaningless inside commandPrefix (Gemini CLI escapes the - // string as a literal), so emit argsPattern with a JSON-anchor instead. - rule.argsPattern = `${COMMAND_ARGS_ANCHOR}${globPatternToRegex(pattern)}`; - logger.warn( - `Gemini CLI does not support glob metacharacters inside a bash command prefix; emitting argsPattern for rule "${toolName}: ${pattern}".`, - ); - return; - } - rule.commandPrefix = trailingWildcardStripped; -} - -function hasGlobMetacharacter(pattern: string): boolean { - return /[*?[\]]/.test(pattern); -} - -function priorityForDecision(decision: "allow" | "deny" | "ask_user"): number { - if (decision === "deny") return PRIORITY_DENY; - if (decision === "ask_user") return PRIORITY_ASK; - return PRIORITY_ALLOW; -} - -function mapToGeminicliDecision(action: "allow" | "deny" | "ask"): "allow" | "deny" | "ask_user" { - if (action === "ask") { - return "ask_user"; - } - return action; -} - -function mapFromGeminicliDecision(decision: unknown): "allow" | "deny" | "ask" | null { - if (decision === "allow") return "allow"; - if (decision === "deny") return "deny"; - if (decision === "ask_user") return "ask"; - return null; -} - -function globPatternToRegex(pattern: string): string { - let regex = ""; - let i = 0; - while (i < pattern.length) { - const char = pattern[i]; - if (char === undefined) { - break; - } - if (char === "*" && pattern[i + 1] === "*") { - regex += DOUBLE_STAR_REGEX; - i += 2; - continue; - } - if (char === "*") { - regex += SINGLE_STAR_REGEX; - i += 1; - continue; - } - if (char === "?") { - regex += SINGLE_CHAR_REGEX; - i += 1; - continue; - } - if (char === "[") { - // Character classes are emitted as regex literals. A class body can easily bypass the - // JSON-field-boundary guard via negation (`[^a]` matches `"`) or ranges that include - // the `"` (34) or `/` (47) code points (e.g. `[!-~]`). Rather than attempt to enumerate - // every unsafe form, we treat `[` as a literal character. Glob character classes are - // extremely rare in permission rules and are not a feature users are expected to rely - // on here. - regex += escapeRegexChar(char); - i += 1; - continue; - } - if (char === "]") { - regex += escapeRegexChar(char); - i += 1; - continue; - } - if (isRegexMetacharacter(char)) { - regex += `\\${char}`; - i += 1; - continue; - } - regex += char; - i += 1; - } - return regex; -} - -function escapeRegexChar(char: string): string { - return `\\${char}`; -} - -function isRegexMetacharacter(char: string): boolean { - return /[.+^${}()|\\]/.test(char); -} - -function regexToGlobPattern(regex: string): string { - // Strip the emitter's trailing value-end anchor so imported patterns round-trip cleanly. - let source = regex; - if (source.endsWith(VALUE_END_ANCHOR)) { - source = source.slice(0, -VALUE_END_ANCHOR.length); - } - let glob = ""; - let i = 0; - while (i < source.length) { - if (source.startsWith(DOUBLE_STAR_REGEX, i)) { - glob += "**"; - i += DOUBLE_STAR_REGEX.length; - continue; - } - if (source.startsWith(LEGACY_DOUBLE_STAR_REGEX, i)) { - glob += "**"; - i += LEGACY_DOUBLE_STAR_REGEX.length; - continue; - } - if (source.startsWith(SINGLE_STAR_REGEX, i)) { - glob += "*"; - i += SINGLE_STAR_REGEX.length; - continue; - } - if (source.startsWith(LEGACY_SINGLE_STAR_REGEX, i)) { - glob += "*"; - i += LEGACY_SINGLE_STAR_REGEX.length; - continue; - } - if (source.startsWith(SINGLE_CHAR_REGEX, i)) { - glob += "?"; - i += SINGLE_CHAR_REGEX.length; - continue; - } - const char = source[i]; - if (char === "\\") { - const escaped = source[i + 1]; - if (escaped !== undefined) { - glob += escaped; - i += 2; - continue; - } - } - glob += char ?? ""; - i += 1; - } - return glob; -} - -const GeminicliPolicyRuleSchema = z.looseObject({ - toolName: z.string(), - decision: z.optional(z.unknown()), - commandPrefix: z.optional(z.string()), - argsPattern: z.optional(z.string()), -}); - -const GeminicliPolicyFileSchema = z.looseObject({ - rule: z.optional(z.array(z.looseObject({}))), -}); - -type GeminicliPolicyRule = z.infer; - -function extractRules(parsed: unknown, logger: Logger): GeminicliPolicyRule[] { - const parsedFile = GeminicliPolicyFileSchema.safeParse(parsed); - if (!parsedFile.success || !parsedFile.data.rule) { - return []; - } - const rules: GeminicliPolicyRule[] = []; - for (const [index, entry] of parsedFile.data.rule.entries()) { - const result = GeminicliPolicyRuleSchema.safeParse(entry); - if (result.success) { - rules.push(result.data); - continue; - } - logger.warn( - `Skipping malformed Gemini CLI policy rule at index ${index}: ${formatError(result.error)}`, - ); - } - return rules; -} - -function extractPattern(rule: GeminicliPolicyRule): string { - if (rule.toolName === "run_shell_command") { - if (rule.argsPattern) { - const stripped = rule.argsPattern.startsWith(COMMAND_ARGS_ANCHOR) - ? rule.argsPattern.slice(COMMAND_ARGS_ANCHOR.length) - : rule.argsPattern; - return regexToGlobPattern(stripped); - } - if (!rule.commandPrefix) return "*"; - // Canonicalize reverse to " *" — the engine matches commandPrefix with a - // word boundary, so "git" and "git *" are equivalent inside Gemini CLI. - return rule.commandPrefix.endsWith(" *") || rule.commandPrefix.endsWith("*") - ? rule.commandPrefix - : `${rule.commandPrefix} *`; - } - if (!rule.argsPattern) return "*"; - const regex = rule.argsPattern.startsWith('"') ? rule.argsPattern.slice(1) : rule.argsPattern; - return regexToGlobPattern(regex); -} diff --git a/src/features/permissions/permissions-processor.test.ts b/src/features/permissions/permissions-processor.test.ts index 8edf82027..dd44d697e 100644 --- a/src/features/permissions/permissions-processor.test.ts +++ b/src/features/permissions/permissions-processor.test.ts @@ -13,7 +13,6 @@ import { AugmentcodePermissions } from "./augmentcode-permissions.js"; import { ClaudecodePermissions } from "./claudecode-permissions.js"; import { ClinePermissions } from "./cline-permissions.js"; import { CodexcliPermissions } from "./codexcli-permissions.js"; -import { GeminicliPermissions } from "./geminicli-permissions.js"; import { KiloPermissions } from "./kilo-permissions.js"; import { KiroPermissions } from "./kiro-permissions.js"; import { OpencodePermissions } from "./opencode-permissions.js"; @@ -87,7 +86,6 @@ describe("PermissionsProcessor", () => { "codexcli", "cursor", "factorydroid", - "geminicli", "kilo", "kiro", "kiro-cli", @@ -110,7 +108,6 @@ describe("PermissionsProcessor", () => { "codexcli", "cursor", "factorydroid", - "geminicli", "goose", "grokcli", "hermesagent", @@ -136,7 +133,6 @@ describe("PermissionsProcessor", () => { "codexcli", "cursor", "factorydroid", - "geminicli", "kilo", "kiro", "kiro-cli", @@ -305,26 +301,6 @@ default_permissions = "rulesync" expect(files[0]).toBeInstanceOf(CodexcliPermissions); }); - it("should load Gemini CLI .gemini/policies/rulesync.toml", async () => { - const policyDir = join(testDir, ".gemini", "policies"); - await ensureDir(policyDir); - await writeFileContent( - join(policyDir, "rulesync.toml"), - '[[rule]]\ntoolName = "run_shell_command"\ndecision = "allow"\ncommandPrefix = "git status"\npriority = 100\n', - ); - - const processor = new PermissionsProcessor({ - logger, - outputRoot: testDir, - toolTarget: "geminicli", - }); - - const files = await processor.loadToolFiles(); - - expect(files).toHaveLength(1); - expect(files[0]).toBeInstanceOf(GeminicliPermissions); - }); - it("should load AugmentCode .augment/settings.json", async () => { const augmentDir = join(testDir, ".augment"); await ensureDir(augmentDir); diff --git a/src/features/permissions/permissions-processor.ts b/src/features/permissions/permissions-processor.ts index 183872423..be1de23a3 100644 --- a/src/features/permissions/permissions-processor.ts +++ b/src/features/permissions/permissions-processor.ts @@ -17,7 +17,6 @@ import { ClinePermissions } from "./cline-permissions.js"; import { CodexcliPermissions, createCodexcliBashRulesFile } from "./codexcli-permissions.js"; import { CursorPermissions } from "./cursor-permissions.js"; import { FactorydroidPermissions } from "./factorydroid-permissions.js"; -import { GeminicliPermissions } from "./geminicli-permissions.js"; import { GoosePermissions } from "./goose-permissions.js"; import { GrokcliPermissions } from "./grokcli-permissions.js"; import { HermesagentPermissions } from "./hermesagent-permissions.js"; @@ -176,17 +175,6 @@ export const toolPermissionsFactories = new Map< }, }, ], - [ - "geminicli", - { - class: GeminicliPermissions, - meta: { - supportsProject: true, - supportsGlobal: true, - supportsImport: true, - }, - }, - ], [ "goose", { diff --git a/src/features/rules/antigravity-cli-rule.ts b/src/features/rules/antigravity-cli-rule.ts index 686dae8ff..9e8f9b89d 100644 --- a/src/features/rules/antigravity-cli-rule.ts +++ b/src/features/rules/antigravity-cli-rule.ts @@ -33,7 +33,7 @@ export type AntigravityCliRuleSettablePathsGlobal = ToolRuleSettablePathsGlobal; * * The CLI reads the same plain-markdown context files as Gemini CLI — a root * context file plus non-root memory files in `.agents/rules/` — so this class - * mirrors {@link GeminiCliRule} but points at the new `.agents/` tree. + * follows that same plain-markdown approach but points at the new `.agents/` tree. * * - Project scope: root `AGENTS.md` (the cross-tool standard, matching * `antigravity-ide`); non-root `.agents/rules/*.md`. diff --git a/src/features/rules/antigravity-ide-rule.ts b/src/features/rules/antigravity-ide-rule.ts index dc3007ee8..d09658caf 100644 --- a/src/features/rules/antigravity-ide-rule.ts +++ b/src/features/rules/antigravity-ide-rule.ts @@ -50,17 +50,14 @@ export type AntigravityIdeRuleSettablePaths = { /** * Rule generator for the Google Antigravity IDE (Antigravity 2.0). * - * This is the v2 successor of {@link AntigravityRule}; it reuses the same - * trigger-strategy frontmatter logic but defaults to the new plural - * `.agents/rules/` directory and adds global scope (`~/.gemini/GEMINI.md`). + * It reuses the same trigger-strategy frontmatter logic (see + * `antigravity-rule.ts`) but defaults to the new plural `.agents/rules/` + * directory and adds global scope (`~/.gemini/GEMINI.md`). * * - Project scope: every rule is placed as a non-root file in * `.agents/rules/` with Antigravity trigger frontmatter. * - Global scope: a single plain `~/.gemini/GEMINI.md` root file (shared with * the Antigravity CLI), without frontmatter. - * - * Back-compat reads of the singular `.agent/` tree are handled by the - * deprecated `antigravity` alias target (see {@link AntigravityRule}). */ export class AntigravityIdeRule extends ToolRule { private readonly frontmatter: AntigravityRuleFrontmatter; diff --git a/src/features/rules/antigravity-rule.test.ts b/src/features/rules/antigravity-rule.test.ts deleted file mode 100644 index c5f10c7ee..000000000 --- a/src/features/rules/antigravity-rule.test.ts +++ /dev/null @@ -1,958 +0,0 @@ -import { join } from "node:path"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { RULESYNC_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; -import { setupTestDirectory } from "../../test-utils/test-directories.js"; -import { ensureDir, writeFileContent } from "../../utils/file.js"; -import { - AntigravityRule, - AntigravityRuleFrontmatter, - AntigravityRuleFrontmatterSchema, -} from "./antigravity-rule.js"; -import { RulesyncRule } from "./rulesync-rule.js"; - -describe("AntigravityRule", () => { - let testDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ testDir, cleanup } = await setupTestDirectory()); - vi.spyOn(process, "cwd").mockReturnValue(testDir); - }); - - afterEach(async () => { - await cleanup(); - vi.restoreAllMocks(); - }); - - describe("constructor", () => { - it("should create instance with default parameters", () => { - const antigravityRule = new AntigravityRule({ - frontmatter: { trigger: "always_on" }, - relativeDirPath: ".agent/rules", - relativeFilePath: "test-rule.md", - body: "# Test Rule\n\nThis is a test rule.", - }); - - expect(antigravityRule).toBeInstanceOf(AntigravityRule); - expect(antigravityRule.getRelativeDirPath()).toBe(".agent/rules"); - expect(antigravityRule.getRelativeFilePath()).toBe("test-rule.md"); - expect(antigravityRule.getFileContent().trim()).toBe(`--- -trigger: always_on ---- -# Test Rule - -This is a test rule.`); - }); - - it("should create instance with custom outputRoot", () => { - const antigravityRule = new AntigravityRule({ - frontmatter: { trigger: "always_on" }, - outputRoot: "/custom/path", - relativeDirPath: ".agent/rules", - relativeFilePath: "test-rule.md", - body: "# Custom Rule", - }); - - expect(antigravityRule.getFilePath()).toBe("/custom/path/.agent/rules/test-rule.md"); - }); - - it("should validate content by default", () => { - expect(() => { - const _instance = new AntigravityRule({ - frontmatter: { trigger: "always_on" }, - relativeDirPath: ".agent/rules", - relativeFilePath: "test-rule.md", - body: "", // empty content should be valid since validate always returns success - }); - }).not.toThrow(); - }); - - it("should skip validation when requested", () => { - expect(() => { - const _instance = new AntigravityRule({ - frontmatter: { trigger: "always_on" }, - relativeDirPath: ".agent/rules", - relativeFilePath: "test-rule.md", - body: "", - validate: false, - }); - }).not.toThrow(); - }); - - it("should handle root rule parameter", () => { - const antigravityRule = new AntigravityRule({ - frontmatter: { trigger: "always_on" }, - relativeDirPath: ".agent/rules", - relativeFilePath: "test-rule.md", - body: "# Root Rule", - root: false, - }); - - expect(antigravityRule.getFileContent().trim()).toBe(`--- -trigger: always_on ---- -# Root Rule`); - }); - }); - - describe("fromFile", () => { - it("should create instance from existing file", async () => { - // Setup test file - const rulesDir = join(testDir, ".agent/rules"); - await ensureDir(rulesDir); - const testContent = - "---\ntrigger: always_on\n---\n\n# Test Rule from File\n\nContent from file."; - await writeFileContent(join(rulesDir, "test.md"), testContent); - - const antigravityRule = await AntigravityRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "test.md", - }); - - expect(antigravityRule.getRelativeDirPath()).toBe(".agent/rules"); - expect(antigravityRule.getRelativeFilePath()).toBe("test.md"); - expect(antigravityRule.getFileContent().trim()).toBe(testContent); - expect(antigravityRule.getFilePath()).toBe(join(testDir, ".agent/rules/test.md")); - }); - - it("should use default outputRoot when not provided", async () => { - // Setup test file using testDir - const rulesDir = join(testDir, ".agent/rules"); - await ensureDir(rulesDir); - const testContent = "---\ntrigger: always_on\n---\n\n# Default OutputRoot Test"; - const testFilePath = join(rulesDir, "default-test.md"); - await writeFileContent(testFilePath, testContent); - - // process.cwd() is already mocked in beforeEach - const antigravityRule = await AntigravityRule.fromFile({ - relativeFilePath: "default-test.md", - }); - - expect(antigravityRule.getRelativeDirPath()).toBe(".agent/rules"); - expect(antigravityRule.getRelativeFilePath()).toBe("default-test.md"); - expect(antigravityRule.getFileContent().trim()).toBe(testContent); - expect(antigravityRule.getFilePath()).toBe(join(testDir, ".agent/rules/default-test.md")); - }); - - it("should handle validation parameter", async () => { - const rulesDir = join(testDir, ".agent/rules"); - await ensureDir(rulesDir); - const testContent = "---\ntrigger: always_on\n---\n\n# Validation Test"; - await writeFileContent(join(rulesDir, "validation-test.md"), testContent); - - const antigravityRuleWithValidation = await AntigravityRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "validation-test.md", - validate: true, - }); - - const antigravityRuleWithoutValidation = await AntigravityRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "validation-test.md", - validate: false, - }); - - expect(antigravityRuleWithValidation.getFileContent().trim()).toBe(testContent); - expect(antigravityRuleWithoutValidation.getFileContent().trim()).toBe(testContent); - }); - - it("should throw error when file does not exist", async () => { - await expect( - AntigravityRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "nonexistent.md", - }), - ).rejects.toThrow(); - }); - }); - - describe("fromRulesyncRule", () => { - it("should create instance from RulesyncRule", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "test-rule.md", - frontmatter: { - root: false, - targets: ["*"], - description: "Test rule", - globs: [], - }, - body: "# Test RulesyncRule\n\nContent from rulesync.", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(antigravityRule).toBeInstanceOf(AntigravityRule); - expect(antigravityRule.getRelativeDirPath()).toBe(".agent/rules"); - expect(antigravityRule.getRelativeFilePath()).toBe("test-rule.md"); - expect(antigravityRule.getFileContent()).toContain( - "# Test RulesyncRule\n\nContent from rulesync.", - ); - }); - - it("should use custom outputRoot", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "custom-base.md", - frontmatter: { - root: false, - targets: ["*"], - description: "", - globs: [], - }, - body: "# Custom Base Directory", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - outputRoot: "/custom/base", - rulesyncRule, - }); - - expect(antigravityRule.getFilePath()).toBe("/custom/base/.agent/rules/custom-base.md"); - }); - - it("should handle validation parameter", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "validation.md", - frontmatter: { - root: false, - targets: ["*"], - description: "", - globs: [], - }, - body: "# Validation Test", - }); - - const withValidation = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - validate: true, - }); - - const withoutValidation = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - validate: false, - }); - - expect(withValidation.getFileContent()).toContain("# Validation Test"); - expect(withoutValidation.getFileContent()).toContain("# Validation Test"); - }); - - it("should place root rules in .agent/rules directory", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "overview.md", - frontmatter: { - root: true, - targets: ["*"], - description: "Project overview", - globs: ["**/*"], - }, - body: "# Project Overview\n\nThis is the root rule.", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - // Root rules are also placed in .agent/rules directory - expect(antigravityRule.getRelativeDirPath()).toBe(".agent/rules"); - expect(antigravityRule.getRelativeFilePath()).toBe("overview.md"); - expect(antigravityRule.isRoot()).toBe(false); - expect(antigravityRule.getFileContent()).toContain("# Project Overview"); - }); - - it("should maintain original filename for root rules", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "my-custom-root.md", - frontmatter: { - root: true, - targets: ["antigravity"], - description: "", - globs: ["**/*"], - }, - body: "# Custom Root Rule", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - outputRoot: "/project", - rulesyncRule, - }); - - expect(antigravityRule.getFilePath()).toBe("/project/.agent/rules/my-custom-root.md"); - expect(antigravityRule.getRelativeFilePath()).toBe("my-custom-root.md"); - }); - - it("should convert PascalCase filenames to kebab-case", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "CodingGuidelines.md", - frontmatter: { - root: false, - targets: ["*"], - description: "", - globs: [], - }, - body: "# Coding Guidelines", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(antigravityRule.getRelativeFilePath()).toBe("coding-guidelines.md"); - expect(antigravityRule.getRelativeDirPath()).toBe(".agent/rules"); - }); - - it("should convert snake_case filenames to kebab-case", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "api_reference.md", - frontmatter: { - root: false, - targets: ["*"], - description: "", - globs: [], - }, - body: "# API Reference", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(antigravityRule.getRelativeFilePath()).toBe("api-reference.md"); - }); - - it("should convert mixed case filenames with numbers to kebab-case", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "API_Guide_v2.md", - frontmatter: { - root: false, - targets: ["*"], - description: "", - globs: [], - }, - body: "# API Guide v2", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - // es-toolkit's kebabCase adds hyphens before numbers - expect(antigravityRule.getRelativeFilePath()).toBe("api-guide-v-2.md"); - }); - - it("should preserve already kebab-case filenames", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "coding-guidelines.md", - frontmatter: { - root: false, - targets: ["*"], - description: "", - globs: [], - }, - body: "# Coding Guidelines", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(antigravityRule.getRelativeFilePath()).toBe("coding-guidelines.md"); - }); - }); - - describe("toRulesyncRule", () => { - it("should convert to RulesyncRule for all triggers", () => { - const testCases: { - frontmatter: AntigravityRuleFrontmatter; - expectedGlobs: string[]; - expectedAntigravityTrigger: string; - }[] = [ - { - frontmatter: { trigger: "glob", globs: "*.ts" }, - expectedGlobs: ["*.ts"], - expectedAntigravityTrigger: "glob", - }, - { - frontmatter: { trigger: "manual" }, - expectedGlobs: [], - expectedAntigravityTrigger: "manual", - }, - { - frontmatter: { trigger: "always_on" }, - expectedGlobs: ["**/*"], - expectedAntigravityTrigger: "always_on", - }, - { - frontmatter: { trigger: "model_decision", description: "desc" }, - expectedGlobs: [], - expectedAntigravityTrigger: "model_decision", - }, - ]; - - for (const { frontmatter, expectedGlobs, expectedAntigravityTrigger } of testCases) { - const antigravityRule = new AntigravityRule({ - frontmatter, - relativeDirPath: ".agent/rules", - relativeFilePath: "test.md", - body: "# Test Rule", - }); - - const rulesyncRule = antigravityRule.toRulesyncRule(); - expect(rulesyncRule).toBeInstanceOf(RulesyncRule); - expect(rulesyncRule.getFrontmatter().globs).toEqual(expectedGlobs); - expect(rulesyncRule.getFrontmatter().antigravity?.trigger).toBe(expectedAntigravityTrigger); - } - }); - - it("should always convert to root: false", () => { - // Test with a rule that was created from a root RulesyncRule - const rootRulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "overview.md", - frontmatter: { - root: true, - targets: ["*"], - description: "Project overview", - globs: ["**/*"], - }, - body: "# Project Overview", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule: rootRulesyncRule, - }); - - const convertedRulesyncRule = antigravityRule.toRulesyncRule(); - - // All Antigravity rules are converted to root: false - expect(convertedRulesyncRule.getFrontmatter().root).toBe(false); - expect(convertedRulesyncRule.getBody()).toBe("# Project Overview"); - expect(convertedRulesyncRule.getRelativeFilePath()).toBe("overview.md"); - }); - - it("should preserve non-root status when converting", () => { - const nonRootRulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "coding-style.md", - frontmatter: { - root: false, - targets: ["*"], - description: "Coding style guide", - globs: ["**/*.ts"], - }, - body: "# Coding Style", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule: nonRootRulesyncRule, - }); - - const convertedRulesyncRule = antigravityRule.toRulesyncRule(); - - expect(convertedRulesyncRule.getFrontmatter().root).toBe(false); - expect(convertedRulesyncRule.getBody()).toBe("# Coding Style"); - }); - }); - - describe("validate", () => { - it("should always return success", () => { - const antigravityRule = new AntigravityRule({ - frontmatter: { trigger: "always_on" }, - relativeDirPath: ".agent/rules", - relativeFilePath: "test.md", - body: "# Test", - }); - - const result = antigravityRule.validate(); - - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - }); - - it("should return error for invalid frontmatter types", () => { - const antigravityRule = new AntigravityRule({ - // Invalid: globs should be string, pass number to force schema failure - frontmatter: { trigger: "glob", globs: 123 } as any, - relativeDirPath: ".agent/rules", - relativeFilePath: "test.md", - body: "# Test", - validate: false, - }); - - const result = antigravityRule.validate(); - - expect(result.success).toBe(false); - expect(result.error).toBeInstanceOf(Error); - expect(result.error?.message).toContain("expected string, received number"); - }); - }); - - describe("isTargetedByRulesyncRule", () => { - it("should return true for wildcard target", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "test.md", - frontmatter: { - root: false, - targets: ["*"], - description: "", - globs: [], - }, - body: "# Test", - }); - - expect(AntigravityRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(true); - }); - - it("should return true for antigravity target", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "test.md", - frontmatter: { - root: false, - targets: ["antigravity"], - description: "", - globs: [], - }, - body: "# Test", - }); - - expect(AntigravityRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(true); - }); - - it("should return false for other specific targets", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "test.md", - frontmatter: { - root: false, - targets: ["cursor"], - description: "", - globs: [], - }, - body: "# Test", - }); - - expect(AntigravityRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(false); - }); - }); - - describe("getSettablePaths", () => { - it("should return correct nonRoot path", () => { - const paths = AntigravityRule.getSettablePaths(); - - expect(paths.nonRoot.relativeDirPath).toBe(".agent/rules"); - }); - }); - describe("frontmatter", () => { - it("should parse frontmatter from file", async () => { - const rulesDir = join(testDir, ".agent/rules"); - await ensureDir(rulesDir); - const content = `--- -trigger: glob -globs: '*.ts' ---- - -# Frontmatter Rule`; - await writeFileContent(join(rulesDir, "frontmatter.md"), content); - - const antigravityRule = await AntigravityRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "frontmatter.md", - }); - - const frontmatter = antigravityRule.getFrontmatter(); - expect(frontmatter).toEqual({ - trigger: "glob", - globs: "*.ts", - }); - expect(antigravityRule.getFileContent().trim()).toBe(content.trim()); - }); - - it("should handle all supported triggers", async () => { - const rulesDir = join(testDir, ".agent/rules"); - await ensureDir(rulesDir); - - const testCases = [ - { - file: "glob.md", - content: "---\ntrigger: glob\nglobs: '*.ts'\n---\n# Glob", - expectedFrontmatter: { trigger: "glob", globs: "*.ts" }, - }, - { - file: "manual.md", - content: "---\ntrigger: manual\n---\n# Manual", - expectedFrontmatter: { trigger: "manual" }, - }, - { - file: "always-on.md", - content: "---\ntrigger: always_on\n---\n# Always On", - expectedFrontmatter: { trigger: "always_on" }, // globs are optional - }, - { - file: "model-decision.md", - content: "---\ntrigger: model_decision\ndescription: test desc\n---\n# Model Decision", - expectedFrontmatter: { trigger: "model_decision", description: "test desc" }, - }, - ]; - - for (const testCase of testCases) { - await writeFileContent(join(rulesDir, testCase.file), testCase.content); - const rule = await AntigravityRule.fromFile({ - outputRoot: testDir, - relativeFilePath: testCase.file, - }); - expect(rule.getFrontmatter()).toMatchObject(testCase.expectedFrontmatter); - } - }); - - it("should use default trigger for file without frontmatter", async () => { - const rulesDir = join(testDir, ".agent/rules"); - await ensureDir(rulesDir); - const content = "# No Frontmatter"; - await writeFileContent(join(rulesDir, "no-frontmatter.md"), content); - - const antigravityRule = await AntigravityRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "no-frontmatter.md", - validate: false, // Skip validation as it might fail without frontmatter - }); - - // Default behavior might depend on implementation, but checking if it handles it - expect(antigravityRule.getFileContent().trim()).toBe("# No Frontmatter"); - }); - }); - - describe("mapping", () => { - it("should map RulesyncRule with specific globs to glob trigger", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "glob-rule.md", - frontmatter: { - globs: ["src/**/*.ts"], - }, - body: "# Glob Rule", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(antigravityRule.getFrontmatter()).toEqual({ - trigger: "glob", - globs: "src/**/*.ts", - }); - }); - - it("should map RulesyncRule with wildcard glob to always_on trigger", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "always-on.md", - frontmatter: { - globs: ["**/*"], - }, - body: "# Always On Rule", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(antigravityRule.getFrontmatter()).toEqual({ - trigger: "always_on", - }); - }); - - it("should map RulesyncRule without globs to always_on trigger", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "no-globs.md", - frontmatter: {}, - body: "# No Globs", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(antigravityRule.getFrontmatter()).toEqual({ - trigger: "always_on", - }); - }); - - it("should map RulesyncRule with persisted trigger regardless of globs", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "persisted.md", - frontmatter: { - globs: ["**/*"], // Would normally infer always_on - antigravity: { - trigger: "manual", // Explicitly set to manual - }, - }, - body: "# Persisted", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(antigravityRule.getFrontmatter()).toEqual({ - trigger: "manual", - }); - }); - - it("should respect explicit globs in antigravity key", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "explicit-globs.md", - frontmatter: { - globs: ["**/*"], // Generic glob - antigravity: { - trigger: "glob", - globs: ["specific.ts"], // Specific glob overrides generic - }, - }, - body: "# Explicit Globs", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(antigravityRule.getFrontmatter()).toEqual({ - trigger: "glob", - globs: "specific.ts", - }); - }); - - it("should handle unknown string trigger gracefully (cast to any)", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "unknown-trigger.md", - frontmatter: { - antigravity: { - trigger: "unknown-trigger", - }, - }, - body: "# Unknown Trigger", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - validate: true, // Validation should pass with loose schema - }); - - expect((antigravityRule.getFrontmatter() as any).trigger).toBe("unknown-trigger"); - }); - }); - - describe("round trip", () => { - it("should maintain content through RulesyncRule -> AntigravityRule -> RulesyncRule conversion", () => { - const initialRulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "round-trip.md", - frontmatter: { - root: false, - targets: ["*"], - description: "Round trip test", - globs: ["*.ts"], - }, - body: "# Round Trip\n\nContent", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule: initialRulesyncRule, - }); - - const finalRulesyncRule = antigravityRule.toRulesyncRule(); - - // Verify essential properties are preserved or correctly mapped - expect(finalRulesyncRule.getRelativeFilePath()).toBe("round-trip.md"); - expect(finalRulesyncRule.getBody().trim()).toBe("# Round Trip\n\nContent"); - expect(finalRulesyncRule.getFrontmatter().globs).toEqual(["*.ts"]); - expect(finalRulesyncRule.getFrontmatter().targets).toEqual(["*"]); - expect(finalRulesyncRule.getFrontmatter().antigravity?.trigger).toBe("glob"); - expect(finalRulesyncRule.getFrontmatter().antigravity?.globs).toEqual(["*.ts"]); - }); - - it("should pass through extra properties in antigravity config", () => { - const extraProps = { - customField: "customValue", - nested: { foo: "bar" }, - }; - - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "passthrough.md", - frontmatter: { - antigravity: { - trigger: "manual", - ...extraProps, - } as any, // Cast because RulesyncRule schema is loose but TypeScript might strict check generic Record if not defined - }, - body: "# Passthrough", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - // Verify AntigravityRule preserved them - expect(antigravityRule.getFrontmatter()).toMatchObject(extraProps); - - // Verify round trip - const finalRulesyncRule = antigravityRule.toRulesyncRule(); - expect(finalRulesyncRule.getFrontmatter().antigravity).toMatchObject(extraProps); - }); - - it("should handle empty globs array explicitly", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "empty-globs.md", - frontmatter: { - antigravity: { - trigger: "glob", - globs: [], - }, - }, - body: "# Empty Globs", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(antigravityRule.getFrontmatter()).toEqual({ - trigger: "glob", - globs: undefined, - }); - }); - - it("should handle model_decision without description", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "no-desc.md", - frontmatter: { - antigravity: { - trigger: "model_decision", - }, - // No generic description either - }, - body: "# No Desc", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(antigravityRule.getFrontmatter()).toEqual({ - trigger: "model_decision", - description: undefined, - }); - }); - - it("should handle single wildcard glob when explicitly persisted", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "wildcard.md", - frontmatter: { - globs: ["*"], - antigravity: { - trigger: "glob", - // Implicitly uses generic globs if not overridden - }, - }, - body: "# Wildcard", - }); - - const antigravityRule = AntigravityRule.fromRulesyncRule({ - rulesyncRule, - }); - - // Even though * usually implies always_on, explicit trigger: glob should be respected - expect(antigravityRule.getFrontmatter()).toEqual({ - trigger: "glob", - globs: "*", - }); - }); - }); - - describe("schema", () => { - it("should parse valid frontmatter", () => { - const testCases = [ - { - name: "glob", - input: { trigger: "glob", globs: "*.ts" }, - expected: { trigger: "glob", globs: "*.ts" }, - }, - { - name: "manual", - input: { trigger: "manual" }, - expected: { trigger: "manual" }, - }, - { - name: "always_on", - input: { trigger: "always_on" }, - expected: { trigger: "always_on" }, - }, - { - name: "model_decision", - input: { trigger: "model_decision", description: "desc" }, - expected: { trigger: "model_decision", description: "desc" }, - }, - ]; - - for (const { input, expected } of testCases) { - const result = AntigravityRuleFrontmatterSchema.safeParse(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toEqual(expected); - } - } - }); - - it("should allow arbitrary triggers (loose schema)", () => { - const result = AntigravityRuleFrontmatterSchema.safeParse({ - trigger: "custom-trigger", - extraField: "value", - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.trigger).toBe("custom-trigger"); - expect((result.data as any).extraField).toBe("value"); - } - }); - - it("should allow missing glob for glob trigger (schema is loose)", () => { - // Logic layer might enforce it, but schema doesn't anymore - const result = AntigravityRuleFrontmatterSchema.safeParse({ - trigger: "glob", - // missing globs - }); - expect(result.success).toBe(true); - }); - - it("should strip invalid fields? No, allow them (loose object)", () => { - const result = AntigravityRuleFrontmatterSchema.safeParse({ - trigger: "always_on", - unknownField: "value", - }); - expect(result.success).toBe(true); - if (result.success) { - expect((result.data as any).unknownField).toBe("value"); - } - }); - }); -}); diff --git a/src/features/rules/antigravity-rule.ts b/src/features/rules/antigravity-rule.ts index 1953e4da0..d67f424a4 100644 --- a/src/features/rules/antigravity-rule.ts +++ b/src/features/rules/antigravity-rule.ts @@ -1,26 +1,5 @@ -import { join } from "node:path"; - import { z } from "zod/mini"; -import { - ANTIGRAVITY_LEGACY_DIR, - ANTIGRAVITY_LEGACY_RULES_DIR_PATH, -} from "../../constants/antigravity-paths.js"; -import { ValidationResult } from "../../types/ai-file.js"; -import { formatError } from "../../utils/error.js"; -import { readFileContent, toKebabCaseFilename } from "../../utils/file.js"; -import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; -import { RulesyncRule } from "./rulesync-rule.js"; -import { - ToolRule, - ToolRuleForDeletionParams, - ToolRuleFromFileParams, - ToolRuleFromRulesyncRuleParams, - ToolRuleParams, - ToolRuleSettablePaths, - buildToolPath, -} from "./tool-rule.js"; - export const AntigravityRuleFrontmatterSchema = z.looseObject({ trigger: z.optional( z.union([ @@ -37,39 +16,6 @@ export const AntigravityRuleFrontmatterSchema = z.looseObject({ export type AntigravityRuleFrontmatter = z.infer; -/** - * Parameters for creating an AntigravityRule instance. - * Requires frontmatter and body separately instead of combined fileContent. - */ -export type AntigravityRuleParams = Omit & { - frontmatter: AntigravityRuleFrontmatter; - body: string; -}; - -export type AntigravityRuleSettablePaths = Omit & { - nonRoot: { - relativeDirPath: string; - }; -}; - -/** - * Rule generator for Google Antigravity IDE - * - * Generates rule files for Antigravity's .agent/rules/ directory. - * All rules (both root and non-root from RulesyncRule) are placed in .agent/rules. - * - * Filename requirements: - * - Filenames must be lowercase, numbers, and hyphens only - * - Automatically converts filenames to kebab-case during generation - * (e.g., "CodingGuidelines.md" → "coding-guidelines.md") - * - * Supports frontmatter configuration with different trigger types: - * - always_on: Rule always applies (default) - * - glob: Rule applies to files matching glob patterns - * - manual: Rule must be manually activated - * - model_decision: Model decides when to apply based on description - */ - // --- Helper Functions for Globs Conversion --- /** @@ -287,225 +233,3 @@ export const STRATEGIES: TriggerStrategy[] = [ unknownStrategy, inferenceStrategy, ]; - -// --------------------------------------------- - -export class AntigravityRule extends ToolRule { - private readonly frontmatter: AntigravityRuleFrontmatter; - private readonly body: string; - - /** - * Creates an AntigravityRule instance. - * - * @param params - Rule parameters including frontmatter and body - * @param params.frontmatter - Antigravity-specific frontmatter configuration - * @param params.body - The markdown body content (without frontmatter) - * - * Note: Files without frontmatter will default to always_on trigger during fromFile(). - */ - constructor({ frontmatter, body, ...rest }: AntigravityRuleParams) { - if (rest.validate !== false) { - const result = AntigravityRuleFrontmatterSchema.safeParse(frontmatter); - if (!result.success) { - throw new Error( - `Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, - ); - } - } - - super({ - ...rest, - // Ensure fileContent includes frontmatter when constructed directly - fileContent: stringifyFrontmatter(body, frontmatter), - }); - this.frontmatter = frontmatter; - this.body = body; - } - - static getSettablePaths( - _options: { - global?: boolean; - excludeToolDir?: boolean; - } = {}, - ): AntigravityRuleSettablePaths { - return { - nonRoot: { - relativeDirPath: buildToolPath(ANTIGRAVITY_LEGACY_DIR, "rules", _options.excludeToolDir), - }, - }; - } - - static async fromFile({ - outputRoot = process.cwd(), - relativeFilePath, - validate = true, - }: ToolRuleFromFileParams): Promise { - const filePath = join(outputRoot, ANTIGRAVITY_LEGACY_RULES_DIR_PATH, relativeFilePath); - const fileContent = await readFileContent(filePath); - const { frontmatter, body } = parseFrontmatter(fileContent, filePath); - - let parsedFrontmatter: AntigravityRuleFrontmatter; - if (validate) { - const result = AntigravityRuleFrontmatterSchema.safeParse(frontmatter); - if (result.success) { - parsedFrontmatter = result.data; - } else { - throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); - } - } else { - parsedFrontmatter = frontmatter as AntigravityRuleFrontmatter; - } - - return new AntigravityRule({ - outputRoot, - relativeDirPath: this.getSettablePaths().nonRoot.relativeDirPath, - relativeFilePath: relativeFilePath, - body, - frontmatter: parsedFrontmatter, - validate, - root: false, - }); - } - - /** - * Converts a RulesyncRule to an AntigravityRule. - * - * Trigger inference: - * - If antigravity.trigger is set, it's preserved - * - If specific globs are set, infers "glob" trigger - * - Otherwise, infers "always_on" trigger - */ - static fromRulesyncRule({ - outputRoot = process.cwd(), - rulesyncRule, - validate = true, - }: ToolRuleFromRulesyncRuleParams): AntigravityRule { - const rulesyncFrontmatter = rulesyncRule.getFrontmatter(); - - // Normalize once before dispatching to strategy - const storedAntigravity = rulesyncFrontmatter.antigravity; - const normalized = normalizeStoredAntigravity(storedAntigravity); - const storedTrigger = storedAntigravity?.trigger; - - const strategy = STRATEGIES.find((s) => s.canHandle(storedTrigger)); - - if (!strategy) { - // Should not happen with current strategies, but fallback safely - throw new Error(`No strategy found for trigger: ${storedTrigger}`); - } - - const frontmatter = strategy.generateFrontmatter(normalized, rulesyncFrontmatter); - - // Both root and non-root rules are placed in .agent/rules directory - const paths = this.getSettablePaths(); - - const kebabCaseFilename = toKebabCaseFilename(rulesyncRule.getRelativeFilePath()); - - return new AntigravityRule({ - outputRoot, - relativeDirPath: paths.nonRoot.relativeDirPath, - relativeFilePath: kebabCaseFilename, - frontmatter, - body: rulesyncRule.getBody(), - validate, - root: false, - }); - } - - /** - * Converts this AntigravityRule to a RulesyncRule. - * - * The Antigravity configuration is preserved in the RulesyncRule's - * frontmatter.antigravity field for round-trip compatibility. - * - * Note: All Antigravity rules are treated as non-root (root: false), - * as they are all placed in the .agent/rules directory. - * - * @returns RulesyncRule instance with Antigravity config preserved - */ - toRulesyncRule(): RulesyncRule { - // Determine appropriate strategy based on current trigger - const strategy = STRATEGIES.find((s) => s.canHandle(this.frontmatter.trigger)); - - // If no strategy found (e.g. unknown trigger and Inference handles undefined), use Unknown behavior? - // Strategies with canHandle(trigger) usually cover all valid cases. - // If trigger is custom string, UnknownStrategy handles it. - // If trigger is undefined, InferenceStrategy handles it. - // So we should find one. If not, fallback to empty array? - let rulesyncData: { - globs: string[]; - description?: string; - antigravity: Record; - } = { - globs: [], - antigravity: this.frontmatter, - }; - - if (strategy) { - rulesyncData = strategy.exportRulesyncData(this.frontmatter); - } - - // Convert antigravity.globs from string to array for RulesyncRule schema - const antigravityForRulesync = { - ...rulesyncData.antigravity, - globs: this.frontmatter.globs ? parseGlobsString(this.frontmatter.globs) : undefined, - }; - - return new RulesyncRule({ - outputRoot: process.cwd(), - relativeDirPath: RulesyncRule.getSettablePaths().recommended.relativeDirPath, - relativeFilePath: this.getRelativeFilePath(), - frontmatter: { - root: false, - targets: ["*"], - ...rulesyncData, - antigravity: antigravityForRulesync, - }, - // When converting back, we only want the body content - body: this.body, - }); - } - - getBody(): string { - return this.body; - } - - // Helper to access raw file content including frontmatter is `this.fileContent` (from ToolFile) - // But we might want `body` only for some operations? - // ToolFile.getFileContent() returns the whole string. - - getFrontmatter(): AntigravityRuleFrontmatter { - return this.frontmatter; - } - - validate(): ValidationResult { - const result = AntigravityRuleFrontmatterSchema.safeParse(this.frontmatter); - if (!result.success) { - return { success: false, error: new Error(formatError(result.error)) }; - } - return { success: true, error: null }; - } - - static forDeletion({ - outputRoot = process.cwd(), - relativeDirPath, - relativeFilePath, - }: ToolRuleForDeletionParams): AntigravityRule { - return new AntigravityRule({ - outputRoot, - relativeDirPath, - relativeFilePath, - frontmatter: {}, - body: "", - validate: false, - root: false, - }); - } - - static isTargetedByRulesyncRule(rulesyncRule: RulesyncRule): boolean { - return this.isTargetedByRulesyncRuleDefault({ - rulesyncRule, - toolTarget: "antigravity", - }); - } -} diff --git a/src/features/rules/geminicli-rule.test.ts b/src/features/rules/geminicli-rule.test.ts deleted file mode 100644 index 53b117d15..000000000 --- a/src/features/rules/geminicli-rule.test.ts +++ /dev/null @@ -1,649 +0,0 @@ -import { join } from "node:path"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - RULESYNC_OVERVIEW_FILE_NAME, - RULESYNC_RELATIVE_DIR_PATH, - RULESYNC_RULES_RELATIVE_DIR_PATH, -} from "../../constants/rulesync-paths.js"; -import { setupTestDirectory } from "../../test-utils/test-directories.js"; -import { ensureDir, writeFileContent } from "../../utils/file.js"; -import { GeminiCliRule } from "./geminicli-rule.js"; -import { RulesyncRule } from "./rulesync-rule.js"; - -describe("GeminiCliRule", () => { - let testDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ testDir, cleanup } = await setupTestDirectory()); - vi.spyOn(process, "cwd").mockReturnValue(testDir); - }); - - afterEach(async () => { - await cleanup(); - vi.restoreAllMocks(); - }); - - describe("constructor", () => { - it("should create instance with default parameters", () => { - const geminiCliRule = new GeminiCliRule({ - relativeDirPath: ".gemini/memories", - relativeFilePath: "test-rule.md", - fileContent: "# Test Rule\n\nThis is a test rule.", - }); - - expect(geminiCliRule).toBeInstanceOf(GeminiCliRule); - expect(geminiCliRule.getRelativeDirPath()).toBe(".gemini/memories"); - expect(geminiCliRule.getRelativeFilePath()).toBe("test-rule.md"); - expect(geminiCliRule.getFileContent()).toBe("# Test Rule\n\nThis is a test rule."); - }); - - it("should create instance with custom outputRoot", () => { - const geminiCliRule = new GeminiCliRule({ - outputRoot: "/custom/path", - relativeDirPath: ".gemini/memories", - relativeFilePath: "test-rule.md", - fileContent: "# Custom Rule", - }); - - expect(geminiCliRule.getFilePath()).toBe("/custom/path/.gemini/memories/test-rule.md"); - }); - - it("should validate content by default", () => { - expect(() => { - const _instance = new GeminiCliRule({ - relativeDirPath: ".gemini/memories", - relativeFilePath: "test-rule.md", - fileContent: "", // empty content should be valid since validate always returns success - }); - }).not.toThrow(); - }); - - it("should skip validation when requested", () => { - expect(() => { - const _instance = new GeminiCliRule({ - relativeDirPath: ".gemini/memories", - relativeFilePath: "test-rule.md", - fileContent: "", - validate: false, - }); - }).not.toThrow(); - }); - - it("should handle root rule parameter", () => { - const geminiCliRule = new GeminiCliRule({ - relativeDirPath: ".", - relativeFilePath: "GEMINI.md", - fileContent: "# Root Rule", - root: true, - }); - - expect(geminiCliRule.getFileContent()).toBe("# Root Rule"); - expect(geminiCliRule.getFilePath()).toBe(join(testDir, "GEMINI.md")); - }); - }); - - describe("fromFile", () => { - it("should create instance from existing non-root file", async () => { - // Setup test file - const memoriesDir = join(testDir, ".gemini/memories"); - await ensureDir(memoriesDir); - const testContent = "# Test Rule from File\n\nContent from file."; - await writeFileContent(join(memoriesDir, "test.md"), testContent); - - const geminiCliRule = await GeminiCliRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "test.md", - }); - - expect(geminiCliRule.getRelativeDirPath()).toBe(".gemini/memories"); - expect(geminiCliRule.getRelativeFilePath()).toBe("test.md"); - expect(geminiCliRule.getFileContent()).toBe(testContent); - expect(geminiCliRule.getFilePath()).toBe(join(testDir, ".gemini/memories/test.md")); - }); - - it("should create instance from root GEMINI.md file", async () => { - // Setup root test file - const testContent = "# Root Gemini CLI Rule\n\nThis is the root configuration."; - await writeFileContent(join(testDir, "GEMINI.md"), testContent); - - const geminiCliRule = await GeminiCliRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "GEMINI.md", - }); - - expect(geminiCliRule.getRelativeDirPath()).toBe("."); - expect(geminiCliRule.getRelativeFilePath()).toBe("GEMINI.md"); - expect(geminiCliRule.getFileContent()).toBe(testContent); - expect(geminiCliRule.getFilePath()).toBe(join(testDir, "GEMINI.md")); - }); - - it("should use default outputRoot when not provided", async () => { - // Since process.cwd() is mocked to return testDir, default outputRoot will use testDir - const memoriesDir = join(testDir, ".gemini/memories"); - await ensureDir(memoriesDir); - const testContent = "# Default OutputRoot Test"; - const testFilePath = join(memoriesDir, "default-test.md"); - await writeFileContent(testFilePath, testContent); - - const geminiCliRule = await GeminiCliRule.fromFile({ - relativeFilePath: "default-test.md", - }); - - expect(geminiCliRule.getRelativeDirPath()).toBe(".gemini/memories"); - expect(geminiCliRule.getRelativeFilePath()).toBe("default-test.md"); - expect(geminiCliRule.getFileContent()).toBe(testContent); - // Verify that the file path uses testDir (which is what process.cwd() returns when mocked) - expect(geminiCliRule.getFilePath()).toBe(join(testDir, ".gemini/memories/default-test.md")); - }); - - it("should handle validation parameter", async () => { - const memoriesDir = join(testDir, ".gemini/memories"); - await ensureDir(memoriesDir); - const testContent = "# Validation Test"; - await writeFileContent(join(memoriesDir, "validation-test.md"), testContent); - - const geminiCliRuleWithValidation = await GeminiCliRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "validation-test.md", - validate: true, - }); - - const geminiCliRuleWithoutValidation = await GeminiCliRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "validation-test.md", - validate: false, - }); - - expect(geminiCliRuleWithValidation.getFileContent()).toBe(testContent); - expect(geminiCliRuleWithoutValidation.getFileContent()).toBe(testContent); - }); - - it("should throw error when file does not exist", async () => { - await expect( - GeminiCliRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "nonexistent.md", - }), - ).rejects.toThrow(); - }); - }); - - describe("fromRulesyncRule", () => { - it("should create instance from RulesyncRule", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "test-rule.md", - frontmatter: { - root: false, - targets: ["*"], - description: "Test rule", - globs: [], - }, - body: "# Test RulesyncRule\n\nContent from rulesync.", - }); - - const geminiCliRule = GeminiCliRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(geminiCliRule).toBeInstanceOf(GeminiCliRule); - expect(geminiCliRule.getRelativeDirPath()).toBe(".gemini/memories"); - expect(geminiCliRule.getRelativeFilePath()).toBe("test-rule.md"); - expect(geminiCliRule.getFileContent()).toContain( - "# Test RulesyncRule\n\nContent from rulesync.", - ); - }); - - it("should create root rule from root RulesyncRule", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "root.md", - frontmatter: { - root: true, - targets: ["*"], - description: "Root rule", - globs: [], - }, - body: "# Root RulesyncRule\n\nRoot content from rulesync.", - }); - - const geminiCliRule = GeminiCliRule.fromRulesyncRule({ - rulesyncRule, - }); - - expect(geminiCliRule).toBeInstanceOf(GeminiCliRule); - expect(geminiCliRule.getRelativeDirPath()).toBe("."); - expect(geminiCliRule.getRelativeFilePath()).toBe("GEMINI.md"); - expect(geminiCliRule.getFileContent()).toContain( - "# Root RulesyncRule\n\nRoot content from rulesync.", - ); - }); - - it("should use custom outputRoot", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "custom-base.md", - frontmatter: { - root: false, - targets: ["*"], - description: "", - globs: [], - }, - body: "# Custom Base Directory", - }); - - const geminiCliRule = GeminiCliRule.fromRulesyncRule({ - outputRoot: "/custom/base", - rulesyncRule, - }); - - expect(geminiCliRule.getFilePath()).toBe("/custom/base/.gemini/memories/custom-base.md"); - }); - - it("should handle validation parameter", () => { - const rulesyncRule = new RulesyncRule({ - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "validation.md", - frontmatter: { - root: false, - targets: ["*"], - description: "", - globs: [], - }, - body: "# Validation Test", - }); - - const geminiCliRuleWithValidation = GeminiCliRule.fromRulesyncRule({ - rulesyncRule, - validate: true, - }); - - const geminiCliRuleWithoutValidation = GeminiCliRule.fromRulesyncRule({ - rulesyncRule, - validate: false, - }); - - expect(geminiCliRuleWithValidation.getFileContent()).toContain("# Validation Test"); - expect(geminiCliRuleWithoutValidation.getFileContent()).toContain("# Validation Test"); - }); - }); - - describe("toRulesyncRule", () => { - it("should convert GeminiCliRule to RulesyncRule", () => { - const geminiCliRule = new GeminiCliRule({ - outputRoot: testDir, - relativeDirPath: ".gemini/memories", - relativeFilePath: "convert-test.md", - fileContent: "# Convert Test\n\nThis will be converted.", - }); - - const rulesyncRule = geminiCliRule.toRulesyncRule(); - - expect(rulesyncRule).toBeInstanceOf(RulesyncRule); - expect(rulesyncRule.getRelativeDirPath()).toBe(RULESYNC_RULES_RELATIVE_DIR_PATH); - expect(rulesyncRule.getRelativeFilePath()).toBe("convert-test.md"); - expect(rulesyncRule.getFileContent()).toContain("# Convert Test\n\nThis will be converted."); - }); - - it("should preserve metadata in conversion", () => { - const geminiCliRule = new GeminiCliRule({ - outputRoot: "/test/path", - relativeDirPath: ".", - relativeFilePath: "GEMINI.md", - fileContent: "# Root Gemini Rule\n\nRoot content.", - root: true, - }); - - const rulesyncRule = geminiCliRule.toRulesyncRule(); - - expect(rulesyncRule.getFilePath()).toBe( - join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME), - ); - expect(rulesyncRule.getFileContent()).toContain("# Root Gemini Rule\n\nRoot content."); - }); - }); - - describe("validate", () => { - it("should always return success", () => { - const geminiCliRule = new GeminiCliRule({ - relativeDirPath: ".gemini/memories", - relativeFilePath: "validation-test.md", - fileContent: "# Any content is valid", - }); - - const result = geminiCliRule.validate(); - - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - }); - - it("should return success for empty content", () => { - const geminiCliRule = new GeminiCliRule({ - relativeDirPath: ".gemini/memories", - relativeFilePath: "empty.md", - fileContent: "", - }); - - const result = geminiCliRule.validate(); - - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - }); - - it("should return success for any content format", () => { - const contents = [ - "# Markdown content", - "Plain text content", - "---\nfrontmatter: true\n---\nContent with frontmatter", - "/* Code comments */", - "Invalid markdown ### ###", - "Unicode characters: 🚀 📝 ✨", - ]; - - for (const content of contents) { - const geminiCliRule = new GeminiCliRule({ - relativeDirPath: ".gemini/memories", - relativeFilePath: "test.md", - fileContent: content, - }); - - const result = geminiCliRule.validate(); - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - } - }); - - it("should return success for root rule", () => { - const geminiCliRule = new GeminiCliRule({ - relativeDirPath: ".", - relativeFilePath: "GEMINI.md", - fileContent: "# Root rule content", - root: true, - }); - - const result = geminiCliRule.validate(); - - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - }); - }); - - describe("getSettablePaths", () => { - it("should return correct paths for root and nonRoot", () => { - const paths = GeminiCliRule.getSettablePaths(); - - expect(paths.root).toEqual({ - relativeDirPath: ".", - relativeFilePath: "GEMINI.md", - }); - - expect(paths.nonRoot).toEqual({ - relativeDirPath: ".gemini/memories", - }); - }); - - it("should have consistent paths structure", () => { - const paths = GeminiCliRule.getSettablePaths(); - - expect(paths).toHaveProperty("root"); - expect(paths).toHaveProperty("nonRoot"); - expect(paths.root).toHaveProperty("relativeDirPath"); - expect(paths.root).toHaveProperty("relativeFilePath"); - expect(paths.nonRoot).toHaveProperty("relativeDirPath"); - }); - }); - - describe("isTargetedByRulesyncRule", () => { - it("should return true for rules targeting geminicli", () => { - const rulesyncRule = new RulesyncRule({ - outputRoot: testDir, - relativeDirPath: ".agents/memories", - relativeFilePath: "test.md", - frontmatter: { - targets: ["geminicli"], - }, - body: "Test content", - }); - - expect(GeminiCliRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(true); - }); - - it("should return true for rules targeting all tools (*)", () => { - const rulesyncRule = new RulesyncRule({ - outputRoot: testDir, - relativeDirPath: ".agents/memories", - relativeFilePath: "test.md", - frontmatter: { - targets: ["*"], - }, - body: "Test content", - }); - - expect(GeminiCliRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(true); - }); - - it("should return false for rules not targeting geminicli", () => { - const rulesyncRule = new RulesyncRule({ - outputRoot: testDir, - relativeDirPath: ".agents/memories", - relativeFilePath: "test.md", - frontmatter: { - targets: ["cursor", "copilot"], - }, - body: "Test content", - }); - - expect(GeminiCliRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(false); - }); - - it("should return false for empty targets", () => { - const rulesyncRule = new RulesyncRule({ - outputRoot: testDir, - relativeDirPath: ".agents/memories", - relativeFilePath: "test.md", - frontmatter: { - targets: [], - }, - body: "Test content", - }); - - expect(GeminiCliRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(false); - }); - - it("should handle mixed targets including geminicli", () => { - const rulesyncRule = new RulesyncRule({ - outputRoot: testDir, - relativeDirPath: ".agents/memories", - relativeFilePath: "test.md", - frontmatter: { - targets: ["cursor", "geminicli", "copilot"], - }, - body: "Test content", - }); - - expect(GeminiCliRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(true); - }); - - it("should handle undefined targets in frontmatter", () => { - const rulesyncRule = new RulesyncRule({ - outputRoot: testDir, - relativeDirPath: ".agents/memories", - relativeFilePath: "test.md", - frontmatter: {}, - body: "Test content", - }); - - expect(GeminiCliRule.isTargetedByRulesyncRule(rulesyncRule)).toBe(true); - }); - }); - - describe("integration tests", () => { - it("should handle complete workflow from file to rulesync rule", async () => { - // Create original file - const memoriesDir = join(testDir, ".gemini/memories"); - await ensureDir(memoriesDir); - const originalContent = "# Integration Test\n\nComplete workflow test."; - await writeFileContent(join(memoriesDir, "integration.md"), originalContent); - - // Load from file - const geminiCliRule = await GeminiCliRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "integration.md", - }); - - // Convert to rulesync rule - const rulesyncRule = geminiCliRule.toRulesyncRule(); - - // Verify conversion - expect(rulesyncRule.getFileContent()).toContain(originalContent); - expect(rulesyncRule.getRelativeDirPath()).toBe(RULESYNC_RULES_RELATIVE_DIR_PATH); - expect(rulesyncRule.getRelativeFilePath()).toBe("integration.md"); - }); - - it("should handle root file workflow", async () => { - // Create root file - const rootContent = "# Root Gemini CLI Configuration\n\nThis is the main configuration."; - await writeFileContent(join(testDir, "GEMINI.md"), rootContent); - - // Load from file - const geminiCliRule = await GeminiCliRule.fromFile({ - outputRoot: testDir, - relativeFilePath: "GEMINI.md", - }); - - // Verify root properties - expect(geminiCliRule.getRelativeDirPath()).toBe("."); - expect(geminiCliRule.getRelativeFilePath()).toBe("GEMINI.md"); - expect(geminiCliRule.getFileContent()).toBe(rootContent); - - // Convert to rulesync rule - const rulesyncRule = geminiCliRule.toRulesyncRule(); - - // Verify conversion - expect(rulesyncRule.getFileContent()).toContain(rootContent); - expect(rulesyncRule.getRelativeDirPath()).toBe(RULESYNC_RULES_RELATIVE_DIR_PATH); - expect(rulesyncRule.getRelativeFilePath()).toBe(RULESYNC_OVERVIEW_FILE_NAME); - }); - - it("should handle roundtrip conversion rulesync -> geminicli -> rulesync", () => { - const originalBody = "# Roundtrip Test\n\nContent should remain the same."; - - // Start with rulesync rule - const originalRulesync = new RulesyncRule({ - outputRoot: testDir, - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "roundtrip.md", - frontmatter: { - root: false, - targets: ["*"], - description: "", - globs: [], - }, - body: originalBody, - }); - - // Convert to gemini cli rule - const geminiCliRule = GeminiCliRule.fromRulesyncRule({ - outputRoot: testDir, - rulesyncRule: originalRulesync, - }); - - // Convert back to rulesync rule - const finalRulesync = geminiCliRule.toRulesyncRule(); - - // Verify content preservation - expect(finalRulesync.getFileContent()).toContain(originalBody); - expect(finalRulesync.getRelativeFilePath()).toBe("roundtrip.md"); - }); - - it("should handle roundtrip conversion with root rule", () => { - const originalBody = "# Root Roundtrip Test\n\nRoot content should remain the same."; - - // Start with root rulesync rule - const originalRulesync = new RulesyncRule({ - outputRoot: testDir, - relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, - relativeFilePath: "root-roundtrip.md", - frontmatter: { - root: true, - targets: ["*"], - description: "", - globs: [], - }, - body: originalBody, - }); - - // Convert to gemini cli rule - const geminiCliRule = GeminiCliRule.fromRulesyncRule({ - outputRoot: testDir, - rulesyncRule: originalRulesync, - }); - - // Verify root conversion - expect(geminiCliRule.getRelativeDirPath()).toBe("."); - expect(geminiCliRule.getRelativeFilePath()).toBe("GEMINI.md"); - - // Convert back to rulesync rule - const finalRulesync = geminiCliRule.toRulesyncRule(); - - // Verify content preservation - expect(finalRulesync.getFileContent()).toContain(originalBody); - expect(finalRulesync.getRelativeFilePath()).toBe(RULESYNC_OVERVIEW_FILE_NAME); - }); - - it("should handle mixed file types in directory", async () => { - // Create multiple files - const memoriesDir = join(testDir, ".gemini/memories"); - await ensureDir(memoriesDir); - - const files = [ - { name: "general.md", content: "# General Rules\n\nGeneral content." }, - { name: "specific.md", content: "# Specific Rules\n\nSpecific content." }, - { name: "guidelines.md", content: "# Guidelines\n\nGuideline content." }, - ]; - - // Write all files - for (const file of files) { - await writeFileContent(join(memoriesDir, file.name), file.content); - } - - // Load each file and verify - for (const file of files) { - const geminiCliRule = await GeminiCliRule.fromFile({ - outputRoot: testDir, - relativeFilePath: file.name, - }); - - expect(geminiCliRule.getFileContent()).toBe(file.content); - expect(geminiCliRule.getRelativeDirPath()).toBe(".gemini/memories"); - expect(geminiCliRule.getRelativeFilePath()).toBe(file.name); - - // Verify conversion works - const rulesyncRule = geminiCliRule.toRulesyncRule(); - expect(rulesyncRule.getFileContent()).toContain(file.content); - } - }); - - it("should handle large content files", () => { - const largeContent = "# Large Content Test\n\n" + "Lorem ipsum dolor sit amet. ".repeat(1000); - - const geminiCliRule = new GeminiCliRule({ - outputRoot: testDir, - relativeDirPath: ".gemini/memories", - relativeFilePath: "large.md", - fileContent: largeContent, - }); - - // Verify validation still works - const result = geminiCliRule.validate(); - expect(result.success).toBe(true); - - // Verify conversion works - const rulesyncRule = geminiCliRule.toRulesyncRule(); - expect(rulesyncRule.getFileContent()).toContain(largeContent); - }); - }); -}); diff --git a/src/features/rules/geminicli-rule.ts b/src/features/rules/geminicli-rule.ts deleted file mode 100644 index bb1e39e26..000000000 --- a/src/features/rules/geminicli-rule.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { join } from "node:path"; - -import { - GEMINICLI_DIR, - GEMINICLI_MEMORIES_DIR_NAME, - GEMINICLI_RULE_FILE_NAME, -} from "../../constants/geminicli-paths.js"; -import { readFileContent } from "../../utils/file.js"; -import { RulesyncRule } from "./rulesync-rule.js"; -import { - ToolRule, - ToolRuleForDeletionParams, - ToolRuleFromFileParams, - ToolRuleFromRulesyncRuleParams, - ToolRuleSettablePaths, - ToolRuleSettablePathsGlobal, - buildToolPath, -} from "./tool-rule.js"; - -export type GeminiCliRuleSettablePaths = ToolRuleSettablePaths & { - root: { - relativeDirPath: string; - relativeFilePath: string; - }; -}; - -export type GeminiCliRuleSettablePathsGlobal = ToolRuleSettablePathsGlobal; - -/** - * Represents a rule file for Gemini CLI - * Gemini CLI uses plain markdown files (GEMINI.md) without frontmatter - */ -export class GeminiCliRule extends ToolRule { - static getSettablePaths({ - global, - excludeToolDir, - }: { - global?: boolean; - excludeToolDir?: boolean; - } = {}): GeminiCliRuleSettablePaths | GeminiCliRuleSettablePathsGlobal { - if (global) { - return { - root: { - relativeDirPath: buildToolPath(GEMINICLI_DIR, ".", excludeToolDir), - relativeFilePath: GEMINICLI_RULE_FILE_NAME, - }, - }; - } - return { - root: { - relativeDirPath: ".", - relativeFilePath: GEMINICLI_RULE_FILE_NAME, - }, - nonRoot: { - relativeDirPath: buildToolPath(GEMINICLI_DIR, GEMINICLI_MEMORIES_DIR_NAME, excludeToolDir), - }, - }; - } - - static async fromFile({ - outputRoot = process.cwd(), - relativeFilePath, - validate = true, - global = false, - }: ToolRuleFromFileParams): Promise { - const paths = this.getSettablePaths({ global }); - const isRoot = relativeFilePath === paths.root.relativeFilePath; - - if (isRoot) { - const relativePath = paths.root.relativeFilePath; - const fileContent = await readFileContent( - join(outputRoot, paths.root.relativeDirPath, relativePath), - ); - - return new GeminiCliRule({ - outputRoot, - relativeDirPath: paths.root.relativeDirPath, - relativeFilePath: paths.root.relativeFilePath, - fileContent, - validate, - root: true, - }); - } - - if (!paths.nonRoot) { - throw new Error(`nonRoot path is not set for ${relativeFilePath}`); - } - - const relativePath = join(paths.nonRoot.relativeDirPath, relativeFilePath); - const fileContent = await readFileContent(join(outputRoot, relativePath)); - return new GeminiCliRule({ - outputRoot, - relativeDirPath: paths.nonRoot.relativeDirPath, - relativeFilePath: relativeFilePath, - fileContent, - validate, - root: false, - }); - } - - static fromRulesyncRule({ - outputRoot = process.cwd(), - rulesyncRule, - validate = true, - global = false, - }: ToolRuleFromRulesyncRuleParams): GeminiCliRule { - const paths = this.getSettablePaths({ global }); - return new GeminiCliRule( - this.buildToolRuleParamsDefault({ - outputRoot, - rulesyncRule, - validate, - rootPath: paths.root, - nonRootPath: paths.nonRoot, - }), - ); - } - - toRulesyncRule(): RulesyncRule { - return this.toRulesyncRuleDefault(); - } - - validate() { - // Gemini CLI uses plain markdown without frontmatter requirements - // Validation always succeeds - return { success: true as const, error: null }; - } - - static forDeletion({ - outputRoot = process.cwd(), - relativeDirPath, - relativeFilePath, - global = false, - }: ToolRuleForDeletionParams): GeminiCliRule { - const paths = this.getSettablePaths({ global }); - const isRoot = relativeFilePath === paths.root.relativeFilePath; - - return new GeminiCliRule({ - outputRoot, - relativeDirPath, - relativeFilePath, - fileContent: "", - validate: false, - root: isRoot, - }); - } - - static isTargetedByRulesyncRule(rulesyncRule: RulesyncRule): boolean { - return this.isTargetedByRulesyncRuleDefault({ - rulesyncRule, - toolTarget: "geminicli", - }); - } -} diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index ee099fdc3..49d6829e6 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -708,7 +708,6 @@ describe("RulesProcessor", () => { "copilot", "cursor", "codexcli", - "geminicli", "junie", "kiro", "opencode", @@ -950,7 +949,6 @@ Content that would fail parsing`; "copilotcli", "deepagents", "factorydroid", - "geminicli", "goose", "grokcli", "junie", @@ -999,7 +997,6 @@ Content that would fail parsing`; expect(globalTargets).toContain("copilotcli"); expect(globalTargets).toContain("deepagents"); expect(globalTargets).toContain("factorydroid"); - expect(globalTargets).toContain("geminicli"); expect(globalTargets).toContain("junie"); expect(globalTargets).toContain("kilo"); expect(globalTargets).toContain("goose"); @@ -1015,7 +1012,7 @@ Content that would fail parsing`; expect(globalTargets).toContain("kiro"); expect(globalTargets).toContain("kiro-cli"); expect(globalTargets).toContain("kiro-ide"); - expect(globalTargets.length).toBe(29); + expect(globalTargets.length).toBe(28); // These targets should NOT be in global mode expect(globalTargets).not.toContain("cursor"); @@ -2171,41 +2168,6 @@ targets: ["claudecode"] ); }); - it("should exclude non-root rules in global mode for a target without global nonRoot support", async () => { - await ensureDir(join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH)); - await writeFileContent( - join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "root.md"), - `--- -root: true -targets: ["geminicli"] ---- -# Root`, - ); - await writeFileContent( - join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "non-root.md"), - `--- -targets: ["geminicli"] ---- -# Non-root`, - ); - - const warnSpy = vi.spyOn(logger, "warn"); - - const processor = new RulesProcessor({ - logger, - outputRoot: testDir, - toolTarget: "geminicli", - global: true, - }); - - const result = await processor.loadRulesyncFiles(); - expect(result).toHaveLength(1); - expect((result[0] as RulesyncRule).getFrontmatter().root).toBe(true); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("non-root rulesync rules found, but it's in global mode"), - ); - }); - it("should filter non-root rules by target in global mode", async () => { await ensureDir(join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH)); await writeFileContent( diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 6cfaa47ad..da36dc801 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -24,7 +24,6 @@ import { RovodevSkill } from "../skills/rovodev-skill.js"; import { RulesyncSkill } from "../skills/rulesync-skill.js"; import { SkillsProcessor } from "../skills/skills-processor.js"; import { AgentsmdSubagent } from "../subagents/agentsmd-subagent.js"; -import { GeminiCliSubagent } from "../subagents/geminicli-subagent.js"; import { QwencodeSubagent } from "../subagents/qwencode-subagent.js"; import { RovodevSubagent } from "../subagents/rovodev-subagent.js"; import { SubagentsProcessor } from "../subagents/subagents-processor.js"; @@ -33,7 +32,6 @@ import { AiassistantRule } from "./aiassistant-rule.js"; import { AmpRule } from "./amp-rule.js"; import { AntigravityCliRule } from "./antigravity-cli-rule.js"; import { AntigravityIdeRule } from "./antigravity-ide-rule.js"; -import { AntigravityRule } from "./antigravity-rule.js"; import { AugmentcodeLegacyRule } from "./augmentcode-legacy-rule.js"; import { AugmentcodeRule } from "./augmentcode-rule.js"; import { ClaudecodeLegacyRule } from "./claudecode-legacy-rule.js"; @@ -46,7 +44,6 @@ import { CursorRule } from "./cursor-rule.js"; import { DeepagentsRule } from "./deepagents-rule.js"; import { DevinRule } from "./devin-rule.js"; import { FactorydroidRule } from "./factorydroid-rule.js"; -import { GeminiCliRule } from "./geminicli-rule.js"; import { GooseRule } from "./goose-rule.js"; import { GrokcliRule } from "./grokcli-rule.js"; import { HermesagentRule } from "./hermesagent-rule.js"; @@ -291,17 +288,6 @@ export const toolRuleFactories = new Map { - const rulesyncSkill = new RulesyncSkill({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "antigravity-alias-skill", - frontmatter: { - name: "Antigravity Alias Skill", - description: "Skill for the deprecated antigravity alias", - targets: ["antigravity"], - }, - body: "Test body", - validate: true, - }); - - expect(AntigravityCliSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(false); - }); - it("should return false when targets includes 'antigravity-ide' only", () => { const rulesyncSkill = new RulesyncSkill({ outputRoot: testDir, diff --git a/src/features/skills/antigravity-ide-skill.test.ts b/src/features/skills/antigravity-ide-skill.test.ts index 80e92fa33..533d646ca 100644 --- a/src/features/skills/antigravity-ide-skill.test.ts +++ b/src/features/skills/antigravity-ide-skill.test.ts @@ -296,23 +296,6 @@ Missing description field.`; expect(AntigravityIdeSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(false); }); - it("should return false when targets includes the deprecated 'antigravity' alias only", () => { - const rulesyncSkill = new RulesyncSkill({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "antigravity-alias-skill", - frontmatter: { - name: "Antigravity Alias Skill", - description: "Skill for the deprecated antigravity alias", - targets: ["antigravity"], - }, - body: "Test body", - validate: true, - }); - - expect(AntigravityIdeSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(false); - }); - it("should return false when targets includes 'antigravity-cli' only", () => { const rulesyncSkill = new RulesyncSkill({ outputRoot: testDir, diff --git a/src/features/skills/antigravity-shared-skill.ts b/src/features/skills/antigravity-shared-skill.ts index 6fcc027f5..04726cd39 100644 --- a/src/features/skills/antigravity-shared-skill.ts +++ b/src/features/skills/antigravity-shared-skill.ts @@ -42,8 +42,7 @@ export type AntigravitySharedSkillParams = { * global skills tree (`~/.gemini//skills/`) and which rulesync target * name they answer to; each concrete subclass supplies those via * {@link AntigravitySharedSkill.getGlobalSubdir} and - * {@link AntigravitySharedSkill.getToolTarget}. The singular `.agent/skills/` - * tree is handled separately by the deprecated `antigravity` alias. + * {@link AntigravitySharedSkill.getToolTarget}. */ export class AntigravitySharedSkill extends ToolSkill { constructor({ diff --git a/src/features/skills/antigravity-skill.test.ts b/src/features/skills/antigravity-skill.test.ts deleted file mode 100644 index 93a0b1277..000000000 --- a/src/features/skills/antigravity-skill.test.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { join } from "node:path"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { SKILL_FILE_NAME } from "../../constants/general.js"; -import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; -import { setupTestDirectory } from "../../test-utils/test-directories.js"; -import { ensureDir, writeFileContent } from "../../utils/file.js"; -import { AntigravitySkill } from "./antigravity-skill.js"; -import { RulesyncSkill } from "./rulesync-skill.js"; - -describe("AntigravitySkill", () => { - let testDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ testDir, cleanup } = await setupTestDirectory()); - vi.spyOn(process, "cwd").mockReturnValue(testDir); - }); - - afterEach(async () => { - await cleanup(); - vi.restoreAllMocks(); - }); - - describe("getSettablePaths", () => { - it("should return .agent/skills as relativeDirPath in project mode", () => { - const paths = AntigravitySkill.getSettablePaths(); - expect(paths.relativeDirPath).toBe(join(".agent", "skills")); - }); - - it("should return .agent/skills as relativeDirPath when global is false", () => { - const paths = AntigravitySkill.getSettablePaths({ global: false }); - expect(paths.relativeDirPath).toBe(join(".agent", "skills")); - }); - - it("should return .gemini/antigravity/skills as relativeDirPath in global mode", () => { - const paths = AntigravitySkill.getSettablePaths({ global: true }); - expect(paths.relativeDirPath).toBe(join(".gemini", "antigravity", "skills")); - }); - }); - - describe("constructor", () => { - it("should create instance with valid content in project mode", () => { - const skill = new AntigravitySkill({ - outputRoot: testDir, - relativeDirPath: join(".agent", "skills"), - dirName: "test-skill", - frontmatter: { - name: "Test Skill", - description: "Test skill description", - }, - body: "This is the body of the antigravity skill.", - validate: true, - global: false, - }); - - expect(skill).toBeInstanceOf(AntigravitySkill); - expect(skill.getBody()).toBe("This is the body of the antigravity skill."); - expect(skill.getFrontmatter()).toEqual({ - name: "Test Skill", - description: "Test skill description", - }); - }); - - it("should create instance with valid content in global mode", () => { - const skill = new AntigravitySkill({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "antigravity", "skills"), - dirName: "test-skill", - frontmatter: { - name: "Test Skill", - description: "Test skill description", - }, - body: "This is the body of the antigravity skill.", - validate: true, - global: true, - }); - - expect(skill).toBeInstanceOf(AntigravitySkill); - expect(skill.getBody()).toBe("This is the body of the antigravity skill."); - expect(skill.getFrontmatter()).toEqual({ - name: "Test Skill", - description: "Test skill description", - }); - }); - - it("should throw error with invalid frontmatter", () => { - expect( - () => - new AntigravitySkill({ - outputRoot: testDir, - relativeDirPath: join(".agent", "skills"), - dirName: "test-skill", - frontmatter: { - name: "Test Skill", - // missing required 'description' field - } as { name: string; description: string }, - body: "Test body", - validate: true, - global: false, - }), - ).toThrow(); - }); - }); - - describe("fromDir", () => { - it("should create instance from valid skill directory in project mode", async () => { - const skillDir = join(testDir, ".agent", "skills", "test-skill"); - await ensureDir(skillDir); - const skillContent = `--- -name: Test Skill -description: Test skill description ---- - -This is the body of the antigravity skill.`; - await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent); - - const skill = await AntigravitySkill.fromDir({ - outputRoot: testDir, - dirName: "test-skill", - global: false, - }); - - expect(skill).toBeInstanceOf(AntigravitySkill); - expect(skill.getBody()).toBe("This is the body of the antigravity skill."); - expect(skill.getFrontmatter()).toEqual({ - name: "Test Skill", - description: "Test skill description", - }); - }); - - it("should create instance from valid skill directory in global mode", async () => { - const skillDir = join(testDir, ".gemini", "antigravity", "skills", "test-skill"); - await ensureDir(skillDir); - const skillContent = `--- -name: Test Skill -description: Test skill description ---- - -This is the body of the antigravity skill.`; - await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent); - - const skill = await AntigravitySkill.fromDir({ - outputRoot: testDir, - dirName: "test-skill", - global: true, - }); - - expect(skill).toBeInstanceOf(AntigravitySkill); - expect(skill.getBody()).toBe("This is the body of the antigravity skill."); - expect(skill.getFrontmatter()).toEqual({ - name: "Test Skill", - description: "Test skill description", - }); - }); - - it("should throw error when SKILL.md not found", async () => { - const skillDir = join(testDir, ".agent", "skills", "empty-skill"); - await ensureDir(skillDir); - - await expect( - AntigravitySkill.fromDir({ - outputRoot: testDir, - dirName: "empty-skill", - global: false, - }), - ).rejects.toThrow(/SKILL\.md not found/); - }); - - it("should throw error with invalid frontmatter", async () => { - const skillDir = join(testDir, ".agent", "skills", "invalid-skill"); - await ensureDir(skillDir); - const skillContent = `--- -name: Test Skill ---- - -Missing description field.`; - await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent); - - await expect( - AntigravitySkill.fromDir({ - outputRoot: testDir, - dirName: "invalid-skill", - global: false, - }), - ).rejects.toThrow(/Invalid frontmatter/); - }); - }); - - describe("fromRulesyncSkill", () => { - it("should create instance from RulesyncSkill in project mode", () => { - const rulesyncSkill = new RulesyncSkill({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "test-skill", - frontmatter: { - name: "Test Skill", - description: "Test skill description", - }, - body: "Test body content", - validate: true, - }); - - const antigravitySkill = AntigravitySkill.fromRulesyncSkill({ - rulesyncSkill, - validate: true, - global: false, - }); - - expect(antigravitySkill).toBeInstanceOf(AntigravitySkill); - expect(antigravitySkill.getBody()).toBe("Test body content"); - expect(antigravitySkill.getFrontmatter()).toEqual({ - name: "Test Skill", - description: "Test skill description", - }); - }); - - it("should create instance from RulesyncSkill in global mode", () => { - const rulesyncSkill = new RulesyncSkill({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "test-skill", - frontmatter: { - name: "Test Skill", - description: "Test skill description", - }, - body: "Test body content", - validate: true, - }); - - const antigravitySkill = AntigravitySkill.fromRulesyncSkill({ - rulesyncSkill, - validate: true, - global: true, - }); - - expect(antigravitySkill).toBeInstanceOf(AntigravitySkill); - expect(antigravitySkill.getBody()).toBe("Test body content"); - expect(antigravitySkill.getFrontmatter()).toEqual({ - name: "Test Skill", - description: "Test skill description", - }); - }); - }); - - describe("isTargetedByRulesyncSkill", () => { - it("should return true when targets includes '*'", () => { - const rulesyncSkill = new RulesyncSkill({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "all-targets-skill", - frontmatter: { - name: "All Targets Skill", - description: "Skill for all targets", - targets: ["*"], - }, - body: "Test body", - validate: true, - }); - - expect(AntigravitySkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(true); - }); - - it("should return true when targets includes 'antigravity'", () => { - const rulesyncSkill = new RulesyncSkill({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "antigravity-skill", - frontmatter: { - name: "Antigravity Skill", - description: "Skill for antigravity", - targets: ["copilot", "antigravity"], - }, - body: "Test body", - validate: true, - }); - - expect(AntigravitySkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(true); - }); - - it("should return false when targets does not include 'antigravity'", () => { - const rulesyncSkill = new RulesyncSkill({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "claudecode-only-skill", - frontmatter: { - name: "ClaudeCode Only Skill", - description: "Skill for claudecode only", - targets: ["claudecode"], - }, - body: "Test body", - validate: true, - }); - - expect(AntigravitySkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(false); - }); - }); - - describe("toRulesyncSkill", () => { - it("should convert to a RulesyncSkill in project mode", () => { - const skill = new AntigravitySkill({ - outputRoot: testDir, - relativeDirPath: join(".agent", "skills"), - dirName: "test-skill", - frontmatter: { - name: "Test Skill", - description: "Test description", - }, - body: "Test body", - validate: true, - global: false, - }); - - const rulesyncSkill = skill.toRulesyncSkill(); - - expect(rulesyncSkill.getFrontmatter()).toEqual({ - name: "Test Skill", - description: "Test description", - targets: ["*"], - }); - expect(rulesyncSkill.getBody()).toBe("Test body"); - }); - - it("should convert to a RulesyncSkill in global mode", () => { - const skill = new AntigravitySkill({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "antigravity", "skills"), - dirName: "test-skill", - frontmatter: { - name: "Test Skill", - description: "Test description", - }, - body: "Test body", - validate: true, - global: true, - }); - - const rulesyncSkill = skill.toRulesyncSkill(); - - expect(rulesyncSkill.getFrontmatter()).toEqual({ - name: "Test Skill", - description: "Test description", - targets: ["*"], - }); - expect(rulesyncSkill.getBody()).toBe("Test body"); - }); - }); - - describe("forDeletion", () => { - it("should create instance for deletion in project mode", () => { - const skill = AntigravitySkill.forDeletion({ - outputRoot: testDir, - relativeDirPath: join(".agent", "skills"), - dirName: "skill-to-delete", - global: false, - }); - - expect(skill).toBeInstanceOf(AntigravitySkill); - expect(skill.getDirName()).toBe("skill-to-delete"); - }); - - it("should create instance for deletion in global mode", () => { - const skill = AntigravitySkill.forDeletion({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "antigravity", "skills"), - dirName: "skill-to-delete", - global: true, - }); - - expect(skill).toBeInstanceOf(AntigravitySkill); - expect(skill.getDirName()).toBe("skill-to-delete"); - }); - }); - - describe("validate", () => { - it("should return success for valid skill", () => { - const skill = new AntigravitySkill({ - outputRoot: testDir, - relativeDirPath: join(".agent", "skills"), - dirName: "valid-skill", - frontmatter: { - name: "Valid Skill", - description: "Valid description", - }, - body: "Valid body", - validate: false, // Skip validation in constructor - global: false, - }); - - const result = skill.validate(); - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - }); - }); -}); diff --git a/src/features/skills/antigravity-skill.ts b/src/features/skills/antigravity-skill.ts index 915687d46..76ff9d893 100644 --- a/src/features/skills/antigravity-skill.ts +++ b/src/features/skills/antigravity-skill.ts @@ -1,219 +1,8 @@ -import { join } from "node:path"; - import { z } from "zod/mini"; -import { - ANTIGRAVITY_GLOBAL_SKILLS_LEGACY_PATH, - ANTIGRAVITY_LEGACY_SKILLS_DIR_PATH, -} from "../../constants/antigravity-paths.js"; -import { SKILL_FILE_NAME } from "../../constants/general.js"; -import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; -import { ValidationResult } from "../../types/ai-dir.js"; -import { formatError } from "../../utils/error.js"; -import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; -import { - ToolSkill, - ToolSkillForDeletionParams, - ToolSkillFromDirParams, - ToolSkillFromRulesyncSkillParams, - ToolSkillSettablePaths, -} from "./tool-skill.js"; - export const AntigravitySkillFrontmatterSchema = z.looseObject({ name: z.string(), description: z.string(), }); export type AntigravitySkillFrontmatter = z.infer; - -export type AntigravitySkillParams = { - outputRoot?: string; - relativeDirPath?: string; - dirName: string; - frontmatter: AntigravitySkillFrontmatter; - body: string; - otherFiles?: SkillFile[]; - validate?: boolean; - global?: boolean; -}; - -/** - * Represents an Antigravity skill directory. - * Antigravity supports skills in both project mode under .agent/skills/ - * and global mode under ~/.gemini/antigravity/skills/ - */ -export class AntigravitySkill extends ToolSkill { - constructor({ - outputRoot = process.cwd(), - relativeDirPath = ANTIGRAVITY_LEGACY_SKILLS_DIR_PATH, - dirName, - frontmatter, - body, - otherFiles = [], - validate = true, - global = false, - }: AntigravitySkillParams) { - super({ - outputRoot, - relativeDirPath, - dirName, - mainFile: { - name: SKILL_FILE_NAME, - body, - frontmatter: { ...frontmatter }, - }, - otherFiles, - global, - }); - - if (validate) { - const result = this.validate(); - if (!result.success) { - throw result.error; - } - } - } - - static getSettablePaths({ - global = false, - }: { - global?: boolean; - } = {}): ToolSkillSettablePaths { - // Antigravity skills use different paths for project and global modes: - // - Project mode: {process.cwd()}/.agent/skills/ - // - Global mode: {getHomeDirectory()}/.gemini/antigravity/skills/ - if (global) { - return { - relativeDirPath: ANTIGRAVITY_GLOBAL_SKILLS_LEGACY_PATH, - }; - } - return { - relativeDirPath: ANTIGRAVITY_LEGACY_SKILLS_DIR_PATH, - }; - } - - getFrontmatter(): AntigravitySkillFrontmatter { - const result = AntigravitySkillFrontmatterSchema.parse(this.requireMainFileFrontmatter()); - return result; - } - - getBody(): string { - return this.mainFile?.body ?? ""; - } - - validate(): ValidationResult { - if (this.mainFile === undefined) { - return { - success: false, - error: new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`), - }; - } - const result = AntigravitySkillFrontmatterSchema.safeParse(this.mainFile.frontmatter); - if (!result.success) { - return { - success: false, - error: new Error( - `Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`, - ), - }; - } - - return { success: true, error: null }; - } - - toRulesyncSkill(): RulesyncSkill { - const frontmatter = this.getFrontmatter(); - const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { - name: frontmatter.name, - description: frontmatter.description, - targets: ["*"], - }; - - return new RulesyncSkill({ - outputRoot: this.outputRoot, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: this.getDirName(), - frontmatter: rulesyncFrontmatter, - body: this.getBody(), - otherFiles: this.getOtherFiles(), - validate: true, - global: this.global, - }); - } - - static fromRulesyncSkill({ - outputRoot = process.cwd(), - rulesyncSkill, - validate = true, - global = false, - }: ToolSkillFromRulesyncSkillParams): AntigravitySkill { - const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); - - const antigravityFrontmatter: AntigravitySkillFrontmatter = { - name: rulesyncFrontmatter.name, - description: rulesyncFrontmatter.description, - }; - - const settablePaths = AntigravitySkill.getSettablePaths({ global }); - - return new AntigravitySkill({ - outputRoot, - relativeDirPath: settablePaths.relativeDirPath, - dirName: rulesyncSkill.getDirName(), - frontmatter: antigravityFrontmatter, - body: rulesyncSkill.getBody(), - otherFiles: rulesyncSkill.getOtherFiles(), - validate, - global, - }); - } - - static isTargetedByRulesyncSkill(rulesyncSkill: RulesyncSkill): boolean { - const targets = rulesyncSkill.getFrontmatter().targets; - return targets.includes("*") || targets.includes("antigravity"); - } - - static async fromDir(params: ToolSkillFromDirParams): Promise { - const loaded = await this.loadSkillDirContent({ - ...params, - getSettablePaths: AntigravitySkill.getSettablePaths, - }); - - const result = AntigravitySkillFrontmatterSchema.safeParse(loaded.frontmatter); - if (!result.success) { - const skillDirPath = join(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName); - throw new Error( - `Invalid frontmatter in ${join(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`, - ); - } - - return new AntigravitySkill({ - outputRoot: loaded.outputRoot, - relativeDirPath: loaded.relativeDirPath, - dirName: loaded.dirName, - frontmatter: result.data, - body: loaded.body, - otherFiles: loaded.otherFiles, - validate: true, - global: loaded.global, - }); - } - - static forDeletion({ - outputRoot = process.cwd(), - relativeDirPath, - dirName, - global = false, - }: ToolSkillForDeletionParams): AntigravitySkill { - return new AntigravitySkill({ - outputRoot, - relativeDirPath, - dirName, - frontmatter: { name: "", description: "" }, - body: "", - otherFiles: [], - validate: false, - global, - }); - } -} diff --git a/src/features/skills/geminicli-skill.test.ts b/src/features/skills/geminicli-skill.test.ts deleted file mode 100644 index f4b003dc8..000000000 --- a/src/features/skills/geminicli-skill.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { join } from "node:path"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { SKILL_FILE_NAME } from "../../constants/general.js"; -import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; -import { setupTestDirectory } from "../../test-utils/test-directories.js"; -import { ensureDir, writeFileContent } from "../../utils/file.js"; -import { GeminiCliSkill, GeminiCliSkillFrontmatterSchema } from "./geminicli-skill.js"; -import { RulesyncSkill } from "./rulesync-skill.js"; - -describe("GeminiCliSkill", () => { - let testDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - const testSetup = await setupTestDirectory(); - testDir = testSetup.testDir; - cleanup = testSetup.cleanup; - vi.spyOn(process, "cwd").mockReturnValue(testDir); - }); - - afterEach(async () => { - await cleanup(); - vi.restoreAllMocks(); - }); - - describe("getSettablePaths", () => { - it("should return .gemini/skills as relativeDirPath", () => { - const paths = GeminiCliSkill.getSettablePaths(); - expect(paths.relativeDirPath).toBe(join(".gemini", "skills")); - }); - - it("should return the same path in global mode", () => { - const paths = GeminiCliSkill.getSettablePaths({ global: true }); - expect(paths.relativeDirPath).toBe(join(".gemini", "skills")); - }); - }); - - describe("constructor", () => { - it("should create instance with valid content", () => { - const skill = new GeminiCliSkill({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "skills"), - dirName: "test-skill", - frontmatter: { - name: "Test Skill", - description: "Test skill description", - }, - body: "This is the body of the gemini cli skill.", - validate: true, - }); - - expect(skill).toBeInstanceOf(GeminiCliSkill); - expect(skill.getBody()).toBe("This is the body of the gemini cli skill."); - expect(skill.getFrontmatter()).toEqual({ - name: "Test Skill", - description: "Test skill description", - }); - }); - }); - - describe("fromDir", () => { - it("should create instance from valid skill directory", async () => { - const skillDir = join(testDir, ".gemini", "skills", "test-skill"); - await ensureDir(skillDir); - const skillContent = `--- -name: Test Skill -description: Test skill description ---- - -This is the body of the gemini cli skill.`; - await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent); - - const skill = await GeminiCliSkill.fromDir({ - outputRoot: testDir, - dirName: "test-skill", - }); - - expect(skill).toBeInstanceOf(GeminiCliSkill); - expect(skill.getBody()).toBe("This is the body of the gemini cli skill."); - expect(skill.getFrontmatter()).toEqual({ - name: "Test Skill", - description: "Test skill description", - }); - }); - - it("should throw error when SKILL.md not found", async () => { - const skillDir = join(testDir, ".gemini", "skills", "empty-skill"); - await ensureDir(skillDir); - - await expect( - GeminiCliSkill.fromDir({ - outputRoot: testDir, - dirName: "empty-skill", - }), - ).rejects.toThrow(/SKILL\.md not found/); - }); - - it("should throw error when frontmatter is invalid", async () => { - const skillDir = join(testDir, ".gemini", "skills", "bad-frontmatter"); - await ensureDir(skillDir); - const skillContent = `--- -name: 123 ---- - -Body content`; - await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent); - - await expect( - GeminiCliSkill.fromDir({ - outputRoot: testDir, - dirName: "bad-frontmatter", - }), - ).rejects.toThrow(/Invalid frontmatter/); - }); - - it("should create instance from directory in global mode", async () => { - const skillDir = join(testDir, ".gemini", "skills", "global-skill"); - await ensureDir(skillDir); - const skillContent = `--- -name: Global Skill -description: A global skill ---- - -Global body content`; - await writeFileContent(join(skillDir, SKILL_FILE_NAME), skillContent); - - const skill = await GeminiCliSkill.fromDir({ - outputRoot: testDir, - dirName: "global-skill", - global: true, - }); - - expect(skill).toBeInstanceOf(GeminiCliSkill); - expect(skill.getGlobal()).toBe(true); - expect(skill.getBody()).toBe("Global body content"); - }); - }); - - describe("fromRulesyncSkill", () => { - it("should create instance from RulesyncSkill", () => { - const rulesyncSkill = new RulesyncSkill({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "test-skill", - frontmatter: { - name: "Test Skill", - description: "Test skill description", - }, - body: "Test body content", - validate: true, - }); - - const geminiCliSkill = GeminiCliSkill.fromRulesyncSkill({ - rulesyncSkill, - validate: true, - }); - - expect(geminiCliSkill).toBeInstanceOf(GeminiCliSkill); - expect(geminiCliSkill.getBody()).toBe("Test body content"); - expect(geminiCliSkill.getFrontmatter()).toEqual({ - name: "Test Skill", - description: "Test skill description", - }); - }); - }); - - describe("isTargetedByRulesyncSkill", () => { - it("should return true when targets includes '*'", () => { - const rulesyncSkill = new RulesyncSkill({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "all-targets-skill", - frontmatter: { - name: "All Targets Skill", - description: "Skill for all targets", - targets: ["*"], - }, - body: "Test body", - validate: true, - }); - - expect(GeminiCliSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(true); - }); - - it("should return true when targets includes 'geminicli'", () => { - const rulesyncSkill = new RulesyncSkill({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "geminicli-skill", - frontmatter: { - name: "GeminiCli Skill", - description: "Skill for geminicli", - targets: ["copilot", "geminicli"], - }, - body: "Test body", - validate: true, - }); - - expect(GeminiCliSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(true); - }); - - it("should return false when targets does not include 'geminicli'", () => { - const rulesyncSkill = new RulesyncSkill({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "claudecode-only-skill", - frontmatter: { - name: "ClaudeCode Only Skill", - description: "Skill for claudecode only", - targets: ["claudecode"], - }, - body: "Test body", - validate: true, - }); - - expect(GeminiCliSkill.isTargetedByRulesyncSkill(rulesyncSkill)).toBe(false); - }); - }); - - describe("toRulesyncSkill", () => { - it("should convert to RulesyncSkill with correct frontmatter", () => { - const skill = new GeminiCliSkill({ - outputRoot: testDir, - relativeDirPath: join(".gemini", "skills"), - dirName: "test-skill", - frontmatter: { - name: "Test Skill", - description: "Test description", - }, - body: "Test body", - validate: true, - }); - - const rulesyncSkill = skill.toRulesyncSkill(); - - expect(rulesyncSkill).toBeInstanceOf(RulesyncSkill); - expect(rulesyncSkill.getFrontmatter()).toEqual({ - name: "Test Skill", - description: "Test description", - targets: ["*"], - }); - expect(rulesyncSkill.getBody()).toBe("Test body"); - }); - }); - - describe("global mode", () => { - it("should support global mode in constructor", () => { - const skill = new GeminiCliSkill({ - dirName: "global-skill", - frontmatter: { - name: "Global Skill", - description: "A global skill", - }, - body: "Global body", - global: true, - }); - - expect(skill.getGlobal()).toBe(true); - }); - - it("should convert from RulesyncSkill in global mode", () => { - const rulesyncSkill = new RulesyncSkill({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: "global-skill", - frontmatter: { - name: "Global Skill", - description: "A globally available skill", - targets: ["geminicli"], - }, - body: "Global content", - }); - - const geminiSkill = GeminiCliSkill.fromRulesyncSkill({ - rulesyncSkill, - global: true, - }); - - expect(geminiSkill.getGlobal()).toBe(true); - expect(geminiSkill.getRelativeDirPath()).toBe(join(".gemini", "skills")); - }); - - it("should support global deletion", () => { - const skill = GeminiCliSkill.forDeletion({ - dirName: "cleanup", - relativeDirPath: join(".gemini", "skills"), - global: true, - }); - - expect(skill.getGlobal()).toBe(true); - }); - }); - - describe("schema", () => { - it("should accept valid frontmatter", () => { - const result = GeminiCliSkillFrontmatterSchema.safeParse({ - name: "skill-name", - description: "Skill description", - }); - - expect(result.success).toBe(true); - }); - - it("should reject invalid frontmatter", () => { - const result = GeminiCliSkillFrontmatterSchema.safeParse({ name: 123, description: true }); - - expect(result.success).toBe(false); - }); - }); - - describe("forDeletion", () => { - it("should create minimal instance for deletion", () => { - const skill = GeminiCliSkill.forDeletion({ - dirName: "cleanup", - relativeDirPath: join(".gemini", "skills"), - }); - - expect(skill.getDirName()).toBe("cleanup"); - expect(skill.getRelativeDirPath()).toBe(join(".gemini", "skills")); - expect(skill.getGlobal()).toBe(false); - }); - }); -}); diff --git a/src/features/skills/geminicli-skill.ts b/src/features/skills/geminicli-skill.ts deleted file mode 100644 index 6e826cb18..000000000 --- a/src/features/skills/geminicli-skill.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { join } from "node:path"; - -import { z } from "zod/mini"; - -import { GEMINICLI_SKILLS_DIR_PATH } from "../../constants/geminicli-paths.js"; -import { SKILL_FILE_NAME } from "../../constants/general.js"; -import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; -import { ValidationResult } from "../../types/ai-dir.js"; -import { formatError } from "../../utils/error.js"; -import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; -import { - ToolSkill, - ToolSkillForDeletionParams, - ToolSkillFromDirParams, - ToolSkillFromRulesyncSkillParams, - ToolSkillSettablePaths, -} from "./tool-skill.js"; - -export const GeminiCliSkillFrontmatterSchema = z.looseObject({ - name: z.string(), - description: z.string(), -}); - -export type GeminiCliSkillFrontmatter = z.infer; - -export type GeminiCliSkillParams = { - outputRoot?: string; - relativeDirPath?: string; - dirName: string; - frontmatter: GeminiCliSkillFrontmatter; - body: string; - otherFiles?: SkillFile[]; - validate?: boolean; - global?: boolean; -}; - -/** - * Represents a Gemini CLI skill directory. - * Gemini CLI has native skill support — it reads .gemini/skills/ directories - * with SKILL.md files. See https://geminicli.com/docs/cli/skills/ - * - * Supports both project mode (.gemini/skills/) and global mode (~/.gemini/skills/). - */ -export class GeminiCliSkill extends ToolSkill { - constructor({ - outputRoot = process.cwd(), - relativeDirPath = GeminiCliSkill.getSettablePaths().relativeDirPath, - dirName, - frontmatter, - body, - otherFiles = [], - validate = true, - global = false, - }: GeminiCliSkillParams) { - super({ - outputRoot, - relativeDirPath, - dirName, - mainFile: { - name: SKILL_FILE_NAME, - body, - frontmatter: { ...frontmatter }, - }, - otherFiles, - global, - }); - - if (validate) { - const result = this.validate(); - if (!result.success) { - throw result.error; - } - } - } - - static getSettablePaths({ - global: _global = false, - }: { - global?: boolean; - } = {}): ToolSkillSettablePaths { - // Gemini CLI skills use the same relative path for both project and global modes - // The actual location differs based on outputRoot: - // - Project mode: {process.cwd()}/.gemini/skills/ - // - Global mode: {getHomeDirectory()}/.gemini/skills/ - return { - relativeDirPath: GEMINICLI_SKILLS_DIR_PATH, - }; - } - - getFrontmatter(): GeminiCliSkillFrontmatter { - const result = GeminiCliSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter()); - return result; - } - - getBody(): string { - return this.mainFile?.body ?? ""; - } - - validate(): ValidationResult { - if (this.mainFile === undefined) { - return { - success: false, - error: new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`), - }; - } - const result = GeminiCliSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter); - if (!result.success) { - return { - success: false, - error: new Error( - `Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`, - ), - }; - } - - return { success: true, error: null }; - } - - toRulesyncSkill(): RulesyncSkill { - const frontmatter = this.getFrontmatter(); - const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { - name: frontmatter.name, - description: frontmatter.description, - targets: ["*"], - }; - - return new RulesyncSkill({ - outputRoot: this.outputRoot, - relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, - dirName: this.getDirName(), - frontmatter: rulesyncFrontmatter, - body: this.getBody(), - otherFiles: this.getOtherFiles(), - validate: true, - global: this.global, - }); - } - - static fromRulesyncSkill({ - outputRoot = process.cwd(), - rulesyncSkill, - validate = true, - global = false, - }: ToolSkillFromRulesyncSkillParams): GeminiCliSkill { - const settablePaths = GeminiCliSkill.getSettablePaths({ global }); - const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); - - const geminiCliFrontmatter: GeminiCliSkillFrontmatter = { - name: rulesyncFrontmatter.name, - description: rulesyncFrontmatter.description, - }; - - return new GeminiCliSkill({ - outputRoot, - relativeDirPath: settablePaths.relativeDirPath, - dirName: rulesyncSkill.getDirName(), - frontmatter: geminiCliFrontmatter, - body: rulesyncSkill.getBody(), - otherFiles: rulesyncSkill.getOtherFiles(), - validate, - global, - }); - } - - static isTargetedByRulesyncSkill(rulesyncSkill: RulesyncSkill): boolean { - const targets = rulesyncSkill.getFrontmatter().targets; - return targets.includes("*") || targets.includes("geminicli"); - } - - static async fromDir(params: ToolSkillFromDirParams): Promise { - const loaded = await this.loadSkillDirContent({ - ...params, - getSettablePaths: GeminiCliSkill.getSettablePaths, - }); - - const result = GeminiCliSkillFrontmatterSchema.safeParse(loaded.frontmatter); - if (!result.success) { - const skillDirPath = join(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName); - throw new Error( - `Invalid frontmatter in ${join(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`, - ); - } - - return new GeminiCliSkill({ - outputRoot: loaded.outputRoot, - relativeDirPath: loaded.relativeDirPath, - dirName: loaded.dirName, - frontmatter: result.data, - body: loaded.body, - otherFiles: loaded.otherFiles, - validate: true, - global: loaded.global, - }); - } - - static forDeletion({ - outputRoot = process.cwd(), - relativeDirPath, - dirName, - global = false, - }: ToolSkillForDeletionParams): GeminiCliSkill { - const settablePaths = GeminiCliSkill.getSettablePaths({ global }); - return new GeminiCliSkill({ - outputRoot, - relativeDirPath: relativeDirPath ?? settablePaths.relativeDirPath, - dirName, - frontmatter: { name: "", description: "" }, - body: "", - otherFiles: [], - validate: false, - global, - }); - } -} diff --git a/src/features/skills/skills-processor.test.ts b/src/features/skills/skills-processor.test.ts index c8d85572f..1cf6f02c2 100644 --- a/src/features/skills/skills-processor.test.ts +++ b/src/features/skills/skills-processor.test.ts @@ -856,7 +856,6 @@ Content that would fail parsing`; "agentsskills", "aiassistant", "amp", - "antigravity", "antigravity-cli", "antigravity-ide", "augmentcode", @@ -869,7 +868,6 @@ Content that would fail parsing`; "cursor", "deepagents", "factorydroid", - "geminicli", "goose", "grokcli", "junie", @@ -900,7 +898,6 @@ Content that would fail parsing`; "agentsskills", "aiassistant", "amp", - "antigravity", "antigravity-cli", "antigravity-ide", "augmentcode", @@ -913,7 +910,6 @@ Content that would fail parsing`; "cursor", "deepagents", "factorydroid", - "geminicli", "goose", "grokcli", "junie", @@ -943,7 +939,6 @@ Content that would fail parsing`; "agentsskills", "aiassistant", "amp", - "antigravity", "antigravity-cli", "antigravity-ide", "augmentcode", @@ -956,7 +951,6 @@ Content that would fail parsing`; "cursor", "deepagents", "factorydroid", - "geminicli", "goose", "grokcli", "junie", @@ -997,7 +991,6 @@ Content that would fail parsing`; expect(targets).toEqual([ "agentsskills", "amp", - "antigravity", "antigravity-cli", "antigravity-ide", "augmentcode", @@ -1009,7 +1002,6 @@ Content that would fail parsing`; "cursor", "deepagents", "factorydroid", - "geminicli", "hermesagent", "grokcli", "junie", @@ -1036,7 +1028,6 @@ Content that would fail parsing`; expect(targets).toEqual([ "agentsskills", "amp", - "antigravity", "antigravity-cli", "antigravity-ide", "augmentcode", @@ -1048,7 +1039,6 @@ Content that would fail parsing`; "cursor", "deepagents", "factorydroid", - "geminicli", "hermesagent", "grokcli", "junie", diff --git a/src/features/skills/skills-processor.ts b/src/features/skills/skills-processor.ts index 7cd233d61..c9de2aa98 100644 --- a/src/features/skills/skills-processor.ts +++ b/src/features/skills/skills-processor.ts @@ -17,7 +17,6 @@ import { AiassistantSkill } from "./aiassistant-skill.js"; import { AmpSkill } from "./amp-skill.js"; import { AntigravityCliSkill } from "./antigravity-cli-skill.js"; import { AntigravityIdeSkill } from "./antigravity-ide-skill.js"; -import { AntigravitySkill } from "./antigravity-skill.js"; import { AugmentcodeSkill } from "./augmentcode-skill.js"; import { ClaudecodeSkill } from "./claudecode-skill.js"; import { ClineSkill } from "./cline-skill.js"; @@ -28,7 +27,6 @@ import { CursorSkill } from "./cursor-skill.js"; import { DeepagentsSkill } from "./deepagents-skill.js"; import { DevinSkill } from "./devin-skill.js"; import { FactorydroidSkill } from "./factorydroid-skill.js"; -import { GeminiCliSkill } from "./geminicli-skill.js"; import { GooseSkill } from "./goose-skill.js"; import { GrokcliSkill } from "./grokcli-skill.js"; import { HermesagentSkill } from "./hermesagent-skill.js"; @@ -132,13 +130,6 @@ export const toolSkillFactories = new Map { - let testDir: string; - let cleanup: () => Promise; - - const validMarkdownContent = `--- -name: Test GeminiCli Agent -description: Test geminicli agent description ---- - -This is the body of the geminicli agent. -It can be multiline.`; - - const invalidMarkdownContent = `--- -# Missing required fields -invalid: true ---- - -Body content`; - - const markdownWithoutFrontmatter = `This is just plain content without frontmatter.`; - - beforeEach(async () => { - const testSetup = await setupTestDirectory(); - testDir = testSetup.testDir; - cleanup = testSetup.cleanup; - vi.spyOn(process, "cwd").mockReturnValue(testDir); - }); - - afterEach(async () => { - await cleanup(); - vi.restoreAllMocks(); - }); - - describe("getSettablePaths", () => { - it("should return correct paths for geminicli subagents", () => { - const paths = GeminiCliSubagent.getSettablePaths(); - expect(paths).toEqual({ - relativeDirPath: join(".gemini", "agents"), - }); - }); - - it("should return same .gemini/agents path in global mode (resolved relative to home)", () => { - // Per https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md - // global subagents live at ~/.gemini/agents/*.md, which uses the same - // relative directory as project mode resolved against the home dir. - const paths = GeminiCliSubagent.getSettablePaths({ global: true }); - expect(paths).toEqual({ - relativeDirPath: join(".gemini", "agents"), - }); - }); - }); - - describe("constructor", () => { - it("should create instance with valid markdown content", () => { - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "test-agent.md", - frontmatter: { - name: "Test GeminiCli Agent", - description: "Test geminicli agent description", - }, - body: "This is the body of the geminicli agent.\nIt can be multiline.", - validate: true, - }); - - expect(subagent).toBeInstanceOf(GeminiCliSubagent); - expect(subagent.getBody()).toBe( - "This is the body of the geminicli agent.\nIt can be multiline.", - ); - expect(subagent.getFrontmatter()).toEqual({ - name: "Test GeminiCli Agent", - description: "Test geminicli agent description", - }); - }); - - it("should create instance with empty name and description", () => { - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "test-agent.md", - frontmatter: { - name: "", - description: "", - }, - body: "This is a geminicli agent without name or description.", - validate: true, - }); - - expect(subagent.getBody()).toBe("This is a geminicli agent without name or description."); - expect(subagent.getFrontmatter()).toEqual({ - name: "", - description: "", - }); - }); - - it("should create instance without validation when validate is false", () => { - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "test-agent.md", - frontmatter: { - name: "Test Agent", - description: "Test description", - }, - body: "Test body", - validate: false, - }); - - expect(subagent).toBeInstanceOf(GeminiCliSubagent); - }); - - it("should throw error for invalid frontmatter when validation is enabled", () => { - expect( - () => - new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "invalid-agent.md", - frontmatter: { - // Missing required fields - } as { name: string }, - body: "Body content", - validate: true, - }), - ).toThrow(); - }); - }); - - describe("getBody", () => { - it("should return the body content", () => { - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "test-agent.md", - frontmatter: { - name: "Test Agent", - description: "Test description", - }, - body: "This is the body content.\nWith multiple lines.", - validate: true, - }); - - expect(subagent.getBody()).toBe("This is the body content.\nWith multiple lines."); - }); - }); - - describe("getFrontmatter", () => { - it("should return frontmatter with name and description", () => { - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "test-agent.md", - frontmatter: { - name: "Test GeminiCli Agent", - description: "Test geminicli agent", - }, - body: "Test body", - validate: true, - }); - - const frontmatter = subagent.getFrontmatter(); - expect(frontmatter).toEqual({ - name: "Test GeminiCli Agent", - description: "Test geminicli agent", - }); - }); - }); - - describe("toRulesyncSubagent", () => { - it("should convert to RulesyncSubagent", () => { - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "test-agent.md", - frontmatter: { - name: "Test Agent", - description: "Test description", - }, - body: "Test body", - validate: true, - }); - - const rulesyncSubagent = subagent.toRulesyncSubagent(); - expect(rulesyncSubagent).toBeInstanceOf(RulesyncSubagent); - expect(rulesyncSubagent.getFrontmatter().name).toBe("Test Agent"); - expect(rulesyncSubagent.getFrontmatter().description).toBe("Test description"); - expect(rulesyncSubagent.getBody()).toBe("Test body"); - }); - }); - - describe("fromRulesyncSubagent", () => { - it("should create GeminiCliSubagent from RulesyncSubagent", () => { - const rulesyncSubagent = new RulesyncSubagent({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: "test-agent.md", - frontmatter: { - targets: ["geminicli"], - name: "Test Agent", - description: "Test description from rulesync", - }, - body: "Test agent content", - validate: true, - }); - - const geminiCliSubagent = GeminiCliSubagent.fromRulesyncSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - rulesyncSubagent, - validate: true, - }) as GeminiCliSubagent; - - expect(geminiCliSubagent).toBeInstanceOf(GeminiCliSubagent); - expect(geminiCliSubagent.getBody()).toBe("Test agent content"); - expect(geminiCliSubagent.getFrontmatter()).toEqual({ - name: "Test Agent", - description: "Test description from rulesync", - }); - expect(geminiCliSubagent.getRelativeFilePath()).toBe("test-agent.md"); - expect(geminiCliSubagent.getRelativeDirPath()).toBe(".gemini/agents"); - }); - - it("should handle RulesyncSubagent with different file extensions", () => { - const rulesyncSubagent = new RulesyncSubagent({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: "complex-agent.txt", - frontmatter: { - targets: ["geminicli"], - name: "Complex Agent", - description: "Complex agent", - }, - body: "Complex content", - validate: true, - }); - - const geminiCliSubagent = GeminiCliSubagent.fromRulesyncSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - rulesyncSubagent, - validate: true, - }) as GeminiCliSubagent; - - expect(geminiCliSubagent.getRelativeFilePath()).toBe("complex-agent.txt"); - }); - - it("should handle empty name and description", () => { - const rulesyncSubagent = new RulesyncSubagent({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: "test-agent.md", - frontmatter: { - targets: ["geminicli"], - name: "", - description: "", - }, - body: "Test content", - validate: true, - }); - - const geminiCliSubagent = GeminiCliSubagent.fromRulesyncSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - rulesyncSubagent, - validate: true, - }) as GeminiCliSubagent; - - expect(geminiCliSubagent.getFrontmatter()).toEqual({ - name: "", - description: "", - }); - }); - }); - - describe("fromFile", () => { - it("should load GeminiCliSubagent from file", async () => { - const subagentsDir = join(testDir, ".gemini", "agents"); - const filePath = join(subagentsDir, "test-file-agent.md"); - - await writeFileContent(filePath, validMarkdownContent); - - const subagent = await GeminiCliSubagent.fromFile({ - outputRoot: testDir, - relativeFilePath: "test-file-agent.md", - validate: true, - }); - - expect(subagent).toBeInstanceOf(GeminiCliSubagent); - expect(subagent.getBody()).toBe( - "This is the body of the geminicli agent.\nIt can be multiline.", - ); - expect(subagent.getFrontmatter()).toEqual({ - name: "Test GeminiCli Agent", - description: "Test geminicli agent description", - }); - expect(subagent.getRelativeFilePath()).toBe("test-file-agent.md"); - }); - - it("should handle file path with subdirectories", async () => { - const subagentsDir = join(testDir, ".gemini", "agents", "subdir"); - const filePath = join(subagentsDir, "nested-agent.md"); - - await writeFileContent(filePath, validMarkdownContent); - - const subagent = await GeminiCliSubagent.fromFile({ - outputRoot: testDir, - relativeFilePath: "subdir/nested-agent.md", - validate: true, - }); - - expect(subagent.getRelativeFilePath()).toBe("subdir/nested-agent.md"); - }); - - it("should throw error when file does not exist", async () => { - await expect( - GeminiCliSubagent.fromFile({ - outputRoot: testDir, - relativeFilePath: "non-existent-agent.md", - validate: true, - }), - ).rejects.toThrow(); - }); - - it("should throw error when file contains invalid frontmatter", async () => { - const subagentsDir = join(testDir, ".gemini", "agents"); - const filePath = join(subagentsDir, "invalid-agent.md"); - - await writeFileContent(filePath, invalidMarkdownContent); - - await expect( - GeminiCliSubagent.fromFile({ - outputRoot: testDir, - relativeFilePath: "invalid-agent.md", - validate: true, - }), - ).rejects.toThrow(); - }); - - it("should handle file without frontmatter", async () => { - const subagentsDir = join(testDir, ".gemini", "agents"); - const filePath = join(subagentsDir, "no-frontmatter.md"); - - await writeFileContent(filePath, markdownWithoutFrontmatter); - - await expect( - GeminiCliSubagent.fromFile({ - outputRoot: testDir, - relativeFilePath: "no-frontmatter.md", - validate: true, - }), - ).rejects.toThrow(); - }); - }); - - describe("validate", () => { - it("should return success for valid frontmatter", () => { - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "valid-agent.md", - frontmatter: { - name: "Valid Agent", - description: "Valid description", - }, - body: "Valid body", - validate: false, - }); - - const result = subagent.validate(); - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - }); - - it("should handle frontmatter with additional properties", () => { - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "agent-with-extras.md", - frontmatter: { - name: "Agent", - description: "Agent with extra properties", - extra: "property", - }, - body: "Body content", - validate: false, - }); - - const result = subagent.validate(); - expect(result.success).toBe(true); - }); - }); - - describe("edge cases", () => { - it("should handle empty body content", () => { - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "empty-body.md", - frontmatter: { - name: "Empty Body Agent", - description: "Agent with empty body", - }, - body: "", - validate: true, - }); - - expect(subagent.getBody()).toBe(""); - expect(subagent.getFrontmatter()).toEqual({ - name: "Empty Body Agent", - description: "Agent with empty body", - }); - }); - - it("should handle special characters in content", () => { - const specialContent = - "Special characters: @#$%^&*()\nUnicode: 你好世界 🌍\nQuotes: \"Hello 'World'\""; - - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "special-char.md", - frontmatter: { - name: "Special Agent", - description: "Special characters test", - }, - body: specialContent, - validate: true, - }); - - expect(subagent.getBody()).toBe(specialContent); - expect(subagent.getBody()).toContain("@#$%^&*()"); - expect(subagent.getBody()).toContain("你好世界 🌍"); - expect(subagent.getBody()).toContain("\"Hello 'World'\""); - }); - - it("should handle very long content", () => { - const longContent = "A".repeat(10000); - - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "long-content.md", - frontmatter: { - name: "Long Agent", - description: "Long content test", - }, - body: longContent, - validate: true, - }); - - expect(subagent.getBody()).toBe(longContent); - expect(subagent.getBody().length).toBe(10000); - }); - - it("should handle multi-line name and description", () => { - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "multiline-fields.md", - frontmatter: { - name: "Multi-line\nAgent Name", - description: "This is a multi-line\ndescription with\nmultiple lines", - }, - body: "Test body", - validate: true, - }); - - expect(subagent.getFrontmatter()).toEqual({ - name: "Multi-line\nAgent Name", - description: "This is a multi-line\ndescription with\nmultiple lines", - }); - }); - - it("should handle Windows-style line endings", () => { - const windowsContent = "Line 1\r\nLine 2\r\nLine 3"; - - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "windows-lines.md", - frontmatter: { - name: "Windows Agent", - description: "Test with Windows line endings", - }, - body: windowsContent, - validate: true, - }); - - expect(subagent.getBody()).toBe(windowsContent); - }); - }); - - describe("inheritance", () => { - it("should be an instance of ToolSubagent", () => { - const subagent = new GeminiCliSubagent({ - outputRoot: testDir, - relativeDirPath: ".gemini/agents", - relativeFilePath: "test.md", - frontmatter: { - name: "Test", - description: "Test", - }, - body: "Test", - validate: true, - }); - - expect(subagent).toBeInstanceOf(GeminiCliSubagent); - expect(subagent).toBeInstanceOf(ToolSubagent); - }); - }); - - describe("isTargetedByRulesyncSubagent", () => { - it("should return true when targets includes geminicli", () => { - const rulesyncSubagent = new RulesyncSubagent({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: "test-agent.md", - frontmatter: { - targets: ["geminicli"], - name: "Test Agent", - description: "Test description", - }, - body: "Test content", - validate: true, - }); - - expect(GeminiCliSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(true); - }); - - it("should return true when targets includes asterisk", () => { - const rulesyncSubagent = new RulesyncSubagent({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: "test-agent.md", - frontmatter: { - targets: ["*"], - name: "Test Agent", - description: "Test description", - }, - body: "Test content", - validate: true, - }); - - expect(GeminiCliSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(true); - }); - - it("should return false when targets array is empty", () => { - const rulesyncSubagent = new RulesyncSubagent({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: "test-agent.md", - frontmatter: { - targets: [], - name: "Test Agent", - description: "Test description", - }, - body: "Test content", - validate: false, - }); - - expect(GeminiCliSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(false); - }); - - it("should return false when targets does not include geminicli", () => { - const rulesyncSubagent = new RulesyncSubagent({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: "test-agent.md", - frontmatter: { - targets: ["copilot", "cline"], - name: "Test Agent", - description: "Test description", - }, - body: "Test content", - validate: true, - }); - - expect(GeminiCliSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(false); - }); - - it("should return true when targets includes geminicli among other targets", () => { - const rulesyncSubagent = new RulesyncSubagent({ - outputRoot: testDir, - relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: "test-agent.md", - frontmatter: { - targets: ["copilot", "geminicli", "cline"], - name: "Test Agent", - description: "Test description", - }, - body: "Test content", - validate: true, - }); - - expect(GeminiCliSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(true); - }); - }); -}); diff --git a/src/features/subagents/geminicli-subagent.ts b/src/features/subagents/geminicli-subagent.ts deleted file mode 100644 index 7dbc28a4e..000000000 --- a/src/features/subagents/geminicli-subagent.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { join } from "node:path"; - -import { z } from "zod/mini"; - -import { GEMINICLI_AGENTS_DIR_PATH } from "../../constants/geminicli-paths.js"; -import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; -import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; -import { formatError } from "../../utils/error.js"; -import { readFileContent } from "../../utils/file.js"; -import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; -import { RulesyncSubagent, RulesyncSubagentFrontmatter } from "./rulesync-subagent.js"; -import { - ToolSubagent, - ToolSubagentForDeletionParams, - ToolSubagentFromFileParams, - ToolSubagentFromRulesyncSubagentParams, - ToolSubagentSettablePaths, -} from "./tool-subagent.js"; - -const GeminiCliSubagentFrontmatterSchema = z.looseObject({ - name: z.string(), - description: z.optional(z.string()), -}); - -type GeminiCliSubagentFrontmatter = z.infer; - -type GeminiCliSubagentParams = { - frontmatter: GeminiCliSubagentFrontmatter; - body: string; -} & Omit & { fileContent?: string }; - -export class GeminiCliSubagent extends ToolSubagent { - private readonly frontmatter: GeminiCliSubagentFrontmatter; - private readonly body: string; - - constructor({ frontmatter, body, fileContent, ...rest }: GeminiCliSubagentParams) { - if (rest.validate !== false) { - const result = GeminiCliSubagentFrontmatterSchema.safeParse(frontmatter); - if (!result.success) { - throw new Error( - `Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, - ); - } - } - - super({ - ...rest, - fileContent: fileContent ?? stringifyFrontmatter(body, frontmatter), - }); - - this.frontmatter = frontmatter; - this.body = body; - } - - static getSettablePaths(_options: { global?: boolean } = {}): ToolSubagentSettablePaths { - return { - relativeDirPath: GEMINICLI_AGENTS_DIR_PATH, - }; - } - - getFrontmatter(): GeminiCliSubagentFrontmatter { - return this.frontmatter; - } - - getBody(): string { - return this.body; - } - - toRulesyncSubagent(): RulesyncSubagent { - const { name, description, ...rest } = this.frontmatter; - - const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { - targets: ["*"] as const, - name, - description, - geminicli: { - ...rest, - }, - }; - - return new RulesyncSubagent({ - outputRoot: ".", - frontmatter: rulesyncFrontmatter, - body: this.body, - relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: this.getRelativeFilePath(), - validate: true, - }); - } - - static fromRulesyncSubagent({ - outputRoot = process.cwd(), - rulesyncSubagent, - validate = true, - global = false, - }: ToolSubagentFromRulesyncSubagentParams): ToolSubagent { - const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter(); - const geminicliSection = rulesyncFrontmatter.geminicli ?? {}; - - const geminicliSubagentFrontmatter: GeminiCliSubagentFrontmatter = { - name: rulesyncFrontmatter.name, - description: rulesyncFrontmatter.description, - ...geminicliSection, - }; - - const body = rulesyncSubagent.getBody(); - const fileContent = stringifyFrontmatter(body, geminicliSubagentFrontmatter, { - avoidBlockScalars: true, - }); - const paths = this.getSettablePaths({ global }); - - return new GeminiCliSubagent({ - outputRoot, - frontmatter: geminicliSubagentFrontmatter, - body, - relativeDirPath: paths.relativeDirPath, - relativeFilePath: rulesyncSubagent.getRelativeFilePath(), - fileContent, - validate, - global, - }); - } - - validate(): ValidationResult { - if (!this.frontmatter) { - return { success: true, error: null }; - } - - const result = GeminiCliSubagentFrontmatterSchema.safeParse(this.frontmatter); - if (result.success) { - return { success: true, error: null }; - } else { - return { - success: false, - error: new Error( - `Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`, - ), - }; - } - } - - static isTargetedByRulesyncSubagent(rulesyncSubagent: RulesyncSubagent): boolean { - return this.isTargetedByRulesyncSubagentDefault({ - rulesyncSubagent, - toolTarget: "geminicli", - }); - } - - static async fromFile({ - outputRoot = process.cwd(), - relativeFilePath, - validate = true, - global = false, - }: ToolSubagentFromFileParams): Promise { - const paths = this.getSettablePaths({ global }); - const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath); - const fileContent = await readFileContent(filePath); - const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); - - const result = GeminiCliSubagentFrontmatterSchema.safeParse(frontmatter); - if (!result.success) { - throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); - } - - return new GeminiCliSubagent({ - outputRoot, - relativeDirPath: paths.relativeDirPath, - relativeFilePath, - frontmatter: result.data, - body: content.trim(), - fileContent, - validate, - global, - }); - } - - static forDeletion({ - outputRoot = process.cwd(), - relativeDirPath, - relativeFilePath, - }: ToolSubagentForDeletionParams): GeminiCliSubagent { - return new GeminiCliSubagent({ - outputRoot, - relativeDirPath, - relativeFilePath, - frontmatter: { name: "", description: "" }, - body: "", - fileContent: "", - validate: false, - }); - } -} diff --git a/src/features/subagents/subagents-processor.test.ts b/src/features/subagents/subagents-processor.test.ts index 598112616..63da2c5c6 100644 --- a/src/features/subagents/subagents-processor.test.ts +++ b/src/features/subagents/subagents-processor.test.ts @@ -1019,7 +1019,7 @@ Second global content`; }); describe("getToolTargets with global: true", () => { - it("should return claudecode, codexcli, copilotcli, cursor, geminicli, kilo, opencode, and rovodev as global-supported targets", () => { + it("should return claudecode, codexcli, copilotcli, cursor, kilo, opencode, and rovodev as global-supported targets", () => { const toolTargets = SubagentsProcessor.getToolTargets({ global: true }); expect(Array.isArray(toolTargets)).toBe(true); @@ -1035,7 +1035,6 @@ Second global content`; "deepagents", "devin", "factorydroid", - "geminicli", "goose", "hermesagent", "grokcli", @@ -1088,7 +1087,6 @@ Second global content`; "deepagents", "devin", "factorydroid", - "geminicli", "goose", "hermesagent", "grokcli", @@ -1237,7 +1235,6 @@ Test agent content`; "copilot", "cursor", "codexcli", - "geminicli", "roo", ]; diff --git a/src/features/subagents/subagents-processor.ts b/src/features/subagents/subagents-processor.ts index a8668c154..e9e9ffc14 100644 --- a/src/features/subagents/subagents-processor.ts +++ b/src/features/subagents/subagents-processor.ts @@ -22,7 +22,6 @@ import { CursorSubagent } from "./cursor-subagent.js"; import { DeepagentsSubagent } from "./deepagents-subagent.js"; import { DevinSubagent } from "./devin-subagent.js"; import { FactorydroidSubagent } from "./factorydroid-subagent.js"; -import { GeminiCliSubagent } from "./geminicli-subagent.js"; import { GooseSubagent } from "./goose-subagent.js"; import { GrokcliSubagent } from "./grokcli-subagent.js"; import { HermesagentSubagent } from "./hermesagent-subagent.js"; @@ -224,13 +223,6 @@ export const toolSubagentFactories = new Map { ); }); - it("should accept the deprecated `baseDirs` alias and forward it to the resolver", async () => { - // The deprecated `baseDirs` alias is declared on `GenerateOptions` so TS - // callers can pass it without a compile error. The resolver itself is - // responsible for emitting the one-shot deprecation warning and mapping - // the value to `outputRoots`; here we only assert the value is forwarded. - await generate({ baseDirs: ["/legacy-a", "/legacy-b"] }); - - expect(ConfigResolver.resolve).toHaveBeenCalledWith( - expect.objectContaining({ baseDirs: ["/legacy-a", "/legacy-b"] }), - ); - }); - it("should mention the input root path in the not-found error", async () => { const inputRootMock = "/some/input-root"; vi.mocked(ConfigResolver.resolve).mockResolvedValue({ diff --git a/src/index.ts b/src/index.ts index c92221d92..3539c7d60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,13 +29,6 @@ export type GenerateOptions = BaseOptions & { targets?: ToolTarget[]; features?: Feature[]; outputRoots?: string[]; - /** - * @deprecated Use `outputRoots` instead. Accepted as a backward-compatible - * alias for the programmatic API; emits a one-shot deprecation warning when - * provided. When both `outputRoots` and `baseDirs` are supplied, - * `outputRoots` wins. Will be removed in a future major release. - */ - baseDirs?: string[]; /** * Directory containing the `.rulesync/` source files. Defaults to the * current working directory at config-construction time. When set, output diff --git a/src/types/features.ts b/src/types/features.ts index b4e696132..0720e08ef 100644 --- a/src/types/features.ts +++ b/src/types/features.ts @@ -1,7 +1,5 @@ import { z } from "zod/mini"; -import type { ToolTarget } from "./tool-targets.js"; - export const ALL_FEATURES = [ "rules", "ignore", @@ -53,10 +51,7 @@ export const PerTargetFeaturesValueSchema = z.union([ z.array(z.enum(ALL_FEATURES_WITH_WILDCARD)), PerFeatureConfigSchema, ]); -export const RulesyncFeaturesSchema = z.union([ - z.array(z.enum(ALL_FEATURES_WITH_WILDCARD)), - z.record(z.string(), PerTargetFeaturesValueSchema), -]); +export const RulesyncFeaturesSchema = z.array(z.enum(ALL_FEATURES_WITH_WILDCARD)); // zod's `z.record(K, V)` infers `Record` (non-partial), but at runtime // missing keys are perfectly valid. We surface that to TS by wrapping the @@ -71,13 +66,7 @@ export type PerFeatureConfig = Partial>; // per-feature object form. export type PerTargetFeaturesValue = Array | PerFeatureConfig; -// Per-target features configuration - maps target to its features. -// Uses `ToolTarget` as key to enforce compile-time safety for target names. -// The Zod schema infers `Record`, but this is overridden via -// the `ConfigParams` type alias (see config.ts) so TS catches typos. -export type PerTargetFeatures = Partial>; - -export type RulesyncFeatures = Array | PerTargetFeatures; +export type RulesyncFeatures = Array; /** * Returns true if a per-feature value enables the feature. diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 41b1165b8..b25090ed6 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -274,21 +274,6 @@ export const DEEPAGENTS_HOOK_EVENTS: readonly HookEvent[] = [ "notification", ]; -/** Hook events supported by Gemini CLI. */ -export const GEMINICLI_HOOK_EVENTS: readonly HookEvent[] = [ - "sessionStart", - "sessionEnd", - "beforeSubmitPrompt", - "stop", - "beforeAgentResponse", - "afterAgentResponse", - "beforeToolSelection", - "preToolUse", - "postToolUse", - "preCompact", - "notification", -]; - /** Hook events supported by Codex CLI. */ export const CODEXCLI_HOOK_EVENTS: readonly HookEvent[] = [ "sessionStart", @@ -375,7 +360,7 @@ export const AUGMENTCODE_HOOK_EVENTS: readonly HookEvent[] = [ * `before_tool` ← `preToolUse`, `after_tool` ← `postToolUse`, and * `post_agent_turn` ← `stop` (fires after every assistant turn that ends * without pending tool calls — the closest canonical equivalent to a - * "turn end"/"stop" event, matching how codexcli/copilot/geminicli map their + * "turn end"/"stop" event, matching how codexcli/copilot map their * stop events). Only the tool events (`before_tool`/`after_tool`) carry the * `match` tool-name matcher (fnmatch glob or `re:` regex) and the `strict` * flag; `post_agent_turn` carries neither. Only `type: "command"` hooks are @@ -406,9 +391,9 @@ export const JUNIE_HOOK_EVENTS: readonly HookEvent[] = [ * Hook events supported by Qwen Code. * * Qwen Code documents a Claude-style PascalCase hooks surface under the `hooks` - * key of `.qwen/settings.json`. Its event set DIFFERS from Gemini CLI's - * (`BeforeAgent`/`AfterTool`/...), so qwencode defines its own constant instead - * of reusing {@link GEMINICLI_HOOK_EVENTS}. The Qwen-specific events + * key of `.qwen/settings.json`. Its event set differs from the Gemini-lineage + * set (`BeforeAgent`/`AfterTool`/...), so qwencode defines its own constant. + * The Qwen-specific events * `TodoCreated`, `TodoCompleted`, and `StopFailure` map to the canonical * `todoCreated`, `todoCompleted`, and `stopFailure` events respectively. * @see https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md @@ -447,7 +432,6 @@ export const HooksConfigSchema = z.looseObject({ opencode: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), kilo: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), factorydroid: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), - geminicli: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), codexcli: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), goose: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), deepagents: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), @@ -675,30 +659,6 @@ export const COPILOTCLI_TO_CANONICAL_EVENT_NAMES: Record = Objec Object.entries(CANONICAL_TO_COPILOTCLI_EVENT_NAMES).map(([k, v]) => [v, k]), ); -/** - * Map canonical camelCase event names to Gemini CLI PascalCase. - */ -export const CANONICAL_TO_GEMINICLI_EVENT_NAMES: Record = { - sessionStart: "SessionStart", - sessionEnd: "SessionEnd", - beforeSubmitPrompt: "BeforeAgent", - stop: "AfterAgent", - beforeAgentResponse: "BeforeModel", - afterAgentResponse: "AfterModel", - beforeToolSelection: "BeforeToolSelection", - preToolUse: "BeforeTool", - postToolUse: "AfterTool", - preCompact: "PreCompress", - notification: "Notification", -}; - -/** - * Map Gemini CLI PascalCase event names to canonical camelCase. - */ -export const GEMINICLI_TO_CANONICAL_EVENT_NAMES: Record = Object.fromEntries( - Object.entries(CANONICAL_TO_GEMINICLI_EVENT_NAMES).map(([k, v]) => [v, k]), -); - /** * Map canonical camelCase event names to Codex CLI PascalCase. */ diff --git a/src/types/tool-display.ts b/src/types/tool-display.ts index bdc3a2f83..1dc16cbb5 100644 --- a/src/types/tool-display.ts +++ b/src/types/tool-display.ts @@ -20,7 +20,6 @@ export const TOOL_DISPLAY: ReadonlyArray = [ { key: "amp", label: "Amp", group: "ai" }, { key: "claudecode", label: "Claude Code", group: "ai" }, { key: "codexcli", label: "Codex CLI", group: "ai" }, - { key: "geminicli", label: "Gemini CLI ⚠️", group: "ai" }, { key: "copilot", label: "GitHub Copilot", group: "ai" }, { key: "copilotcli", label: "GitHub Copilot CLI", group: "ai" }, { key: "goose", label: "Goose", group: "ai" }, @@ -43,7 +42,6 @@ export const TOOL_DISPLAY: ReadonlyArray = [ { key: "kiro-ide", label: "Kiro IDE", group: "ai" }, { key: "antigravity-ide", label: "Google Antigravity IDE", group: "ai" }, { key: "antigravity-cli", label: "Google Antigravity CLI", group: "ai" }, - { key: "antigravity", label: "Google Antigravity ⚠️", group: "ai" }, { key: "aiassistant", label: "JetBrains AI Assistant", group: "ai" }, { key: "junie", label: "JetBrains Junie", group: "ai" }, { key: "augmentcode", label: "AugmentCode", group: "ai" }, diff --git a/src/types/tool-target-tuples.ts b/src/types/tool-target-tuples.ts index be585da14..9a0416f6e 100644 --- a/src/types/tool-target-tuples.ts +++ b/src/types/tool-target-tuples.ts @@ -6,7 +6,6 @@ export const rulesProcessorToolTargetTuple = [ "agentsmd", "aiassistant", "amp", - "antigravity", "antigravity-cli", "antigravity-ide", "augmentcode", @@ -20,7 +19,6 @@ export const rulesProcessorToolTargetTuple = [ "cursor", "deepagents", "factorydroid", - "geminicli", "goose", "grokcli", "hermesagent", @@ -50,7 +48,6 @@ export const ignoreProcessorToolTargetTuple = [ "claudecode-legacy", "cline", "cursor", - "geminicli", "goose", "junie", "kilo", @@ -79,7 +76,6 @@ export const mcpProcessorToolTargetTuple = [ "cursor", "deepagents", "factorydroid", - "geminicli", "goose", "grokcli", "hermesagent", @@ -101,7 +97,6 @@ export const mcpProcessorToolTargetTuple = [ export const commandsProcessorToolTargetTuple = [ "agentsmd", - "antigravity", "antigravity-ide", "augmentcode", "claudecode", @@ -111,7 +106,6 @@ export const commandsProcessorToolTargetTuple = [ "copilot", "cursor", "factorydroid", - "geminicli", "goose", "hermesagent", "junie", @@ -141,7 +135,6 @@ export const subagentsProcessorToolTargetTuple = [ "deepagents", "devin", "factorydroid", - "geminicli", "goose", "grokcli", "hermesagent", @@ -162,7 +155,6 @@ export const skillsProcessorToolTargetTuple = [ "agentsskills", "aiassistant", "amp", - "antigravity", "antigravity-cli", "antigravity-ide", "augmentcode", @@ -175,7 +167,6 @@ export const skillsProcessorToolTargetTuple = [ "cursor", "deepagents", "factorydroid", - "geminicli", "goose", "grokcli", "hermesagent", @@ -208,7 +199,6 @@ export const hooksProcessorToolTargetTuple = [ "copilotcli", "opencode", "factorydroid", - "geminicli", "goose", "hermesagent", "deepagents", @@ -231,7 +221,6 @@ export const permissionsProcessorToolTargetTuple = [ "codexcli", "cursor", "factorydroid", - "geminicli", "goose", "grokcli", "hermesagent", diff --git a/src/types/tool-targets.test.ts b/src/types/tool-targets.test.ts index 072fe0fca..d6f6238e8 100644 --- a/src/types/tool-targets.test.ts +++ b/src/types/tool-targets.test.ts @@ -19,7 +19,6 @@ describe("tool targets", () => { "agentsmd", "aiassistant", "amp", - "antigravity", "antigravity-cli", "antigravity-ide", "augmentcode", @@ -33,7 +32,6 @@ describe("tool targets", () => { "cursor", "deepagents", "factorydroid", - "geminicli", "goose", "grokcli", "hermesagent", From 101d9fc7ebc5a35c556c58a8d41f290be095a6ac Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Thu, 25 Jun 2026 00:53:48 -0700 Subject: [PATCH 2/2] test(e2e): remove bare 'antigravity' target cases from e2e specs The bare 'antigravity' alias target was removed from the tool tuples, but the e2e rules/commands/skills specs still asserted on it, causing the E2E workflow to fail on all platforms. Drop those test rows; the antigravity-ide / antigravity-cli cases remain. --- src/e2e/e2e-commands.spec.ts | 3 --- src/e2e/e2e-rules.spec.ts | 6 ------ src/e2e/e2e-skills.spec.ts | 10 ---------- 3 files changed, 19 deletions(-) diff --git a/src/e2e/e2e-commands.spec.ts b/src/e2e/e2e-commands.spec.ts index 9079b1d3a..5e9a0a2d7 100644 --- a/src/e2e/e2e-commands.spec.ts +++ b/src/e2e/e2e-commands.spec.ts @@ -24,7 +24,6 @@ describe("E2E: commands", () => { { target: "kilo", outputPath: join(".kilo", "commands", "review-pr.md") }, { target: "roo", outputPath: join(".roo", "commands", "review-pr.md") }, { target: "kiro", outputPath: join(".kiro", "prompts", "review-pr.md") }, - { target: "antigravity", outputPath: join(".agent", "workflows", "review-pr.md") }, { target: "antigravity-ide", outputPath: join(".agents", "workflows", "review-pr.md") }, { target: "junie", outputPath: join(".junie", "commands", "review-pr.md") }, { target: "takt", outputPath: join(".takt", "facets", "instructions", "review-pr.md") }, @@ -89,7 +88,6 @@ Check the PR diff and provide feedback. { target: "kilo", orphanPath: join(".kilo", "commands", "orphan.md") }, { target: "roo", orphanPath: join(".roo", "commands", "orphan.md") }, { target: "kiro", orphanPath: join(".kiro", "prompts", "orphan.md") }, - { target: "antigravity", orphanPath: join(".agent", "workflows", "orphan.md") }, { target: "antigravity-ide", orphanPath: join(".agents", "workflows", "orphan.md") }, { target: "junie", orphanPath: join(".junie", "commands", "orphan.md") }, { target: "pi", orphanPath: join(".pi", "prompts", "orphan.md") }, @@ -137,7 +135,6 @@ describe("E2E: commands (import)", () => { { target: "kilo", sourcePath: join(".kilo", "commands", "review-pr.md") }, { target: "roo", sourcePath: join(".roo", "commands", "review-pr.md") }, { target: "kiro", sourcePath: join(".kiro", "prompts", "review-pr.md") }, - { target: "antigravity", sourcePath: join(".agent", "workflows", "review-pr.md") }, { target: "antigravity-ide", sourcePath: join(".agents", "workflows", "review-pr.md") }, { target: "junie", sourcePath: join(".junie", "commands", "review-pr.md") }, { target: "pi", sourcePath: join(".pi", "prompts", "review-pr.md") }, diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index 262f6c32c..709debc50 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -80,7 +80,6 @@ This is a test rule for E2E testing. { target: "kiro", outputPath: join(".kiro", "steering", "overview.md") }, { target: "kiro-cli", outputPath: join(".kiro", "steering", "overview.md") }, { target: "kiro-ide", outputPath: join(".kiro", "steering", "overview.md") }, - { target: "antigravity", outputPath: join(".agent", "rules", "overview.md") }, { target: "antigravity-ide", outputPath: join(".agents", "rules", "overview.md") }, { target: "augmentcode", outputPath: join(".augment", "rules", "overview.md") }, { target: "devin", outputPath: join(".devin", "rules", "overview.md") }, @@ -607,11 +606,6 @@ describe("E2E: rules (import)", () => { sourcePath: join(".kiro", "steering", "overview.md"), importedFileName: "overview.md", }, - { - target: "antigravity", - sourcePath: join(".agent", "rules", "overview.md"), - importedFileName: "overview.md", - }, { target: "antigravity-ide", sourcePath: join(".agents", "rules", "overview.md"), diff --git a/src/e2e/e2e-skills.spec.ts b/src/e2e/e2e-skills.spec.ts index 58d4daa4e..784fcbb21 100644 --- a/src/e2e/e2e-skills.spec.ts +++ b/src/e2e/e2e-skills.spec.ts @@ -83,10 +83,6 @@ describe("E2E: skills", () => { target: "kiro", outputPath: join(".kiro", "skills", "test-skill", "SKILL.md"), }, - { - target: "antigravity", - outputPath: join(".agent", "skills", "test-skill", "SKILL.md"), - }, { target: "antigravity-ide", outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), @@ -198,7 +194,6 @@ This is the test skill body content. { target: "devin", orphanPath: join(".devin", "skills", "orphan-skill", "SKILL.md") }, { target: "warp", orphanPath: join(".warp", "skills", "orphan-skill", "SKILL.md") }, { target: "kiro", orphanPath: join(".kiro", "skills", "orphan-skill", "SKILL.md") }, - { target: "antigravity", orphanPath: join(".agent", "skills", "orphan-skill", "SKILL.md") }, { target: "antigravity-ide", orphanPath: join(".agents", "skills", "orphan-skill", "SKILL.md"), @@ -260,7 +255,6 @@ describe("E2E: skills (import)", () => { { target: "devin", sourcePath: join(".devin", "skills", "test-skill", "SKILL.md") }, { target: "warp", sourcePath: join(".warp", "skills", "test-skill", "SKILL.md") }, { target: "kiro", sourcePath: join(".kiro", "skills", "test-skill", "SKILL.md") }, - { target: "antigravity", sourcePath: join(".agent", "skills", "test-skill", "SKILL.md") }, { target: "antigravity-ide", sourcePath: join(".agents", "skills", "test-skill", "SKILL.md") }, { target: "antigravity-cli", sourcePath: join(".agents", "skills", "test-skill", "SKILL.md") }, { target: "junie", sourcePath: join(".junie", "skills", "test-skill", "SKILL.md") }, @@ -386,10 +380,6 @@ describe("E2E: skills (global mode)", () => { target: "warp", outputPath: join(".warp", "skills", "test-skill", "SKILL.md"), }, - { - target: "antigravity", - outputPath: join(".gemini", "antigravity", "skills", "test-skill", "SKILL.md"), - }, { target: "antigravity-ide", outputPath: join(".gemini", "config", "skills", "test-skill", "SKILL.md"),