diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..32f8ccec --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,27 @@ +{ + "name": "syncable", + "owner": { + "name": "Syncable", + "email": "support@syncable.dev" + }, + "metadata": { + "description": "Syncable CLI skills for AI coding agents — project analysis, security, vulnerabilities, dependencies, IaC validation, and cloud deployment.", + "version": "0.1.0" + }, + "plugins": [ + { + "name": "syncable-cli-skills", + "source": "./installer/plugins/syncable-cli-skills", + "description": "Syncable CLI skills for project analysis, security scanning, vulnerability detection, dependency auditing, IaC validation, Kubernetes optimization, and cloud deployment.", + "version": "0.1.0", + "author": { + "name": "Syncable", + "email": "support@syncable.dev" + }, + "homepage": "https://syncable.dev", + "repository": "https://github.com/syncable-dev/syncable-cli", + "license": "MIT", + "keywords": ["syncable", "devops", "security", "deployment", "kubernetes", "docker", "iac"] + } + ] +} diff --git a/installer-investigation-report.md b/installer-investigation-report.md new file mode 100644 index 00000000..cb1fa0a5 --- /dev/null +++ b/installer-investigation-report.md @@ -0,0 +1,358 @@ +# Syncable CLI Installer Investigation Report + +**Date:** March 29, 2026 +**Scope:** `npx syncable-cli-skills` installation failures across Claude Code, Gemini CLI, and Codex + +--- + +## Executive Summary + +After investigating the installer code against the official documentation for all three agents, I identified **5 critical bugs** and **3 UX problems** that explain the user-reported failures. The root causes fall into three categories: + +1. **Claude Code:** The installer uses an undocumented/incorrect plugin registration method. It writes directly to internal JSON files instead of using the official CLI or settings system. Users must manually enable the plugin because the installer never actually registers it correctly. + +2. **Gemini CLI:** The installer writes skills to the wrong directory (`~/.gemini/antigravity/skills/`). Gemini CLI discovers skills from `~/.gemini/skills/` (user-level) or `.gemini/skills/` (project-level), not from a profile subdirectory. The "antigravity" profile path is not a documented skill location. + +3. **All agents:** `sync-ctl` installs to `~/.cargo/bin/` but agents spawn fresh shells that may not have this directory in PATH. The installer only verifies sync-ctl within its own Node.js process (where it temporarily adds cargo/bin to PATH), creating a false positive. + +--- + +## Bug #1: Claude Code Plugin Registration is Completely Wrong + +### What the installer does (WRONG) + +The installer (`transformers/claude.ts`) manually writes to three internal files: + +``` +~/.claude/plugins/cache/syncable/syncable-cli-skills/0.1.0/skills/... +~/.claude/plugins/installed_plugins.json +~/.claude/plugins/known_marketplaces.json +``` + +This is the `installClaudePlugin()` function at line 143 of `transformers/claude.ts`. It creates a `plugin.json` manifest, writes skill files to a cache directory, then directly manipulates `installed_plugins.json` and `known_marketplaces.json`. + +### Why this is wrong + +According to the official Claude Code documentation: + +- **Plugins are installed via CLI:** `claude plugin install plugin-name@marketplace-name` +- **Plugins are enabled via `enabledPlugins` in `~/.claude/settings.json`:** `{"enabledPlugins": {"syncable-cli-skills@syncable": true}}` +- **Marketplace plugins are cached to `~/.claude/plugins/cache/`** but this is an internal mechanism managed by Claude Code itself, not meant to be written to directly +- **There is no file called `installed_plugins.json`** in the documented plugin system. The installer invented this file. Plugins are tracked through `enabledPlugins` in settings.json +- **`known_marketplaces.json`** is not the documented way to register marketplaces. The correct method is either `claude plugin marketplace add` or `extraKnownMarketplaces` in `.claude/settings.json` + +### Why users have to manually enable + +Because the installer writes to non-standard files that Claude Code doesn't actually read for plugin activation. The plugin files exist on disk but Claude Code doesn't know they're "enabled" because `enabledPlugins` in `settings.json` was never updated. + +### The fix + +**Option A (Recommended): Use the CLI for programmatic installation** + +```bash +# Register the marketplace +claude plugin marketplace add syncable-dev/syncable-cli + +# Install the plugin +claude plugin install syncable-cli-skills@syncable --scope user +``` + +This is the documented, supported way. It handles caching, registration, and enabling all at once. + +**Option B: Write to settings.json directly** + +If you need to bypass the CLI (e.g., Claude Code isn't running), write the plugin to the cache AND update `~/.claude/settings.json`: + +```json +{ + "enabledPlugins": { + "syncable-cli-skills@syncable": true + } +} +``` + +But you'd still need the marketplace registered properly. Option A is much safer. + +**Option C: Use `--plugin-dir` for local plugins** + +If the goal is to load a local plugin without a marketplace: + +```bash +claude --plugin-dir ~/.local/share/syncable/plugin +``` + +This works for development/testing but isn't persistent across sessions. + +--- + +## Bug #2: Gemini CLI Skills Go to the Wrong Directory + +### What the installer does (WRONG) + +The installer (`agents/gemini.ts`, line 12-38) searches for a `~/.gemini/antigravity/skills/` directory, or any profile subdirectory with a `skills/` folder: + +```typescript +function findGeminiSkillsDir(): string { + const antigravitySkills = path.join(geminiDir, 'antigravity', 'skills'); + if (fs.existsSync(antigravitySkills)) { + return antigravitySkills; // WRONG PATH + } + // Falls back to: ~/.gemini/antigravity/skills/ +} +``` + +### Why this is wrong + +According to the official Gemini CLI documentation, skills are discovered from these locations (in precedence order): + +1. **Workspace skills:** `.gemini/skills/` or `.agents/skills/` (project-level) +2. **User skills:** `~/.gemini/skills/` or `~/.agents/skills/` (global) +3. **Extension skills:** bundled within installed extensions + +There is **no profile subdirectory** in the skill discovery path. `~/.gemini/antigravity/` is not a documented skill location. The correct user-level path is simply `~/.gemini/skills/`. + +### Why it works for some users but not others + +If a user happens to have configured Gemini with custom profile settings that somehow include the "antigravity" path, it might work. But for default Gemini CLI installations, skills placed in `~/.gemini/antigravity/skills/` are invisible to Gemini because it only scans `~/.gemini/skills/`. + +### The fix + +Update `agents/gemini.ts`: + +```typescript +export const geminiAgent: AgentConfig = { + name: 'gemini', + displayName: 'Gemini CLI', + installType: 'global', + detect: async () => { + return fs.existsSync(path.join(os.homedir(), '.gemini')) + || await commandExists('gemini'); + }, + getSkillPath: () => { + // Gemini CLI discovers user skills from ~/.gemini/skills/ + // The .agents/skills/ alias also works but .gemini/skills/ is primary + return path.join(os.homedir(), '.gemini', 'skills'); + }, +}; +``` + +Remove the entire `findGeminiSkillsDir()` function with its profile/antigravity logic. + +--- + +## Bug #3: sync-ctl PATH Not Available to Agents + +### The problem + +When the installer runs `cargo install syncable-cli`, the binary goes to `~/.cargo/bin/sync-ctl`. The installer then calls `prependCargoToPath()` which does: + +```typescript +export function prependCargoToPath(): void { + process.env.PATH = `${cargoBinDir()}${path.delimiter}${process.env.PATH}`; +} +``` + +This only modifies the **current Node.js process** PATH. When the installer later runs `sync-ctl --version` to verify, it succeeds because the installer's own process has the modified PATH. But when an agent (Claude, Gemini, Codex) spawns a shell to run a skill command, that shell gets its PATH from the user's shell profile (`.bashrc`, `.zshrc`, `.profile`). If Rust was just installed or `~/.cargo/bin` isn't in their shell profile, `sync-ctl: command not found`. + +This is exactly the bug users report: `which sync-ctl` works in their terminal (because their terminal has sourced their profile) but the agent says it's not available (because the agent's shell may have a different PATH, or the user opened a new terminal without sourcing the profile after installing Rust). + +### The fix + +Add a post-install verification AND a PATH setup helper: + +```typescript +// After installing sync-ctl, verify it's accessible from a fresh shell +async function verifySyncCtlInPath(): Promise { + try { + // Spawn a fresh login shell to check if sync-ctl is in the default PATH + const shell = process.env.SHELL || '/bin/bash'; + await execCommand(`${shell} -l -c "which sync-ctl"`); + return true; + } catch { + return false; + } +} + +// If not in PATH, offer to create a symlink in /usr/local/bin +async function ensureSyncCtlInPath(): Promise { + const inPath = await verifySyncCtlInPath(); + if (inPath) return; + + const syncCtlPath = path.join(cargoBinDir(), 'sync-ctl'); + if (!fs.existsSync(syncCtlPath)) return; + + console.log(chalk.yellow('\n sync-ctl is installed but not in your shell PATH.')); + console.log(chalk.yellow(' AI agents may not be able to find it.\n')); + + // Option 1: Symlink to /usr/local/bin + const { fix } = await inquirer.prompt([{ + type: 'list', + name: 'fix', + message: 'How would you like to fix this?', + choices: [ + { name: 'Create symlink in /usr/local/bin (recommended)', value: 'symlink' }, + { name: 'Add ~/.cargo/bin to shell profile', value: 'profile' }, + { name: 'Skip (I will fix it manually)', value: 'skip' }, + ], + }]); + + if (fix === 'symlink') { + try { + await execCommand(`sudo ln -sf ${syncCtlPath} /usr/local/bin/sync-ctl`); + console.log(chalk.green(' Symlink created successfully.')); + } catch { + console.log(chalk.red(' Failed to create symlink. Try manually:')); + console.log(chalk.dim(` sudo ln -sf ${syncCtlPath} /usr/local/bin/sync-ctl`)); + } + } else if (fix === 'profile') { + const shellProfile = getShellProfile(); + const line = 'export PATH="$HOME/.cargo/bin:$PATH"'; + try { + fs.appendFileSync(shellProfile, `\n${line}\n`); + console.log(chalk.green(` Added to ${shellProfile}. Restart your terminal.`)); + } catch { + console.log(chalk.red(` Failed. Add this to ${shellProfile} manually:`)); + console.log(chalk.dim(` ${line}`)); + } + } +} +``` + +Additionally, **each skill's SKILL.md should include a fallback PATH** so the agent tries `~/.cargo/bin` explicitly: + +```markdown +## Prerequisites +- sync-ctl binary (check with: `~/.cargo/bin/sync-ctl --version` or `sync-ctl --version`) + +If sync-ctl is not found, try: `export PATH="$HOME/.cargo/bin:$PATH"` then retry. +``` + +--- + +## Bug #4: Codex Skills Require `--enable skills` Flag + +### The problem + +The installer places skills in `~/.agents/skills/` which is correct per the Codex documentation. However, Codex requires the user to explicitly run with `--enable skills` for skills to be active: + +> "You have to run Codex with the `--enable skills` option." + +The installer never tells users this. They install skills, open Codex, and the skills don't work because they're not enabled. + +### The fix + +Add a post-install message for Codex users: + +```typescript +if (agent.name === 'codex') { + console.log(chalk.cyan('\n NOTE: To use skills in Codex, run:')); + console.log(chalk.cyan(' codex --enable skills')); + console.log(chalk.cyan(' Or invoke explicitly with: $syncable-analyze\n')); +} +``` + +--- + +## Bug #5: Gemini SKILL.md Format Missing Required Fields + +### What the installer produces + +The Gemini transformer (`transformers/gemini.ts`) produces: + +```markdown +--- +name: syncable-analyze +description: Run sync-ctl analyze for project analysis... +--- + +[skill body] +``` + +### What Gemini CLI expects + +Per the documentation, Gemini CLI loads the **name and description** from frontmatter at startup and injects them into the system prompt. The model then decides whether to activate a skill. This format is actually correct for basic discovery. + +However, the skill names use `syncable-` prefix which creates directory names like `syncable-analyze/SKILL.md`. This is fine structurally, but the descriptions in the skills should be more explicit about what triggers them, since Gemini only loads name+description initially and activates the full SKILL.md on demand. + +### Minor fix + +Ensure descriptions are optimized for Gemini's lazy-loading behavior (name + description only at startup): + +```typescript +// In transformers/gemini.ts, ensure description is activation-friendly +const content = `--- +name: "${skillName}" +description: "${skill.frontmatter.description}" +--- + +${skill.body}`; +``` + +--- + +## UX Problem #1: No Post-Install Verification + +The installer shows "Setup complete!" without verifying that the skills actually work. It should: + +1. Verify sync-ctl is accessible from a fresh shell +2. Verify skill files exist in the correct agent directories +3. For Claude Code: verify the plugin appears in `enabledPlugins` +4. Print agent-specific instructions (like Codex's `--enable skills`) + +--- + +## UX Problem #2: No Error Recovery + +If the installation partially fails (e.g., skills install for Claude but Gemini path is wrong), there's no way for users to diagnose what went wrong. The `status` command only counts files, it doesn't verify they're in the right place or format. + +### Suggested improvement + +Add a `syncable-cli-skills doctor` command that: +- Checks if sync-ctl is in PATH (from a fresh shell, not the current process) +- For each agent, verifies skills are in the documented discovery paths +- For Claude Code, checks if the plugin is actually enabled in settings.json +- For Codex, checks if `--enable skills` configuration exists + +--- + +## UX Problem #3: Claude Plugin Requires Manual Enable + +Even if the plugin registration is fixed (Bug #1), the current UX flow is: + +1. User runs `npx syncable-cli-skills` +2. Installer says "Setup complete!" +3. User opens Claude Code +4. Skills don't work +5. User has to figure out they need to go to `/plugin` and enable it + +### The ideal flow + +1. User runs `npx syncable-cli-skills` +2. Installer detects Claude Code is installed +3. Installer runs `claude plugin install syncable-cli-skills@syncable` (which auto-enables) +4. Installer confirms: "Skills installed and enabled for Claude Code" +5. User opens Claude Code, skills work immediately + +--- + +## Summary of Required Code Changes + +| File | Bug | Change Required | +|------|-----|----------------| +| `transformers/claude.ts` | #1 | Replace `installClaudePlugin()` with `claude plugin install` CLI call or write to `enabledPlugins` in `settings.json` | +| `agents/gemini.ts` | #2 | Change `getSkillPath()` to return `~/.gemini/skills/` (remove `findGeminiSkillsDir` and all antigravity/profile logic) | +| `src/index.ts` | #3 | Add `verifySyncCtlInPath()` post-install check using a fresh login shell | +| `src/index.ts` | #3 | Add PATH fix helper (symlink or profile edit) | +| `src/index.ts` | #4 | Add Codex-specific post-install message about `--enable skills` | +| `skills/*.md` | #3 | Add `~/.cargo/bin/sync-ctl` fallback path in prerequisites | +| New: `doctor` command | UX | Add diagnostic command to verify installation health | + +--- + +## Priority Order + +1. **Bug #1 (Claude plugin)** - Highest impact, affects all Claude Code users +2. **Bug #2 (Gemini path)** - Affects all Gemini CLI users +3. **Bug #3 (PATH issue)** - Affects users who freshly install Rust +4. **Bug #4 (Codex enable)** - Simple fix, just needs a message +5. **UX improvements** - Important but not blocking diff --git a/installer/.claude-plugin/marketplace.json b/installer/.claude-plugin/marketplace.json index f97440a9..4e810e0a 100644 --- a/installer/.claude-plugin/marketplace.json +++ b/installer/.claude-plugin/marketplace.json @@ -12,8 +12,16 @@ { "name": "syncable-cli-skills", "source": "./plugins/syncable-cli-skills", - "description": "Syncable CLI skills for project analysis, security scanning, vulnerability detection, dependency auditing, IaC validation, and cloud deployment.", - "version": "0.1.0" + "description": "Syncable CLI skills for project analysis, security scanning, vulnerability detection, dependency auditing, IaC validation, Kubernetes optimization, and cloud deployment.", + "version": "0.1.0", + "author": { + "name": "Syncable", + "email": "support@syncable.dev" + }, + "homepage": "https://syncable.dev", + "repository": "https://github.com/syncable-dev/syncable-cli", + "license": "MIT", + "keywords": ["syncable", "devops", "security", "deployment", "kubernetes", "docker", "iac"] } ] } diff --git a/installer/package-lock.json b/installer/package-lock.json index d877486f..9d91be5e 100644 --- a/installer/package-lock.json +++ b/installer/package-lock.json @@ -1,12 +1,13 @@ { "name": "syncable-cli-skills", - "version": "0.1.0", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "syncable-cli-skills", - "version": "0.1.0", + "version": "0.1.3", + "license": "GPL-3.0", "dependencies": { "chalk": "^5.0.0", "commander": "^12.0.0", diff --git a/installer/src/agents/codex.ts b/installer/src/agents/codex.ts index 2d5aafad..0cc90386 100644 --- a/installer/src/agents/codex.ts +++ b/installer/src/agents/codex.ts @@ -4,6 +4,21 @@ import os from 'os'; import { AgentConfig } from './types.js'; import { commandExists } from '../utils.js'; +/** + * Codex agent configuration. + * + * Per OpenAI Codex documentation, skills are discovered from: + * - Project-level: .codex/skills/ (checked into repo) + * - User-level: ~/.codex/skills/ (personal, cross-project) + * - System: ~/.codex/skills/.system/ (built-in, read-only) + * + * The installer writes to ~/.codex/skills/ for global installation. + * + * IMPORTANT: Users must run `codex --enable skills` for skills to be active. + * The $skill-installer and $skill-creator system skills can also manage skills. + * + * Reference: https://developers.openai.com/codex/skills + */ export const codexAgent: AgentConfig = { name: 'codex', displayName: 'Codex', @@ -12,7 +27,7 @@ export const codexAgent: AgentConfig = { return fs.existsSync(path.join(os.homedir(), '.codex')) || await commandExists('codex'); }, getSkillPath: () => { - // Codex user-level skills path per docs: $HOME/.agents/skills - return path.join(os.homedir(), '.agents', 'skills'); + // Codex discovers user-level skills from ~/.codex/skills/ + return path.join(os.homedir(), '.codex', 'skills'); }, }; diff --git a/installer/src/agents/gemini.ts b/installer/src/agents/gemini.ts index dd1e9bf3..0a627293 100644 --- a/installer/src/agents/gemini.ts +++ b/installer/src/agents/gemini.ts @@ -5,38 +5,20 @@ import { AgentConfig } from './types.js'; import { commandExists } from '../utils.js'; /** - * Find the Gemini CLI skills directory. - * Gemini CLI stores skills under ~/.gemini//skills/ - * The default profile is 'antigravity'. + * Gemini CLI agent configuration. + * + * Per the official Gemini CLI documentation, skills are discovered from: + * 1. Workspace skills: .gemini/skills/ or .agents/skills/ (project-level) + * 2. User skills: ~/.gemini/skills/ or ~/.agents/skills/ (global) + * 3. Extension skills: bundled within installed extensions + * + * For global installation, we use ~/.gemini/skills/ which is the documented + * user-level skills directory. There is NO profile subdirectory in the + * skill discovery path — ~/.gemini/antigravity/skills/ is NOT a valid + * skill location. + * + * Reference: https://geminicli.com/docs/cli/skills/ */ -function findGeminiSkillsDir(): string { - const geminiDir = path.join(os.homedir(), '.gemini'); - - // Check for antigravity profile (default) - const antigravitySkills = path.join(geminiDir, 'antigravity', 'skills'); - if (fs.existsSync(antigravitySkills)) { - return antigravitySkills; - } - - // Check for any profile with a skills directory - if (fs.existsSync(geminiDir)) { - try { - const entries = fs.readdirSync(geminiDir); - for (const entry of entries) { - const skillsPath = path.join(geminiDir, entry, 'skills'); - if (fs.existsSync(skillsPath) && fs.statSync(skillsPath).isDirectory()) { - return skillsPath; - } - } - } catch { - // Ignore errors - } - } - - // Default to antigravity profile - return antigravitySkills; -} - export const geminiAgent: AgentConfig = { name: 'gemini', displayName: 'Gemini CLI', @@ -45,6 +27,7 @@ export const geminiAgent: AgentConfig = { return fs.existsSync(path.join(os.homedir(), '.gemini')) || await commandExists('gemini'); }, getSkillPath: () => { - return findGeminiSkillsDir(); + // User-level skills directory — Gemini CLI auto-discovers skills here + return path.join(os.homedir(), '.gemini', 'skills'); }, }; diff --git a/installer/src/commands/install.ts b/installer/src/commands/install.ts index 18b994dc..130b751b 100644 --- a/installer/src/commands/install.ts +++ b/installer/src/commands/install.ts @@ -9,10 +9,11 @@ import { transformForGemini } from '../transformers/gemini.js'; import { SKILL_MARKER_START, SKILL_MARKER_END } from '../constants.js'; import { TransformResult } from '../transformers/types.js'; -export function writeSkillsForClaude(skills: Skill[], _destDir: string): void { - // Claude Code uses the plugin marketplace system — destDir is ignored. - // Skills are installed as a plugin at ~/.claude/plugins/cache/syncable/... - installClaudePlugin(skills); +export async function writeSkillsForClaude(skills: Skill[], _destDir: string): Promise { + // Claude Code uses the plugin system — destDir is ignored. + // installClaudePlugin tries the CLI first, then falls back to + // writing cache files + enabling in settings.json. + await installClaudePlugin(skills); } export function writeSkillsForCodex(skills: Skill[], destDir: string): void { @@ -48,7 +49,7 @@ export function writeSkillsForWindsurf(skills: Skill[], destDir: string): void { export function writeSkillsForGemini(skills: Skill[], destDir: string): void { // Gemini CLI uses skills//SKILL.md format - // destDir is ~/.gemini//skills/ + // destDir is ~/.gemini/skills/ (the documented user-level discovery path) for (const skill of skills) { const results = transformForGemini(skill); for (const { relativePath, content } of results) { @@ -69,7 +70,7 @@ export interface InstallOptions { verbose: boolean; } -export type AgentWriter = (skills: Skill[], destOrPath: string) => void; +export type AgentWriter = (skills: Skill[], destOrPath: string) => void | Promise; export const agentWriters: Record = { claude: writeSkillsForClaude, diff --git a/installer/src/index.ts b/installer/src/index.ts index aedeea25..f01e740c 100644 --- a/installer/src/index.ts +++ b/installer/src/index.ts @@ -5,6 +5,7 @@ import inquirer from 'inquirer'; import ora from 'ora'; import path from 'path'; import os from 'os'; +import fs from 'fs'; import chalk from 'chalk'; import { createRequire } from 'module'; import { checkNodeVersion, checkCargo, checkSyncCtl } from './prerequisites/check.js'; @@ -24,6 +25,7 @@ import { import { removeSyncableSkills, removeGeminiSection } from './commands/uninstall.js'; import { uninstallClaudePlugin } from './transformers/claude.js'; import { countInstalledSkills } from './commands/status.js'; +import { isSyncCtlInLoginShell, getShellProfile, cargoBinDir, execCommand, isWindows } from './utils.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json'); @@ -35,6 +37,142 @@ program .description('Install Syncable CLI skills for AI coding agents') .version(pkg.version); +/** + * Verify sync-ctl is accessible from a fresh login shell and fix PATH if needed. + * This ensures AI agents (which spawn fresh shells) can actually find sync-ctl. + */ +async function verifySyncCtlPath(opts: { yes?: boolean }): Promise { + const inLoginShell = await isSyncCtlInLoginShell(); + if (inLoginShell) { + console.log(chalk.green(' ✓ sync-ctl accessible from shell PATH')); + return; + } + + const syncCtlBinary = path.join(cargoBinDir(), isWindows() ? 'sync-ctl.exe' : 'sync-ctl'); + if (!fs.existsSync(syncCtlBinary)) { + // Binary doesn't exist at all — nothing to fix here + return; + } + + console.log(chalk.yellow('\n ⚠ sync-ctl is installed but NOT in your shell PATH.')); + console.log(chalk.yellow(' AI agents will fail with "sync-ctl: command not found".\n')); + + if (isWindows()) { + console.log(chalk.cyan(' To fix, add this to your system PATH:')); + console.log(chalk.dim(` ${cargoBinDir()}\n`)); + return; + } + + const choices = [ + { name: 'Create symlink in /usr/local/bin (recommended, may need sudo)', value: 'symlink' }, + { name: `Add ~/.cargo/bin to shell profile (${getShellProfile()})`, value: 'profile' }, + { name: 'Skip — I will fix it manually', value: 'skip' }, + ]; + + const { fix } = opts.yes + ? { fix: 'profile' } + : await inquirer.prompt([{ + type: 'list', + name: 'fix', + message: 'How would you like to fix this?', + choices, + }]); + + if (fix === 'symlink') { + const spinner = ora(' Creating symlink...').start(); + try { + await execCommand(`sudo ln -sf "${syncCtlBinary}" /usr/local/bin/sync-ctl`); + spinner.succeed(' Symlink created: /usr/local/bin/sync-ctl'); + } catch { + spinner.fail(' Failed to create symlink (sudo may have been denied)'); + console.log(chalk.dim(` Try manually: sudo ln -sf "${syncCtlBinary}" /usr/local/bin/sync-ctl`)); + } + } else if (fix === 'profile') { + const profilePath = getShellProfile(); + const exportLine = 'export PATH="$HOME/.cargo/bin:$PATH"'; + + // Check if it's already in the profile + try { + const profileContent = fs.existsSync(profilePath) ? fs.readFileSync(profilePath, 'utf-8') : ''; + if (profileContent.includes('.cargo/bin')) { + console.log(chalk.yellow(` ~/.cargo/bin is already in ${profilePath} but your current shell hasn't sourced it.`)); + console.log(chalk.cyan(` Run: source ${profilePath}\n`)); + return; + } + } catch { + // Can't read profile — proceed with append + } + + try { + fs.appendFileSync(profilePath, `\n# Added by syncable-cli-skills installer\n${exportLine}\n`); + console.log(chalk.green(` ✓ Added to ${profilePath}`)); + console.log(chalk.cyan(` Restart your terminal or run: source ${profilePath}\n`)); + } catch { + console.log(chalk.red(` Failed to update ${profilePath}. Add this line manually:`)); + console.log(chalk.dim(` ${exportLine}\n`)); + } + } else { + console.log(chalk.dim(` To fix later, run: export PATH="$HOME/.cargo/bin:$PATH"\n`)); + } +} + +/** + * Print agent-specific post-install instructions that users need to know. + */ +function printPostInstallNotes(agents: AgentConfig[]): void { + const notes: string[] = []; + + for (const agent of agents) { + switch (agent.name) { + case 'claude': + notes.push( + ` ${chalk.cyan('Claude Code')}: Skills are auto-enabled. If they don't appear:`, + ` 1. Run ${chalk.bold('/reload-plugins')} inside Claude Code`, + ` 2. Or manually: ${chalk.bold('/plugin marketplace add syncable-dev/syncable-cli')}`, + ` then: ${chalk.bold('/plugin install syncable-cli-skills@syncable')}`, + ); + break; + + case 'codex': + notes.push( + ` ${chalk.cyan('Codex')}: You must enable skills when starting Codex:`, + ` ${chalk.bold('codex --enable skills')}`, + ` Or invoke explicitly: ${chalk.bold('$syncable-analyze')}`, + ` Skills installed to: ${chalk.dim('~/.codex/skills/')}`, + ); + break; + + case 'gemini': + notes.push( + ` ${chalk.cyan('Gemini CLI')}: Skills are auto-discovered. Verify with:`, + ` ${chalk.bold('/skills list')} inside Gemini CLI`, + ` Skills installed to: ${chalk.dim('~/.gemini/skills/')}`, + ); + break; + + case 'cursor': + notes.push( + ` ${chalk.cyan('Cursor')}: Rules are loaded automatically in projects.`, + ); + break; + + case 'windsurf': + notes.push( + ` ${chalk.cyan('Windsurf')}: Rules are loaded automatically in projects.`, + ); + break; + } + } + + if (notes.length > 0) { + console.log(chalk.bold('\n Agent-specific notes:\n')); + for (const note of notes) { + console.log(note); + } + console.log(); + } +} + program .command('install', { isDefault: true }) .description('Install sync-ctl and skills') @@ -117,6 +255,9 @@ program } } } + + // Verify sync-ctl is actually in the shell PATH (not just this process) + await verifySyncCtlPath(opts); } // Detect agents @@ -189,7 +330,7 @@ program const dest = agent.getSkillPath(); switch (agent.name) { case 'claude': - writeSkillsForClaude(skills, dest); + await writeSkillsForClaude(skills, dest); break; case 'codex': writeSkillsForCodex(skills, dest); @@ -217,7 +358,15 @@ program console.log(` Installed:`); console.log(` • ${commandCount} command skills + ${workflowCount} workflow skills`); console.log(` • Agents: ${selectedAgents.map((a) => a.displayName).join(', ')}`); - console.log(`\n Try it: Open Claude Code and say "assess this project"\n`); + + // Print agent-specific post-install notes (Codex --enable skills, etc.) + printPostInstallNotes(selectedAgents); + + // Manual install fallback — if installer didn't work for some reason + console.log(chalk.dim(' If skills are not loading, install manually from GitHub:')); + console.log(chalk.dim(' https://github.com/syncable-dev/syncable-cli/tree/main/installer/skills')); + console.log(); + console.log(` Try it: Open your agent and say "assess this project"\n`); }); program @@ -246,7 +395,7 @@ program const dest = agent.getSkillPath(); switch (agent.name) { case 'claude': - uninstallClaudePlugin(); + await uninstallClaudePlugin(); break; case 'codex': removeSyncableSkills(dest, 'syncable-*'); @@ -261,6 +410,11 @@ program break; case 'gemini': removeSyncableSkills(dest, 'syncable-*'); + // Also clean old antigravity profile location from previous installer versions + const oldGeminiDir = path.join(os.homedir(), '.gemini', 'antigravity', 'skills'); + if (fs.existsSync(oldGeminiDir)) { + removeSyncableSkills(oldGeminiDir, 'syncable-*'); + } break; } spinner.succeed(` Skills removed from ${agent.displayName}`); @@ -324,7 +478,85 @@ program } else { console.log(` cargo ${chalk.red('✗ not found')}`); } + + // Check if sync-ctl is visible in login shell + const inPath = await isSyncCtlInLoginShell(); + if (syncCtlStatus.status === 'ok' && !inPath) { + console.log(chalk.yellow(`\n ⚠ sync-ctl is installed but NOT in your shell PATH.`)); + console.log(chalk.yellow(` AI agents may not be able to run skills.`)); + console.log(chalk.dim(` Fix: run "syncable-cli-skills install" to update your PATH`)); + } + console.log(); }); +program + .command('doctor') + .description('Diagnose installation health') + .action(async () => { + console.log(chalk.bold('\n Syncable CLI Skills Doctor\n')); + let issues = 0; + + // 1. Check sync-ctl binary exists + const syncCtlStatus = await checkSyncCtl(); + if (syncCtlStatus.status === 'ok') { + console.log(chalk.green(` ✓ sync-ctl v${syncCtlStatus.version} installed`)); + } else { + console.log(chalk.red(' ✗ sync-ctl not installed')); + console.log(chalk.dim(' Fix: cargo install syncable-cli')); + issues++; + } + + // 2. Check sync-ctl is in login shell PATH + const inPath = await isSyncCtlInLoginShell(); + if (inPath) { + console.log(chalk.green(' ✓ sync-ctl accessible from shell PATH')); + } else if (syncCtlStatus.status === 'ok') { + console.log(chalk.red(' ✗ sync-ctl NOT in shell PATH — agents will fail')); + console.log(chalk.dim(' Fix: run "syncable-cli-skills install" to update PATH')); + issues++; + } + + // 3. Check each agent's skill directory + const detectionResults = await detectAgents(); + for (const { agent, detected } of detectionResults) { + if (!detected) continue; + + const dest = agent.getSkillPath(); + const count = countInstalledSkills(dest, agent.name); + if (count > 0) { + console.log(chalk.green(` ✓ ${agent.displayName}: ${count} skills at ${dest}`)); + } else { + console.log(chalk.red(` ✗ ${agent.displayName}: no skills found at ${dest}`)); + issues++; + } + + // Claude-specific: check enabledPlugins + if (agent.name === 'claude') { + const settingsFile = path.join(os.homedir(), '.claude', 'settings.json'); + try { + const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8')); + const key = 'syncable-cli-skills@syncable'; + if (settings.enabledPlugins && settings.enabledPlugins[key] === true) { + console.log(chalk.green(' ✓ Claude Code: plugin enabled in settings.json')); + } else { + console.log(chalk.red(' ✗ Claude Code: plugin NOT enabled in settings.json')); + console.log(chalk.dim(' Fix: run "syncable-cli-skills install --agents claude"')); + issues++; + } + } catch { + console.log(chalk.red(' ✗ Claude Code: could not read settings.json')); + issues++; + } + } + } + + console.log('\n ' + '─'.repeat(40)); + if (issues === 0) { + console.log(chalk.green.bold(' ✓ Everything looks good!\n')); + } else { + console.log(chalk.yellow.bold(` Found ${issues} issue${issues === 1 ? '' : 's'} — see above for fixes.\n`)); + } + }); + program.parse(); diff --git a/installer/src/prerequisites/install-cli.ts b/installer/src/prerequisites/install-cli.ts index d9d93c8a..31fcbfc8 100644 --- a/installer/src/prerequisites/install-cli.ts +++ b/installer/src/prerequisites/install-cli.ts @@ -1,11 +1,23 @@ -import { execCommand } from '../utils.js'; +import { execCommand, prependCargoToPath } from '../utils.js'; +/** + * Install or update sync-ctl via cargo. + * + * Always uses --force when `force` is true so that an outdated version + * is replaced with the latest from crates.io. + * + * After installation, cargo/bin is added to the current process PATH + * so subsequent checks within the installer can find the binary. + */ export async function installSyncCtl(force: boolean = false): Promise { try { const cmd = force ? 'cargo install syncable-cli --force' : 'cargo install syncable-cli'; await execCommand(cmd); + + // Ensure the binary is on PATH for the rest of this installer session + prependCargoToPath(); return true; } catch { return false; diff --git a/installer/src/transformers/claude.ts b/installer/src/transformers/claude.ts index 6c15f518..11f57217 100644 --- a/installer/src/transformers/claude.ts +++ b/installer/src/transformers/claude.ts @@ -3,27 +3,26 @@ import path from 'path'; import os from 'os'; import { Skill } from '../skills.js'; import { TransformResult } from './types.js'; +import { execCommand, commandExists } from '../utils.js'; const PLUGIN_NAME = 'syncable-cli-skills'; const PLUGIN_VERSION = '0.1.0'; const MARKETPLACE_NAME = 'syncable'; +const MARKETPLACE_REPO = 'syncable-dev/syncable-cli'; /** * Transform a skill into Claude Code plugin format. * Each skill becomes a directory with SKILL.md inside skills// */ export function transformForClaude(skill: Skill): TransformResult[] { - // Skill name from filename (strip .md extension) const skillName = skill.filename.replace(/\.md$/, ''); - // Build YAML-safe description (double-quoted, no inner unescaped quotes) const safeDesc = skill.frontmatter.description .replace(/"/g, '\\"') - .replace(/: /g, ' - ') // Remove colons that break YAML - .replace(/Trigger on:.*$/, '') // Strip trigger phrases + .replace(/: /g, ' - ') + .replace(/Trigger on:.*$/, '') .trim(); - // Only description in frontmatter — directory name is the skill name const content = `---\ndescription: "${safeDesc}"\n---\n\n${skill.body}`; return [{ relativePath: `skills/${skillName}/SKILL.md`, content }]; @@ -44,8 +43,61 @@ export function getClaudePluginCacheDir(): string { ); } +// ──────────────────────────────────────────────────────────────────────────── +// Installation strategy (in priority order): +// +// 1. `claude plugin marketplace add` + `claude plugin install` +// The official documented flow. This registers the marketplace, clones the +// plugin from the GitHub repo, caches it, AND auto-enables it in settings. +// 100 % guaranteed to work if the `claude` CLI is on PATH. +// +// 2. Manual write: cache files + enabledPlugins in settings.json +// If the CLI is unavailable (user hasn't installed Claude Code yet, or +// they're on a CI machine), we write the plugin files directly to the +// cache directory AND register it in ~/.claude/settings.json so that +// next time Claude Code starts, the plugin loads automatically. +// ──────────────────────────────────────────────────────────────────────────── + /** - * Write the plugin.json manifest. + * Try to install the plugin via the Claude Code CLI. + * Returns true if it fully succeeded. + */ +async function tryClaudeCliInstall(): Promise { + const hasClaude = await commandExists('claude'); + if (!hasClaude) return false; + + try { + // Step 1: Register the marketplace (idempotent — safe to re-add) + await execCommand(`claude plugin marketplace add ${MARKETPLACE_REPO}`); + } catch { + // Marketplace may already exist — continue + } + + try { + // Step 2: Install the plugin (auto-enables in user scope) + await execCommand(`claude plugin install ${PLUGIN_NAME}@${MARKETPLACE_NAME} --scope user`); + return true; + } catch { + // install can fail if plugin already exists at same version — check settings + try { + const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); + if (fs.existsSync(settingsPath)) { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + const key = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`; + if (settings.enabledPlugins?.[key] === true) { + // Already installed and enabled — that's fine + return true; + } + } + } catch { + // Couldn't verify — fall through to manual path + } + return false; + } +} + +/** + * Write the plugin.json manifest inside the cache directory. */ function writePluginManifest(cacheDir: string): void { const manifestDir = path.join(cacheDir, '.claude-plugin'); @@ -61,6 +113,7 @@ function writePluginManifest(cacheDir: string): void { email: 'support@syncable.dev', }, homepage: 'https://syncable.dev', + repository: `https://github.com/${MARKETPLACE_REPO}`, license: 'MIT', keywords: ['syncable', 'devops', 'security', 'deployment', 'kubernetes', 'docker', 'iac'], }; @@ -69,78 +122,65 @@ function writePluginManifest(cacheDir: string): void { } /** - * Register the plugin in installed_plugins.json. + * Enable the plugin in ~/.claude/settings.json. + * + * Per Claude Code docs, plugins are activated via the `enabledPlugins` key. + * We also register the marketplace in `extraKnownMarketplaces` so that + * Claude Code can discover future updates automatically. */ -function registerPlugin(cacheDir: string): void { - const pluginsFile = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'); +function enablePluginInSettings(): void { + const settingsFile = path.join(os.homedir(), '.claude', 'settings.json'); - let data: { version: number; plugins: Record } = { version: 2, plugins: {} }; + let settings: Record = {}; - if (fs.existsSync(pluginsFile)) { + if (fs.existsSync(settingsFile)) { try { - data = JSON.parse(fs.readFileSync(pluginsFile, 'utf-8')); + settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8')); } catch { - // Corrupted file — start fresh - data = { version: 2, plugins: {} }; + try { fs.copyFileSync(settingsFile, settingsFile + '.bak'); } catch { /* */ } + settings = {}; } } - const key = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`; - const now = new Date().toISOString(); - - data.plugins[key] = [ - { - scope: 'user', - installPath: cacheDir, - version: PLUGIN_VERSION, - installedAt: now, - lastUpdated: now, - }, - ]; - - fs.mkdirSync(path.dirname(pluginsFile), { recursive: true }); - fs.writeFileSync(pluginsFile, JSON.stringify(data, null, 2)); -} - -/** - * Register the marketplace in known_marketplaces.json so Claude Code knows about it. - */ -function registerMarketplace(): void { - const marketFile = path.join(os.homedir(), '.claude', 'plugins', 'known_marketplaces.json'); - - let data: Record = {}; - - if (fs.existsSync(marketFile)) { - try { - data = JSON.parse(fs.readFileSync(marketFile, 'utf-8')); - } catch { - data = {}; - } + // Enable the plugin + if (!settings.enabledPlugins || typeof settings.enabledPlugins !== 'object') { + settings.enabledPlugins = {}; } + const pluginKey = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`; + (settings.enabledPlugins as Record)[pluginKey] = true; - // Only add if not already present - if (!data[MARKETPLACE_NAME]) { - data[MARKETPLACE_NAME] = { + // Register the marketplace so Claude Code can auto-update + if (!settings.extraKnownMarketplaces || typeof settings.extraKnownMarketplaces !== 'object') { + settings.extraKnownMarketplaces = {}; + } + const marketplaces = settings.extraKnownMarketplaces as Record; + if (!marketplaces[MARKETPLACE_NAME]) { + marketplaces[MARKETPLACE_NAME] = { source: { source: 'github', - repo: 'syncable-dev/syncable-cli', + repo: MARKETPLACE_REPO, }, - installLocation: path.join(os.homedir(), '.claude', 'plugins', 'marketplaces', MARKETPLACE_NAME), - lastUpdated: new Date().toISOString(), }; - - fs.writeFileSync(marketFile, JSON.stringify(data, null, 2)); } + + fs.mkdirSync(path.dirname(settingsFile), { recursive: true }); + fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2)); } /** - * Full Claude Code plugin installation: - * 1. Write SKILL.md files into plugin cache - * 2. Write plugin.json manifest - * 3. Register in installed_plugins.json - * 4. Register marketplace in known_marketplaces.json + * Full Claude Code plugin installation. + * + * 1. Try `claude plugin marketplace add` + `claude plugin install` + * 2. Fall back to manual: write cache files + update settings.json */ -export function installClaudePlugin(skills: Skill[]): { cacheDir: string; skillCount: number } { +export async function installClaudePlugin(skills: Skill[]): Promise<{ cacheDir: string; skillCount: number }> { + // ── Attempt 1: Official CLI ──────────────────────────────────────── + const cliSuccess = await tryClaudeCliInstall(); + if (cliSuccess) { + return { cacheDir: getClaudePluginCacheDir(), skillCount: skills.length }; + } + + // ── Attempt 2: Manual write + settings.json ──────────────────────── const cacheDir = getClaudePluginCacheDir(); // Clear old skills @@ -149,7 +189,7 @@ export function installClaudePlugin(skills: Skill[]): { cacheDir: string; skillC fs.rmSync(skillsDir, { recursive: true }); } - // Write each skill as skills//SKILL.md + // Write each skill for (const skill of skills) { const results = transformForClaude(skill); for (const { relativePath, content } of results) { @@ -162,11 +202,8 @@ export function installClaudePlugin(skills: Skill[]): { cacheDir: string; skillC // Write plugin manifest writePluginManifest(cacheDir); - // Register plugin - registerPlugin(cacheDir); - - // Register marketplace - registerMarketplace(); + // Enable in settings.json (THE KEY FIX) + enablePluginInSettings(); return { cacheDir, skillCount: skills.length }; } @@ -174,34 +211,60 @@ export function installClaudePlugin(skills: Skill[]): { cacheDir: string; skillC /** * Remove the Claude Code plugin. */ -export function uninstallClaudePlugin(): void { - const cacheDir = getClaudePluginCacheDir(); +export async function uninstallClaudePlugin(): Promise { + // Try CLI first + const hasClaude = await commandExists('claude'); + if (hasClaude) { + try { + await execCommand(`claude plugin uninstall ${PLUGIN_NAME}@${MARKETPLACE_NAME} --scope user`); + return; + } catch { /* fall through */ } + } - // Remove cache directory + // Manual cleanup + const cacheDir = getClaudePluginCacheDir(); if (fs.existsSync(cacheDir)) { fs.rmSync(cacheDir, { recursive: true }); } - // Remove from installed_plugins.json - const pluginsFile = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'); - if (fs.existsSync(pluginsFile)) { + // Remove from enabledPlugins in settings.json + const settingsFile = path.join(os.homedir(), '.claude', 'settings.json'); + if (fs.existsSync(settingsFile)) { try { - const data = JSON.parse(fs.readFileSync(pluginsFile, 'utf-8')); - const key = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`; - delete data.plugins[key]; - fs.writeFileSync(pluginsFile, JSON.stringify(data, null, 2)); - } catch { - // Ignore errors + const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8')); + const pluginKey = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`; + if (settings.enabledPlugins && typeof settings.enabledPlugins === 'object') { + delete settings.enabledPlugins[pluginKey]; + fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2)); + } + } catch { /* */ } + } + + // Clean up legacy files from previous installer versions + const legacyFiles = [ + path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'), + path.join(os.homedir(), '.claude', 'plugins', 'known_marketplaces.json'), + ]; + for (const legacyFile of legacyFiles) { + if (fs.existsSync(legacyFile)) { + try { + const data = JSON.parse(fs.readFileSync(legacyFile, 'utf-8')); + const key = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`; + if (data.plugins) delete data.plugins[key]; + if (data[MARKETPLACE_NAME]) delete data[MARKETPLACE_NAME]; + fs.writeFileSync(legacyFile, JSON.stringify(data, null, 2)); + } catch { /* */ } } } - // Also clean up old-style flat skills if they exist - const oldSkillsDir = path.join(os.homedir(), '.claude', 'skills', 'syncable'); - if (fs.existsSync(oldSkillsDir)) { - fs.rmSync(oldSkillsDir, { recursive: true }); + // Clean up old flat-file skills + const oldDirs = [ + path.join(os.homedir(), '.claude', 'skills', 'syncable'), + ]; + for (const dir of oldDirs) { + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true }); } - // Clean up flat files from failed earlier installs const flatSkillsDir = path.join(os.homedir(), '.claude', 'skills'); if (fs.existsSync(flatSkillsDir)) { for (const file of fs.readdirSync(flatSkillsDir)) { diff --git a/installer/src/transformers/gemini.ts b/installer/src/transformers/gemini.ts index c1f2561e..1526c4df 100644 --- a/installer/src/transformers/gemini.ts +++ b/installer/src/transformers/gemini.ts @@ -9,7 +9,14 @@ import { SKILL_MARKER_START, SKILL_MARKER_END } from '../constants.js'; */ export function transformForGemini(skill: Skill): TransformResult[] { const skillName = skill.filename.replace(/\.md$/, ''); - const content = `---\nname: ${skillName}\ndescription: ${skill.frontmatter.description}\n---\n\n${skill.body}`; + + // Gemini CLI loads name + description at startup, then activates the full + // SKILL.md on demand when a task matches. Description should be concise + // and clearly describe when to activate. Max ~125 chars recommended. + const safeDesc = skill.frontmatter.description + .replace(/"/g, '\\"'); + + const content = `---\nname: "${skillName}"\ndescription: "${safeDesc}"\n---\n\n${skill.body}`; return [{ relativePath: `${skillName}/SKILL.md`, content }]; } diff --git a/installer/src/utils.ts b/installer/src/utils.ts index e7c17b94..1320aeae 100644 --- a/installer/src/utils.ts +++ b/installer/src/utils.ts @@ -1,5 +1,6 @@ import { exec as execCb } from 'child_process'; import { promisify } from 'util'; +import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -56,3 +57,52 @@ export async function commandExists(command: string): Promise { export function prependCargoToPath(): void { process.env.PATH = `${cargoBinDir()}${path.delimiter}${process.env.PATH}`; } + +/** + * Check if sync-ctl is accessible from a fresh login shell. + * + * This is critical because the installer's Node.js process may have + * ~/.cargo/bin in PATH (via prependCargoToPath), but the user's actual + * shell — and more importantly, the AI agent's shell — may not. + * + * A false here means agents will fail with "sync-ctl: command not found" + * even though the binary is installed. + */ +export async function isSyncCtlInLoginShell(): Promise { + try { + if (isWindows()) { + // On Windows, check if sync-ctl is in the system PATH + await execAsync('where sync-ctl'); + return true; + } + + // Spawn a fresh login shell to check — this mimics what agents do + const shell = process.env.SHELL || '/bin/bash'; + await execAsync(`${shell} -l -c "which sync-ctl"`, { timeout: 10_000 }); + return true; + } catch { + return false; + } +} + +/** + * Get the user's shell profile file path for PATH modifications. + */ +export function getShellProfile(): string { + const shell = process.env.SHELL || '/bin/bash'; + + if (shell.endsWith('/zsh')) { + const zshrc = path.join(os.homedir(), '.zshrc'); + if (fs.existsSync(zshrc)) return zshrc; + return path.join(os.homedir(), '.zprofile'); + } + + if (shell.endsWith('/fish')) { + return path.join(os.homedir(), '.config', 'fish', 'config.fish'); + } + + // Default to bash + const bashrc = path.join(os.homedir(), '.bashrc'); + if (fs.existsSync(bashrc)) return bashrc; + return path.join(os.homedir(), '.bash_profile'); +} diff --git a/installer/tests/commands/install.test.ts b/installer/tests/commands/install.test.ts index 024069f7..df3174c5 100644 --- a/installer/tests/commands/install.test.ts +++ b/installer/tests/commands/install.test.ts @@ -89,8 +89,8 @@ describe('writeSkillsForGemini', () => { it('includes frontmatter with name and description', () => { writeSkillsForGemini(sampleSkills, tmpDir); const content = fs.readFileSync(path.join(tmpDir, 'syncable-analyze', 'SKILL.md'), 'utf-8'); - expect(content).toContain('name: syncable-analyze'); - expect(content).toContain('description: Analyze'); + expect(content).toContain('name: "syncable-analyze"'); + expect(content).toContain('description: "Analyze"'); expect(content).toContain('Analyze.'); }); }); diff --git a/installer/tests/transformers/gemini.test.ts b/installer/tests/transformers/gemini.test.ts index 3b315ea0..8f4a42db 100644 --- a/installer/tests/transformers/gemini.test.ts +++ b/installer/tests/transformers/gemini.test.ts @@ -18,8 +18,8 @@ describe('transformForGemini', () => { it('includes frontmatter with name and description', () => { const result = transformForGemini(sampleSkill); - expect(result[0].content).toContain('name: syncable-analyze'); - expect(result[0].content).toContain('description: Analyze stuff'); + expect(result[0].content).toContain('name: "syncable-analyze"'); + expect(result[0].content).toContain('description: "Analyze stuff"'); }); it('includes skill body content', () => {