From f6e5a23cc14148db50b98be6b7c436eb91644ba0 Mon Sep 17 00:00:00 2001 From: Matt Luedke Date: Wed, 22 Apr 2026 17:21:15 -0400 Subject: [PATCH 01/12] docs: document agents folder support --- AGENTS_FOLDER_SUPPORT.md | 195 ++++++++++++++++++ .../content/docs/editors/supported-editors.md | 71 ++++++- 2 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 AGENTS_FOLDER_SUPPORT.md diff --git a/AGENTS_FOLDER_SUPPORT.md b/AGENTS_FOLDER_SUPPORT.md new file mode 100644 index 0000000..fe2c241 --- /dev/null +++ b/AGENTS_FOLDER_SUPPORT.md @@ -0,0 +1,195 @@ +# .agents support across aix editors + +Research date: 2026-04-22 + +This covers the editors currently supported by aix: Claude Code, Cursor, GitHub Copilot, Windsurf, Codex, Gemini, and Zed. + +I treated ".agents folder standard" as the shared Agent Skills convention, mainly `.agents/skills//SKILL.md`. `AGENTS.md` is related, but it is a Markdown instruction file, not a subfolder of `.agents`. + +## Baseline standards + +Agent Skills are directories with a required `SKILL.md` file. The public Agent Skills spec defines optional `scripts/`, `references/`, and `assets/` directories, plus required `name` and `description` frontmatter fields. It also documents optional `license`, `compatibility`, `metadata`, and experimental `allowed-tools` fields. [Source][agent-skills-spec] + +`AGENTS.md` is a separate open format for repo instructions. The public site describes it as a predictable place for coding-agent instructions, recommends putting it at the repo root, and also describes nested `AGENTS.md` files for monorepos. [Source][agents-md] + +## Summary + +| Editor | `.agents/` folder support | `AGENTS.md` support | Evidence | +| -------------- | ------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Codex | Yes | Yes | Codex reads `.agents/skills` from repo, user, admin, and system locations, and has official `AGENTS.md` discovery docs. [Skills][codex-skills] [AGENTS.md][codex-agents-md] | +| Gemini | Yes | Yes | Gemini CLI supports `.agents/skills` as an alias for `.gemini/skills`. `AGENTS.md` can be used through `context.fileName`, though `GEMINI.md` remains the default. [Skills][gemini-skills] [Config][gemini-config] | +| GitHub Copilot | Yes | Yes | Copilot lists `.agents/skills` as a project and personal skill location, and its repo instructions docs support one or more `AGENTS.md` files. [Skills][copilot-skills] [Instructions][copilot-repo-instructions] | +| Windsurf | Yes | Yes | Windsurf discovers `.agents/skills`, and its Cascade docs describe native `AGENTS.md` discovery and directory scoping. [Skills][windsurf-skills] [AGENTS.md][windsurf-agents-md] | +| Cursor | Yes | Yes | Cursor's blog says it supports the Agent Skills open standard. Its rules docs support root-level `AGENTS.md`. The `.agents/skills` directory list is visible in Cursor forum excerpts of the docs, but I could not fetch a canonical Cursor docs page for that path through the search tool. [Blog][cursor-skills-blog] [Rules][cursor-rules] [Forum excerpt][cursor-forum-skills] | +| Claude Code | No | No | Claude Code supports Agent Skills, but the documented paths are `.claude/skills`, `~/.claude/skills`, plugin skills, and managed skills. Its instruction file is `CLAUDE.md`, not `AGENTS.md`. [Skills][claude-skills] | +| Zed | No | Yes | Zed supports `AGENTS.md` as one of several compatible rules filenames. Its docs do not document Agent Skills or `.agents/skills`. [Rules][zed-rules] | + +## Editor details + +### Codex + +Status: yes for `.agents/`, yes for `AGENTS.md`. + +Codex reads skills from repository, user, admin, and bundled system locations. For repositories, it scans `.agents/skills` from the current working directory up to the repo root. It also reads `$HOME/.agents/skills`, `/etc/codex/skills`, and bundled OpenAI system skills. [Source][codex-skills] + +Supported skill contents: + +- `SKILL.md` with `name` and `description` +- optional `scripts/` +- optional `references/` +- optional `assets/` +- optional `agents/openai.yaml` for Codex app metadata, invocation policy, and tool dependencies + +Codex can activate a skill explicitly through `/skills` or `$skill-name`, or implicitly when the task matches the skill description. [Source][codex-skills] + +Codex also has first-party `AGENTS.md` support. It reads global guidance from the Codex home directory, then project guidance from the repo root down to the current working directory. Files closer to the current directory override earlier guidance because they appear later in the combined prompt. [Source][codex-agents-md] + +### Gemini + +Status: yes for `.agents/`, yes for `AGENTS.md` through configuration. + +Gemini CLI supports `.agents/skills` as an alias for `.gemini/skills`. + +Supported locations: + +- workspace: `.gemini/skills/` or `.agents/skills/` +- user: `~/.gemini/skills/` or `~/.agents/skills/` +- extension skills bundled inside installed extensions + +Precedence is Workspace > User > Extension. Within the same tier, `.agents/skills` takes precedence over `.gemini/skills`. [Source][gemini-skills] + +Gemini discovers skill metadata at session start. When it decides a skill is relevant, it calls `activate_skill`, asks for consent, then loads the `SKILL.md` body and folder structure into the conversation and grants access to bundled assets. [Source][gemini-skills] + +Gemini's default context filename is `GEMINI.md`, but `context.fileName` can be a string or array of strings. That means `AGENTS.md` is supported when configured as a context filename. The AGENTS.md site also shows this configuration pattern for Gemini CLI. [Gemini config][gemini-config] [AGENTS.md FAQ][agents-md] + +Gemini extensions can also include `agents/` sub-agent definitions and `policies/` policy files inside the extension root. These are extension features, not `.agents/` folder features. [Source][gemini-extension-reference] + +### GitHub Copilot + +Status: yes for `.agents/`, yes for `AGENTS.md`. + +GitHub Copilot supports Agent Skills in project and personal locations. The documented project paths are `.github/skills`, `.claude/skills`, and `.agents/skills`. The documented personal paths are `~/.copilot/skills`, `~/.claude/skills`, and `~/.agents/skills`. [Source][copilot-skills] + +Supported skill contents are described as folders of instructions, scripts, and resources. [Source][copilot-skills] + +Copilot coding agent supports one or more `AGENTS.md` files anywhere in the repo. The nearest file in the directory tree takes precedence. A single root `CLAUDE.md` or `GEMINI.md` can also be used instead. [Source][copilot-repo-instructions] + +Other Copilot customization paths are not `.agents` paths: + +- custom agents: `.github/agents/AGENT-NAME.md` +- hooks: `.github/hooks/*.json` +- prompts: `.github/prompts/*.prompt.md` + +Those paths come from GitHub's Copilot customization cheat sheet. [Source][copilot-cheat-sheet] + +### Windsurf + +Status: yes for `.agents/`, yes for `AGENTS.md`. + +Windsurf's native skill locations are `.windsurf/skills/` and `~/.codeium/windsurf/skills/`. For cross-agent compatibility, Windsurf also discovers `.agents/skills/` and `~/.agents/skills/`. [Source][windsurf-skills] + +Each skill requires a `SKILL.md` file with YAML frontmatter containing `name` and `description`. Supporting files can live beside `SKILL.md`; Windsurf's examples include checklists, rollback docs, config templates, shell scripts, and CI config files. [Source][windsurf-skills] + +Invocation: + +- automatic when the request matches the skill description +- manual with `@skill-name` + +Windsurf automatically discovers `AGENTS.md` and `agents.md` files in the workspace. A root file becomes an always-on rule, and subdirectory files become scoped rules for that directory tree. [Source][windsurf-agents-md] + +### Cursor + +Status: yes for `.agents/`, yes for `AGENTS.md`. + +Cursor's official blog says Cursor supports the Agent Skills open standard and describes dynamic context loading, scripts, and reusable skills. [Source][cursor-skills-blog] + +Cursor's rules docs support `AGENTS.md` as a plain Markdown alternative to `.cursor/rules`, with current limitations: root-level only, no scoping, and a single file. [Source][cursor-rules] + +The exact `.agents/skills` path support is harder to cite cleanly. A Cursor forum thread quotes the official docs as listing these skill directories: + +- `.agents/skills/` for project-level skills +- `.cursor/skills/` for project-level skills +- `~/.agents/skills/` for user-level skills +- `~/.cursor/skills/` for user-level skills + +That same thread is about skill discovery not working as expected, so treat Cursor support as real but still rough in practice. [Source][cursor-forum-skills] + +Another Cursor forum thread reports Remote SSH skill-loading failures in Cursor 3.1.15. I would not build critical automation on Cursor skill discovery without testing the exact environment. [Source][cursor-remote-ssh-skills] + +### Claude Code + +Status: no for `.agents/`, no for `AGENTS.md`. + +Claude Code supports Agent Skills, but the documented locations are Claude-specific: + +- project: `.claude/skills//SKILL.md` +- personal: `~/.claude/skills//SKILL.md` +- plugin: `/skills//SKILL.md` +- enterprise managed skills + +The same page documents nested `.claude/skills/` discovery and optional supporting files inside a skill directory. [Source][claude-skills] + +Claude Code supports additional skill frontmatter such as `disable-model-invocation`, `user-invocable`, `allowed-tools`, `arguments`, `context: fork`, `agent`, and skill lifecycle hooks. [Source][claude-skills] + +Other Claude Code customization paths are also Claude-specific: + +- subagents: `.claude/agents/` [Source][claude-subagents] +- slash commands: `.claude/commands/`, which still work but have been merged into skills [Source][claude-slash-commands] +- memory/instructions: `CLAUDE.md` [Source][claude-memory] + +I found no first-party Claude Code documentation for native `.agents/skills` or `AGENTS.md` discovery. A compatibility bridge would need to be explicit, such as symlinking `.claude/skills` to `.agents/skills`. + +### Zed + +Status: no for `.agents/`, yes for `AGENTS.md`. + +Zed supports project rules, not Agent Skills. It auto-includes a `.rules` file at the root of a project tree, and it also supports several compatibility filenames. The first matching file in this list is used: + +- `.rules` +- `.cursorrules` +- `.windsurfrules` +- `.clinerules` +- `.github/copilot-instructions.md` +- `AGENT.md` +- `AGENTS.md` +- `CLAUDE.md` +- `GEMINI.md` + +Zed's docs also describe the Rules Library and `@rule` mentions, but they do not document `.agents/skills` or `SKILL.md` loading. [Source][zed-rules] + +## Implications for aix + +Editors where aix can target `.agents/skills` without losing native skill behavior: + +- Codex +- Gemini +- GitHub Copilot +- Windsurf +- Cursor, after testing the specific Cursor version and environment + +Editors where aix should keep using native or compatibility-specific paths: + +- Claude Code: keep `.claude/skills`, or make a deliberate symlink bridge to `.agents/skills` +- Zed: keep pointer rules or generated `.rules` content; there is no native Agent Skills target + +[agent-skills-spec]: https://agentskills.io/specification +[agents-md]: https://agents.md/ +[codex-skills]: https://developers.openai.com/codex/skills +[codex-agents-md]: https://developers.openai.com/codex/guides/agents-md +[gemini-skills]: https://geminicli.com/docs/cli/skills/ +[gemini-config]: https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md +[gemini-extension-reference]: https://geminicli.com/docs/extensions/reference/ +[copilot-skills]: https://docs.github.com/en/copilot/concepts/agents/about-agent-skills +[copilot-repo-instructions]: https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions +[copilot-cheat-sheet]: https://docs.github.com/en/copilot/reference/customization-cheat-sheet +[windsurf-skills]: https://docs.windsurf.com/windsurf/cascade/skills +[windsurf-agents-md]: https://docs.windsurf.com/windsurf/cascade/agents-md +[cursor-skills-blog]: https://cursor.com/blog/dynamic-context-discovery +[cursor-rules]: https://docs.cursor.com/context/rules-for-ai +[cursor-forum-skills]: https://forum.cursor.com/t/why-agents-can-not-see-my-skills-in-cursor-skills-folder/158131 +[cursor-remote-ssh-skills]: https://forum.cursor.com/t/remote-ssh-to-windows-host-all-agent-skills-fail-to-load-including-built-in-create-skill-cursor-3-1-15-macos-windows/158377 +[claude-skills]: https://code.claude.com/docs/en/skills +[claude-memory]: https://docs.anthropic.com/en/docs/claude-code/memory +[claude-subagents]: https://docs.anthropic.com/en/docs/claude-code/sub-agents +[claude-slash-commands]: https://docs.anthropic.com/en/docs/claude-code/slash-commands +[zed-rules]: https://zed.dev/docs/ai/rules diff --git a/packages/site/src/content/docs/editors/supported-editors.md b/packages/site/src/content/docs/editors/supported-editors.md index db9ad1c..ed65a22 100644 --- a/packages/site/src/content/docs/editors/supported-editors.md +++ b/packages/site/src/content/docs/editors/supported-editors.md @@ -6,15 +6,17 @@ title: Supported Editors description: Feature support matrix for all editors detected by aix. --- -aix currently supports 6 AI code editors. - -| Feature | Cursor | GitHub Copilot | Claude Code | Windsurf | Zed | Codex | -| ----------- | :----: | :------------: | :---------: | :------: | :-: | :---: | -| **Rules** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Prompts** | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | -| **MCP** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Skills** | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | -| **Hooks** | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +aix currently supports 7 AI code editors. + +| Editor | Rules | Prompts | MCP | Skills | Hooks | AGENTS.md | `.agents/` folder | +| -------------- | :---: | :-----: | :-: | :----: | :---: | :-------: | :---------------: | +| Cursor | ✅ | ✅ | ✅ | ✅ | ✅ | Yes | Yes | +| GitHub Copilot | ✅ | ✅ | ✅ | ✅ | ✅ | Yes | Yes | +| Claude Code | ✅ | ✅ | ✅ | ✅ | ✅ | No | No | +| Windsurf | ✅ | ✅ | ✅ | ✅ | ✅ | Yes | Yes | +| Zed | ✅ | ❌ | ✅ | ⚠️ | ❌ | Yes | No | +| Codex | ✅ | ✅ | ✅ | ✅ | ❌ | Yes | Yes | +| Gemini | ✅ | ✅ | ✅ | ✅ | ❌ | Yes | Yes | ⚠️ = supported via pointer rules (no native Agent Skills) @@ -30,6 +32,10 @@ How `ai.json` concepts map to each editor: - **Skills**: `.aix/skills/{name}/` with symlinks from `.cursor/skills/`. - **Hooks**: `.cursor/hooks.json`. Supports `sessionStart`, `sessionEnd`, `preToolUse`, `postToolUse`, `beforeReadFile`, `beforeShellExecution`, `afterShellExecution`, `beforeMCPExecution`, `afterMCPExecution`, `afterFileEdit`, `beforeSubmitPrompt`, and `stop`. +#### .agents/ folder + +Cursor supports [`AGENTS.md`][cursor-rules] as a plain Markdown alternative to `.cursor/rules`, with root-level scope. Cursor also supports Agent Skills; Cursor's own blog describes support for the [Agent Skills open standard][cursor-skills-blog], and current [docs excerpts in the Cursor forum][cursor-forum-skills] list `.agents/skills/` and `.cursor/skills/` as project-level skill directories. + ### GitHub Copilot - **Rules**: `.github/instructions/*.instructions.md`. @@ -38,6 +44,10 @@ How `ai.json` concepts map to each editor: - **Skills**: `.aix/skills/{name}/` with symlinks from `.github/skills/`. - **Hooks**: `.github/hooks/*.json`. Supports `sessionStart`, `sessionEnd`, `userPromptSubmitted`, `preToolUse`, `postToolUse`, `preCompact`, `subagentStart`, `subagentStop`, and `stop`. +#### .agents/ folder + +GitHub Copilot supports [`.agents/skills/`][copilot-skills] for both project skills and personal skills (`~/.agents/skills/`). Copilot coding agent also supports [one or more `AGENTS.md` files][copilot-repo-instructions] anywhere in the repository; the nearest `AGENTS.md` in the directory tree takes precedence. + ### Claude Code - **Rules**: `.claude/rules/*.md`. @@ -46,6 +56,10 @@ How `ai.json` concepts map to each editor: - **Skills**: `.aix/skills/{name}/` with symlinks from `.claude/skills/`. - **Hooks**: `.claude/settings.json`. Supports `SessionStart`, `SessionEnd`, `InstructionsLoaded`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PermissionDenied`, `PostToolUse`, `PostToolUseFailure`, `Notification`, `SubagentStart`, `SubagentStop`, `TaskCreated`, `TaskCompleted`, `Stop`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `Elicitation`, and `ElicitationResult`. +#### .agents/ folder + +Claude Code supports Agent Skills, but its documented skill paths are [`.claude/skills/`, `~/.claude/skills/`, plugin skills, and enterprise managed skills][claude-skills]. It does not document native `.agents/skills/` discovery or `AGENTS.md`; its native repo instruction file is [`CLAUDE.md`][claude-memory]. + ### Windsurf - **Rules**: `.windsurf/rules/*.md`. Supports Cascade's "auto" activation natively. @@ -54,6 +68,10 @@ How `ai.json` concepts map to each editor: - **Skills**: `.aix/skills/{name}/` with symlinks from `.windsurf/skills/`. - **Hooks**: `.windsurf/hooks.json`. Supports `pre_read_code`, `post_read_code`, `pre_write_code`, `post_write_code`, `pre_run_command`, `post_run_command`, `pre_mcp_tool_use`, `post_mcp_tool_use`, `pre_user_prompt`, `post_cascade_response`, `post_cascade_response_with_transcript`, and `post_setup_worktree`. +#### .agents/ folder + +Windsurf discovers [`.agents/skills/` and `~/.agents/skills/`][windsurf-skills] for cross-agent compatibility, alongside its native `.windsurf/skills/` and `~/.codeium/windsurf/skills/` paths. It also [discovers `AGENTS.md` and `agents.md`][windsurf-agents-md] files throughout the workspace and scopes them by location. + ### Zed - **Rules**: `.rules` file at project root (all rules concatenated). Zed also auto-detects `.cursorrules`, `AGENTS.md`, `CLAUDE.md`, and other common rules files for compatibility. @@ -61,9 +79,44 @@ How `ai.json` concepts map to each editor: - **Prompts**: Not supported (file-based user prompts). Zed supports MCP server-side prompts natively. - **Skills**: Pointer rules (no native Agent Skills support). +#### .agents/ folder + +Zed supports [`AGENTS.md`][zed-rules] as one of several compatibility filenames for project rules. It does not document Agent Skills or `.agents/skills/` discovery, so aix exposes skills through pointer rules instead. + ### Codex - **Rules**: `AGENTS.md` at project root (and in subdirectories for glob-scoped rules). - **MCP**: Global config at `~/.codex/config.toml`. Also supports project-scoped config at `.codex/config.toml` (trusted projects only). aix currently only writes to the global config. - **Prompts**: Global at `~/.codex/prompts/`. - **Skills**: `.aix/skills/{name}/` with project symlinks from `.agents/skills/`. Global/personal Codex skills live under `~/.codex/skills/`. + +#### .agents/ folder + +Codex has native support for both pieces of the standard. It discovers skills from [`.agents/skills/`][codex-skills] in the current directory, parent directories up to the repo root, the repo root, `$HOME/.agents/skills`, `/etc/codex/skills`, and bundled system skills. It also reads [`AGENTS.md`][codex-agents-md] from the Codex home directory and from the project root down to the current working directory, layering more specific files later in the prompt. + +### Gemini + +- **Rules**: `GEMINI.md` at project root (using section-managed markdown to preserve user content). +- **MCP**: `.gemini/settings.json` (project and global supported). +- **Prompts**: `.gemini/commands/*.toml`. +- **Skills**: `.aix/skills/{name}/` with symlinks from `.gemini/skills/`. +- **Hooks**: Not supported. + +#### .agents/ folder + +Gemini CLI supports [`.agents/skills/`][gemini-skills] as an alias for `.gemini/skills/`, at both workspace and user scope. Within the same scope, `.agents/skills/` takes precedence over `.gemini/skills/`. Gemini's default context file is `GEMINI.md`, but [`context.fileName`][gemini-config] can be configured to load `AGENTS.md`, so aix treats `AGENTS.md` support as yes. + +[codex-skills]: https://developers.openai.com/codex/skills +[codex-agents-md]: https://developers.openai.com/codex/guides/agents-md +[gemini-skills]: https://geminicli.com/docs/cli/skills/ +[gemini-config]: https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md +[copilot-skills]: https://docs.github.com/en/copilot/concepts/agents/about-agent-skills +[copilot-repo-instructions]: https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions +[windsurf-skills]: https://docs.windsurf.com/windsurf/cascade/skills +[windsurf-agents-md]: https://docs.windsurf.com/windsurf/cascade/agents-md +[cursor-skills-blog]: https://cursor.com/blog/dynamic-context-discovery +[cursor-rules]: https://docs.cursor.com/context/rules-for-ai +[cursor-forum-skills]: https://forum.cursor.com/t/why-agents-can-not-see-my-skills-in-cursor-skills-folder/158131 +[claude-skills]: https://code.claude.com/docs/en/skills +[claude-memory]: https://docs.anthropic.com/en/docs/claude-code/memory +[zed-rules]: https://zed.dev/docs/ai/rules From cf0de1f9bdb0516a50bbc85e637d7ddc23421eb5 Mon Sep 17 00:00:00 2001 From: Matt Luedke Date: Wed, 22 Apr 2026 17:21:33 -0400 Subject: [PATCH 02/12] refactor: fix lint warnings --- packages/cli/src/commands/list/index.ts | 157 +++++++++--------- packages/cli/src/lib/install-helper.ts | 24 ++- .../core/src/__tests__/state-tracker.test.ts | 128 ++++++++++++-- packages/core/src/state/tracker.ts | 62 +++++-- 4 files changed, 255 insertions(+), 116 deletions(-) diff --git a/packages/cli/src/commands/list/index.ts b/packages/cli/src/commands/list/index.ts index 26a37e4..2b60fb3 100644 --- a/packages/cli/src/commands/list/index.ts +++ b/packages/cli/src/commands/list/index.ts @@ -30,6 +30,21 @@ type EditorItemRow = { path: string | undefined; }; +type EditorListContext = { + sections: Section[]; + scopeFilter: 'user' | 'project' | undefined; + projectState: StateFile; + userState: StateFile; +}; + +type EditorItemInput = { + type: EditorItemRow['type']; + name: string; + section: StateSection; + path: string | undefined; + detectedScope: 'project' | 'user' | undefined; +}; + export default class List extends BaseCommand { static override aliases = ['ls']; @@ -301,13 +316,12 @@ export default class List extends BaseCommand { const jsonResult: Record = {}; for (const { editor, result } of allResults) { - jsonResult[editor] = this.buildEditorJson( - result, + jsonResult[editor] = this.buildEditorJson(result, { sections, scopeFilter, projectState, userState, - ); + }); } this.output.json(jsonResult); return; @@ -319,10 +333,12 @@ export default class List extends BaseCommand { const didPrint = this.printEditorConfig( editor, result, - sections, - scopeFilter, - projectState, - userState, + { + sections, + scopeFilter, + projectState, + userState, + }, printed > 0, ); @@ -343,12 +359,10 @@ export default class List extends BaseCommand { private buildEditorJson( result: Awaited>, - sections: Section[], - scopeFilter: 'user' | 'project' | undefined, - projectState: StateFile, - userState: StateFile, + context: EditorListContext, ): Record { const out: Record = {}; + const { sections, scopeFilter, projectState, userState } = context; if (includesSection(sections, 'mcp') && Object.keys(result.mcp).length > 0) { const items: Record = {}; @@ -440,19 +454,10 @@ export default class List extends BaseCommand { private printEditorConfig( editor: EditorName, result: Awaited>, - sections: Section[], - scopeFilter: 'user' | 'project' | undefined, - projectState: StateFile, - userState: StateFile, + context: EditorListContext, addLeadingBlankLine: boolean, ): boolean { - const rows = this.getEditorItemRows( - result, - sections, - scopeFilter, - projectState, - userState, - ); + const rows = this.getEditorItemRows(result, context); if (rows.length === 0) { return false; @@ -461,32 +466,32 @@ export default class List extends BaseCommand { if (addLeadingBlankLine) { this.output.log(''); } - this.output.log(`${chalk.bold(editor)} ${chalk.dim(`${rows.length} ${rows.length === 1 ? 'item' : 'items'}`)}`); + this.output.log( + `${chalk.bold(editor)} ${chalk.dim(`${rows.length} ${rows.length === 1 ? 'item' : 'items'}`)}`, + ); this.printEditorRows(rows); return true; } private getEditorItemRows( result: Awaited>, - sections: Section[], - scopeFilter: 'user' | 'project' | undefined, - projectState: StateFile, - userState: StateFile, + context: EditorListContext, ): EditorItemRow[] { const rows: EditorItemRow[] = []; + const { sections } = context; if (includesSection(sections, 'mcp')) { rows.push( ...Object.keys(result.mcp).flatMap((name) => this.toEditorItemRow( - 'mcp', - name, - 'mcp', - result.paths.mcp[name], - result.scopes.mcp[name], - scopeFilter, - projectState, - userState, + { + type: 'mcp', + name, + section: 'mcp', + path: result.paths.mcp[name], + detectedScope: result.scopes.mcp[name], + }, + context, ), ), ); @@ -496,14 +501,14 @@ export default class List extends BaseCommand { rows.push( ...result.rules.flatMap((rule) => this.toEditorItemRow( - 'rule', - rule.name, - 'rules', - rule.path ?? result.paths.rules[rule.name], - rule.scope ?? result.scopes.rules[rule.name], - scopeFilter, - projectState, - userState, + { + type: 'rule', + name: rule.name, + section: 'rules', + path: rule.path ?? result.paths.rules[rule.name], + detectedScope: rule.scope ?? result.scopes.rules[rule.name], + }, + context, ), ), ); @@ -513,14 +518,14 @@ export default class List extends BaseCommand { rows.push( ...Object.keys(result.skills).flatMap((name) => this.toEditorItemRow( - 'skill', - name, - 'skills', - result.paths.skills[name], - result.scopes.skills[name], - scopeFilter, - projectState, - userState, + { + type: 'skill', + name, + section: 'skills', + path: result.paths.skills[name], + detectedScope: result.scopes.skills[name], + }, + context, ), ), ); @@ -530,14 +535,14 @@ export default class List extends BaseCommand { rows.push( ...Object.keys(result.prompts).flatMap((name) => this.toEditorItemRow( - 'prompt', - name, - 'prompts', - result.paths.prompts[name], - result.scopes.prompts[name], - scopeFilter, - projectState, - userState, + { + type: 'prompt', + name, + section: 'prompts', + path: result.paths.prompts[name], + detectedScope: result.scopes.prompts[name], + }, + context, ), ), ); @@ -546,16 +551,9 @@ export default class List extends BaseCommand { return rows; } - private toEditorItemRow( - type: EditorItemRow['type'], - name: string, - section: StateSection, - path: string | undefined, - detectedScope: 'project' | 'user' | undefined, - scopeFilter: 'user' | 'project' | undefined, - projectState: StateFile, - userState: StateFile, - ): EditorItemRow[] { + private toEditorItemRow(input: EditorItemInput, context: EditorListContext): EditorItemRow[] { + const { type, name, section, path, detectedScope } = input, + { scopeFilter, projectState, userState } = context; const managed = this.isAixManaged(name, section, projectState, userState), scope = detectedScope ?? managed?.scope; @@ -563,18 +561,23 @@ export default class List extends BaseCommand { return []; } - return [{ - type, - name, - source: managed ? 'aix' : 'external', - scope, - path, - }]; + return [ + { + type, + name, + source: managed ? 'aix' : 'external', + scope, + path, + }, + ]; } private printEditorRows(rows: EditorItemRow[]): void { const typeWidth = Math.max('type'.length, ...rows.map((row) => row.type.length)), - scopeWidth = Math.max('scope'.length, ...rows.map((row) => (row.scope ?? 'unknown').length)), + scopeWidth = Math.max( + 'scope'.length, + ...rows.map((row) => (row.scope ?? 'unknown').length), + ), sourceWidth = Math.max('source'.length, ...rows.map((row) => row.source.length)), nameWidth = Math.max('name'.length, ...rows.map((row) => row.name.length)); diff --git a/packages/cli/src/lib/install-helper.ts b/packages/cli/src/lib/install-helper.ts index d3d1edf..80765e6 100644 --- a/packages/cli/src/lib/install-helper.ts +++ b/packages/cli/src/lib/install-helper.ts @@ -62,7 +62,9 @@ export function formatInstallResults( * Install to configured editors after an add operation. Only installs if editors are configured in * ai.json. Returns info about what was installed. */ -export async function installAfterAdd(options: InstallAfterAddOptions): Promise { +export async function installAfterAdd( + options: InstallAfterAddOptions, +): Promise { const loaded = await loadConfig(options.configPath); if (!loaded) { @@ -86,11 +88,12 @@ export async function installAfterAdd(options: InstallAfterAddOptions): Promise< targetScope = options.scope ?? resolveScope(loaded.config), results = await pMap( editors, - (editor) => installToEditor(editor, loaded.config, projectRoot, { - scopes: options.sections, - configBaseDir: loaded.configBaseDir, - targetScope, - }), + (editor) => + installToEditor(editor, loaded.config, projectRoot, { + scopes: options.sections, + configBaseDir: loaded.configBaseDir, + targetScope, + }), { concurrency: 2 }, ); @@ -116,11 +119,13 @@ export interface InstallItemOptions { * Install a single item directly to editor configs. * Used by add/remove commands for immediate installation without requiring ai.json editors config. */ -export async function installSingleItem(options: InstallItemOptions): Promise { +export async function installSingleItem( + options: InstallItemOptions, +): Promise { const { section, name, value, scope, projectRoot } = options; // Detect editors if not explicitly provided - const editors = options.editors ?? await detectEditors(projectRoot); + const editors = options.editors ?? (await detectEditors(projectRoot)); if (editors.length === 0) { return { installed: false, results: [], editors: [] }; @@ -148,7 +153,8 @@ export async function installSingleItem(options: InstallItemOptions): Promise installToEditor(editor, config, projectRoot, { scopes: [section], targetScope: scope }), + (editor) => + installToEditor(editor, config, projectRoot, { scopes: [section], targetScope: scope }), { concurrency: 2 }, ); diff --git a/packages/core/src/__tests__/state-tracker.test.ts b/packages/core/src/__tests__/state-tracker.test.ts index 26ebc3d..66276d0 100644 --- a/packages/core/src/__tests__/state-tracker.test.ts +++ b/packages/core/src/__tests__/state-tracker.test.ts @@ -103,7 +103,13 @@ describe('writeState / readState roundtrip', () => { describe('trackInstall', () => { it('adds a new entry', async () => { - await trackInstall('project', 'mcp', 'my-server', ['claude-code', 'cursor'], testDir); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'my-server', + editors: ['claude-code', 'cursor'], + projectRoot: testDir, + }); const state = await readState('project', testDir); @@ -112,12 +118,24 @@ describe('trackInstall', () => { }); it('updates an existing entry and merges editors', async () => { - await trackInstall('project', 'mcp', 'my-server', ['claude-code'], testDir); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'my-server', + editors: ['claude-code'], + projectRoot: testDir, + }); // Small delay so updatedAt differs await new Promise((r) => setTimeout(r, 10)); - await trackInstall('project', 'mcp', 'my-server', ['cursor'], testDir); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'my-server', + editors: ['cursor'], + projectRoot: testDir, + }); const state = await readState('project', testDir); const item = state.installed.mcp['my-server']!; @@ -130,7 +148,13 @@ describe('trackInstall', () => { describe('trackRemoval', () => { it('removes a tracked entry', async () => { - await trackInstall('project', 'mcp', 'my-server', ['claude-code'], testDir); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'my-server', + editors: ['claude-code'], + projectRoot: testDir, + }); await trackRemoval('project', 'mcp', 'my-server', testDir); const state = await readState('project', testDir); @@ -149,8 +173,20 @@ describe('trackRemoval', () => { describe('getInstalledNames / getInstalledItem', () => { it('lists installed names', async () => { - await trackInstall('project', 'mcp', 'a', ['cursor'], testDir); - await trackInstall('project', 'mcp', 'b', ['cursor'], testDir); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'a', + editors: ['cursor'], + projectRoot: testDir, + }); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'b', + editors: ['cursor'], + projectRoot: testDir, + }); const state = await readState('project', testDir); @@ -158,7 +194,13 @@ describe('getInstalledNames / getInstalledItem', () => { }); it('returns item metadata', async () => { - await trackInstall('project', 'rules', 'style-guide', ['claude-code'], testDir); + await trackInstall({ + scope: 'project', + section: 'rules', + name: 'style-guide', + editors: ['claude-code'], + projectRoot: testDir, + }); const state = await readState('project', testDir); const item = getInstalledItem(state, 'rules', 'style-guide'); @@ -176,9 +218,27 @@ describe('getInstalledNames / getInstalledItem', () => { describe('detectRemovedItems', () => { it('detects items in state but not in config', async () => { - await trackInstall('project', 'mcp', 'a', ['cursor'], testDir); - await trackInstall('project', 'mcp', 'b', ['cursor'], testDir); - await trackInstall('project', 'mcp', 'c', ['cursor'], testDir); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'a', + editors: ['cursor'], + projectRoot: testDir, + }); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'b', + editors: ['cursor'], + projectRoot: testDir, + }); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'c', + editors: ['cursor'], + projectRoot: testDir, + }); const state = await readState('project', testDir); const removed = detectRemovedItems(state, 'mcp', ['a', 'c']); @@ -187,7 +247,13 @@ describe('detectRemovedItems', () => { }); it('returns empty when nothing was removed', async () => { - await trackInstall('project', 'mcp', 'a', ['cursor'], testDir); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'a', + editors: ['cursor'], + projectRoot: testDir, + }); const state = await readState('project', testDir); @@ -197,7 +263,13 @@ describe('detectRemovedItems', () => { describe('detectNewItems', () => { it('detects items in config but not in state', async () => { - await trackInstall('project', 'mcp', 'a', ['cursor'], testDir); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'a', + editors: ['cursor'], + projectRoot: testDir, + }); const state = await readState('project', testDir); const newItems = detectNewItems(state, 'mcp', ['a', 'b', 'c']); @@ -208,8 +280,20 @@ describe('detectNewItems', () => { describe('syncSectionState', () => { it('replaces section state with current names', async () => { - await trackInstall('project', 'mcp', 'old-server', ['cursor'], testDir); - await syncSectionState('project', 'mcp', ['new-a', 'new-b'], ['claude-code'], testDir); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'old-server', + editors: ['cursor'], + projectRoot: testDir, + }); + await syncSectionState({ + scope: 'project', + section: 'mcp', + names: ['new-a', 'new-b'], + editors: ['claude-code'], + projectRoot: testDir, + }); const state = await readState('project', testDir); @@ -218,7 +302,13 @@ describe('syncSectionState', () => { }); it('preserves existing metadata for items that remain', async () => { - await trackInstall('project', 'mcp', 'keep', ['cursor'], testDir); + await trackInstall({ + scope: 'project', + section: 'mcp', + name: 'keep', + editors: ['cursor'], + projectRoot: testDir, + }); const before = await readState('project', testDir); const originalInstall = before.installed.mcp['keep']!.installedAt; @@ -226,7 +316,13 @@ describe('syncSectionState', () => { // Small delay to ensure updatedAt differs await new Promise((r) => setTimeout(r, 10)); - await syncSectionState('project', 'mcp', ['keep', 'new'], ['claude-code'], testDir); + await syncSectionState({ + scope: 'project', + section: 'mcp', + names: ['keep', 'new'], + editors: ['claude-code'], + projectRoot: testDir, + }); const state = await readState('project', testDir); diff --git a/packages/core/src/state/tracker.ts b/packages/core/src/state/tracker.ts index 903a060..3a832b7 100644 --- a/packages/core/src/state/tracker.ts +++ b/packages/core/src/state/tracker.ts @@ -5,6 +5,30 @@ import { join, dirname } from 'pathe'; import type { ConfigScope } from '@a1st/aix-schema'; import type { StateFile, StateSection, InstalledItemMeta, InstalledItems } from './types.js'; +export interface TrackInstallOptions { + scope: ConfigScope; + section: StateSection; + name: string; + editors: string[]; + projectRoot?: string; +} + +export interface SyncSectionStateOptions { + scope: ConfigScope; + section: StateSection; + names: string[]; + editors: string[]; + projectRoot?: string; +} + +type TrackInstallArgs = + | [options: TrackInstallOptions] + | [scope: ConfigScope, section: StateSection, name: string, editors: string[], projectRoot?: string]; + +type SyncSectionStateArgs = + | [options: SyncSectionStateOptions] + | [scope: ConfigScope, section: StateSection, names: string[], editors: string[], projectRoot?: string]; + /** * Resolve the state file path for a given scope. * - project → `/.aix/state.json` @@ -67,13 +91,8 @@ export async function writeState( /** * Record that an item was installed. Creates or updates the entry. */ -export async function trackInstall( - scope: ConfigScope, - section: StateSection, - name: string, - editors: string[], - projectRoot?: string, -): Promise { +export async function trackInstall(...args: TrackInstallArgs): Promise { + const { scope, section, name, editors, projectRoot } = normalizeTrackInstallArgs(args); const state = await readState(scope, projectRoot), now = new Date().toISOString(), existing = state.installed[section][name]; @@ -164,13 +183,8 @@ export function detectNewItems( /** * Replace the entire installed set for a section. Useful after a full install pass. */ -export async function syncSectionState( - scope: ConfigScope, - section: StateSection, - names: string[], - editors: string[], - projectRoot?: string, -): Promise { +export async function syncSectionState(...args: SyncSectionStateArgs): Promise { + const { scope, section, names, editors, projectRoot } = normalizeSyncSectionStateArgs(args); const state = await readState(scope, projectRoot), now = new Date().toISOString(); @@ -186,3 +200,23 @@ export async function syncSectionState( state.installed[section] = updated; await writeState(state, scope, projectRoot); } + +function normalizeTrackInstallArgs(args: TrackInstallArgs): TrackInstallOptions { + if (typeof args[0] === 'object') { + return args[0]; + } + + const [scope, section, name, editors, projectRoot] = args; + + return { scope, section, name, editors, projectRoot }; +} + +function normalizeSyncSectionStateArgs(args: SyncSectionStateArgs): SyncSectionStateOptions { + if (typeof args[0] === 'object') { + return args[0]; + } + + const [scope, section, names, editors, projectRoot] = args; + + return { scope, section, names, editors, projectRoot }; +} From 2d322d2a4980b080dde267478246c2ced618a361 Mon Sep 17 00:00:00 2001 From: Matt Luedke Date: Wed, 22 Apr 2026 17:21:42 -0400 Subject: [PATCH 03/12] docs: add Gemini project instructions --- GEMINI.md | 1319 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1319 insertions(+) diff --git a/GEMINI.md b/GEMINI.md index 47921ae..0ebdf11 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -60,3 +60,1322 @@ The project is structured as a TypeScript monorepo using npm workspaces. - `packages/schema/src/config.ts`: Defines the main `AiJsonConfig` schema. - `packages/cli/src/commands/`: Implementation of CLI commands (init, add, install, etc.). - `packages/core/src/loader.ts`: Logic for loading and merging `ai.json` files. + + +## general + +## Core Principles + +* Readability and clarity over brevity +* Follow existing patterns in the codebase before inventing new ones +* Do deep research to find existing libraries (NPM, GitHub, Cargo, etc.) that solve + problems instead of writing code + * Code writing is a last resort +* Separate formatting-only changes from functional changes + +## Working Habits + +* Be critical and thorough. Prefer truth and direct feedback over politeness +* Look around and use existing patterns and code when possible. Look for: + * Similar components and use their patterns + * Library code you can reuse + * Existing dependencies from package.json or Cargo.toml that you should use +* Always consider the developer experience: + * Am I placing a burden on the developer with this change? + * Is it as easy to use / execute / import / configure as possible? +* When making _any_ changes: + * Consider the impact on other parts of the codebase + * What tests, documentation, etc. needs to be updated? + * Search for other files that should be changed after what you just did + * How has the context changed now that I've made this change? + * Should I refactor the code to introduce an abstraction to make it more + maintainable? + * Should I delete anything that's now unused? +* Check your work after you finish a task: + * Did I address everything I was asked to? + * Run `npm run standards` (or `tsc` / `eslint` / `commitlint` / `markdownlint` / + `cargo lint-clippy && cargo lint-fmt` as appropriate) + * Test significant changes by: + * Running the tests + * Running the app and manually testing the changes (Tauri MCP/CLI or Playwright MCP/CLI) + +## Naming Conventions (General) + +* Use PascalCase for classes +* Use camelCase for variables, instance functions, and methods +* Use snake_case for static functions +* Use kebab-case for file and directory names +* Use UPPERCASE for environment variables +* Files exporting classes: PascalCase.js (e.g., `User.js`) +* Files exporting functions/objects: kebab-case.js (e.g., `my-function.js`) +* Tests: `ClassTheyAreTesting.test.js` +* Avoid magic numbers and define constants +* When it has an acronym or initialism, use all lowercase or all caps, never mixed-case: + * `url` or `URL`, _never_ `Url` + * `id` or `ID`, _never_ `Id`. Prefer `ID` over `id` when writing docs/sentences + unless documenting a third-party entity or when specifically referring to a code + object with that exact casing (parameter, variable, etc.) + +## Formatting Rules + +### Indentation + +* **3 spaces** (never tabs) +* Wrapped lines: indent one level from first line +* Chained functions: indent one level from chain start + +### Braces & Structure + +* Opening brace at end of line (K&R style) +* Always use braces for conditionals/loops (even single line) +* One blank line between unrelated statements +* One-two blank lines between functions + +### Spacing & Operators + +* Space after control structures: `if (condition)` +* No space between function name and parenthesis: `myFunction()` +* No space between `catch` and parentheses: `catch(error) {` +* Space around operators (except unary: `!`, `++`, `--`) +* Space after commas in arrays/arguments +* Spaces inside array brackets: `[ 'item' ]` and object braces: `{ key: 'value' }` +* Empty arrays/objects: no spaces (`[]`, `{}`) +* Multi-line arrays/objects: always trailing comma + +## Control Structures + +* Avoid deep nesting (low cyclomatic complexity) +* Most common case in `if` (not `else`) +* Use positive logic over negative +* Break complex conditions into variables/functions +* Check error conditions early with early returns +* Do not add defensive empty checks before operations that naturally handle empty inputs + +## Variable Best Practices + +* Declare in lowest possible scope +* Declare at top of scope before statements +* Initialized variables before uninitialized +* Avoid modifying input parameters (except immediate sanitization) +* Always sanitize user input +* Prefer immutability (`readonly`, `as const`, `const`) + +## Function Documentation + +* Document the function purpose in JSDoc format +* Document types or parameters if they are not obvious from the code +* Omit JSDoc entirely when the function name already conveys its purpose + (e.g., do not add `/** Creates the foo */` to `createFoo()`) +* Add JSDoc comments to enum values when the name alone doesn't convey the + domain-specific meaning + +## Comments + +* Only add a comment if: + * The code's rationale is not obvious from naming/context + * The comment answers "why," NOT "what" or "how" + * The surrounding code uses comments in a similar way +* Do not comment on types, parameters, or usage that are clear from code or naming +* Use ASCII in comments, never unicode symbols + +## File Standards + +* End with newline character (not blank line) +* No Windows line endings +* No commented-out code without reason +* Ternary operator only for simple conditions + +## Front-End Development + +* Pay attention to the current version of the component, and use a similar pattern as + set by existing elements +* Consider accessibility / a11y +* Create reusable components rather than ad-hoc solutions + +## Library Usage + +* Use existing libraries to the fullest extent possible +* Always verify function signatures before using + +## css-scss + +* Always use SCSS, not CSS +* If using a component library, use the component's existing props or built-in options + over custom styling. Reuse appropriate CSS classes +* If none are available, prefer pre-existing utility classes over custom styling +* Avoid ad-hoc CSS unless absolutely necessary +* Consider adding a custom utility class to the global SCSS if a pattern is used in + multiple places (e.g. text truncation, screen reader text, grid patterns) +* Use CSS logical properties: `margin-inline-start`, not `margin-left`; + `text-align: start`, not `text-align: left` +* Use CSS variables for theme-able values + +## ABSOLUTELY DO NOT + +* DO NOT use `@extend` +* DO NOT override `line-height` to values other than `1` unless you have a very good + reason + +## SCSS API + +If the project auto-injects SCSS namespaces via its build config (e.g. Vite's +`css.preprocessorOptions`), these are generally libraries and you should prefer these +mixins/vars/functions over hard-coded values. + +Check the project's `vite.config.ts` or equivalent to see what is available. + +## Style Block Structure + +Vue components have **two** optional ` + + +``` + +Usage: + +```ts +import { MyComponentProps }, MyComponent from './MyComponent.vue'; +``` + +## Props + +### TypeScript interface props (preferred) + +Define a TypeScript interface in the non-setup ` + + +``` + +### Discriminated union props + +When a component has mutually-exclusive prop combinations, define separate interfaces and +combine them with a union type. Use `never` to exclude invalid combinations: + +```ts +export interface IconOnlyButtonProps extends BaseButtonProps { + icon: IconName; + label?: never; + ariaLabel: string; // Required when no visible label +} + +export interface LabelOnlyButtonProps extends BaseButtonProps { + icon?: never; + label: string; + ariaLabel?: string; +} + +export type ButtonProps = IconOnlyButtonProps | LabelOnlyButtonProps; +``` + +### `defineModel` + +For v-model bindings, use `defineModel` (Vue 3.4+): + +```ts +const modelValue = defineModel({ default: false }); +``` + +In the template: + +```vue + +``` + +## Emits + +Unlike props and slots interfaces, emits may be defined inline: + +```ts +defineEmits<{ + 'update:selected': [value: boolean]; + select: [event: Event]; +}>(); +``` + +## Slots + +Define slot types in the non-setup ` + + +``` + +### Slot props + +Pass CSS classes and internal state to slot consumers via `v-bind`: + +```vue + + + + + +

{{ title }}

+
+``` + +### Checking slot existence + +Use `useSlots()` or `$slots` to conditionally render wrapper elements: + +```ts +const slots = useSlots(); +``` + +```vue +
+ +
+``` + +## VueUse + +Use `@vueuse/core` (and `@vueuse/components` if installed) instead of raw browser APIs. +VueUse composables handle lifecycle cleanup automatically and are SSR-safe. + +| Instead of | Use | +| --- | --- | +| `addEventListener` / `removeEventListener` | `useEventListener()` | +| `setTimeout` / `clearTimeout` | `useTimeoutFn()` | +| `setInterval` / `clearInterval` | `useIntervalFn()` | +| `new ResizeObserver()` | `useResizeObserver()` | +| `new IntersectionObserver()` | `useIntersectionObserver()` | +| `window.matchMedia()` | `useMediaQuery()` | +| Manual scroll position tracking | `useScroll()` | +| `lodash.debounce` / hand-rolled debounce | `useDebounceFn()` | +| `getComputedStyle` / manual CSS var reads | `useCssVar()` | + +Other commonly used composables: `onClickOutside()`, `useElementSize()`, `useFocusTrap()`, +`useVModel()`. + +## Template Refs + +Use `useTemplateRef` (Vue 3.5+): + +```ts +const thumbnail = useTemplateRef('thumbnail'); +``` + +## Component Composition + +### Dynamic element rendering + +Use `` to switch between element types based on props: + +```ts +const elementType = computed(() => { + return props.href ? 'a' : 'button'; +}); +``` + +```vue + + + +``` + +If using a headless component library (e.g. Reka UI, Radix Vue), consider its +`` component for root-level elements — it provides `asChild` for composability +without extra wrapper elements in the DOM. + +### `inheritAttrs` + +When a component needs manual control over where `$attrs` are applied, disable automatic +attribute inheritance and spread attrs explicitly: + +```ts +defineOptions({ inheritAttrs: false }); +``` + +```vue + +``` + +## Exports + +### Component index + +Every component should be exported from the component index with both a named default +export and a wildcard re-export (for types): + +```ts +export * from './Button.vue'; +export { default as Button } from './Button.vue'; +``` + +### Type exports + +Export all public interfaces, type aliases, and constants from the non-setup ` + + + + + + +``` + +Usage: + +```ts +import { MyComponentProps }, MyComponent from './MyComponent.vue'; +``` + +## Props + +### TypeScript interface props (preferred) + +Define a TypeScript interface in the non-setup ` + + +``` + +### Discriminated union props + +When a component has mutually-exclusive prop combinations, define separate interfaces and +combine them with a union type. Use `never` to exclude invalid combinations: + +```ts +export interface IconOnlyButtonProps extends BaseButtonProps { + icon: IconName; + label?: never; + ariaLabel: string; // Required when no visible label +} + +export interface LabelOnlyButtonProps extends BaseButtonProps { + icon?: never; + label: string; + ariaLabel?: string; +} + +export type ButtonProps = IconOnlyButtonProps | LabelOnlyButtonProps; +``` + +### `defineModel` + +For v-model bindings, use `defineModel` (Vue 3.4+): + +```ts +const modelValue = defineModel({ default: false }); +``` + +In the template: + +```vue + +``` + +## Emits + +Unlike props and slots interfaces, emits may be defined inline: + +```ts +defineEmits<{ + 'update:selected': [value: boolean]; + select: [event: Event]; +}>(); +``` + +## Slots + +Define slot types in the non-setup ` + + +``` + +### Slot props + +Pass CSS classes and internal state to slot consumers via `v-bind`: + +```vue + + + + + +

{{ title }}

+
+``` + +### Checking slot existence + +Use `useSlots()` or `$slots` to conditionally render wrapper elements: + +```ts +const slots = useSlots(); +``` + +```vue +
+ +
+``` + +## VueUse + +Use `@vueuse/core` (and `@vueuse/components` if installed) instead of raw browser APIs. +VueUse composables handle lifecycle cleanup automatically and are SSR-safe. + +| Instead of | Use | +| --- | --- | +| `addEventListener` / `removeEventListener` | `useEventListener()` | +| `setTimeout` / `clearTimeout` | `useTimeoutFn()` | +| `setInterval` / `clearInterval` | `useIntervalFn()` | +| `new ResizeObserver()` | `useResizeObserver()` | +| `new IntersectionObserver()` | `useIntersectionObserver()` | +| `window.matchMedia()` | `useMediaQuery()` | +| Manual scroll position tracking | `useScroll()` | +| `lodash.debounce` / hand-rolled debounce | `useDebounceFn()` | +| `getComputedStyle` / manual CSS var reads | `useCssVar()` | + +Other commonly used composables: `onClickOutside()`, `useElementSize()`, `useFocusTrap()`, +`useVModel()`. + +## Template Refs + +Use `useTemplateRef` (Vue 3.5+): + +```ts +const thumbnail = useTemplateRef('thumbnail'); +``` + +## Component Composition + +### Dynamic element rendering + +Use `` to switch between element types based on props: + +```ts +const elementType = computed(() => { + return props.href ? 'a' : 'button'; +}); +``` + +```vue + + + +``` + +If using a headless component library (e.g. Reka UI, Radix Vue), consider its +`` component for root-level elements — it provides `asChild` for composability +without extra wrapper elements in the DOM. + +### `inheritAttrs` + +When a component needs manual control over where `$attrs` are applied, disable automatic +attribute inheritance and spread attrs explicitly: + +```ts +defineOptions({ inheritAttrs: false }); +``` + +```vue + +``` + +## Exports + +### Component index + +Every component should be exported from the component index with both a named default +export and a wildcard re-export (for types): + +```ts +export * from './Button.vue'; +export { default as Button } from './Button.vue'; +``` + +### Type exports + +Export all public interfaces, type aliases, and constants from the non-setup `