diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..045e908 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install + - run: bun run typecheck + - run: bun test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd6e803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..13172f0 --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# ags + +[![CI](https://github.com/moqa-studio/agents-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/moqa-studio/agents-cli/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +One CLI to manage skills, commands, and rules across AI coding assistants. + +Your skills are scattered across `~/.claude/skills/`, `.cursor/rules/`, `CLAUDE.md`, `.cursorrules` — different formats, different locations, no visibility into what's actually loaded. **ags** gives you a single command to scan, lint, measure, and manage all of it. + +``` +$ ags scan + + NAME TYPE SCOPE AGENTS TOKENS + ────────────────────────────────────────────────────────── + ags-manager skill local ◈ claude 2,847 + code-review skill global ◈ claude ⌘ cursor 1,204 + api-guidelines rule local ⌘ cursor 892 + deploy-helper command global ◈ claude 456 + + 4 items across 2 agents · 5,399 tokens +``` + +## Install + +Requires [Bun](https://bun.sh) v1.0+. + +```bash +bun install -g @moqa/ags +``` + +Or from source: + +```bash +git clone https://github.com/moqa-studio/agents-cli.git +cd agents-cli && bun install && bun link +``` + +### Shell completions + +```bash +# Bash +echo 'source /path/to/agents-cli/completions/ags.bash' >> ~/.bashrc + +# Zsh +cp completions/_ags /usr/local/share/zsh/site-functions/ +``` + +## Supported agents + +| Agent | Flag | Status | What ags manages | +|-------|------|--------|------------------| +| Claude Code | `--agent claude` | Full | Skills, commands, subagents, memory, MCP config | +| Cursor | `--agent cursor` | Core | Skills, rules (`.cursorrules`, `.mdc`) | +| Codex | `--agent codex` | Basic | Skills | + +Every command accepts `--agent ` to filter. Without it, ags operates across all agents at once. + +## Commands + +### `ags scan` + +Discover everything across all agents. + +``` +ags scan # all items +ags scan --agent claude # Claude Code only +ags scan --type skill # skills only +ags scan --scope local # project-level only +ags scan --installed # which agents are installed +``` + +### `ags context` + +See what's loaded into your agent's context — config files, skills, commands, memory, MCP servers. + +``` +ags context # all agents +ags context --agent claude # Claude Code only +``` + +### `ags lint` + +Catch issues: missing frontmatter, short descriptions, oversized files, name conflicts, unsupported keys. + +``` +ags lint # everything +ags lint --agent cursor # Cursor rules only +``` + +### `ags skill-cost` + +Token budget — per-skill cost ranked by size, context usage bar per agent, suggestions to free tokens. + +``` +ags skill-cost # full report +ags skill-cost --scope local # project skills only +``` + +### `ags grab ` + +Install a skill from GitHub. + +``` +ags grab https://github.com/org/repo/blob/main/skills/foo/SKILL.md +ags grab --to cursor # install for Cursor +ags grab --dry-run # preview only +``` + +### `ags rm ` + +Remove a skill, command, agent, or rule. + +``` +ags rm my-skill # all agents +ags rm my-skill --agent claude # Claude Code only +``` + +### `ags stats` + +Usage dashboard (Claude Code only). + +``` +ags stats # last 30 days +ags stats --period 7d # last week +ags stats --period all-time # everything +``` + +All commands support `--json` for structured output and `--help` for usage details. + +## Contributing + +```bash +bun test # run tests +bun run typecheck # type-check +``` + +### Adding support for a new agent + +The entire multi-agent system is driven by a single registry in `src/core/agents.ts`. To add an agent: + +1. Add an entry to `AGENTS` in `src/core/agents.ts` — paths, binary name, config files, frontmatter keys +2. Add the name to `AgentName` in `src/types.ts` +3. Add a context limit in `getContextLimit()` in `src/core/agents.ts` +4. Add styling in `AGENT_STYLE` in `src/utils/output.ts` — icon and brand color +5. Update completions in `completions/` + +All commands (scan, lint, skill-cost, grab, rm, context) work automatically from the registry. Agent-specific features (memory, stats, MCP) are opt-in in the relevant command file. + +## License + +MIT diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..36e92c0 --- /dev/null +++ b/build.ts @@ -0,0 +1,51 @@ +import { $ } from "bun"; +import { mkdirSync, existsSync, copyFileSync } from "fs"; + +const targets = [ + { name: "ags-darwin-arm64", target: "bun-darwin-arm64" }, + { name: "ags-darwin-x64", target: "bun-darwin-x64" }, + { name: "ags-linux-x64", target: "bun-linux-x64" }, +] as const; + +const outDir = "./dist"; + +async function build() { + if (!existsSync(outDir)) { + mkdirSync(outDir, { recursive: true }); + } + + // Build binaries + console.log("Building AGS binaries...\n"); + + for (const { name, target } of targets) { + const outPath = `${outDir}/${name}`; + console.log(` Building ${name}...`); + + try { + await $`bun build --compile --target=${target} --outfile=${outPath} ./src/index.ts`.quiet(); + console.log(` ✓ ${outPath}`); + + const proc = Bun.spawn(["shasum", "-a", "256", outPath], { + stdout: "pipe", + }); + await proc.exited; + const sha = (await new Response(proc.stdout).text()).trim(); + await Bun.write(`${outPath}.sha256`, sha + "\n"); + console.log(` ✓ ${outPath}.sha256`); + } catch (err) { + console.error(` ✗ Failed to build ${name}:`, err); + } + + console.log(); + } + + // Copy completions to dist + mkdirSync(`${outDir}/completions`, { recursive: true }); + copyFileSync("completions/_ags", `${outDir}/completions/_ags`); + copyFileSync("completions/ags.bash", `${outDir}/completions/ags.bash`); + console.log(" ✓ dist/completions/ (for Homebrew formula)\n"); + + console.log("Done!"); +} + +build(); diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..acce0d0 Binary files /dev/null and b/bun.lockb differ diff --git a/completions/_ags b/completions/_ags new file mode 100644 index 0000000..05ccda9 --- /dev/null +++ b/completions/_ags @@ -0,0 +1,82 @@ +#compdef ags + +_ags() { + local -a commands + commands=( + 'scan:Discover all skills, commands, agents, and rules' + 'context:What'\''s loaded into your agent'\''s context' + 'lint:Validate skill files for quality issues' + 'skill-cost:How much context your skills consume' + 'grab:Install skill from GitHub URL' + 'rm:Remove a skill, command, agent, or rule' + 'stats:Usage stats and activity dashboard' + ) + + local -a global_opts + global_opts=( + '--json[Output as JSON]' + '--help[Show help]' + '--version[Show version]' + ) + + if (( CURRENT == 2 )); then + _describe 'command' commands + return + fi + + case "${words[2]}" in + scan) + _arguments \ + '--agent[Filter by agent]:agent:(claude cursor codex)' \ + '--type[Filter by type]:type:(skill command rule agent)' \ + '--scope[Filter by scope]:scope:(local global all)' \ + '--installed[Show which agents are installed]' \ + '--json[Output as JSON]' \ + '--help[Show help]' + ;; + context) + _arguments \ + '--agent[Filter by agent]:agent:(claude cursor codex)' \ + '--json[Output as JSON]' \ + '--help[Show help]' + ;; + lint) + _arguments \ + '--agent[Filter by agent]:agent:(claude cursor codex)' \ + '--scope[Filter by scope]:scope:(local global all)' \ + '--json[Output as JSON]' \ + '--help[Show help]' + ;; + skill-cost|budget) + _arguments \ + '--scope[Filter by scope]:scope:(local global all)' \ + '--json[Output as JSON]' \ + '--help[Show help]' + ;; + grab) + _arguments \ + '--to[Target agent]:agent:(claude cursor codex)' \ + '--dry-run[Preview without writing files]' \ + '--json[Output as JSON]' \ + '--help[Show help]' + ;; + rm|remove) + _arguments \ + '--agent[Filter by agent]:agent:(claude cursor codex)' \ + '--dry-run[Preview without deleting files]' \ + '--json[Output as JSON]' \ + '--help[Show help]' + ;; + stats) + _arguments \ + '--period[Time range]:period:(7d 14d 30d 90d 6m 1y week month year all-time)' \ + '--json[Output as JSON]' \ + '--help[Show help]' + ;; + *) + _arguments $global_opts + ;; + esac +} + +_ags "$@" diff --git a/completions/ags.bash b/completions/ags.bash new file mode 100644 index 0000000..3a6609f --- /dev/null +++ b/completions/ags.bash @@ -0,0 +1,49 @@ +_ags() { + local cur prev commands + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + commands="scan context lint skill-cost grab rm stats" + + if [[ ${COMP_CWORD} -eq 1 ]]; then + COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") ) + return 0 + fi + + case "${prev}" in + --agent|--to) + COMPREPLY=( $(compgen -W "claude cursor codex" -- "${cur}") ) + return 0 + ;; + --type) + COMPREPLY=( $(compgen -W "skill command rule agent" -- "${cur}") ) + return 0 + ;; + --scope) + COMPREPLY=( $(compgen -W "local global all" -- "${cur}") ) + return 0 + ;; + --period) + COMPREPLY=( $(compgen -W "7d 14d 30d 90d 6m 1y week month year all-time" -- "${cur}") ) + return 0 + ;; + esac + + local cmd="${COMP_WORDS[1]}" + local opts="--json --help" + + case "${cmd}" in + scan) opts="--agent --type --scope --installed --json --help" ;; + context) opts="--agent --json --help" ;; + lint) opts="--agent --scope --json --help" ;; + skill-cost) opts="--scope --json --help" ;; + grab) opts="--to --dry-run --json --help" ;; + rm|remove) opts="--agent --dry-run --json --help" ;; + stats) opts="--period --json --help" ;; + esac + + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 +} + +complete -F _ags ags diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad4646c --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "@moqa/ags", + "version": "0.1.0", + "type": "module", + "bin": { + "ags": "./src/index.ts" + }, + "scripts": { + "dev": "bun run src/index.ts", + "build": "bun run build.ts", + "test": "bun test", + "typecheck": "bun run --bun tsc --noEmit" + }, + "license": "MIT", + "devDependencies": { + "bun-types": "^1.3.11" + } +} diff --git a/skills/ags-manager/SKILL.md b/skills/ags-manager/SKILL.md new file mode 100644 index 0000000..6a8329c --- /dev/null +++ b/skills/ags-manager/SKILL.md @@ -0,0 +1,153 @@ +--- +name: ags-manager +description: Proactively manage context budget and skill health across AI agents. Use BEFORE installing skills (check budget headroom), when starting work in unfamiliar projects (map what's loaded), when diagnosing bloated context, or when the user asks about skills, agents, or context cost. Run ags context to see the full context map, ags lint to validate skill quality, ags scan --type skill for a quick inventory. +--- + +# AGS — Agent Skills Manager + +Every skill, agent, config file, and MCP tool eats into your context window before the user even types their first message. You can't see this cost. `ags` makes it visible — and actionable. + +All commands support `--json` for structured output. Always use `--json` when you need to process the results programmatically. + +## When to use ags (proactive triggers) + +Do NOT wait for the user to ask. Reach for ags when you notice these situations: + +| Situation | Run this | Why | +|-----------|----------|-----| +| Starting work in an unfamiliar project | `ags context --json` | Know what's loaded into context — skills, configs, memory, MCP servers. Understand the landscape before you work. | +| User asks to install or create a skill | `ags skill-cost --json` first | Check budget headroom. Don't blindly add to a full context window. | +| User shares a GitHub skill URL | `ags grab --to claude --json` | Install with conflict detection and token tracking. | +| After installing or removing a skill | `ags context --json` | Confirm the change, check for new conflicts, verify budget impact. | +| You notice responses feel constrained or context is tight | `ags context --json` | Diagnose what's consuming budget. Find the heavy items. | +| User asks "what skills do I have" or mentions skills/agents | `ags scan --type skill --json` | Quick, focused inventory with token costs and health badges. | +| User asks about context cost or budget | `ags skill-cost --json` | Ranked cost breakdown with optimization suggestions. | +| Before recommending skill changes | `ags lint --json` | Check if existing skills have quality issues worth fixing first. | +| User wants to validate their skill setup | `ags lint --json` | Find missing frontmatter, conflicts, oversized files, unsupported keys. | +| User asks "which agents are installed" | `ags scan --installed --json` | Show installed agents with skill counts and active paths. | +| User wants to remove a skill | `ags rm --dry-run --json` first | Preview what will be removed before deleting. | +| User asks for usage stats | `ags stats --json` | Sessions, tokens, skills used, peak hours, activity patterns. | + +## Commands + +### ags scan — Discover everything + +The central discovery command. All skills, commands, agents, and rules across all agents. + +```bash +ags scan --json # Everything +ags scan --type skill --json # Skills only +ags scan --type agent --json # Subagents only +ags scan --agent claude --json # Claude Code only +ags scan --scope local --json # Project-level only +ags scan --scope global --json # User-level only +ags scan --installed --json # Which agents are installed +``` + +**Filters:** `--agent` (claude, cursor, codex), `--type` (skill, command, rule, agent), `--scope` (local, global, all), `--installed` + +**Health badges in output:** +- `STALE` — not modified in 30+ days +- `HEAVY` — over 5,000 characters +- `OVERSIZED` — over 500 lines +- `CONFLICT` — same name exists at different paths +- `SHARED` — same file used by multiple agents + +### ags context — What's loaded into context + +Shows everything consuming context for this project: config files, skills, commands, agents, memory files, and MCP server configs. + +```bash +ags context --json # Full context map for all agents +ags context --agent claude --json # Claude Code only +``` + +**Output shape:** +```json +{ + "projectRoot": "/path/to/project", + "agents": [ + { + "agent": "claude", + "items": [ + { "name": "CLAUDE.md", "category": "config", "tokens": 450, "filePath": "..." }, + { "name": "my-skill", "category": "skill", "tokens": 1200, "filePath": "..." }, + { "name": "memory/user_prefs.md", "category": "memory", "tokens": 300, "filePath": "..." } + ], + "totalTokens": 8500, + "contextLimit": 200000, + "percentage": 4.3 + } + ], + "grandTotal": 12000 +} +``` + +**Categories:** config, skill, command, agent, memory, mcp + +### ags lint — Validate skill quality + +Checks all skill files for issues that hurt discoverability or waste context. Uses the same `scanAll()` discovery as `scan`. + +```bash +ags lint --json # Lint everything +ags lint --agent claude --json # Claude skills only +ags lint --scope local --json # Project-level only +``` + +**Rules checked:** +- `missing-frontmatter` — No YAML frontmatter (agents can't discover the skill) +- `missing-description` — No description (agents don't know when to use it) +- `missing-name` — No name field (falls back to filename) +- `short-description` — Description under 10 chars (not specific enough) +- `heavy` — Over 5,000 chars (context hog) +- `oversized` — Over 500 lines (dominates context) +- `name-conflict` — Same name at different paths (ambiguous) +- `unsupported-key` — Frontmatter key not recognized by the target agent +- `empty-body` — Frontmatter only, no instructions + +Exits with code 1 if there are errors. Use this in CI or pre-commit hooks. + +### ags skill-cost — Context budget breakdown + +Per-skill token costs ranked by size, with optimization suggestions. Uses the same `scanAll()` discovery as `scan`. + +```bash +ags skill-cost --json +ags skill-cost --scope local --json +``` + +### ags grab — Install a skill from GitHub + +```bash +ags grab --to claude --json +ags grab --to cursor --json +``` + +Supports GitHub blob URLs and raw.githubusercontent.com URLs. + +### ags rm — Remove a skill + +```bash +ags rm my-skill --dry-run --json # Preview first +ags rm my-skill --json # Remove by name +ags rm my-skill --agent claude --json # Remove only from Claude Code +ags rm ~/.claude/agents/old.md --json # Remove by path +``` + +### ags stats — Usage analytics + +```bash +ags stats --json # Last 30 days +ags stats --period 7d --json # Last 7 days +ags stats --period all-time --json # Everything +``` + +## Error handling + +All errors in `--json` mode return: +```json +{ "error": "Human-readable message", "code": "ERROR_CODE" } +``` + +Exit codes: `0` success, `1` error (or lint failures). diff --git a/src/commands/budget.ts b/src/commands/budget.ts new file mode 100644 index 0000000..ca7d044 --- /dev/null +++ b/src/commands/budget.ts @@ -0,0 +1,204 @@ +import { existsSync, readFileSync } from "fs"; +import { resolve } from "path"; +import type { + ParsedArgs, + AgentName, + BudgetResult, + BudgetConfigFile, + BudgetEntry, + BudgetAgentSummary, + BudgetContextLimit, +} from "../types"; +import { scanAll } from "../core/scanner"; +import { + getAllAgentConfigs, + findProjectRoot, + getContextLimit, +} from "../core/agents"; +import { estimateTokens, formatTokens, tokenBar } from "../core/tokens"; +import { printJson, printError, parseScopeFlag, formatAgent, c } from "../utils/output"; + +export async function run(args: ParsedArgs): Promise { + const json = args.flags.json === true; + const projectRoot = findProjectRoot(); + + const scopes = parseScopeFlag(args.flags.scope, json); + + // Scan only skills (not agents, commands, rules) + const allItems = await scanAll({ projectRoot, scopes, types: ["skill"] }); + + // Scan config files + const configFiles: BudgetConfigFile[] = []; + for (const config of getAllAgentConfigs()) { + for (const cf of config.configFiles) { + const fullPath = resolve(projectRoot, cf); + if (existsSync(fullPath)) { + try { + const content = readFileSync(fullPath, "utf-8"); + const tokens = estimateTokens(content); + configFiles.push({ name: cf, tokens, filePath: fullPath }); + } catch { + // skip unreadable + } + } + } + } + + // Build per-skill entries (sorted by tokens desc) + const entries: BudgetEntry[] = allItems + .flatMap((s) => + s.agents.map((agent) => ({ + name: s.name, + agent, + tokens: s.tokenEstimate, + percentage: 0, + filePath: s.filePath, + })) + ) + .sort((a, b) => b.tokens - a.tokens); + + // Per-agent summaries + const byAgent: Partial> = {}; + for (const entry of entries) { + const existing = byAgent[entry.agent] || { tokens: 0, count: 0 }; + existing.tokens += entry.tokens; + existing.count += 1; + byAgent[entry.agent] = existing; + } + + // Add config file tokens to agent totals + for (const cf of configFiles) { + let agent: AgentName | null = null; + if (cf.name.toLowerCase().includes("claude")) agent = "claude"; + else if (cf.name.includes("cursorrules")) agent = "cursor"; + + if (agent) { + const existing = byAgent[agent] || { tokens: 0, count: 0 }; + existing.tokens += cf.tokens; + byAgent[agent] = existing; + } + } + + // Context limits + const contextLimits: Partial> = {}; + const totalTokens = + entries.reduce((sum, e) => sum + e.tokens, 0) + + configFiles.reduce((sum, cf) => sum + cf.tokens, 0); + + for (const [agent, summary] of Object.entries(byAgent)) { + const limit = getContextLimit(agent as AgentName); + const pct = (summary.tokens / limit) * 100; + contextLimits[agent as AgentName] = { + limit, + used: summary.tokens, + percentage: Math.round(pct * 10) / 10, + }; + } + + // Update percentages — bars relative to context limit, not to max skill + for (const entry of entries) { + const limit = getContextLimit(entry.agent); + entry.percentage = Math.round((entry.tokens / limit) * 1000) / 10; + } + + // Generate top suggestions (max 5, ranked by token savings) + const suggestions: string[] = []; + const candidates: { text: string; tokens: number }[] = []; + + for (const skill of allItems) { + if (!skill.badges.includes("STALE")) continue; + const daysAgo = Math.floor((Date.now() / 1000 - skill.lastModified) / 86400); + if (skill.badges.includes("HEAVY")) { + candidates.push({ + text: `Remove "${skill.name}" — stale (${daysAgo}d), saves ${formatTokens(skill.tokenEstimate)} tokens`, + tokens: skill.tokenEstimate, + }); + } else { + candidates.push({ + text: `Remove stale "${skill.name}" (${daysAgo}d unused, ${formatTokens(skill.tokenEstimate)} tokens)`, + tokens: skill.tokenEstimate, + }); + } + } + + // Sort by biggest savings first, take top 5 + candidates.sort((a, b) => b.tokens - a.tokens); + for (const c of candidates.slice(0, 5)) { + suggestions.push(c.text); + } + + // Add context warning if over 10% + for (const [agent, cl] of Object.entries(contextLimits)) { + if (cl.percentage > 10) { + suggestions.push( + `${agent}: ${cl.percentage}% of context used by skills before your first message` + ); + } + } + + const result: BudgetResult = { + totalTokens, + configFiles, + skills: entries, + byAgent, + contextLimits, + suggestions, + }; + + if (json) { + printJson({ ok: true, data: result }); + } + + // Human output + console.log(c.bold("\nAGS Skill Cost\n")); + + // Config files + if (configFiles.length > 0) { + console.log(c.bold("Config files")); + for (const cf of configFiles) { + console.log( + ` ${cf.name.padEnd(30)} ${formatTokens(cf.tokens).padStart(6)} tokens` + ); + } + console.log(); + } + + // Skills ranked by token cost — bars relative to agent context limit + if (entries.length > 0) { + console.log(c.bold("Skills ranked by token cost")); + console.log(); + for (const entry of entries) { + const limit = getContextLimit(entry.agent); + const name = c.bold(entry.name.padEnd(24)); + const agent = formatAgent(entry.agent); + const tokens = formatTokens(entry.tokens).padStart(6); + const bar = tokenBar(entry.tokens, limit, 20); + console.log(` ${name} ${agent} ${tokens} ${bar}`); + } + console.log(); + } + + // Agent subtotals + if (Object.keys(contextLimits).length > 0) { + console.log(c.bold("Context usage per agent")); + console.log(); + for (const [agent, cl] of Object.entries(contextLimits)) { + const icon = formatAgent(agent as AgentName); + const tokens = formatTokens(cl.used); + const limit = formatTokens(cl.limit); + const bar = tokenBar(cl.used, cl.limit, 30); + console.log(` ${icon} ${bar} ${tokens} / ${limit}`); + } + console.log(); + } + + // Suggestions (max 5) + if (suggestions.length > 0) { + console.log(c.bold("Top suggestions")); + console.log(); + for (const s of suggestions) { + console.log(` ${c.yellow("→")} ${s}`); + } + console.log(); + } +} diff --git a/src/commands/context.ts b/src/commands/context.ts new file mode 100644 index 0000000..f88685c --- /dev/null +++ b/src/commands/context.ts @@ -0,0 +1,246 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "fs"; +import { resolve, basename, join } from "path"; +import type { ParsedArgs, AgentName } from "../types"; +import { scanAll } from "../core/scanner"; +import { + getAllAgentConfigs, + findProjectRoot, + getContextLimit, +} from "../core/agents"; +import { estimateTokens, formatTokens, tokenBar } from "../core/tokens"; +import { printJson, parseAgentFlag, formatAgent, shortenPath, c } from "../utils/output"; + +// ── Types ──────────────────────────────────────────────────────── + +interface ContextItem { + name: string; + category: string; // "config" | "skill" | "command" | "agent" | "memory" | "mcp" + tokens: number; + filePath: string; +} + +interface AgentContext { + agent: AgentName; + items: ContextItem[]; + totalTokens: number; + contextLimit: number; + percentage: number; +} + +interface ContextResult { + projectRoot: string; + agents: AgentContext[]; + grandTotal: number; +} + +// ── Command ────────────────────────────────────────────────────── + +export async function run(args: ParsedArgs): Promise { + const json = args.flags.json === true; + const projectRoot = findProjectRoot(); + + const agentFilter = parseAgentFlag(args.flags.agent, json); + + const configs = getAllAgentConfigs(); + const allSkills = await scanAll({ projectRoot }); + const agentContexts: AgentContext[] = []; + + for (const config of configs) { + if (agentFilter && !agentFilter.includes(config.name)) continue; + + const items: ContextItem[] = []; + + // 1. Config files (CLAUDE.md, .cursorrules) + for (const cf of config.configFiles) { + const fullPath = resolve(projectRoot, cf); + if (existsSync(fullPath)) { + try { + const content = readFileSync(fullPath, "utf-8"); + items.push({ + name: cf, + category: "config", + tokens: estimateTokens(content), + filePath: fullPath, + }); + } catch { /* skip unreadable */ } + } + } + + // 2. Skills, commands, agents for this agent + const agentSkills = allSkills.filter((s) => s.agents.includes(config.name)); + for (const skill of agentSkills) { + items.push({ + name: skill.name, + category: skill.type, + tokens: skill.tokenEstimate, + filePath: skill.filePath, + }); + } + + // 3. Memory files (Claude-specific) + if (config.name === "claude") { + const memoryItems = scanMemoryFiles(projectRoot); + items.push(...memoryItems); + } + + // 4. MCP server configs (from settings files) + if (config.name === "claude") { + const mcpItems = scanMcpConfig(projectRoot); + items.push(...mcpItems); + } + + const totalTokens = items.reduce((sum, i) => sum + i.tokens, 0); + const limit = getContextLimit(config.name); + + agentContexts.push({ + agent: config.name, + items, + totalTokens, + contextLimit: limit, + percentage: Math.round((totalTokens / limit) * 1000) / 10, + }); + } + + const grandTotal = agentContexts.reduce((sum, a) => sum + a.totalTokens, 0); + const result: ContextResult = { projectRoot, agents: agentContexts, grandTotal }; + + if (json) { + printJson({ ok: true, data: result }); + } + + // Human output + console.log(c.bold(`\nAGS Context Map`)); + console.log(c.dim(`Project: ${shortenPath(projectRoot)}\n`)); + + for (const ctx of agentContexts) { + if (ctx.items.length === 0) continue; + + const icon = formatAgent(ctx.agent); + const bar = tokenBar(ctx.totalTokens, ctx.contextLimit, 25); + console.log(`${icon} ${bar} ${formatTokens(ctx.totalTokens)} / ${formatTokens(ctx.contextLimit)}`); + console.log(); + + // Group by category + const categories = groupByCategory(ctx.items); + for (const [cat, catItems] of categories) { + const catTokens = catItems.reduce((sum, i) => sum + i.tokens, 0); + console.log(` ${categoryLabel(cat)} ${c.dim(`(${catItems.length} items, ${formatTokens(catTokens)} tokens)`)}`); + + // Sort by tokens desc within category + const sorted = [...catItems].sort((a, b) => b.tokens - a.tokens); + for (const item of sorted) { + const tokens = formatTokens(item.tokens).padStart(6); + console.log(` ${c.bold(item.name.padEnd(28))} ${tokens} ${c.dim(shortenPath(item.filePath))}`); + } + console.log(); + } + } + + console.log(`${c.dim("Grand total:")} ${formatTokens(grandTotal)} tokens loaded before your first message\n`); +} + +// ── Helpers ────────────────────────────────────────────────────── + +function scanMemoryFiles(projectRoot: string): ContextItem[] { + const items: ContextItem[] = []; + const home = process.env.HOME || ""; + + // Find the Claude project memory directory + // Claude stores project data in ~/.claude/projects/ with path-encoded names + const claudeProjectsDir = join(home, ".claude", "projects"); + if (!existsSync(claudeProjectsDir)) return items; + + try { + const projectDirs = readdirSync(claudeProjectsDir); + for (const dir of projectDirs) { + const memoryDir = join(claudeProjectsDir, dir, "memory"); + if (!existsSync(memoryDir)) continue; + + // Check if this project dir corresponds to our project + // Claude encodes paths like: -Users-robin-Documents-project + const encoded = projectRoot.replace(/\//g, "-"); + if (!dir.includes(encoded) && !dir.endsWith(encoded)) continue; + + try { + const files = readdirSync(memoryDir); + for (const file of files) { + if (!file.endsWith(".md")) continue; + const filePath = join(memoryDir, file); + try { + const content = readFileSync(filePath, "utf-8"); + items.push({ + name: `memory/${file}`, + category: "memory", + tokens: estimateTokens(content), + filePath, + }); + } catch { /* skip */ } + } + } catch { /* skip */ } + } + } catch { /* skip */ } + + // Also check MEMORY.md index + const memoryIndex = join(home, ".claude", "projects"); + // The MEMORY.md is in the project-specific dir, already covered above + + return items; +} + +function scanMcpConfig(projectRoot: string): ContextItem[] { + const items: ContextItem[] = []; + const home = process.env.HOME || ""; + + // Check project-level .claude/settings.local.json and ~/.claude/settings.json + const settingsPaths = [ + join(projectRoot, ".claude", "settings.local.json"), + join(home, ".claude", "settings.json"), + join(home, ".claude", "settings.local.json"), + ]; + + for (const settingsPath of settingsPaths) { + if (!existsSync(settingsPath)) continue; + try { + const content = readFileSync(settingsPath, "utf-8"); + const parsed = JSON.parse(content); + if (parsed.mcpServers && Object.keys(parsed.mcpServers).length > 0) { + const mcpSection = JSON.stringify(parsed.mcpServers, null, 2); + items.push({ + name: `mcp-servers (${basename(settingsPath)})`, + category: "mcp", + tokens: estimateTokens(mcpSection), + filePath: settingsPath, + }); + } + } catch { /* skip */ } + } + + return items; +} + +function groupByCategory(items: ContextItem[]): [string, ContextItem[]][] { + const order = ["config", "skill", "command", "agent", "memory", "mcp"]; + const groups = new Map(); + + for (const item of items) { + const list = groups.get(item.category) || []; + list.push(item); + groups.set(item.category, list); + } + + return order + .filter((cat) => groups.has(cat)) + .map((cat) => [cat, groups.get(cat)!]); +} + +function categoryLabel(cat: string): string { + switch (cat) { + case "config": return c.yellow("Config files"); + case "skill": return c.green("Skills"); + case "command": return c.yellow("Commands"); + case "agent": return c.cyan("Agents/subagents"); + case "memory": return c.magenta("Memory files"); + case "mcp": return c.blue("MCP servers"); + default: return cat; + } +} diff --git a/src/commands/grab.ts b/src/commands/grab.ts new file mode 100644 index 0000000..9c66458 --- /dev/null +++ b/src/commands/grab.ts @@ -0,0 +1,104 @@ +import { existsSync, mkdirSync, writeFileSync } from "fs"; +import { dirname } from "path"; +import type { ParsedArgs, AgentName, GrabResult } from "../types"; +import { getAgentConfig, findProjectRoot, isValidAgentName, expandPattern } from "../core/agents"; +import { parseSkillFile, nameFromFilePath } from "../core/parser"; +import { estimateTokens, formatTokens } from "../core/tokens"; +import { parseGitHubUrl, fetchRawContent } from "../utils/github"; +import { printJson, printError, c } from "../utils/output"; + +export async function run(args: ParsedArgs): Promise { + const json = args.flags.json === true; + const url = args.positional[0]; + + if (!url) { + return printError("Usage: ags grab [--to agent]", "MISSING_URL", json); + } + + const targetAgent = (args.flags.to as string) || "claude"; + if (!isValidAgentName(targetAgent)) { + return printError(`Unknown agent: ${targetAgent}`, "INVALID_AGENT", json); + } + + const info = parseGitHubUrl(url); + if (!info) { + return printError( + "Invalid GitHub URL. Expected: https://github.com/owner/repo/blob/branch/path/to/SKILL.md", + "INVALID_URL", + json + ); + } + + let content: string; + try { + content = await fetchRawContent(info); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to fetch"; + return printError(msg, "FETCH_FAILED", json); + } + + const parsed = parseSkillFile(content); + const rawName = parsed.frontmatter.name + ? String(parsed.frontmatter.name) + : nameFromFilePath(info.path); + const name = rawName.replace(/[\/\\]/g, "-").replace(/\.\./g, ""); + + const tokens = estimateTokens(content); + + const projectRoot = findProjectRoot(); + const agentConfig = getAgentConfig(targetAgent); + + const projectPath = agentConfig.paths.find((p) => p.scope === "project" && p.format === "skill"); + const userPath = agentConfig.paths.find((p) => p.scope === "user" && p.format === "skill"); + const targetPath = projectPath || userPath; + + if (!targetPath) { + return printError(`No skill path configured for ${targetAgent}`, "NO_PATH", json); + } + + const destPattern = expandPattern(targetPath.pattern, projectRoot); + const destination = destPattern.replace("*/SKILL.md", `${name}/SKILL.md`); + + if (existsSync(destination)) { + return printError( + `Skill "${name}" already exists at ${destination}`, + "SKILL_EXISTS", + json + ); + } + + const dryRun = args.flags["dry-run"] === true; + + const result: GrabResult = { + name, + source: url, + destination, + tokens, + agent: targetAgent, + }; + + if (!dryRun) { + const destDir = dirname(destination); + mkdirSync(destDir, { recursive: true }); + writeFileSync(destination, content, "utf-8"); + } + + if (json) { + return printJson({ ok: true, data: { ...result, dryRun } }); + } + + const prefix = dryRun ? c.yellow("[dry-run] ") : ""; + console.log(c.bold(`\n${prefix}AGS Grab\n`)); + console.log(` ${c.bold("Name:")} ${name}`); + console.log(` ${c.bold("Agent:")} ${targetAgent}`); + console.log(` ${c.bold("Tokens:")} ${formatTokens(tokens)}`); + console.log(` ${c.bold("Source:")} ${c.dim(url)}`); + console.log(` ${c.bold("Saved:")} ${destination}`); + console.log(); + if (dryRun) { + console.log(c.yellow(` ⚠ Dry run — no files were written`)); + } else { + console.log(c.green(` ✓ Skill "${name}" installed for ${targetAgent}`)); + } + console.log(); +} diff --git a/src/commands/lint.ts b/src/commands/lint.ts new file mode 100644 index 0000000..2ce8bf1 --- /dev/null +++ b/src/commands/lint.ts @@ -0,0 +1,218 @@ +import type { ParsedArgs } from "../types"; +import { scanAll } from "../core/scanner"; +import { getAgentConfig } from "../core/agents"; +import { + printJson, + shortenPath, + parseScopeFlag, + parseAgentFlag, + c, +} from "../utils/output"; + +// ── Types ──────────────────────────────────────────────────────── + +type Severity = "error" | "warning" | "info"; + +interface LintIssue { + severity: Severity; + rule: string; + message: string; + skill: string; + filePath: string; +} + +interface LintResult { + issues: LintIssue[]; + scanned: number; + errors: number; + warnings: number; + passed: number; +} + +// ── Command ────────────────────────────────────────────────────── + +export async function run(args: ParsedArgs): Promise { + const json = args.flags.json === true; + + const agents = parseAgentFlag(args.flags.agent, json); + + const scopes = parseScopeFlag(args.flags.scope, json); + const skills = await scanAll({ agents, scopes }); + const issues: LintIssue[] = []; + + for (const skill of skills) { + // Rule: missing-frontmatter + if (Object.keys(skill.frontmatter).length === 0) { + issues.push({ + severity: "error", + rule: "missing-frontmatter", + message: "No YAML frontmatter found — agents may not discover or understand this skill", + skill: skill.name, + filePath: skill.filePath, + }); + } + + // Rule: missing-name + if (!skill.frontmatter.name) { + issues.push({ + severity: "warning", + rule: "missing-name", + message: `No "name" in frontmatter — falling back to filename "${skill.name}"`, + skill: skill.name, + filePath: skill.filePath, + }); + } + + // Rule: missing-description + if (!skill.frontmatter.description) { + issues.push({ + severity: "error", + rule: "missing-description", + message: "No \"description\" in frontmatter — agents use this to decide when to invoke the skill", + skill: skill.name, + filePath: skill.filePath, + }); + } + + // Rule: empty-description + if (skill.frontmatter.description && String(skill.frontmatter.description).trim().length < 10) { + issues.push({ + severity: "warning", + rule: "short-description", + message: `Description is very short (${String(skill.frontmatter.description).trim().length} chars) — be specific about when agents should use this`, + skill: skill.name, + filePath: skill.filePath, + }); + } + + // Rule: heavy (over 5k chars) + if (skill.badges.includes("HEAVY")) { + issues.push({ + severity: "warning", + rule: "heavy", + message: `File is ${skill.fileSize.toLocaleString()} chars — consider trimming to save context budget`, + skill: skill.name, + filePath: skill.filePath, + }); + } + + // Rule: oversized (over 500 lines) + if (skill.badges.includes("OVERSIZED")) { + issues.push({ + severity: "error", + rule: "oversized", + message: `File is ${skill.lineCount} lines — this dominates context and may crowd out other skills`, + skill: skill.name, + filePath: skill.filePath, + }); + } + + // Rule: name-conflict + if (skill.badges.includes("CONFLICT")) { + issues.push({ + severity: "error", + rule: "name-conflict", + message: `Another skill with name "${skill.name}" exists at a different path — agents may load the wrong one`, + skill: skill.name, + filePath: skill.filePath, + }); + } + + // Rule: unsupported-frontmatter (check against agent's supported keys) + for (const agent of skill.agents) { + const config = getAgentConfig(agent); + const supported = new Set(config.supportedFrontmatter); + for (const key of Object.keys(skill.frontmatter)) { + if (!supported.has(key)) { + issues.push({ + severity: "info", + rule: "unsupported-key", + message: `Frontmatter key "${key}" is not recognized by ${config.displayName}`, + skill: skill.name, + filePath: skill.filePath, + }); + } + } + } + + // Rule: empty-body + if (skill.rawContent.replace(/^---[\s\S]*?---\s*/, "").trim().length === 0) { + issues.push({ + severity: "error", + rule: "empty-body", + message: "Skill has no body content — just frontmatter with no instructions", + skill: skill.name, + filePath: skill.filePath, + }); + } + } + + // Sort: errors first, then warnings, then info + const severityOrder: Record = { error: 0, warning: 1, info: 2 }; + issues.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); + + const errors = issues.filter((i) => i.severity === "error").length; + const warnings = issues.filter((i) => i.severity === "warning").length; + const passed = skills.length - new Set(issues.filter((i) => i.severity === "error").map((i) => i.filePath)).size; + + const result: LintResult = { + issues, + scanned: skills.length, + errors, + warnings, + passed, + }; + + if (json) { + printJson({ ok: true, data: result }); + } + + // Human output + console.log(c.bold(`\nAGS Lint — ${skills.length} files scanned\n`)); + + if (issues.length === 0) { + console.log(c.green(" All clear — no issues found.\n")); + return; + } + + // Group issues by file + const byFile = new Map(); + for (const issue of issues) { + const list = byFile.get(issue.filePath) || []; + list.push(issue); + byFile.set(issue.filePath, list); + } + + for (const [filePath, fileIssues] of byFile) { + const skillName = fileIssues[0].skill; + console.log(` ${c.bold(skillName)} ${c.dim(shortenPath(filePath))}`); + + for (const issue of fileIssues) { + const icon = severityIcon(issue.severity); + console.log(` ${icon} ${issue.message} ${c.dim(`[${issue.rule}]`)}`); + } + console.log(); + } + + // Summary + const parts: string[] = []; + if (errors > 0) parts.push(c.red(`${errors} errors`)); + if (warnings > 0) parts.push(c.yellow(`${warnings} warnings`)); + const infos = issues.length - errors - warnings; + if (infos > 0) parts.push(c.dim(`${infos} info`)); + + console.log(` ${parts.join(" ")} | ${passed}/${skills.length} files passed\n`); + + // Exit with error code if there are errors + if (errors > 0) { + process.exit(1); + } +} + +function severityIcon(severity: Severity): string { + switch (severity) { + case "error": return c.red("✕"); + case "warning": return c.yellow("!"); + case "info": return c.dim("·"); + } +} diff --git a/src/commands/rm.ts b/src/commands/rm.ts new file mode 100644 index 0000000..3fdc29a --- /dev/null +++ b/src/commands/rm.ts @@ -0,0 +1,138 @@ +import { existsSync, unlinkSync, rmdirSync, readdirSync } from "fs"; +import { dirname, resolve } from "path"; +import type { ParsedArgs, AgentName, DiscoveredSkill } from "../types"; +import { scanAll } from "../core/scanner"; +import { isValidAgentName } from "../core/agents"; +import { formatTokens } from "../core/tokens"; +import { printJson, printError, formatAgent, c } from "../utils/output"; + +interface RmResult { + removed: { name: string; agent: AgentName; type: string; path: string; tokens: number }[]; + notFound: string[]; +} + +export async function run(args: ParsedArgs): Promise { + const json = args.flags.json === true; + const target = args.positional[0]; + + if (!target) { + return printError("Usage: ags rm [--agent X]", "MISSING_TARGET", json); + } + + // Optional agent filter + let agentFilter: AgentName | undefined; + if (args.flags.agent) { + const a = String(args.flags.agent); + if (!isValidAgentName(a)) { + return printError(`Unknown agent: ${a}`, "INVALID_AGENT", json); + } + agentFilter = a; + } + + // Find matching items + const allItems = await scanAll(); + let matches: DiscoveredSkill[]; + + // Match by path (absolute or shortened) + const absTarget = resolve(target); + const isPath = target.includes("/") || target.includes("."); + + if (isPath) { + matches = allItems.filter((s) => + s.filePath === absTarget || + s.filePath === target || + s.filePath.endsWith("/" + target) + ); + } else { + // Match by name + matches = allItems.filter((s) => s.name === target); + } + + // Filter by agent if specified + if (agentFilter) { + matches = matches.filter((s) => s.agents.includes(agentFilter!)); + } + + if (matches.length === 0) { + return printError( + `No match found for "${target}"${agentFilter ? ` (agent: ${agentFilter})` : ""}`, + "NOT_FOUND", + json + ); + } + + const dryRun = args.flags["dry-run"] === true; + + // Remove each match + const result: RmResult = { removed: [], notFound: [] }; + + for (const item of matches) { + if (!existsSync(item.filePath)) { + result.notFound.push(item.filePath); + continue; + } + + if (!dryRun) { + // Delete the file + unlinkSync(item.filePath); + + // If it was a SKILL.md inside a skill directory, clean up the directory if empty + const dir = dirname(item.filePath); + if (item.filePath.endsWith("/SKILL.md")) { + tryRemoveEmptyDir(dir); + } + } + + result.removed.push({ + name: item.name, + agent: item.agents[0], + type: item.type, + path: item.filePath, + tokens: item.tokenEstimate, + }); + } + + if (json) { + return printJson({ ok: true, data: { ...result, dryRun } }); + } + + // Human output + const prefix = dryRun ? c.yellow("[dry-run] ") : ""; + console.log(c.bold(`\n${prefix}AGS Remove\n`)); + + for (const r of result.removed) { + const verb = dryRun ? c.yellow("~") : c.red("✕"); + console.log( + ` ${verb} ${c.bold(r.name)} ${c.dim(`(${r.type})`)} ${formatAgent(r.agent as AgentName)} ${c.dim("−" + formatTokens(r.tokens))}` + ); + console.log(` ${c.dim(r.path)}`); + } + + if (result.notFound.length > 0) { + for (const p of result.notFound) { + console.log(` ${c.yellow("?")} ${c.dim(p)} — file already gone`); + } + } + + const totalTokens = result.removed.reduce((sum, r) => sum + r.tokens, 0); + if (dryRun) { + console.log( + `\n ${result.removed.length} would be removed, ${formatTokens(totalTokens)} tokens would be freed\n` + ); + } else { + console.log( + `\n ${result.removed.length} removed, ${formatTokens(totalTokens)} tokens freed\n` + ); + } +} + +function tryRemoveEmptyDir(dir: string): void { + try { + const entries = readdirSync(dir); + if (entries.length === 0) { + rmdirSync(dir); + } + } catch { + // ignore + } +} diff --git a/src/commands/scan.ts b/src/commands/scan.ts new file mode 100644 index 0000000..bbe99e9 --- /dev/null +++ b/src/commands/scan.ts @@ -0,0 +1,209 @@ +import { existsSync } from "fs"; +import type { ParsedArgs, AgentName, SkillType, ScanResult, DiscoveredSkill } from "../types"; +import { scanAll } from "../core/scanner"; +import { + getAllAgentConfigs, + getBinaryPath, + resolveAgentPaths, + findProjectRoot, +} from "../core/agents"; +import { formatTokens } from "../core/tokens"; +import { + printError, + printJson, + table, + formatBadges, + formatAgent, + formatAgents, + formatType, + formatScope, + scopeLabel, + shortenPath, + parseScopeFlag, + parseAgentFlag, + c, +} from "../utils/output"; + +// Display order and section labels for each type +const TYPE_SECTIONS: { type: SkillType; label: string; icon: string }[] = [ + { type: "skill", label: "Skills", icon: "◇" }, + { type: "command", label: "Commands", icon: "▸" }, + { type: "agent", label: "Agents", icon: "●" }, + { type: "rule", label: "Rules", icon: "§" }, +]; + +export async function run(args: ParsedArgs): Promise { + const json = args.flags.json === true; + + // Parse filters + const agents = parseAgentFlag(args.flags.agent, json); + + let types: SkillType[] | undefined; + if (args.flags.type) { + const t = String(args.flags.type) as SkillType; + if (!["skill", "command", "rule", "agent"].includes(t)) { + return printError(`Unknown type: ${t}. Use: skill, command, rule, agent`, "INVALID_TYPE", json); + } + types = [t]; + } + + const scopes = parseScopeFlag(args.flags.scope, json); + const showInstalled = args.flags.installed === true; + + // If --installed, show agent installation info + if (showInstalled) { + return runInstalled(json); + } + + const skills = await scanAll({ agents, types, scopes }); + + // Build summary + const totalTokens = skills.reduce((sum, s) => sum + s.tokenEstimate, 0); + const summary: ScanResult["summary"] = { + total: skills.length, + totalTokens, + byAgent: {}, + byType: {}, + byScope: {}, + badges: {}, + }; + + for (const skill of skills) { + for (const agent of skill.agents) { + summary.byAgent[agent] = (summary.byAgent[agent] || 0) + 1; + } + summary.byType[skill.type] = (summary.byType[skill.type] || 0) + 1; + summary.byScope[skill.scope] = (summary.byScope[skill.scope] || 0) + 1; + for (const badge of skill.badges) { + summary.badges[badge] = (summary.badges[badge] || 0) + 1; + } + } + + const result: ScanResult = { skills, summary }; + + if (json) { + printJson({ ok: true, data: result }); + } + + // Human output + if (skills.length === 0) { + console.log(c.dim("Nothing found.")); + return; + } + + console.log(c.bold(`\nAGS Scan — ${skills.length} items found\n`)); + + // Group by type and render separate tables + for (const section of TYPE_SECTIONS) { + const items = skills.filter((s) => s.type === section.type); + if (items.length === 0) continue; + + const sectionTitle = `${section.icon} ${section.label} (${items.length})`; + console.log(c.bold(formatType(section.type).replace(section.type, sectionTitle))); + console.log(); + + const rows = items.map((s) => renderRow(s)); + + console.log( + table( + ["NAME", "SCOPE", "AGENT(S)", "TOKENS", "BADGES", "PATH"], + rows + ) + ); + console.log(); + } + + // Summary + const agentParts = Object.entries(summary.byAgent) + .map(([k, v]) => `${formatAgent(k as AgentName)} ${v}`) + .join(" "); + const typeParts = Object.entries(summary.byType) + .map(([k, v]) => `${formatType(k as SkillType)} ${v}`) + .join(" "); + const scopeParts = Object.entries(summary.byScope) + .map(([k, v]) => `${scopeLabel(k)}: ${v}`) + .join(" | "); + + console.log( + `${c.dim("Total:")} ${summary.total} | ${agentParts} | ${typeParts} | ${scopeParts} | ${formatTokens(totalTokens)} tokens` + ); + + if (Object.keys(summary.badges).length > 0) { + const badgeParts = Object.entries(summary.badges) + .map(([k, v]) => `${v} ${k}`) + .join(" | "); + console.log(`${c.dim("Badges:")} ${badgeParts}`); + } + + console.log(); +} + +// ── --installed: show agent installation info ─────────────────── + +async function runInstalled(json: boolean): Promise { + const projectRoot = findProjectRoot(); + const configs = getAllAgentConfigs(); + const allSkills = await scanAll({ projectRoot }); + + const agents = []; + + for (const config of configs) { + const binaryPath = await getBinaryPath(config.name); + const installed = binaryPath !== null; + + // Resolve paths and check existence + const resolved = resolveAgentPaths(config, projectRoot); + const seenDirs = new Set(); + const paths: { scope: string; path: string; exists: boolean }[] = []; + + for (const rp of resolved) { + const baseDir = rp.absolutePattern.split("*")[0].replace(/\/$/, ""); + if (seenDirs.has(baseDir)) continue; + seenDirs.add(baseDir); + paths.push({ scope: rp.scope, path: baseDir, exists: existsSync(baseDir) }); + } + + const skillCount = allSkills.filter((s) => s.agents.includes(config.name)).length; + + agents.push({ + name: config.name, + displayName: config.displayName, + installed, + binaryPath, + skillCount, + paths, + }); + } + + if (json) { + printJson({ ok: true, data: { agents } }); + } + + console.log(c.bold("\nAGS Agents\n")); + + const rows = agents.map((a) => { + const status = a.installed ? c.green("✓") : c.red("✗"); + const pathSummary = a.paths + .filter((p) => p.exists) + .map((p) => shortenPath(p.path)) + .join(", ") || c.dim("—"); + + return [a.displayName, status, String(a.skillCount), pathSummary]; + }); + + console.log(table(["AGENT", "INSTALLED", "SKILLS", "ACTIVE PATHS"], rows)); + console.log(); +} + +// ── Helpers ───────────────────────────────────────────────────── + +function renderRow(s: DiscoveredSkill): string[] { + return [ + c.bold(s.name), + formatScope(s.scope), + formatAgents(s.agents), + formatTokens(s.tokenEstimate), + formatBadges(s.badges), + c.dim(shortenPath(s.filePath)), + ]; +} diff --git a/src/commands/stats.ts b/src/commands/stats.ts new file mode 100644 index 0000000..8f29407 --- /dev/null +++ b/src/commands/stats.ts @@ -0,0 +1,594 @@ +import { existsSync, readdirSync, statSync } from "fs"; +import { resolve } from "path"; +import type { ParsedArgs, AgentName } from "../types"; +import { formatTokens } from "../core/tokens"; +import { printJson, printError, pad, formatAgent, c } from "../utils/output"; + +// ── Types ─────────────────────────────────────────────────────── + +interface ProjectStats { + dirName: string; + displayName: string; + sessionCount: number; + lastActive: Date; +} + +interface DayActivity { + date: string; + sessions: number; +} + +interface StatsResult { + agent: string; + period: { from: string; to: string; activeDays: number }; + overview: { + totalSessions: number; + totalProjects: number; + totalPRs: number; + totalUserMessages: number; + totalAssistantMessages: number; + }; + tokens: { + input: number; + output: number; + model: string; + }; + topProjects: { name: string; sessions: number }[]; + integrations: { name: string; calls: number }[]; + skills: { name: string; calls: number }[]; + subagents: { name: string; calls: number }[]; + web: { searches: number; fetches: number }; + apiErrors: number; + hourCounts: Record; + dailyActivity: DayActivity[]; +} + +// ── Spinner ───────────────────────────────────────────────────── + +class Spinner { + private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + private idx = 0; + private interval: ReturnType | null = null; + private text = ""; + + start(text: string) { + this.text = text; + if (!process.stdout.isTTY) return; + this.interval = setInterval(() => { + const frame = c.cyan(this.frames[this.idx % this.frames.length]); + process.stdout.write(`\r ${frame} ${c.dim(this.text)}`); + this.idx++; + }, 80); + } + + update(text: string) { + this.text = text; + } + + stop() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + process.stdout.write("\r" + " ".repeat(this.text.length + 10) + "\r"); + } + } +} + +// ── Command ───────────────────────────────────────────────────── + +export async function run(args: ParsedArgs): Promise { + const json = args.flags.json === true; + const home = process.env.HOME || ""; + const projectsDir = resolve(home, ".claude/projects"); + + if (!existsSync(projectsDir)) { + return printError("No stats found. ~/.claude/projects/ not found.", "NO_STATS", json); + } + + // Parse --period flag (default: 30d) + const rawPeriod = args.flags.period as string | undefined; + const period = rawPeriod || "30d"; + const cutoff = parsePeriod(period); + const periodLabel = getPeriodLabel(period); + + const spinner = new Spinner(); + if (!json) spinner.start("Scanning sessions..."); + + // ── Phase 1: Scan project directories for session counts ──── + + const allDirs = readdirSync(projectsDir); + const projects: ProjectStats[] = []; + const dailyMap = new Map(); + const sessionJsonls: string[] = []; // top-level only, no subagents + let totalSessions = 0; + let earliest: Date | null = null; + let latest: Date | null = null; + + for (const dirName of allDirs) { + if (dirName.includes("-claude-worktrees-")) continue; + + const dirPath = resolve(projectsDir, dirName); + try { + if (!statSync(dirPath).isDirectory()) continue; + } catch { + continue; + } + + let sessionCount = 0; + let lastActive = new Date(0); + + try { + for (const file of readdirSync(dirPath)) { + if (!file.endsWith(".jsonl")) continue; + + const filePath = resolve(dirPath, file); + try { + const stat = statSync(filePath); + if (stat.size > 50 * 1024 * 1024) continue; // skip >50MB files + const mtime = stat.mtime; + + // Filter by period + if (cutoff && mtime < cutoff) continue; + + sessionCount++; + if (mtime > lastActive) lastActive = mtime; + if (!earliest || mtime < earliest) earliest = mtime; + if (!latest || mtime > latest) latest = mtime; + + const day = mtime.toISOString().split("T")[0]; + dailyMap.set(day, (dailyMap.get(day) || 0) + 1); + + sessionJsonls.push(filePath); + } catch { + // skip + } + } + } catch { + continue; + } + + totalSessions += sessionCount; + projects.push({ dirName, displayName: cleanDirName(dirName), sessionCount, lastActive }); + } + + const merged = mergeProjects(projects); + const topProjects = merged + .filter((p) => p.sessionCount > 0) + .sort((a, b) => b.sessionCount - a.sessionCount) + .slice(0, 8); + + const dailyActivity = Array.from(dailyMap.entries()) + .map(([date, sessions]) => ({ date, sessions })) + .sort((a, b) => a.date.localeCompare(b.date)) + .slice(-14); + + // ── Phase 2: Single pass through all session jsonls ───────── + + if (!json) spinner.update(`Analyzing ${sessionJsonls.length} sessions...`); + + let totalUserMessages = 0; + let totalAssistantMessages = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalPRs = 0; + let apiErrors = 0; + let webSearches = 0; + let webFetches = 0; + const modelCounts: Record = {}; + const mcpServices: Record = {}; + const skillCounts: Record = {}; + const subagentCounts: Record = {}; + const hourCounts: Record = {}; + + for (const jsonlPath of sessionJsonls) { + try { + // Stream line-by-line to avoid loading entire files into memory + const file = Bun.file(jsonlPath); + const stream = file.stream(); + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let remainder = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = remainder + decoder.decode(value, { stream: true }); + const lines = chunk.split("\n"); + remainder = lines.pop() || ""; + + for (const line of lines) { + if (!line) continue; + processJsonlLine(line); + } + } + // Process any trailing content + if (remainder) processJsonlLine(remainder); + } catch { + // skip bad file + } + } + + function processJsonlLine(line: string): void { + try { + const d = JSON.parse(line); + const type = d.type; + + if (type === "user") { + totalUserMessages++; + const ts = d.message?.timestamp || d.timestamp; + if (ts) { + const h = new Date(ts).getHours(); + if (!isNaN(h)) hourCounts[h] = (hourCounts[h] || 0) + 1; + } + } else if (type === "assistant") { + totalAssistantMessages++; + const msg = d.message || {}; + + const model = msg.model; + if (model && model !== "") { + modelCounts[model] = (modelCounts[model] || 0) + 1; + } + + const usage = msg.usage; + if (usage) { + totalInputTokens += usage.input_tokens || 0; + totalOutputTokens += usage.output_tokens || 0; + } + + const blocks = msg.content; + if (Array.isArray(blocks)) { + for (const block of blocks) { + if (!block || block.type !== "tool_use") continue; + const name = block.name || ""; + const input = block.input || {}; + + if (name === "Skill") { + let skill = input.skill || input.name || "unknown"; + if (skill.includes(":")) skill = skill.split(":").pop()!; + skillCounts[skill] = (skillCounts[skill] || 0) + 1; + } else if (name === "Agent") { + const agent = input.subagent_type || "general-purpose"; + subagentCounts[agent] = (subagentCounts[agent] || 0) + 1; + } else if (name === "WebSearch") { + webSearches++; + } else if (name === "WebFetch") { + webFetches++; + } else if (name.startsWith("mcp__")) { + const service = extractMcpService(name); + mcpServices[service] = (mcpServices[service] || 0) + 1; + } + } + } + } else if (type === "pr-link") { + totalPRs++; + } else if (type === "system" && d.subtype === "api_error") { + apiErrors++; + } + } catch { + // skip bad line + } + } + + spinner.stop(); + + // ── Build result ──────────────────────────────────────────── + + const primaryModel = Object.entries(modelCounts) + .sort((a, b) => b[1] - a[1])[0]?.[0] || "unknown"; + + const topIntegrations = Object.entries(mcpServices) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([name, calls]) => ({ name, calls })); + + const topSkills = Object.entries(skillCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([name, calls]) => ({ name, calls })); + + const topSubagents = Object.entries(subagentCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([name, calls]) => ({ name, calls })); + + const activeDays = dailyMap.size; + const firstDate = earliest ? earliest.toISOString().split("T")[0] : "unknown"; + const lastDate = latest ? latest.toISOString().split("T")[0] : "unknown"; + + const result: StatsResult = { + agent: "claude", + period: { from: firstDate, to: lastDate, activeDays }, + overview: { + totalSessions, + totalProjects: projects.length, + totalPRs, + totalUserMessages, + totalAssistantMessages, + }, + tokens: { + input: totalInputTokens, + output: totalOutputTokens, + model: shortenModel(primaryModel), + }, + topProjects: topProjects.map((p) => ({ name: p.displayName, sessions: p.sessionCount })), + integrations: topIntegrations, + skills: topSkills, + subagents: topSubagents, + web: { searches: webSearches, fetches: webFetches }, + apiErrors, + hourCounts, + dailyActivity, + }; + + if (json) { + printJson({ ok: true, data: result }); + } + + // ── Human output ──────────────────────────────────────────── + + const earliest_str = earliest ? formatDate(earliest.toISOString().split("T")[0]) : "unknown"; + const periodSuffix = periodLabel === "all time" + ? ` ${c.dim(`all time (earliest data: ${earliest_str})`)}` + : ` ${c.dim(periodLabel || "last 30 days")}`; + console.log(c.bold(`\nAGS Stats — ${formatAgent("claude" as AgentName)}`) + periodSuffix); + console.log(); + + // Overview line + const nums = [ + `${c.bold(String(totalSessions))} sessions`, + `${c.bold(String(projects.length))} projects`, + `${c.bold(String(totalPRs))} PRs`, + `${c.bold(String(activeDays))} active days`, + ]; + console.log(` ${nums.join(c.dim(" · "))}`); + + // Tokens + model + web + errors on one line + const totalTokens = totalInputTokens + totalOutputTokens; + const meta = [ + `${formatTokens(totalTokens)} tokens ${c.dim(`(${formatTokens(totalOutputTokens)} output)`)}`, + `model: ${c.bold(shortenModel(primaryModel))}`, + `${webSearches + webFetches} web calls`, + apiErrors > 0 ? `${c.yellow(String(apiErrors))} API errors` : null, + ].filter(Boolean); + console.log(` ${c.dim(meta.join(" · "))}`); + console.log(); + + // ── Projects & Integrations side by side ──────────────────── + const COL_LEFT = 46; + + if (topProjects.length > 0 || topIntegrations.length > 0) { + console.log(` ${c.bold("Projects")}${" ".repeat(COL_LEFT - 10)}${c.bold("Integrations")}`); + console.log(); + const maxRows = Math.max(topProjects.length, topIntegrations.length); + const maxSessions = topProjects[0]?.sessionCount || 1; + const maxIntCalls = topIntegrations[0]?.calls || 1; + + for (let i = 0; i < maxRows; i++) { + const left = i < topProjects.length + ? pad(renderBar(topProjects[i].displayName, topProjects[i].sessionCount, maxSessions, 6, 26), COL_LEFT) + : " ".repeat(COL_LEFT); + + const right = i < topIntegrations.length + ? renderBar(topIntegrations[i].name, topIntegrations[i].calls, maxIntCalls, 6, 12) + : ""; + + console.log(`${left}${right}`); + } + console.log(); + } + + // ── Skills & Subagents side by side ───────────────────────── + + if (topSkills.length > 0 || topSubagents.length > 0) { + const hasSkills = topSkills.length > 0; + const hasAgents = topSubagents.length > 0; + + let header = ""; + if (hasSkills && hasAgents) { + header = ` ${c.bold("Skills")}${" ".repeat(COL_LEFT - 8)}${c.bold("Subagents")}`; + } else if (hasSkills) { + header = ` ${c.bold("Skills")}`; + } else { + header = ` ${c.bold("Subagents")}`; + } + console.log(header); + console.log(); + + const maxRows = Math.max(topSkills.length, topSubagents.length); + const maxSkill = topSkills[0]?.calls || 1; + const maxAgent = topSubagents[0]?.calls || 1; + + for (let i = 0; i < maxRows; i++) { + let left = " ".repeat(COL_LEFT); + if (hasSkills && i < topSkills.length) { + left = pad(renderBar(topSkills[i].name, topSkills[i].calls, maxSkill, 4, 28), COL_LEFT); + } else if (!hasSkills && i < topSubagents.length) { + left = ` ${renderBar(topSubagents[i].name, topSubagents[i].calls, maxAgent, 4, 28)}`; + } + + let right = ""; + if (hasSkills && hasAgents && i < topSubagents.length) { + right = renderBar(topSubagents[i].name, topSubagents[i].calls, maxAgent, 4, 16); + } + + console.log(`${left}${right}`); + } + console.log(); + } + + // ── Hours heatmap ─────────────────────────────────────────── + + if (Object.keys(hourCounts).length > 0) { + const maxH = Math.max(...Object.values(hourCounts), 1); + const blocks = ["░", "▒", "▓", "█"]; + let heatmap = ""; + for (let h = 0; h < 24; h++) { + const count = hourCounts[h] || 0; + const intensity = Math.min(Math.floor((count / maxH) * 4), 3); + heatmap += count > 0 ? c.cyan(blocks[intensity]) : c.dim("·"); + } + console.log(` ${c.dim("Hours:")} ${heatmap}`); + console.log(` ${c.dim("0 6 12 18 23")}`); + console.log(); + } + +} + +// ── Helpers ───────────────────────────────────────────────────── + +function renderBar(name: string, value: number, max: number, barWidth: number, nameWidth: number): string { + const bar = miniBar(value, max, barWidth); + const label = name.padEnd(nameWidth).slice(0, nameWidth); + const count = String(value).padStart(4); + return ` ${bar} ${label} ${c.dim(count)}`; +} + +const MCP_FRIENDLY_NAMES: [RegExp, string][] = [ + [/linear/i, "Linear"], + [/context7/i, "Context7"], + [/notion/i, "Notion"], + [/revenuecat/i, "RevenueCat"], + [/chrome/i, "Chrome"], + [/astro/i, "Astro"], +]; + +function extractMcpService(toolName: string): string { + const raw = toolName.replace("mcp__", "").split("__")[0] || toolName; + return MCP_FRIENDLY_NAMES.find(([re]) => re.test(raw))?.[1] || raw; +} + +function cleanDirName(dirName: string): string { + // Dir names encode paths: -Users-robin-Projects-foo → /Users/robin/Projects/foo + // Strategy: reconstruct the path, strip the HOME prefix, take the last meaningful segments. + const home = process.env.HOME || ""; + const homeParts = home.split("/").filter(Boolean); + + // Only skip segments that match the actual HOME path prefix (in order), + // plus generic container directories — but never project-name-like words. + const containerDirs = new Set([ + "Documents", "Projects", "Code", "Workspace", "Developer", + "repos", "src", "dev", "work", "git", "Desktop", "Downloads", + ]); + + const segments = dirName.split("-"); + + // Phase 1: strip HOME prefix segments in order + let startIdx = 0; + let homeIdx = 0; + for (let i = 0; i < segments.length && homeIdx < homeParts.length; i++) { + if (segments[i] === "" || segments[i] === homeParts[homeIdx]) { + startIdx = i + 1; + if (segments[i] === homeParts[homeIdx]) homeIdx++; + } else { + break; + } + } + + // Phase 2: skip container directories that immediately follow the home prefix + while (startIdx < segments.length && containerDirs.has(segments[startIdx])) { + startIdx++; + } + + return segments.slice(startIdx).join("-") || dirName; +} + +function mergeProjects(projects: ProjectStats[]): ProjectStats[] { + const sorted = [...projects].sort((a, b) => a.displayName.length - b.displayName.length); + const merged: ProjectStats[] = []; + + for (const proj of sorted) { + const parent = merged.find((m) => + proj.displayName === m.displayName || + proj.displayName.startsWith(m.displayName + "-") || + proj.displayName.startsWith(m.displayName + "/") + ); + if (parent) { + parent.sessionCount += proj.sessionCount; + if (proj.lastActive > parent.lastActive) parent.lastActive = proj.lastActive; + } else { + merged.push({ ...proj }); + } + } + + return merged; +} + +function parsePeriod(period: string | undefined): Date | null { + if (!period) return null; + const now = new Date(); + + // Shortcuts: 7d, 14d, 30d, 90d, 6m, 1y + const match = period.match(/^(\d+)(d|w|m|y)$/); + if (match) { + const n = parseInt(match[1]); + const unit = match[2]; + const cutoff = new Date(now); + switch (unit) { + case "d": cutoff.setDate(cutoff.getDate() - n); break; + case "w": cutoff.setDate(cutoff.getDate() - n * 7); break; + case "m": cutoff.setMonth(cutoff.getMonth() - n); break; + case "y": cutoff.setFullYear(cutoff.getFullYear() - n); break; + } + return cutoff; + } + + // Named periods + switch (period) { + case "week": return new Date(now.getTime() - 7 * 86400000); + case "month": return new Date(now.getFullYear(), now.getMonth(), 1); + case "year": return new Date(now.getFullYear(), 0, 1); + case "all": return null; + case "all-time": return null; + } + + // Try as date string + const d = new Date(period); + return isNaN(d.getTime()) ? null : d; +} + +function getPeriodLabel(period: string | undefined): string | null { + if (!period || period === "all" || period === "all-time") return "all time"; + + const match = period.match(/^(\d+)(d|w|m|y)$/); + if (match) { + const n = parseInt(match[1]); + const units: Record = { d: "day", w: "week", m: "month", y: "year" }; + const unit = units[match[2]]; + return `last ${n} ${unit}${n > 1 ? "s" : ""}`; + } + + switch (period) { + case "week": return "last 7 days"; + case "month": return "this month"; + case "year": return "this year"; + } + + return `since ${period}`; +} + +function shortenModel(model: string): string { + if (model.includes("opus")) return "Opus"; + if (model.includes("sonnet")) return "Sonnet"; + if (model.includes("haiku")) return "Haiku"; + return model; +} + +function formatDate(dateStr: string): string { + try { + return new Date(dateStr).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + } catch { + return dateStr; + } +} + +function miniBar(value: number, max: number, width: number): string { + const blocks = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"]; + const ratio = Math.min(value / max, 1); + const totalEighths = Math.round(ratio * width * 8); + const fullBlocks = Math.floor(totalEighths / 8); + const remainder = totalEighths % 8; + let bar = "█".repeat(fullBlocks); + if (remainder > 0 && fullBlocks < width) bar += blocks[remainder]; + const empty = Math.max(0, width - bar.length); + return c.cyan(bar) + c.dim("░".repeat(empty)); +} diff --git a/src/core/agents.ts b/src/core/agents.ts new file mode 100644 index 0000000..6ff1ea7 --- /dev/null +++ b/src/core/agents.ts @@ -0,0 +1,177 @@ +import { resolve, dirname } from "path"; +import { existsSync } from "fs"; +import type { AgentConfig, AgentName, AgentPathConfig, SkillScope } from "../types"; + +// ── Agent registry ────────────────────────────────────────────── + +const AGENTS: AgentConfig[] = [ + { + name: "claude", + displayName: "Claude Code", + paths: [ + // Skills — directory-based (SKILL.md) and flat (.md) + { scope: "user", pattern: "~/.claude/skills/*/SKILL.md", format: "skill" }, + { scope: "user", pattern: "~/.claude/skills/*.md", format: "skill" }, + { scope: "project", pattern: ".claude/skills/*/SKILL.md", format: "skill" }, + { scope: "project", pattern: ".claude/skills/*.md", format: "skill" }, + // Commands — flat .md files + { scope: "user", pattern: "~/.claude/commands/*.md", format: "command" }, + { scope: "project", pattern: ".claude/commands/*.md", format: "command" }, + // Agents (subagents) — flat .md files + { scope: "user", pattern: "~/.claude/agents/*.md", format: "agent" }, + { scope: "project", pattern: ".claude/agents/*.md", format: "agent" }, + ], + binaryNames: ["claude"], + configFiles: ["CLAUDE.md", "claude.md"], + supportedFrontmatter: [ + "name", "description", "disable-model-invocation", "user-invocable", + "allowed-tools", "model", "effort", "context", "agent", "hooks", + "paths", "shell", "argument-hint", + ], + }, + { + name: "cursor", + displayName: "Cursor", + paths: [ + // Skills — user-level (skills-cursor is Cursor's actual dir name) + { scope: "user", pattern: "~/.cursor/skills-cursor/*/SKILL.md", format: "skill" }, + { scope: "user", pattern: "~/.cursor/skills-cursor/*.md", format: "skill" }, + { scope: "user", pattern: "~/.cursor/skills/*/SKILL.md", format: "skill" }, + { scope: "user", pattern: "~/.cursor/skills/*.md", format: "skill" }, + // Skills — project-level (shared .agents/ path) + { scope: "project", pattern: ".agents/skills/*/SKILL.md", format: "skill" }, + // Rules + { scope: "project", pattern: ".cursor/.rules/*.md", format: "rule" }, + { scope: "project", pattern: ".cursor/rules/*.mdc", format: "rule" }, + { scope: "project", pattern: ".cursor/rules/*.md", format: "rule" }, + ], + binaryNames: ["cursor"], + configFiles: [".cursorrules"], + supportedFrontmatter: [ + "name", "description", "disable-model-invocation", + "license", "compatibility", "metadata", + ], + }, + { + name: "codex", + displayName: "Codex", + paths: [ + { scope: "user", pattern: "~/.agents/skills/*/SKILL.md", format: "skill" }, + { scope: "user", pattern: "~/.agents/skills/*.md", format: "skill" }, + { scope: "project", pattern: ".agents/skills/*/SKILL.md", format: "skill" }, + { scope: "admin", pattern: "/etc/codex/skills/*/SKILL.md", format: "skill" }, + ], + binaryNames: ["codex"], + configFiles: [], + supportedFrontmatter: ["name", "description"], + }, +]; + +const agentMap = new Map( + AGENTS.map((a) => [a.name, a]) +); + +// ── Public API ────────────────────────────────────────────────── + +export function getAgentConfig(name: AgentName): AgentConfig { + const config = agentMap.get(name); + if (!config) throw new Error(`Unknown agent: ${name}`); + return config; +} + +export function getAllAgentConfigs(): AgentConfig[] { + return AGENTS; +} + +export interface ResolvedPath { + scope: SkillScope; + absolutePattern: string; + format: AgentPathConfig["format"]; + agent: AgentName; +} + +export function resolveAgentPaths( + config: AgentConfig, + projectRoot: string +): ResolvedPath[] { + return config.paths.map((p) => { + const pattern = expandPattern(p.pattern, projectRoot); + return { + scope: p.scope, + absolutePattern: pattern, + format: p.format, + agent: config.name, + }; + }); +} + +export async function isAgentInstalled(name: AgentName): Promise { + return (await getBinaryPath(name)) !== null; +} + +export async function getBinaryPath(name: AgentName): Promise { + const config = getAgentConfig(name); + for (const bin of config.binaryNames) { + try { + const proc = Bun.spawn(["which", bin], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + if (exitCode === 0) { + const text = await new Response(proc.stdout).text(); + return text.trim() || null; + } + } catch { + // binary not found + } + } + return null; +} + +export function expandPattern(pattern: string, projectRoot: string): string { + const home = process.env.HOME || process.env.USERPROFILE || "~"; + if (pattern.startsWith("~/")) { + return resolve(home, pattern.slice(2)); + } + if (!pattern.startsWith("/")) { + return resolve(projectRoot, pattern); + } + return pattern; +} + +const projectRootCache = new Map(); + +export function findProjectRoot(startDir?: string): string { + const start = resolve(startDir || process.cwd()); + const cached = projectRootCache.get(start); + if (cached) return cached; + + let dir = start; + const root = resolve("/"); + while (dir !== root) { + const gitDir = resolve(dir, ".git"); + if (existsSync(gitDir)) { + projectRootCache.set(start, dir); + return dir; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + const fallback = resolve(startDir || process.cwd()); + projectRootCache.set(start, fallback); + return fallback; +} + +export function getContextLimit(name: AgentName): number { + switch (name) { + case "claude": return 200_000; + case "cursor": return 120_000; + case "codex": return 200_000; + } +} + +export function isValidAgentName(name: string): name is AgentName { + return agentMap.has(name as AgentName); +} diff --git a/src/core/health.ts b/src/core/health.ts new file mode 100644 index 0000000..340f610 --- /dev/null +++ b/src/core/health.ts @@ -0,0 +1,51 @@ +import type { DiscoveredSkill, HealthBadge } from "../types"; + +const THIRTY_DAYS = 30 * 24 * 60 * 60; +const HEAVY_THRESHOLD = 5000; // chars +const OVERSIZED_THRESHOLD = 500; // lines + +export interface BadgeContext { + allSkills: DiscoveredSkill[]; +} + +export function computeBadges( + skill: DiscoveredSkill, + ctx: BadgeContext +): HealthBadge[] { + const badges: HealthBadge[] = []; + const now = Math.floor(Date.now() / 1000); + + // STALE: not modified in 30+ days — only for project-scope skills + // User-level skills are installed tools; their file mtime reflects install + // date, not relevance. Git history is unavailable outside repos. + if (skill.scope === "project" && now - skill.lastModified > THIRTY_DAYS) { + badges.push("STALE"); + } + + // HEAVY: over 5k chars + if (skill.rawContent.length > HEAVY_THRESHOLD) { + badges.push("HEAVY"); + } + + // OVERSIZED: over 500 lines + if (skill.lineCount > OVERSIZED_THRESHOLD) { + badges.push("OVERSIZED"); + } + + // CONFLICT: another skill has the same name but different file path + const hasConflict = ctx.allSkills.some( + (other) => + other.name === skill.name && + other.filePath !== skill.filePath + ); + if (hasConflict) { + badges.push("CONFLICT"); + } + + // SHARED: skill is used by multiple agents (same path, e.g., .agents/skills/) + if (skill.agents.length > 1) { + badges.push("SHARED"); + } + + return badges; +} \ No newline at end of file diff --git a/src/core/parser.ts b/src/core/parser.ts new file mode 100644 index 0000000..28ae212 --- /dev/null +++ b/src/core/parser.ts @@ -0,0 +1,199 @@ +import type { Frontmatter } from "../types"; + +export interface ParsedSkillFile { + frontmatter: Frontmatter; + body: string; + raw: string; +} + +const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/; + +export function parseSkillFile(content: string): ParsedSkillFile { + const match = content.match(FRONTMATTER_RE); + if (!match) { + return { + frontmatter: {}, + body: content.trim(), + raw: content, + }; + } + + const yamlBlock = match[1]; + const body = content.slice(match[0].length).trim(); + const frontmatter = parseYaml(yamlBlock); + + return { frontmatter, body, raw: content }; +} + +/** + * Minimal YAML parser for skill frontmatter. + * + * Supports: flat key:value pairs, lists (- items), inline arrays [a, b], + * quoted strings, booleans, numbers, and multi-line strings (| and >). + * + * Limitations: + * - No nested objects (sub-keys are collected as flat list items) + * - No anchors/aliases, flow mappings, or tagged types + * - Multi-line strings use simple indentation detection + * + * For complex frontmatter, consider using a full YAML parser. + */ +function parseYaml(yaml: string): Frontmatter { + const result: Record = {}; + const lines = yaml.split("\n"); + let currentKey: string | null = null; + let currentList: string[] | null = null; + let multiLineMode: "|" | ">" | null = null; + let multiLineLines: string[] = []; + let multiLineIndent = -1; + + function flushMultiLine() { + if (currentKey && multiLineMode) { + const joined = multiLineMode === "|" + ? multiLineLines.join("\n") + : multiLineLines.join(" "); + result[currentKey] = joined.trim(); + } + multiLineMode = null; + multiLineLines = []; + multiLineIndent = -1; + } + + function flushList() { + if (currentKey && currentList) { + result[currentKey] = currentList; + } + currentKey = null; + currentList = null; + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Accumulate multi-line string content + if (multiLineMode && currentKey) { + if (trimmed === "") { + multiLineLines.push(""); + continue; + } + const indent = line.length - line.trimStart().length; + if (multiLineIndent < 0) multiLineIndent = indent; + if (indent >= multiLineIndent) { + multiLineLines.push(line.slice(multiLineIndent)); + continue; + } + // De-indented line → end of multi-line block, re-process this line + flushMultiLine(); + } + + if (!trimmed || trimmed.startsWith("#")) continue; + + // List item under current key + if (trimmed.startsWith("- ") && currentKey && currentList) { + currentList.push(String(parseValue(trimmed.slice(2).trim()))); + continue; + } + + // Flush any pending list + flushList(); + + // Key: value pair + const colonIdx = trimmed.indexOf(": "); + const colonEnd = trimmed.indexOf(":"); + + if (colonIdx > 0) { + const key = trimmed.slice(0, colonIdx).trim(); + const rawValue = trimmed.slice(colonIdx + 2).trim(); + + if (rawValue === "|" || rawValue === ">") { + currentKey = key; + multiLineMode = rawValue as "|" | ">"; + multiLineLines = []; + multiLineIndent = -1; + continue; + } + + if (rawValue === "") { + // Could be start of a list + currentKey = key; + currentList = []; + continue; + } + + result[key] = parseValue(rawValue); + } else if (colonEnd === trimmed.length - 1) { + // Key with no value (e.g., "metadata:") + const key = trimmed.slice(0, -1).trim(); + currentKey = key; + currentList = []; + } + } + + // Flush final pending state + if (multiLineMode) flushMultiLine(); + flushList(); + + return result as Frontmatter; +} + +function parseValue(raw: string): string | number | boolean | string[] { + // Boolean + if (raw === "true") return true; + if (raw === "false") return false; + + // Number + if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw); + + // Inline array: [a, b, c] + if (raw.startsWith("[") && raw.endsWith("]")) { + const inner = raw.slice(1, -1).trim(); + if (!inner) return []; + return inner.split(",").map((s) => { + const v = s.trim(); + return stripQuotes(v); + }); + } + + // Quoted string + return stripQuotes(raw); +} + +function stripQuotes(s: string): string { + if ( + (s.startsWith('"') && s.endsWith('"')) || + (s.startsWith("'") && s.endsWith("'")) + ) { + return s.slice(1, -1); + } + return s; +} + +export function extractDescription(parsed: ParsedSkillFile): string { + if (parsed.frontmatter.description) { + const desc = String(parsed.frontmatter.description); + return desc.length > 250 ? desc.slice(0, 247) + "..." : desc; + } + + // Fall back to first paragraph of body + const firstPara = parsed.body.split(/\n\n/)[0] || ""; + const cleaned = firstPara.replace(/^#+\s+.*\n?/, "").trim(); + if (cleaned) { + return cleaned.length > 250 ? cleaned.slice(0, 247) + "..." : cleaned; + } + + return ""; +} + +export function nameFromFilePath(filePath: string): string { + const parts = filePath.split("/"); + const fileName = parts[parts.length - 1]; + + // SKILL.md → use parent directory name + if (fileName === "SKILL.md") { + return parts[parts.length - 2] || "unknown"; + } + + // some-command.md → "some-command" + return fileName.replace(/\.(md|mdc)$/, ""); +} diff --git a/src/core/scanner.ts b/src/core/scanner.ts new file mode 100644 index 0000000..9d8f1f1 --- /dev/null +++ b/src/core/scanner.ts @@ -0,0 +1,152 @@ +import { resolve, dirname } from "path"; +import { existsSync, readFileSync, statSync } from "fs"; +import { Glob } from "bun"; +import type { + AgentName, + DiscoveredSkill, + SkillType, + SkillScope, +} from "../types"; +import { + getAllAgentConfigs, + resolveAgentPaths, + findProjectRoot, + type ResolvedPath, +} from "./agents"; +import { parseSkillFile, extractDescription, nameFromFilePath } from "./parser"; +import { estimateTokens } from "./tokens"; +import { getLastModified } from "../utils/git"; +import { computeBadges } from "./health"; + +export interface ScanOptions { + agents?: AgentName[]; + types?: SkillType[]; + scopes?: SkillScope[]; + projectRoot?: string; +} + +export async function scanAll(opts?: ScanOptions): Promise { + const projectRoot = opts?.projectRoot || findProjectRoot(); + const configs = getAllAgentConfigs(); + + // Map from absolute file path -> DiscoveredSkill (for deduplication) + const skillMap = new Map(); + + for (const config of configs) { + if (opts?.agents && !opts.agents.includes(config.name)) continue; + + const resolved = resolveAgentPaths(config, projectRoot); + + for (const rp of resolved) { + if (opts?.types && !opts.types.includes(rp.format)) continue; + if (opts?.scopes && !opts.scopes.includes(rp.scope)) continue; + + const files = await globFiles(rp.absolutePattern); + + for (const filePath of files) { + const absPath = resolve(filePath); + + // Deduplication: if we already found this file via another agent + const existing = skillMap.get(absPath); + if (existing) { + if (!existing.agents.includes(config.name)) { + existing.agents.push(config.name); + } + continue; + } + + const skill = await buildSkill( + absPath, + rp.format, + rp.scope, + config.name + ); + if (skill) { + skillMap.set(absPath, skill); + } + } + } + } + + // Convert to array and compute badges + const skills = Array.from(skillMap.values()); + const ctx = { allSkills: skills }; + for (const skill of skills) { + skill.badges = computeBadges(skill, ctx); + } + + // Sort alphabetically by name + skills.sort((a, b) => a.name.localeCompare(b.name)); + + return skills; +} + +async function buildSkill( + filePath: string, + type: SkillType, + scope: SkillScope, + agent: AgentName +): Promise { + try { + const content = readFileSync(filePath, "utf-8"); + const parsed = parseSkillFile(content); + const stat = statSync(filePath); + const lastModified = await getLastModified(filePath); + + const name = + parsed.frontmatter.name + ? String(parsed.frontmatter.name) + : nameFromFilePath(filePath); + + return { + name, + type, + scope, + description: extractDescription(parsed), + agents: [agent], + filePath, + dirPath: dirname(filePath), + tokenEstimate: estimateTokens(content), + fileSize: stat.size, + lineCount: content.split("\n").length, + lastModified, + badges: [], // computed after all skills are collected + frontmatter: parsed.frontmatter, + rawContent: content, + }; + } catch { + return null; + } +} + +async function globFiles(pattern: string): Promise { + // Split pattern into base directory and glob part + // e.g., "/Users/x/.claude/skills/*/SKILL.md" + // base: "/Users/x/.claude/skills" + // glob: "*/SKILL.md" + + const parts = pattern.split("/"); + let baseIdx = parts.length - 1; + + // Find where the glob characters start + for (let i = 0; i < parts.length; i++) { + if (parts[i].includes("*") || parts[i].includes("?") || parts[i].includes("[")) { + baseIdx = i; + break; + } + } + + const baseDir = parts.slice(0, baseIdx).join("/") || "/"; + const globPattern = parts.slice(baseIdx).join("/"); + + if (!existsSync(baseDir)) return []; + + const results: string[] = []; + const glob = new Glob(globPattern); + + for await (const match of glob.scan({ cwd: baseDir, absolute: true })) { + results.push(match); + } + + return results; +} diff --git a/src/core/tokens.ts b/src/core/tokens.ts new file mode 100644 index 0000000..6431891 --- /dev/null +++ b/src/core/tokens.ts @@ -0,0 +1,27 @@ +export function estimateTokens(text: string): number { + // Word-based heuristic: avg English word ≈ 1.3 tokens, + // code symbols and whitespace add ~15% overhead. + // This is significantly more accurate than length/4 for mixed prose+code. + const words = text.split(/\s+/).filter(Boolean).length; + const symbols = (text.match(/[{}()\[\]<>;:.,=+\-*/%!&|^~@#$?\\]/g) || []).length; + return Math.ceil(words * 1.3 + symbols * 0.5); +} + +export function formatTokens(tokens: number): string { + if (tokens < 1000) return `${tokens}`; + if (tokens < 100_000) return `${(tokens / 1000).toFixed(1)}k`; + return `${Math.round(tokens / 1000)}k`; +} + +export function tokenBar( + tokens: number, + maxTokens: number, + width = 30 +): string { + const ratio = Math.min(tokens / maxTokens, 1); + const filled = Math.round(ratio * width); + const empty = width - filled; + const bar = "\u2588".repeat(filled) + "\u2591".repeat(empty); + const pct = (ratio * 100).toFixed(1); + return `${bar} ${pct}%`; +} diff --git a/src/index.ts b/src/index.ts new file mode 100755 index 0000000..3b1183e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,322 @@ +#!/usr/bin/env bun + +import type { ParsedArgs } from "./types"; +import { printError, c } from "./utils/output"; + +// Short flag aliases: -j → --json, -a → --agent, -t → --type, -s → --scope, -p → --period +const SHORT_FLAGS: Record = { + j: "json", + a: "agent", + t: "type", + s: "scope", + p: "period", + v: "version", + h: "help", +}; + +// Flags that never take a value (always boolean) +const BOOLEAN_FLAGS = new Set(["json", "help", "version", "dry-run", "installed"]); + +function parseArgs(argv: string[]): ParsedArgs { + const args = argv.slice(2); + const command = args[0] || "help"; + const positional: string[] = []; + const flags: Record = {}; + + let i = 1; + while (i < args.length) { + const arg = args[i]; + + if (arg.startsWith("--")) { + // Handle --key=value syntax + const eqIdx = arg.indexOf("="); + if (eqIdx > 2) { + const key = arg.slice(2, eqIdx); + flags[key] = arg.slice(eqIdx + 1); + i++; + } else { + const key = arg.slice(2); + if (BOOLEAN_FLAGS.has(key)) { + flags[key] = true; + i++; + } else { + const next = args[i + 1]; + if (next && !next.startsWith("-")) { + flags[key] = next; + i += 2; + } else { + flags[key] = true; + i++; + } + } + } + } else if (arg.startsWith("-") && arg.length > 1 && !arg.startsWith("--")) { + // Short flags: -j, -a claude, -js (combined booleans) + const chars = arg.slice(1); + for (let ci = 0; ci < chars.length; ci++) { + const ch = chars[ci]; + const longName = SHORT_FLAGS[ch] || ch; + if (BOOLEAN_FLAGS.has(longName)) { + flags[longName] = true; + } else if (ci === chars.length - 1) { + // Last char in group: next arg is the value + const next = args[i + 1]; + if (next && !next.startsWith("-")) { + flags[longName] = next; + i++; + } else { + flags[longName] = true; + } + } else { + // Non-boolean in the middle of a group — treat as boolean + flags[longName] = true; + } + } + i++; + } else { + positional.push(arg); + i++; + } + } + + return { command, positional, flags }; +} + +// Read version from package.json so it stays in sync +const { version: VERSION } = await import("../package.json"); + +// ── Per-command help ──────────────────────────────────────────── + +const COMMAND_HELP: Record = { + scan: ` +${c.bold("ags scan")} — Discover all skills, commands, agents, and rules + +${c.bold("Usage:")} ags scan [options] + +${c.bold("Options:")} + --agent X Filter by agent: claude, cursor, codex (comma-separated) + --type X Filter by type: skill, command, rule, agent + --scope X Filter by scope: local, global, all (default: all) + --installed Show which agents are installed (binary, paths, skill count) + --json Output as JSON + +${c.bold("Examples:")} + ags scan Show everything + ags scan --type skill Skills only (replaces list-skills) + ags scan --installed Which agents are installed + ags scan --scope local Project-level only + ags scan --scope global User-level only + ags scan --agent claude Claude Code only + ags scan --type agent Subagents only + ags scan --agent cursor --json Cursor skills as JSON +`, + + "skill-cost": ` +${c.bold("ags skill-cost")} — How much context your skills consume + +${c.bold("Usage:")} ags skill-cost [options] + +${c.bold("Options:")} + --scope X Filter by scope: local, global, all (default: all) + --json Output as JSON + +${c.bold("Shows:")} + Per-skill token cost ranked by size, config file overhead, + context usage per agent (bar chart vs. context limit), + and top suggestions to free tokens. + +${c.bold("Examples:")} + ags skill-cost Full cost report + ags skill-cost --scope local Project skills only + ags skill-cost --json Structured output for agents +`, + + grab: ` +${c.bold("ags grab")} — Install a skill from GitHub + +${c.bold("Usage:")} ags grab [options] + +${c.bold("Options:")} + --to X Target agent: claude, cursor, codex (default: claude) + --dry-run Preview without writing files + --json Output as JSON + +${c.bold("Supported URLs:")} + https://github.com/owner/repo/blob/branch/path/to/SKILL.md + https://raw.githubusercontent.com/owner/repo/branch/path/to/SKILL.md + +${c.bold("Examples:")} + ags grab https://github.com/org/repo/blob/main/skills/foo/SKILL.md + ags grab https://github.com/org/repo/blob/main/skills/foo/SKILL.md --to cursor +`, + + rm: ` +${c.bold("ags rm")} — Remove a skill, command, agent, or rule + +${c.bold("Usage:")} ags rm [options] + +${c.bold("Options:")} + --agent X Only remove from this agent (if name matches multiple) + --dry-run Preview without deleting files + --json Output as JSON + +${c.bold("Examples:")} + ags rm my-skill Remove by name (all agents) + ags rm my-skill --agent claude Remove only from Claude Code + ags rm ~/.claude/agents/old-agent.md Remove by path +`, + + stats: ` +${c.bold("ags stats")} — Usage stats and activity dashboard + +${c.bold("Usage:")} ags stats [options] + +${c.bold("Options:")} + --period X Time range: 7d, 14d, 30d, 90d, 6m, 1y, week, month, year, all-time + Also accepts a date: 2026-03-01 (default: 30d) + --json Output as JSON + +${c.bold("Shows:")} + Sessions, PRs created, token usage, MCP integrations, skills, + subagents, peak hours, and daily activity. + +${c.bold("Examples:")} + ags stats Last 30 days (default) + ags stats --period 7d Last 7 days + ags stats --period 90d Last 90 days + ags stats --period month This calendar month + ags stats --period all-time Everything + ags stats --period 2026-03-01 Since a specific date +`, + + context: ` +${c.bold("ags context")} — What's loaded into your agent's context + +${c.bold("Usage:")} ags context [options] + +${c.bold("Options:")} + --agent X Filter by agent: claude, cursor, codex + --json Output as JSON + +${c.bold("Shows:")} + Full context map: config files, skills, commands, agents, + memory files, MCP server configs. Token costs and usage + bars per agent. + +${c.bold("Examples:")} + ags context Full map for all agents + ags context --agent claude Claude Code only + ags context --json Structured output for agents +`, + + lint: ` +${c.bold("ags lint")} — Validate skill files + +${c.bold("Usage:")} ags lint [options] + +${c.bold("Options:")} + --agent X Filter by agent: claude, cursor, codex + --scope X Filter by scope: local, global, all (default: all) + --json Output as JSON + +${c.bold("Rules checked:")} + missing-frontmatter No YAML frontmatter block + missing-description No description field + missing-name No name field (uses filename fallback) + short-description Description under 10 chars + heavy Over 5,000 characters + oversized Over 500 lines + name-conflict Same name at different paths + unsupported-key Frontmatter key not recognized by agent + empty-body No content after frontmatter + +${c.bold("Examples:")} + ags lint Lint everything + ags lint --agent claude Claude skills only + ags lint --scope local --json Project-level, JSON output +`, +}; + +function printUsage(): void { + console.log(` +${c.bold("ags")} v${VERSION} — Agent Skills CLI + +${c.bold("Usage:")} ags [options] + +${c.bold("Commands:")} + scan Discover skills, commands, agents, rules (--type, --installed) + context What's loaded into your agent's context + lint Validate skill files for quality issues + skill-cost How much context your skills consume + grab Install skill from GitHub URL + rm Remove a skill, command, agent, or rule + stats Usage stats and activity dashboard + +${c.bold("Global options:")} + --json Output as JSON (all commands) + --help Show help for a command + --version Show version + +Run ${c.dim("ags --help")} for command-specific options. +`); +} + +async function main(): Promise { + const args = parseArgs(process.argv); + const json = args.flags.json === true; + + // Per-command --help + if (args.flags.help === true && args.command in COMMAND_HELP) { + console.log(COMMAND_HELP[args.command]); + return; + } + + switch (args.command) { + case "scan": { + const { run } = await import("./commands/scan"); + return run(args); + } + case "skill-cost": + case "budget": { + const { run } = await import("./commands/budget"); + return run(args); + } + case "grab": { + const { run } = await import("./commands/grab"); + return run(args); + } + case "rm": + case "remove": { + const { run } = await import("./commands/rm"); + return run(args); + } + case "stats": { + const { run } = await import("./commands/stats"); + return run(args); + } + case "context": { + const { run } = await import("./commands/context"); + return run(args); + } + case "lint": { + const { run } = await import("./commands/lint"); + return run(args); + } + case "help": + case "--help": + printUsage(); + return; + case "version": + case "--version": + case "-v": + console.log(VERSION); + return; + default: + printError(`Unknown command: ${args.command}`, "UNKNOWN_COMMAND", json); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e3fce10 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,152 @@ +// ── Agent identity ────────────────────────────────────────────── + +export type AgentName = "claude" | "cursor" | "codex"; + +export type SkillType = "skill" | "command" | "rule" | "agent"; + +export type SkillScope = "user" | "project" | "admin" | "system"; + +export type HealthBadge = "STALE" | "HEAVY" | "OVERSIZED" | "CONFLICT" | "SHARED"; + +// ── Frontmatter (superset of all agents) ──────────────────────── + +export interface Frontmatter { + name?: string; + description?: string; + + // Claude Code specific + "disable-model-invocation"?: boolean; + "user-invocable"?: boolean; + "allowed-tools"?: string | string[]; + model?: string; + effort?: string; + context?: string; + agent?: string; + hooks?: Record; + paths?: string | string[]; + shell?: string; + "argument-hint"?: string; + + // Cursor specific + license?: string; + compatibility?: string | Record; + metadata?: Record; + + // Catch-all for unknown fields + [key: string]: unknown; +} + +// ── Discovered skill ──────────────────────────────────────────── + +export interface DiscoveredSkill { + name: string; + type: SkillType; + scope: SkillScope; + description: string; + agents: AgentName[]; + filePath: string; + dirPath: string; + tokenEstimate: number; + fileSize: number; + lineCount: number; + lastModified: number; + badges: HealthBadge[]; + frontmatter: Frontmatter; + rawContent: string; +} + +// ── Agent configuration ───────────────────────────────────────── + +export interface AgentPathConfig { + scope: SkillScope; + pattern: string; + format: SkillType; +} + +export interface AgentConfig { + name: AgentName; + displayName: string; + paths: AgentPathConfig[]; + binaryNames: string[]; + configFiles: string[]; + supportedFrontmatter: string[]; +} + +// ── Command output schemas ────────────────────────────────────── + +export interface ScanResult { + skills: DiscoveredSkill[]; + summary: { + total: number; + totalTokens: number; + byAgent: Partial>; + byType: Partial>; + byScope: Partial>; + badges: Partial>; + }; +} + +export interface BudgetConfigFile { + name: string; + tokens: number; + filePath: string; +} + +export interface BudgetEntry { + name: string; + agent: AgentName; + tokens: number; + percentage: number; + filePath: string; +} + +export interface BudgetAgentSummary { + tokens: number; + count: number; +} + +export interface BudgetContextLimit { + limit: number; + used: number; + percentage: number; +} + +export interface BudgetResult { + totalTokens: number; + configFiles: BudgetConfigFile[]; + skills: BudgetEntry[]; + byAgent: Partial>; + contextLimits: Partial>; + suggestions: string[]; +} + +export interface GrabResult { + name: string; + source: string; + destination: string; + tokens: number; + agent: AgentName; +} + +// ── CLI output wrapper ────────────────────────────────────────── + +export interface CliSuccess { + ok: true; + data: T; +} + +export interface CliError { + ok: false; + error: string; + code: string; +} + +export type CliOutput = CliSuccess | CliError; + +// ── Parsed CLI arguments ──────────────────────────────────────── + +export interface ParsedArgs { + command: string; + positional: string[]; + flags: Record; +} diff --git a/src/utils/git.ts b/src/utils/git.ts new file mode 100644 index 0000000..6b62a32 --- /dev/null +++ b/src/utils/git.ts @@ -0,0 +1,28 @@ +import { dirname } from "path"; +import { statSync } from "fs"; + +export async function getLastModified(filePath: string): Promise { + // Try git in the file's own directory (not cwd) + try { + const proc = Bun.spawn( + ["git", "log", "-1", "--format=%ct", "--", filePath], + { stdout: "pipe", stderr: "pipe", cwd: dirname(filePath) } + ); + const exitCode = await proc.exited; + if (exitCode === 0) { + const text = await new Response(proc.stdout).text(); + const ts = parseInt(text.trim(), 10); + if (!isNaN(ts) && ts > 0) return ts; + } + } catch { + // git not available or not a git repo + } + + // Fallback to filesystem mtime + try { + const stat = statSync(filePath); + return Math.floor(stat.mtimeMs / 1000); + } catch { + return Math.floor(Date.now() / 1000); + } +} diff --git a/src/utils/github.ts b/src/utils/github.ts new file mode 100644 index 0000000..0cc4125 --- /dev/null +++ b/src/utils/github.ts @@ -0,0 +1,72 @@ +export interface GitHubFileInfo { + owner: string; + repo: string; + branch: string; + path: string; + rawUrl: string; + fileName: string; +} + +// Matches: https://github.com/owner/repo/blob/branch/path/to/file.md +const BLOB_RE = + /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/; + +// Matches: https://raw.githubusercontent.com/owner/repo/branch/path/to/file.md +const RAW_RE = + /^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/; + +export function parseGitHubUrl(url: string): GitHubFileInfo | null { + const isBlob = url.match(BLOB_RE); + const match = isBlob || url.match(RAW_RE); + if (!match) return null; + + const [, owner, repo, branch, path] = match; + const fileName = path.split("/").pop() || "SKILL.md"; + const rawUrl = isBlob + ? `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}` + : url; + + return { owner, repo, branch, path, rawUrl, fileName }; +} + +const MAX_FETCH_SIZE = 1_024 * 1_024; // 1MB +const FETCH_TIMEOUT_MS = 10_000; + +export async function fetchRawContent(info: GitHubFileInfo): Promise { + const response = await fetch(info.rawUrl, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch ${info.rawUrl}: ${response.status} ${response.statusText}` + ); + } + + // Check content-length header if present + const contentLength = response.headers.get("content-length"); + if (contentLength !== null && Number(contentLength) > MAX_FETCH_SIZE) { + throw new Error(`File too large (${contentLength} bytes, max ${MAX_FETCH_SIZE})`); + } + + // Read body with size cap regardless of content-length header + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_FETCH_SIZE) { + reader.cancel(); + throw new Error(`File too large (>${MAX_FETCH_SIZE} bytes)`); + } + chunks.push(value); + } + + const decoder = new TextDecoder(); + return chunks.map((c) => decoder.decode(c, { stream: true })).join("") + decoder.decode(); +} diff --git a/src/utils/output.ts b/src/utils/output.ts new file mode 100644 index 0000000..6f82597 --- /dev/null +++ b/src/utils/output.ts @@ -0,0 +1,207 @@ +import type { AgentName, CliOutput, HealthBadge, SkillScope, SkillType } from "../types"; + +// ── ANSI colors ───────────────────────────────────────────────── + +function supportsColor(): boolean { + if (process.env.NO_COLOR) return false; + if (process.env.TERM === "dumb") return false; + if (!process.stdout.isTTY) return false; + return true; +} + +const enabled = supportsColor(); + +function wrap(code: string, text: string): string { + if (!enabled) return text; + return `${code}${text}\x1b[0m`; +} + +export const c = { + bold: (s: string) => wrap("\x1b[1m", s), + dim: (s: string) => wrap("\x1b[2m", s), + red: (s: string) => wrap("\x1b[31m", s), + green: (s: string) => wrap("\x1b[32m", s), + yellow: (s: string) => wrap("\x1b[33m", s), + blue: (s: string) => wrap("\x1b[34m", s), + magenta: (s: string) => wrap("\x1b[35m", s), + cyan: (s: string) => wrap("\x1b[36m", s), + gray: (s: string) => wrap("\x1b[90m", s), + // 256-color for brand colors + rgb: (r: number, g: number, b: number, s: string) => + wrap(`\x1b[38;2;${r};${g};${b}m`, s), +}; + +// ── JSON output ───────────────────────────────────────────────── + +export function printJson(output: CliOutput): never { + console.log(JSON.stringify(output.ok ? output.data : output, null, 2)); + process.exit(output.ok ? 0 : 1); +} + +export function printError( + message: string, + code: string, + json: boolean +): never { + if (json) { + printJson({ ok: false, error: message, code }); + } + console.error(c.red(`Error: ${message}`)); + process.exit(1); +} + +// ── Table rendering ───────────────────────────────────────────── + +export function table(headers: string[], rows: string[][]): string { + const widths = headers.map((h, i) => { + const colValues = rows.map((r) => stripAnsi(r[i] || "").length); + return Math.max(stripAnsi(h).length, ...colValues); + }); + + const sep = " "; + const headerLine = headers + .map((h, i) => pad(h, widths[i])) + .join(sep); + const divider = widths.map((w) => "─".repeat(w)).join(sep); + const body = rows + .map((row) => + row.map((cell, i) => pad(cell, widths[i])).join(sep) + ) + .join("\n"); + + return `${c.bold(headerLine)}\n${c.dim(divider)}\n${body}`; +} + +export function pad(text: string, width: number): string { + const visible = stripAnsi(text).length; + const needed = Math.max(0, width - visible); + return text + " ".repeat(needed); +} + +export function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, ""); +} + +export function shortenPath(filePath: string): string { + const home = process.env.HOME || ""; + if (home && filePath.startsWith(home)) { + return "~" + filePath.slice(home.length); + } + const cwd = process.cwd(); + if (filePath.startsWith(cwd + "/")) { + return filePath.slice(cwd.length + 1); + } + return filePath; +} + +export function parseScopeFlag( + raw: string | boolean | undefined, + json: boolean +): SkillScope[] | undefined { + if (!raw) return undefined; + const s = String(raw); + const validScopes: Record = { + local: ["project"], + global: ["user"], + project: ["project"], + user: ["user"], + all: undefined, + }; + if (!(s in validScopes)) { + return printError(`Unknown scope: ${s}. Use: local, global, all`, "INVALID_SCOPE", json); + } + return validScopes[s]; +} + +// ── Badge formatting ──────────────────────────────────────────── + +export function formatBadge(badge: HealthBadge): string { + switch (badge) { + case "STALE": return c.yellow("STALE"); + case "HEAVY": return c.magenta("HEAVY"); + case "OVERSIZED": return c.red("OVERSIZED"); + case "CONFLICT": return c.red(c.bold("CONFLICT")); + case "SHARED": return c.cyan("SHARED"); + } +} + +export function formatBadges(badges: HealthBadge[]): string { + if (badges.length === 0) return ""; + return badges.map(formatBadge).join(" "); +} + +// ── Agent formatting ──────────────────────────────────────────── + +const AGENT_STYLE: Record string }> = { + claude: { + icon: "◈", + color: (s) => c.rgb(217, 119, 87, s), // Claude terracotta/orange + }, + cursor: { + icon: "⌘", + color: (s) => c.rgb(0, 112, 243, s), // Cursor blue + }, + codex: { + icon: "◆", + color: (s) => c.rgb(16, 163, 127, s), // OpenAI green + }, +}; + +export function formatAgent(name: AgentName): string { + const style = AGENT_STYLE[name]; + if (!style) return name; + return style.color(`${style.icon} ${name}`); +} + +export function formatAgents(names: AgentName[]): string { + return names.map(formatAgent).join(c.dim(",")); +} + +// ── Scope formatting ──────────────────────────────────────────── + +export function formatScope(scope: SkillScope): string { + switch (scope) { + case "project": return c.blue("local"); + case "user": return c.cyan("global"); + case "admin": return c.yellow("admin"); + case "system": return c.dim("system"); + default: return scope; + } +} + +export function scopeLabel(scope: string): string { + switch (scope) { + case "project": return "local"; + case "user": return "global"; + default: return scope; + } +} + +// ── Type formatting ───────────────────────────────────────────── + +export function formatType(type: SkillType): string { + switch (type) { + case "skill": return c.green("skill"); + case "command": return c.yellow("command"); + case "rule": return c.magenta("rule"); + case "agent": return c.cyan("agent"); + default: return type; + } +} + +// ── Agent flag parsing ────────────────────────────────────────── + +export function parseAgentFlag( + raw: string | boolean | undefined, + json: boolean +): AgentName[] | undefined { + if (!raw) return undefined; + const names = String(raw).split(","); + for (const name of names) { + if (!["claude", "cursor", "codex"].includes(name)) { + return printError(`Unknown agent: ${name}`, "INVALID_AGENT", json); + } + } + return names as AgentName[]; +} + diff --git a/tests/github.test.ts b/tests/github.test.ts new file mode 100644 index 0000000..bc7d7fc --- /dev/null +++ b/tests/github.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { parseGitHubUrl } from "../src/utils/github"; + +describe("parseGitHubUrl", () => { + test("parses blob URL into components and rawUrl", () => { + const r = parseGitHubUrl("https://github.com/owner/repo/blob/main/skills/foo/SKILL.md")!; + expect(r.owner).toBe("owner"); + expect(r.repo).toBe("repo"); + expect(r.branch).toBe("main"); + expect(r.path).toBe("skills/foo/SKILL.md"); + expect(r.rawUrl).toContain("raw.githubusercontent.com"); + }); + + test("parses raw.githubusercontent.com URL", () => { + const url = "https://raw.githubusercontent.com/o/r/main/SKILL.md"; + expect(parseGitHubUrl(url)!.rawUrl).toBe(url); + }); + + test("rejects non-GitHub and incomplete URLs", () => { + expect(parseGitHubUrl("https://example.com/foo")).toBeNull(); + expect(parseGitHubUrl("https://github.com/owner/repo")).toBeNull(); + }); +}); diff --git a/tests/health.test.ts b/tests/health.test.ts new file mode 100644 index 0000000..c2fc0bd --- /dev/null +++ b/tests/health.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { computeBadges, type BadgeContext } from "../src/core/health"; +import type { DiscoveredSkill } from "../src/types"; + +function makeSkill(overrides: Partial = {}): DiscoveredSkill { + return { + name: "test-skill", type: "skill", scope: "user", description: "", + agents: ["claude"], filePath: "/test/SKILL.md", dirPath: "/test", + tokenEstimate: 100, fileSize: 400, lineCount: 20, + lastModified: Math.floor(Date.now() / 1000), + badges: [], frontmatter: {}, rawContent: "content", + ...overrides, + }; +} + +describe("computeBadges", () => { + test("healthy skill gets no badges", () => { + const s = makeSkill(); + expect(computeBadges(s, { allSkills: [s] })).toEqual([]); + }); + + test("STALE at 31 days for project-scope, not for user-scope", () => { + const day = 24 * 60 * 60; + const now = Math.floor(Date.now() / 1000); + // Project-scope: STALE at 31 days, not at 29 + expect(computeBadges(makeSkill({ scope: "project", lastModified: now - 31 * day }), { allSkills: [] })).toContain("STALE"); + expect(computeBadges(makeSkill({ scope: "project", lastModified: now - 29 * day }), { allSkills: [] })).not.toContain("STALE"); + // User-scope: never STALE (mtime reflects install date, not relevance) + expect(computeBadges(makeSkill({ scope: "user", lastModified: now - 31 * day }), { allSkills: [] })).not.toContain("STALE"); + }); + + test("HEAVY at 5001 chars, OVERSIZED at 501 lines", () => { + expect(computeBadges(makeSkill({ rawContent: "x".repeat(5001) }), { allSkills: [] })).toContain("HEAVY"); + expect(computeBadges(makeSkill({ lineCount: 501 }), { allSkills: [] })).toContain("OVERSIZED"); + }); + + test("CONFLICT when same name different path", () => { + const a = makeSkill({ filePath: "/a/SKILL.md" }); + const b = makeSkill({ filePath: "/b/SKILL.md" }); + expect(computeBadges(a, { allSkills: [a, b] })).toContain("CONFLICT"); + }); + + test("SHARED when multi-agent", () => { + expect(computeBadges(makeSkill({ agents: ["claude", "cursor"] }), { allSkills: [] })).toContain("SHARED"); + }); +}); diff --git a/tests/parser.test.ts b/tests/parser.test.ts new file mode 100644 index 0000000..8207830 --- /dev/null +++ b/tests/parser.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { parseSkillFile, extractDescription, nameFromFilePath } from "../src/core/parser"; + +describe("parseSkillFile", () => { + test("parses frontmatter key-values, booleans, lists, and body", () => { + const content = `--- +name: my-skill +user-invocable: true +paths: + - src/ + - lib/ +--- +# Body here`; + const result = parseSkillFile(content); + expect(result.frontmatter.name).toBe("my-skill"); + expect(result.frontmatter["user-invocable"]).toBe(true); + expect(result.frontmatter["paths"]).toEqual(["src/", "lib/"]); + expect(result.body).toBe("# Body here"); + }); + + test("handles missing frontmatter", () => { + const result = parseSkillFile("# Just markdown"); + expect(result.frontmatter).toEqual({}); + }); + + test("parses multi-line literal (|) and folded (>) blocks", () => { + const content = `--- +literal: | + line one + line two +folded: > + word one + word two +name: test +---`; + const result = parseSkillFile(content); + expect(result.frontmatter.literal).toBe("line one\nline two"); + expect(result.frontmatter.folded).toBe("word one word two"); + expect(result.frontmatter.name).toBe("test"); + }); +}); + +describe("extractDescription", () => { + test("prefers frontmatter, falls back to body, truncates at 250", () => { + expect(extractDescription(parseSkillFile(`---\ndescription: hello\n---\nBody`))).toBe("hello"); + expect(extractDescription(parseSkillFile(`---\nname: x\n---\n# Title\nFirst para.`))).toBe("First para."); + const long = extractDescription(parseSkillFile(`---\ndescription: ${"A".repeat(300)}\n---`)); + expect(long.length).toBe(250); + }); +}); + +describe("nameFromFilePath", () => { + test("SKILL.md uses parent dir, others strip extension", () => { + expect(nameFromFilePath("/skills/my-skill/SKILL.md")).toBe("my-skill"); + expect(nameFromFilePath("/commands/deploy.md")).toBe("deploy"); + }); +}); diff --git a/tests/tokens.test.ts b/tests/tokens.test.ts new file mode 100644 index 0000000..1a6738b --- /dev/null +++ b/tests/tokens.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { estimateTokens, formatTokens } from "../src/core/tokens"; + +describe("estimateTokens", () => { + test("empty → 0, non-empty → positive, longer → more", () => { + expect(estimateTokens("")).toBe(0); + expect(estimateTokens("hello world")).toBeGreaterThan(0); + expect(estimateTokens("a b c d e f g h")).toBeGreaterThan(estimateTokens("hello")); + }); +}); + +describe("formatTokens", () => { + test("formats across ranges", () => { + expect(formatTokens(42)).toBe("42"); + expect(formatTokens(1500)).toBe("1.5k"); + expect(formatTokens(200000)).toBe("200k"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..01d68b1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["bun-types"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}