diff --git a/bun.lockb b/bun.lockb index 6132261..acce0d0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/src/commands/budget.ts b/src/commands/budget.ts index d41b33d..ca7d044 100644 --- a/src/commands/budget.ts +++ b/src/commands/budget.ts @@ -3,7 +3,6 @@ import { resolve } from "path"; import type { ParsedArgs, AgentName, - SkillScope, BudgetResult, BudgetConfigFile, BudgetEntry, @@ -17,28 +16,13 @@ import { getContextLimit, } from "../core/agents"; import { estimateTokens, formatTokens, tokenBar } from "../core/tokens"; -import { printJson, printError, heading, formatAgent, c } from "../utils/output"; +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(); - // Parse scope filter - let scopes: SkillScope[] | undefined; - if (args.flags.scope) { - const s = String(args.flags.scope); - 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); - } - scopes = validScopes[s]; - } + const scopes = parseScopeFlag(args.flags.scope, json); // Scan only skills (not agents, commands, rules) const allItems = await scanAll({ projectRoot, scopes, types: ["skill"] }); @@ -122,14 +106,14 @@ export async function run(args: ParsedArgs): Promise { const candidates: { text: string; tokens: number }[] = []; for (const skill of allItems) { - if (skill.badges.includes("STALE") && skill.badges.includes("HEAVY")) { - const daysAgo = Math.floor((Date.now() / 1000 - skill.lastModified) / 86400); + 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 if (skill.badges.includes("STALE")) { - const daysAgo = Math.floor((Date.now() / 1000 - skill.lastModified) / 86400); + } else { candidates.push({ text: `Remove stale "${skill.name}" (${daysAgo}d unused, ${formatTokens(skill.tokenEstimate)} tokens)`, tokens: skill.tokenEstimate, @@ -166,7 +150,7 @@ export async function run(args: ParsedArgs): Promise { } // Human output - console.log(heading("\nAGS Skill Cost\n")); + console.log(c.bold("\nAGS Skill Cost\n")); // Config files if (configFiles.length > 0) { diff --git a/src/commands/grab.ts b/src/commands/grab.ts index 80c24e8..f0caeda 100644 --- a/src/commands/grab.ts +++ b/src/commands/grab.ts @@ -1,11 +1,11 @@ import { existsSync, mkdirSync, writeFileSync } from "fs"; -import { resolve, dirname } from "path"; +import { dirname } from "path"; import type { ParsedArgs, AgentName, GrabResult } from "../types"; -import { getAgentConfig, findProjectRoot, isValidAgentName } from "../core/agents"; +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, heading, c } from "../utils/output"; +import { printJson, printError, c } from "../utils/output"; export async function run(args: ParsedArgs): Promise { const json = args.flags.json === true; @@ -38,9 +38,10 @@ export async function run(args: ParsedArgs): Promise { } const parsed = parseSkillFile(content); - const name = parsed.frontmatter.name + const rawName = parsed.frontmatter.name ? String(parsed.frontmatter.name) : nameFromFilePath(info.path); + const name = rawName.replace(/[\/\\]/g, "-").replace(/\.\./g, ""); const tokens = estimateTokens(content); @@ -55,14 +56,7 @@ export async function run(args: ParsedArgs): Promise { return printError(`No skill path configured for ${targetAgent}`, "NO_PATH", json); } - const home = process.env.HOME || ""; - let destPattern = targetPath.pattern; - if (destPattern.startsWith("~/")) { - destPattern = resolve(home, destPattern.slice(2)); - } else if (!destPattern.startsWith("/")) { - destPattern = resolve(projectRoot, destPattern); - } - + const destPattern = expandPattern(targetPath.pattern, projectRoot); const destination = destPattern.replace("*/SKILL.md", `${name}/SKILL.md`); if (existsSync(destination)) { @@ -89,7 +83,7 @@ export async function run(args: ParsedArgs): Promise { printJson({ ok: true, data: result }); } - console.log(heading("\nAGS Grab\n")); + console.log(c.bold("\nAGS Grab\n")); console.log(` ${c.bold("Name:")} ${name}`); console.log(` ${c.bold("Agent:")} ${targetAgent}`); console.log(` ${c.bold("Tokens:")} ${formatTokens(tokens)}`); diff --git a/src/commands/list-agents.ts b/src/commands/list-agents.ts index 9d6e0aa..3a7c086 100644 --- a/src/commands/list-agents.ts +++ b/src/commands/list-agents.ts @@ -3,13 +3,12 @@ import { resolve } from "path"; import type { ParsedArgs, AgentInfo, AgentPathInfo, ListAgentsResult } from "../types"; import { getAllAgentConfigs, - isAgentInstalled, getBinaryPath, resolveAgentPaths, findProjectRoot, } from "../core/agents"; import { scanAll } from "../core/scanner"; -import { printJson, heading, table, c } from "../utils/output"; +import { printJson, table, shortenPath, c } from "../utils/output"; export async function run(args: ParsedArgs): Promise { const json = args.flags.json === true; @@ -19,10 +18,8 @@ export async function run(args: ParsedArgs): Promise { const agents: AgentInfo[] = []; for (const config of configs) { - const [installed, binaryPath] = await Promise.all([ - isAgentInstalled(config.name), - getBinaryPath(config.name), - ]); + const binaryPath = await getBinaryPath(config.name); + const installed = binaryPath !== null; // Resolve paths and check existence const resolved = resolveAgentPaths(config, projectRoot); @@ -65,7 +62,7 @@ export async function run(args: ParsedArgs): Promise { } // Human output - console.log(heading("\nAGS Agents\n")); + console.log(c.bold("\nAGS Agents\n")); const rows = agents.map((a) => { const status = a.installed ? c.green("✓") : c.red("✗"); @@ -85,15 +82,3 @@ export async function run(args: ParsedArgs): Promise { console.log(table(["AGENT", "INSTALLED", "SKILLS", "ACTIVE PATHS"], rows)); console.log(); } - -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; -} diff --git a/src/commands/rm.ts b/src/commands/rm.ts index c8498f5..f5ad6c9 100644 --- a/src/commands/rm.ts +++ b/src/commands/rm.ts @@ -4,7 +4,7 @@ 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, heading, formatAgent, c } from "../utils/output"; +import { printJson, printError, formatAgent, c } from "../utils/output"; interface RmResult { removed: { name: string; agent: AgentName; type: string; path: string; tokens: number }[]; @@ -41,7 +41,7 @@ export async function run(args: ParsedArgs): Promise { matches = allItems.filter((s) => s.filePath === absTarget || s.filePath === target || - s.filePath.endsWith(target) + s.filePath.endsWith("/" + target) ); } else { // Match by name @@ -93,7 +93,7 @@ export async function run(args: ParsedArgs): Promise { } // Human output - console.log(heading("\nAGS Remove\n")); + console.log(c.bold("\nAGS Remove\n")); for (const r of result.removed) { console.log( diff --git a/src/commands/scan.ts b/src/commands/scan.ts index 24335d2..d4bc573 100644 --- a/src/commands/scan.ts +++ b/src/commands/scan.ts @@ -1,4 +1,4 @@ -import type { ParsedArgs, AgentName, SkillType, SkillScope, ScanResult, DiscoveredSkill } from "../types"; +import type { ParsedArgs, AgentName, SkillType, ScanResult, DiscoveredSkill } from "../types"; import { scanAll } from "../core/scanner"; import { isValidAgentName } from "../core/agents"; import { formatTokens } from "../core/tokens"; @@ -6,11 +6,12 @@ import { printError, printJson, table, - heading, formatBadges, formatAgent, formatAgents, formatType, + shortenPath, + parseScopeFlag, c, } from "../utils/output"; @@ -46,21 +47,7 @@ export async function run(args: ParsedArgs): Promise { types = [t]; } - let scopes: SkillScope[] | undefined; - if (args.flags.scope) { - const s = String(args.flags.scope); - const validScopes: Record = { - local: ["project"], - global: ["user"], - project: ["project"], - user: ["user"], - all: undefined as unknown as SkillScope[], - }; - if (!(s in validScopes)) { - return printError(`Unknown scope: ${s}. Use: local, global, all`, "INVALID_SCOPE", json); - } - scopes = validScopes[s] || undefined; - } + const scopes = parseScopeFlag(args.flags.scope, json); const skills = await scanAll({ agents, types, scopes }); @@ -96,7 +83,7 @@ export async function run(args: ParsedArgs): Promise { return; } - console.log(heading(`\nAGS Scan — ${skills.length} items found\n`)); + console.log(c.bold(`\nAGS Scan — ${skills.length} items found\n`)); // Group by type and render separate tables for (const section of TYPE_SECTIONS) { @@ -172,14 +159,3 @@ function scopeLabel(scope: string): string { } } -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; -} diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 24fe425..b290336 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { resolve } from "path"; import type { ParsedArgs, AgentName } from "../types"; import { formatTokens } from "../core/tokens"; -import { printJson, printError, heading, formatAgent, c } from "../utils/output"; +import { printJson, printError, pad, formatAgent, c } from "../utils/output"; // ── Types ─────────────────────────────────────────────────────── @@ -123,7 +123,9 @@ export async function run(args: ParsedArgs): Promise { const filePath = resolve(dirPath, file); try { - const mtime = statSync(filePath).mtime; + 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; @@ -314,7 +316,7 @@ export async function run(args: ParsedArgs): Promise { const periodSuffix = periodLabel === "all time" ? ` ${c.dim(`all time (earliest data: ${earliest_str})`)}` : ` ${c.dim(periodLabel || "last 30 days")}`; - console.log(heading(`\nAGS Stats — ${formatAgent("claude" as AgentName)}`) + periodSuffix); + console.log(c.bold(`\nAGS Stats — ${formatAgent("claude" as AgentName)}`) + periodSuffix); console.log(); // Overview line @@ -349,7 +351,7 @@ export async function run(args: ParsedArgs): Promise { for (let i = 0; i < maxRows; i++) { const left = i < topProjects.length - ? padLeft(renderBar(topProjects[i].displayName, topProjects[i].sessionCount, maxSessions, 6, 26), COL_LEFT) + ? pad(renderBar(topProjects[i].displayName, topProjects[i].sessionCount, maxSessions, 6, 26), COL_LEFT) : " ".repeat(COL_LEFT); const right = i < topIntegrations.length @@ -385,7 +387,7 @@ export async function run(args: ParsedArgs): Promise { for (let i = 0; i < maxRows; i++) { let left = " ".repeat(COL_LEFT); if (hasSkills && i < topSkills.length) { - left = padLeft(renderBar(topSkills[i].name, topSkills[i].calls, maxSkill, 4, 28), COL_LEFT); + 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)}`; } @@ -427,27 +429,18 @@ function renderBar(name: string, value: number, max: number, barWidth: number, n return ` ${bar} ${label} ${c.dim(count)}`; } -function padLeft(text: string, width: number): string { - const visible = stripAnsi(text).length; - return text + " ".repeat(Math.max(0, width - visible)); -} +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 { - // mcp__claude_ai_Linear__save_issue → Linear - // mcp__linear-server__create_issue → linear-server - // mcp__plugin_context7_context7__query-docs → Context7 - // mcp__revenuecat__foo → RevenueCat - const parts = toolName.replace("mcp__", "").split("__"); - const raw = parts[0] || toolName; - - // Friendly names - if (raw.includes("Linear") || raw === "linear-server") return "Linear"; - if (raw.includes("context7")) return "Context7"; - if (raw.includes("notion") || raw.includes("Notion")) return "Notion"; - if (raw.includes("revenuecat")) return "RevenueCat"; - if (raw.includes("chrome") || raw.includes("Chrome")) return "Chrome"; - if (raw.includes("astro")) return "Astro"; - return raw; + const raw = toolName.replace("mcp__", "").split("__")[0] || toolName; + return MCP_FRIENDLY_NAMES.find(([re]) => re.test(raw))?.[1] || raw; } function cleanDirName(dirName: string): string { @@ -567,16 +560,6 @@ function formatDate(dateStr: string): string { } } -function formatNum(n: number): string { - if (n < 1000) return String(n); - if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; - return `${(n / 1_000_000).toFixed(1)}M`; -} - -function stripAnsi(s: string): string { - return s.replace(/\x1b\[[0-9;]*m/g, ""); -} - function miniBar(value: number, max: number, width: number): string { const blocks = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"]; const ratio = Math.min(value / max, 1); diff --git a/src/core/agents.ts b/src/core/agents.ts index a7ed2f1..67315a9 100644 --- a/src/core/agents.ts +++ b/src/core/agents.ts @@ -83,10 +83,6 @@ export function getAllAgentConfigs(): AgentConfig[] { return AGENTS; } -export function getAgentNames(): AgentName[] { - return AGENTS.map((a) => a.name); -} - export interface ResolvedPath { scope: SkillScope; absolutePattern: string; @@ -98,14 +94,8 @@ export function resolveAgentPaths( config: AgentConfig, projectRoot: string ): ResolvedPath[] { - const home = process.env.HOME || process.env.USERPROFILE || "~"; return config.paths.map((p) => { - let pattern = p.pattern; - if (pattern.startsWith("~/")) { - pattern = resolve(home, pattern.slice(2)); - } else if (!pattern.startsWith("/")) { - pattern = resolve(projectRoot, pattern); - } + const pattern = expandPattern(p.pattern, projectRoot); return { scope: p.scope, absolutePattern: pattern, @@ -116,20 +106,7 @@ export function resolveAgentPaths( } export async function isAgentInstalled(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) return true; - } catch { - // binary not found - } - } - return false; + return (await getBinaryPath(name)) !== null; } export async function getBinaryPath(name: AgentName): Promise { @@ -152,6 +129,17 @@ export async function getBinaryPath(name: AgentName): Promise { 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; +} + export function findProjectRoot(startDir?: string): string { let dir = resolve(startDir || process.cwd()); const root = resolve("/"); diff --git a/src/core/health.ts b/src/core/health.ts index b633845..164299b 100644 --- a/src/core/health.ts +++ b/src/core/health.ts @@ -46,16 +46,4 @@ export function computeBadges( } return badges; -} - -export function badgeSeverity( - badge: HealthBadge -): "info" | "warn" | "error" { - switch (badge) { - case "SHARED": return "info"; - case "STALE": return "warn"; - case "HEAVY": return "warn"; - case "OVERSIZED": return "error"; - case "CONFLICT": return "error"; - } -} +} \ No newline at end of file diff --git a/src/core/parser.ts b/src/core/parser.ts index d846b99..e87ecdf 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -114,41 +114,6 @@ function stripQuotes(s: string): string { return s; } -export function serializeFrontmatter(fm: Frontmatter): string { - const lines: string[] = ["---"]; - - for (const [key, value] of Object.entries(fm)) { - if (value === undefined || value === null) continue; - - if (Array.isArray(value)) { - if (value.length === 0) continue; - // Short arrays inline, long arrays as list - if (value.length <= 3 && value.every((v) => typeof v === "string" && v.length < 30)) { - lines.push(`${key}: [${value.join(", ")}]`); - } else { - lines.push(`${key}:`); - for (const item of value) { - lines.push(` - ${item}`); - } - } - } else if (typeof value === "object") { - lines.push(`${key}:`); - for (const [k, v] of Object.entries(value)) { - if (v !== undefined && v !== null) { - lines.push(` ${k}: ${v}`); - } - } - } else if (typeof value === "string" && value.includes(": ")) { - lines.push(`${key}: "${value}"`); - } else { - lines.push(`${key}: ${value}`); - } - } - - lines.push("---"); - return lines.join("\n"); -} - export function extractDescription(parsed: ParsedSkillFile): string { if (parsed.frontmatter.description) { const desc = String(parsed.frontmatter.description); diff --git a/src/utils/git.ts b/src/utils/git.ts index 5cb4993..6c75a7d 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -24,16 +24,4 @@ export async function getLastModified(filePath: string): Promise { } catch { return Math.floor(Date.now() / 1000); } -} - -export async function isGitRepo(dir: string): Promise { - try { - const proc = Bun.spawn( - ["git", "rev-parse", "--git-dir"], - { stdout: "pipe", stderr: "pipe", cwd: dir } - ); - return (await proc.exited) === 0; - } catch { - return false; - } -} +} \ No newline at end of file diff --git a/src/utils/github.ts b/src/utils/github.ts index 0856820..44c518d 100644 --- a/src/utils/github.ts +++ b/src/utils/github.ts @@ -16,39 +16,26 @@ const RAW_RE = /^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/; export function parseGitHubUrl(url: string): GitHubFileInfo | null { - let match = url.match(BLOB_RE); - if (match) { - const [, owner, repo, branch, path] = match; - const fileName = path.split("/").pop() || "SKILL.md"; - return { - owner, - repo, - branch, - path, - rawUrl: `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`, - fileName, - }; - } + const isBlob = url.match(BLOB_RE); + const match = isBlob || url.match(RAW_RE); + if (!match) return null; - match = url.match(RAW_RE); - if (match) { - const [, owner, repo, branch, path] = match; - const fileName = path.split("/").pop() || "SKILL.md"; - return { - owner, - repo, - branch, - path, - rawUrl: url, - fileName, - }; - } + 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 null; + 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); + const response = await fetch(info.rawUrl, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); if (!response.ok) { throw new Error( @@ -56,5 +43,10 @@ export async function fetchRawContent(info: GitHubFileInfo): Promise { ); } + const contentLength = Number(response.headers.get("content-length")); + if (contentLength > MAX_FETCH_SIZE) { + throw new Error(`File too large (${contentLength} bytes, max ${MAX_FETCH_SIZE})`); + } + return response.text(); } diff --git a/src/utils/output.ts b/src/utils/output.ts index a14a549..8f88322 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -1,4 +1,4 @@ -import type { AgentName, CliOutput, HealthBadge, SkillType } from "../types"; +import type { AgentName, CliOutput, HealthBadge, SkillScope, SkillType } from "../types"; // ── ANSI colors ───────────────────────────────────────────────── @@ -38,12 +38,6 @@ export function printJson(output: CliOutput): never { process.exit(output.ok ? 0 : 1); } -export function printSuccess(data: T, json: boolean): void { - if (json) { - printJson({ ok: true, data }); - } -} - export function printError( message: string, code: string, @@ -78,16 +72,47 @@ export function table(headers: string[], rows: string[][]): string { return `${c.bold(headerLine)}\n${c.dim(divider)}\n${body}`; } -function pad(text: string, width: number): string { +export function pad(text: string, width: number): string { const visible = stripAnsi(text).length; const needed = Math.max(0, width - visible); return text + " ".repeat(needed); } -function stripAnsi(s: string): string { +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 { @@ -144,12 +169,3 @@ export function formatType(type: SkillType): string { } } -// ── Headings ──────────────────────────────────────────────────── - -export function heading(text: string): string { - return c.bold(text); -} - -export function subheading(text: string): string { - return c.dim(text); -}