diff --git a/docs/docs/configure/skills.md b/docs/docs/configure/skills.md index 6a807cce3f..9a55caa6dd 100644 --- a/docs/docs/configure/skills.md +++ b/docs/docs/configure/skills.md @@ -34,7 +34,8 @@ Focus on the query: $ARGUMENTS Skills are loaded from these locations (in priority order): -1. **altimate-code directories** (project-scoped, highest priority): +1. **Project directories** (project-scoped, highest priority): + - `.opencode/skills/` - `.altimate-code/skill/` - `.altimate-code/skills/` @@ -88,9 +89,96 @@ altimate ships with built-in skills for common data engineering tasks. Type `/` | `/train` | Learn standards from documents/style guides | | `/training-status` | Dashboard of all learned knowledge | +## CLI Commands + +Manage skills from the command line: + +```bash +# Browse skills +altimate-code skill list # table view +altimate-code skill list --json # JSON (for scripting) +altimate-code skill show dbt-develop # view full skill content + +# Create +altimate-code skill create my-tool # scaffold skill + bash tool +altimate-code skill create my-tool --language python # python tool stub +altimate-code skill create my-tool --language node # node tool stub +altimate-code skill create my-tool --skill-only # skill only, no CLI tool + +# Validate +altimate-code skill test my-tool # check frontmatter + tool --help + +# Install from GitHub +altimate-code skill install owner/repo # GitHub shorthand +altimate-code skill install https://github.com/... # full URL (web URLs work too) +altimate-code skill install ./local-path # local directory +altimate-code skill install owner/repo --global # install globally + +# Remove +altimate-code skill remove my-tool # remove skill + paired tool +``` + +### TUI + +Type `/skills` in the TUI prompt to open the skill browser. From there: + +| Key | Action | +|-----|--------| +| Enter | Use — inserts `/` into the prompt | +| `ctrl+a` | Actions — show, edit, test, or remove the selected skill | +| `ctrl+n` | New — scaffold a new skill + CLI tool | +| `ctrl+i` | Install — install skills from a GitHub repo or URL | +| Esc | Back — returns to previous screen | + ## Adding Custom Skills -Add your own skills as Markdown files in `.altimate-code/skill/`: +The fastest way to create a custom skill is with the scaffolder: + +```bash +altimate-code skill create freshness-check +``` + +This creates two files: + +- `.opencode/skills/freshness-check/SKILL.md` — teaches the agent when and how to use your tool +- `.opencode/tools/freshness-check` — executable CLI tool stub + +### Pairing Skills with CLI Tools + +Skills become powerful when paired with CLI tools. Drop any executable into `.opencode/tools/` and it's automatically available on the agent's PATH: + +``` +.opencode/tools/ # Project-level tools (auto-discovered) +~/.config/altimate-code/tools/ # Global tools (shared across projects) +``` + +A skill references its paired CLI tool through bash code blocks: + +```markdown +--- +name: freshness-check +description: Check data freshness across tables +--- + +# Freshness Check + +## CLI Reference +\`\`\`bash +freshness-check --table users --threshold 24h +freshness-check --all --report +\`\`\` + +## Workflow +1. Ask the user which tables to check +2. Run `freshness-check` with appropriate flags +3. Interpret the output and suggest fixes +``` + +The tool can be written in any language (bash, Python, Node.js, etc.) — as long as it's executable. + +### Skill-Only (No CLI Tool) + +You can also create skills as plain prompt templates: ```markdown --- @@ -104,9 +192,11 @@ Focus on: $ARGUMENTS `$ARGUMENTS` is replaced with whatever the user types after the skill name (e.g., `/cost-review SELECT * FROM orders` passes `SELECT * FROM orders`). +### Skill Paths + Skills are loaded from these paths (highest priority first): -1. `.altimate-code/skill/` (project) +1. `.opencode/skills/` and `.altimate-code/skill/` (project) 2. `~/.altimate-code/skills/` (global) 3. Custom paths via config: diff --git a/docs/docs/configure/tools/custom.md b/docs/docs/configure/tools/custom.md index 18f121070c..3e7dddd13d 100644 --- a/docs/docs/configure/tools/custom.md +++ b/docs/docs/configure/tools/custom.md @@ -1,8 +1,90 @@ # Custom Tools -Create custom tools using TypeScript and the altimate plugin system. +There are two ways to extend altimate-code with custom tools: -## Quick Start +1. **CLI tools** (recommended) — simple executables paired with skills +2. **Plugin tools** — TypeScript-based tools using the plugin API + +## CLI Tools (Recommended) + +The simplest way to add custom functionality. Drop any executable into `.opencode/tools/` and it's automatically available to the agent via bash. + +### Quick Start + +```bash +# Scaffold a skill + CLI tool pair +altimate-code skill create my-tool + +# Or create manually: +mkdir -p .opencode/tools +cat > .opencode/tools/my-tool << 'EOF' +#!/usr/bin/env bash +set -euo pipefail +echo "Hello from my-tool!" +EOF +chmod +x .opencode/tools/my-tool +``` + +Tools in `.opencode/tools/` are automatically prepended to PATH when the agent runs bash commands. No configuration needed. + +### Tool Locations + +| Location | Scope | Auto-discovered | +|----------|-------|-----------------| +| `.opencode/tools/` | Project | Yes | +| `~/.config/altimate-code/tools/` | Global (all projects) | Yes | + +### Pairing with Skills + +Create a `SKILL.md` that teaches the agent when and how to use your tool: + +```bash +altimate-code skill create my-tool --language python +``` + +This creates both `.opencode/skills/my-tool/SKILL.md` and `.opencode/tools/my-tool`. Edit both files to implement your tool. + +### Validating + +```bash +altimate-code skill test my-tool +``` + +This checks that the SKILL.md is valid and the paired tool is executable. + +### Installing Community Skills + +Install skills (with their paired tools) from GitHub: + +```bash +# From a GitHub repo +altimate-code skill install anthropics/skills +altimate-code skill install dagster-io/skills + +# From a GitHub web URL (pasted from browser) +altimate-code skill install https://github.com/owner/repo/tree/main/skills/my-skill + +# Remove an installed skill +altimate-code skill remove my-skill +``` + +Or use the TUI: type `/skills`, then `ctrl+i` to install or `ctrl+a` → Remove to delete. + +### Output Conventions + +For best results with the AI agent: + +- **Default output:** Human-readable text (the agent reads this well) +- **`--json` flag:** Structured JSON for scripting +- **Summary first:** "Found 12 matches:" or "3 issues detected:" +- **Errors to stderr**, results to stdout +- **Exit code 0** = success, **1** = error + +## Plugin Tools (Advanced) + +For more complex tools that need access to the altimate-code runtime, use the TypeScript plugin system. + +### Quick Start 1. Create a tools directory: diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index f71f820dd4..25ae604150 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -350,6 +350,32 @@ export namespace Telemetry { skill_source: "builtin" | "global" | "project" duration_ms: number } + // altimate_change start — telemetry for skill management operations + | { + type: "skill_created" + timestamp: number + session_id: string + skill_name: string + language: string + source: "cli" | "tui" + } + | { + type: "skill_installed" + timestamp: number + session_id: string + install_source: string + skill_count: number + skill_names: string[] + source: "cli" | "tui" + } + | { + type: "skill_removed" + timestamp: number + session_id: string + skill_name: string + source: "cli" | "tui" + } + // altimate_change end | { type: "sql_execute_failure" timestamp: number diff --git a/packages/opencode/src/cli/cmd/skill-helpers.ts b/packages/opencode/src/cli/cmd/skill-helpers.ts new file mode 100644 index 0000000000..aebb611e73 --- /dev/null +++ b/packages/opencode/src/cli/cmd/skill-helpers.ts @@ -0,0 +1,108 @@ +// altimate_change start — shared helpers for skill CLI commands +import path from "path" +import fs from "fs/promises" +import { Global } from "@/global" +import { Instance } from "../../project/instance" + +/** Shell builtins, common utilities, and agent tool names to filter when detecting CLI tool references. */ +export const SHELL_BUILTINS = new Set([ + // Shell builtins + "echo", "cd", "export", "set", "if", "then", "else", "fi", "for", "do", "done", + "case", "esac", "printf", "source", "alias", "read", "local", "return", "exit", + "break", "continue", "shift", "trap", "type", "command", "builtin", "eval", "exec", + "test", "true", "false", + // Common CLI utilities (not user tools) + "cat", "grep", "awk", "sed", "rm", "cp", "mv", "mkdir", "ls", "chmod", "which", + "curl", "wget", "pwd", "touch", "head", "tail", "sort", "uniq", "wc", "tee", + "xargs", "find", "tar", "gzip", "unzip", "git", "npm", "yarn", "bun", "pip", + "python", "python3", "node", "bash", "sh", "zsh", "docker", "make", + // System utilities unlikely to be user tools + "sudo", "kill", "ps", "env", "whoami", "id", "date", "sleep", "diff", "less", "more", + // Agent tool names that appear in skill content but aren't CLI tools + "glob", "write", "edit", +]) + +/** Detect CLI tool references inside a skill's content (bash code blocks mentioning executables). */ +export function detectToolReferences(content: string): string[] { + const tools = new Set() + + // Match "Tools used: bash (runs `altimate-dbt` commands), ..." + const toolsUsedMatch = content.match(/Tools used:\s*(.+)/i) + if (toolsUsedMatch) { + const refs = toolsUsedMatch[1].matchAll(/`([a-z][\w-]*)`/gi) + for (const m of refs) { + if (!SHELL_BUILTINS.has(m[1])) tools.add(m[1]) + } + } + + // Match executable names in bash code blocks: lines starting with an executable name + const bashBlocks = content.matchAll(/```(?:bash|sh)\r?\n([\s\S]*?)```/g) + for (const block of bashBlocks) { + const lines = block[1].split("\n") + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + // Extract the first word (the command) + const cmdMatch = trimmed.match(/^(?:\$\s+)?([a-z][\w.-]*(?:-[\w]+)*)/i) + if (cmdMatch) { + const cmd = cmdMatch[1] + if (!SHELL_BUILTINS.has(cmd)) { + tools.add(cmd) + } + } + } + } + + return Array.from(tools) +} + +/** Determine the source label for a skill based on its location. */ +export function skillSource(location: string): string { + if (location.startsWith("builtin:")) return "builtin" + const home = Global.Path.home + // Builtin skills shipped with altimate-code + if (location.startsWith(path.join(home, ".altimate", "builtin"))) return "builtin" + // Global user skills (~/.claude/skills/, ~/.agents/skills/, ~/.config/altimate-code/skills/) + const globalDirs = [ + path.join(home, ".claude", "skills"), + path.join(home, ".agents", "skills"), + path.join(home, ".altimate-code", "skills"), + path.join(Global.Path.config, "skills"), + ] + if (globalDirs.some((dir) => location.startsWith(dir))) return "global" + // Everything else is project-level + return "project" +} + +/** Check if a tool is available on the current PATH (including .opencode/tools/). */ +export async function isToolOnPath(toolName: string, cwd: string): Promise { + // Check .opencode/tools/ in both cwd and worktree (they may differ in monorepos) + const dirsToCheck = new Set([ + path.join(cwd, ".opencode", "tools"), + path.join(Instance.worktree !== "/" ? Instance.worktree : cwd, ".opencode", "tools"), + path.join(Global.Path.config, "tools"), + ]) + + for (const dir of dirsToCheck) { + try { + await fs.access(path.join(dir, toolName), fs.constants.X_OK) + return true + } catch {} + } + + // Check system PATH + const sep = process.platform === "win32" ? ";" : ":" + const binDir = process.env.ALTIMATE_BIN_DIR + const pathDirs = (process.env.PATH ?? "").split(sep).filter(Boolean) + if (binDir) pathDirs.unshift(binDir) + + for (const dir of pathDirs) { + try { + await fs.access(path.join(dir, toolName), fs.constants.X_OK) + return true + } catch {} + } + + return false +} +// altimate_change end diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts new file mode 100644 index 0000000000..3bad700398 --- /dev/null +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -0,0 +1,741 @@ +// altimate_change start — top-level `skill` command for managing skills and user tools +import { EOL } from "os" +import path from "path" +import fs from "fs/promises" +import { Skill } from "../../skill" +import { bootstrap } from "../bootstrap" +import { cmd } from "./cmd" +import { Instance } from "../../project/instance" +import { Global } from "@/global" +import { detectToolReferences, skillSource, isToolOnPath } from "./skill-helpers" +// altimate_change start — telemetry for skill operations +import { Telemetry } from "@/altimate/telemetry" +// altimate_change end + +// --------------------------------------------------------------------------- +// Templates +// --------------------------------------------------------------------------- + +function skillTemplate(name: string, opts: { withTool: boolean }): string { + const cliSection = opts.withTool + ? ` +## CLI Reference +\`\`\`bash +${name} --help +${name} [options] +\`\`\` + +## Workflow +1. Understand what the user needs +2. Run the appropriate CLI command +3. Interpret the output and act on it` + : ` +## Workflow +1. Understand what the user needs +2. Provide guidance based on the instructions below` + + return `--- +name: ${name} +description: TODO — describe what this skill does +--- + +# ${name} + +## When to Use +TODO — describe when the agent should invoke this skill. +${cliSection} +` +} + +function bashToolTemplate(name: string): string { + return `#!/usr/bin/env bash +set -euo pipefail +# ${name} — TODO describe what this tool does +# Usage: ${name} [args] + +show_help() { + cat < [options] + +Commands: + help Show this help message + +Options: + -h, --help Show help + +Examples: + ${name} help +EOF +} + +case "\${1:-help}" in + help|--help|-h) + show_help + ;; + *) + echo "Error: Unknown command '\${1}'" >&2 + echo "Run '${name} help' for usage information." >&2 + exit 1 + ;; +esac +` +} + +function pythonToolTemplate(name: string): string { + return `#!/usr/bin/env python3 +"""${name} — TODO describe what this tool does.""" +import argparse +import json +import sys + + +def main(): + parser = argparse.ArgumentParser( + prog="${name}", + description="TODO — describe what this tool does", + ) + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Example subcommand + subparsers.add_parser("help", help="Show help information") + + args = parser.parse_args() + + if not args.command or args.command == "help": + parser.print_help() + sys.exit(0) + + # TODO: implement commands + print(json.dumps({"status": "ok", "command": args.command})) + + +if __name__ == "__main__": + main() +` +} + +function nodeToolTemplate(name: string): string { + return `#!/usr/bin/env node +// ${name} — TODO describe what this tool does +// Usage: ${name} [args] + +const args = process.argv.slice(2) +const command = args[0] || "help" + +function showHelp() { + console.log(\`Usage: ${name} [options] + +Commands: + help Show this help message + +Examples: + ${name} help\`) +} + +switch (command) { + case "help": + case "--help": + case "-h": + showHelp() + break + default: + console.error(\`Error: Unknown command '\${command}'\`) + console.error(\`Run '${name} help' for usage information.\`) + process.exit(1) +} +` +} + +// --------------------------------------------------------------------------- +// Subcommands +// --------------------------------------------------------------------------- + +const SkillListCommand = cmd({ + command: "list", + describe: "list all available skills with their paired tools", + builder: (yargs) => + yargs.option("json", { + type: "boolean", + describe: "output as JSON", + default: false, + }), + async handler(args) { + await bootstrap(process.cwd(), async () => { + const skills = await Skill.all() + const cwd = Instance.directory + + // Sort alphabetically for consistent output + skills.sort((a, b) => a.name.localeCompare(b.name)) + + if (args.json) { + const enriched = await Promise.all( + skills.map(async (skill) => { + const tools = detectToolReferences(skill.content) + const toolStatus = await Promise.all( + tools.map(async (t) => ({ name: t, available: await isToolOnPath(t, cwd) })), + ) + return { + name: skill.name, + description: skill.description, + source: skillSource(skill.location), + location: skill.location, + tools: toolStatus, + } + }), + ) + process.stdout.write(JSON.stringify(enriched, null, 2) + EOL) + return + } + + // Human-readable table output + if (skills.length === 0) { + process.stdout.write("No skills found." + EOL) + process.stdout.write(EOL + `Create one with: altimate-code skill create ` + EOL) + return + } + + // Calculate column widths + const nameWidth = Math.max(6, ...skills.map((s) => s.name.length)) + const toolsWidth = 20 + + const header = `${"SKILL".padEnd(nameWidth)} ${"TOOLS".padEnd(toolsWidth)} DESCRIPTION` + const separator = "─".repeat(header.length) + + process.stdout.write(EOL) + process.stdout.write(header + EOL) + process.stdout.write(separator + EOL) + + for (const skill of skills) { + const tools = detectToolReferences(skill.content) + const rawToolStr = tools.length > 0 ? tools.join(", ") : "—" + const toolStr = rawToolStr.length > toolsWidth ? rawToolStr.slice(0, toolsWidth - 3) + "..." : rawToolStr + // Truncate on word boundary + let desc = skill.description + if (desc.length > 60) { + desc = desc.slice(0, 60) + const lastSpace = desc.lastIndexOf(" ") + if (lastSpace > 40) desc = desc.slice(0, lastSpace) + desc += "..." + } + + process.stdout.write( + `${skill.name.padEnd(nameWidth)} ${toolStr.padEnd(toolsWidth)} ${desc}` + EOL, + ) + } + + process.stdout.write(EOL) + process.stdout.write(`${skills.length} skill(s) found.` + EOL) + process.stdout.write(`Create a new skill: altimate-code skill create ` + EOL) + }) + }, +}) + +const SkillCreateCommand = cmd({ + command: "create ", + describe: "scaffold a new skill with a paired CLI tool", + builder: (yargs) => + yargs + .positional("name", { + type: "string", + describe: "name of the skill to create", + demandOption: true, + }) + .option("language", { + alias: "l", + type: "string", + describe: "language for the CLI tool stub", + choices: ["bash", "python", "node"], + default: "bash", + }) + .option("skill-only", { + alias: "s", + type: "boolean", + describe: "create only the skill without a CLI tool", + default: false, + }), + async handler(args) { + const name = args.name as string + const language = args.language as string + const noTool = args["skill-only"] as boolean + + // Validate name before bootstrap (fast fail) + if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name) || name.length < 2) { + process.stderr.write(`Error: Skill name must be lowercase alphanumeric with hyphens, at least 2 chars (e.g., "my-tool")` + EOL) + process.exit(1) + } + if (name.length > 64) { + process.stderr.write(`Error: Skill name must be 64 characters or fewer` + EOL) + process.exit(1) + } + + await bootstrap(process.cwd(), async () => { + // Use worktree (git root) so skills are always at the project root, + // even when the command is run from a subdirectory. + const rootDir = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + + // Create skill directory and SKILL.md + const skillDir = path.join(rootDir, ".opencode", "skills", name) + const skillFile = path.join(skillDir, "SKILL.md") + + try { + await fs.access(skillFile) + process.stderr.write(`Error: Skill already exists at ${skillFile}` + EOL) + process.exit(1) + } catch { + // File doesn't exist, good + } + + await fs.mkdir(skillDir, { recursive: true }) + await fs.writeFile(skillFile, skillTemplate(name, { withTool: !noTool }), "utf-8") + process.stdout.write(`✓ Created skill: ${path.relative(rootDir, skillFile)}` + EOL) + + // Create CLI tool stub + if (!noTool) { + const toolsDir = path.join(rootDir, ".opencode", "tools") + const toolFile = path.join(toolsDir, name) + + try { + await fs.access(toolFile) + process.stderr.write(`Warning: Tool already exists at ${toolFile}, skipping` + EOL) + } catch { + await fs.mkdir(toolsDir, { recursive: true }) + + let template: string + switch (language) { + case "python": + template = pythonToolTemplate(name) + break + case "node": + template = nodeToolTemplate(name) + break + default: + template = bashToolTemplate(name) + } + + await fs.writeFile(toolFile, template, { mode: 0o755 }) + process.stdout.write(`✓ Created tool: ${path.relative(rootDir, toolFile)}` + EOL) + } + } + + // altimate_change start — telemetry + try { + Telemetry.track({ + type: "skill_created", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId || "", + skill_name: name, + language, + source: "cli", + }) + } catch {} + // altimate_change end + + process.stdout.write(EOL) + process.stdout.write(`Next steps:` + EOL) + process.stdout.write(` 1. Edit .opencode/skills/${name}/SKILL.md — teach the agent when and how to use your tool` + EOL) + if (!noTool) { + process.stdout.write(` 2. Edit .opencode/tools/${name} — implement your tool's commands` + EOL) + process.stdout.write(` 3. Test it: altimate-code skill test ${name}` + EOL) + } + }) + }, +}) + +const SkillTestCommand = cmd({ + command: "test ", + describe: "validate a skill and its paired CLI tool", + builder: (yargs) => + yargs.positional("name", { + type: "string", + describe: "name of the skill to test", + demandOption: true, + }), + async handler(args) { + const name = args.name as string + const cwd = process.cwd() + let hasErrors = false + + const pass = (msg: string) => process.stdout.write(` ✓ ${msg}` + EOL) + const fail = (msg: string) => { + process.stdout.write(` ✗ ${msg}` + EOL) + hasErrors = true + } + const warn = (msg: string) => process.stdout.write(` ⚠ ${msg}` + EOL) + + process.stdout.write(EOL + `Testing skill: ${name}` + EOL + EOL) + + // 1. Check SKILL.md exists + await bootstrap(cwd, async () => { + const skill = await Skill.get(name) + if (!skill) { + fail(`Skill "${name}" not found. Check .opencode/skills/${name}/SKILL.md exists.`) + process.exitCode = 1 + return + } + pass(`SKILL.md found at ${skill.location}`) + + // 2. Check frontmatter + if (skill.name && skill.description) { + pass(`Frontmatter valid (name: "${skill.name}", description present)`) + } else { + fail(`Frontmatter incomplete — name and description are required`) + } + + if (skill.description.startsWith("TODO")) { + warn(`Description starts with "TODO" — update it before sharing`) + } + + // 3. Check content has substance + const contentLines = skill.content.split("\n").filter((l) => l.trim()).length + if (contentLines > 3) { + pass(`Content has ${contentLines} non-empty lines`) + } else { + warn(`Content is minimal (${contentLines} lines) — consider adding more detail`) + } + + // 4. Detect and check paired tools + const projectDir = Instance.directory + const tools = detectToolReferences(skill.content) + if (tools.length === 0) { + warn(`No CLI tool references detected in skill content`) + } else { + process.stdout.write(EOL + ` Paired tools:` + EOL) + for (const tool of tools) { + const available = await isToolOnPath(tool, projectDir) + if (available) { + pass(`"${tool}" found on PATH`) + + // Try running --help (with 5s timeout to prevent hangs) + try { + const worktreeDir = Instance.worktree !== "/" ? Instance.worktree : projectDir + const toolEnv = { + ...process.env, + PATH: [ + process.env.ALTIMATE_BIN_DIR, + path.join(worktreeDir, ".opencode", "tools"), + path.join(projectDir, ".opencode", "tools"), + path.join(Global.Path.config, "tools"), + process.env.PATH, + ] + .filter(Boolean) + .join(process.platform === "win32" ? ";" : ":"), + } + const proc = Bun.spawn([tool, "--help"], { + cwd: projectDir, + stdout: "pipe", + stderr: "pipe", + env: toolEnv, + }) + const timeout = setTimeout(() => proc.kill(), 5000) + const exitCode = await proc.exited + clearTimeout(timeout) + if (exitCode === 0) { + pass(`"${tool} --help" exits cleanly`) + } else if (exitCode === null || exitCode === 137 || exitCode === 143) { + fail(`"${tool} --help" timed out after 5s`) + } else { + fail(`"${tool} --help" exited with code ${exitCode}`) + } + } catch { + fail(`"${tool} --help" failed to execute`) + } + } else { + fail(`"${tool}" not found on PATH`) + } + } + } + + process.stdout.write(EOL) + if (hasErrors) { + process.stdout.write(`Result: FAIL — fix the issues above` + EOL) + process.exitCode = 1 + } else { + process.stdout.write(`Result: PASS — skill is ready to use!` + EOL) + } + }) + }, +}) + +const SkillShowCommand = cmd({ + command: "show ", + describe: "display the full content of a skill", + builder: (yargs) => + yargs.positional("name", { + type: "string", + describe: "name of the skill to show", + demandOption: true, + }), + async handler(args) { + const name = args.name as string + await bootstrap(process.cwd(), async () => { + const skill = await Skill.get(name) + if (!skill) { + process.stderr.write(`Error: Skill "${name}" not found.` + EOL) + process.exit(1) + } + + const tools = detectToolReferences(skill.content) + + process.stdout.write(EOL) + process.stdout.write(` Name: ${skill.name}` + EOL) + process.stdout.write(` Description: ${skill.description}` + EOL) + process.stdout.write(` Location: ${skill.location}` + EOL) + if (tools.length > 0) { + process.stdout.write(` Tools: ${tools.join(", ")}` + EOL) + } + process.stdout.write(EOL + "─".repeat(60) + EOL + EOL) + process.stdout.write(skill.content + EOL) + }) + }, +}) + +const SkillInstallCommand = cmd({ + command: "install ", + describe: "install a skill from GitHub or a local path", + builder: (yargs) => + yargs + .positional("source", { + type: "string", + describe: "GitHub repo (owner/repo), URL, or local path", + demandOption: true, + }) + .option("global", { + alias: "g", + type: "boolean", + describe: "install globally instead of per-project", + default: false, + }), + async handler(args) { + let source = (args.source as string).trim().replace(/\.git$/, "") + const isGlobal = args.global as boolean + + if (!source) { + process.stderr.write(`Error: Source is required. Use owner/repo, URL, or local path.` + EOL) + process.exit(1) + } + + await bootstrap(process.cwd(), async () => { + const rootDir = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + const targetDir = isGlobal + ? path.join(Global.Path.config, "skills") + : path.join(rootDir, ".opencode", "skills") + + // Determine source type and fetch + let skillDir: string + + // Normalize GitHub web URLs (e.g. /tree/main/path) to clonable repo URLs + const ghWebMatch = source.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/(?:tree|blob)\/.*)?$/) + if (ghWebMatch) { + source = `https://github.com/${ghWebMatch[1]}.git` + } + + if (source.startsWith("http://") || source.startsWith("https://")) { + // URL: clone the repo + process.stdout.write(`Fetching from ${source}...` + EOL) + const tmpDir = path.join(Global.Path.cache, "skill-install-" + Date.now()) + const proc = Bun.spawnSync(["git", "clone", "--depth", "1", source, tmpDir], { + stdout: "pipe", + stderr: "pipe", + }) + if (proc.exitCode !== 0) { + process.stderr.write(`Error: Failed to clone ${source}` + EOL) + process.stderr.write(proc.stderr.toString() + EOL) + process.exit(1) + } + skillDir = tmpDir + } else if (source.match(/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/)) { + // GitHub shorthand: owner/repo + const url = `https://github.com/${source}.git` + process.stdout.write(`Fetching from github.com/${source}...` + EOL) + const tmpDir = path.join(Global.Path.cache, "skill-install-" + Date.now()) + const proc = Bun.spawnSync(["git", "clone", "--depth", "1", url, tmpDir], { + stdout: "pipe", + stderr: "pipe", + }) + if (proc.exitCode !== 0) { + process.stderr.write(`Error: Failed to clone ${url}` + EOL) + process.stderr.write(proc.stderr.toString() + EOL) + process.exit(1) + } + skillDir = tmpDir + } else { + // Local path + const resolved = path.isAbsolute(source) ? source : path.resolve(source) + try { + await fs.access(resolved) + } catch { + process.stderr.write(`Error: Path not found: ${resolved}` + EOL) + process.exit(1) + } + skillDir = resolved + } + + // Find all SKILL.md files in the source + const { Glob: BunGlob } = globalThis.Bun + const glob = new BunGlob("**/SKILL.md") + const matches: string[] = [] + for await (const match of glob.scan({ cwd: skillDir, absolute: true })) { + // Skip .git directory + if (!match.includes("/.git/")) matches.push(match) + } + + if (matches.length === 0) { + process.stderr.write(`Error: No SKILL.md files found in ${source}` + EOL) + // Clean up tmp if cloned + if (skillDir.startsWith(Global.Path.cache)) { + await fs.rm(skillDir, { recursive: true, force: true }) + } + process.exit(1) + } + + let installed = 0 + const installedNames: string[] = [] + for (const skillFile of matches) { + const skillParent = path.dirname(skillFile) + const skillName = path.basename(skillParent) + const dest = path.join(targetDir, skillName) + + // Check if already installed + try { + await fs.access(dest) + process.stdout.write(` ⚠ Skipping "${skillName}" — already exists` + EOL) + continue + } catch { + // Not installed, proceed + } + + // Copy the entire skill directory (SKILL.md + any supporting files) + // Use lstat to skip symlinks (security: prevents file disclosure from malicious repos) + await fs.mkdir(dest, { recursive: true }) + const files = await fs.readdir(skillParent) + for (const file of files) { + const src = path.join(skillParent, file) + const dst = path.join(dest, file) + const stat = await fs.lstat(src) + if (stat.isSymbolicLink()) continue + if (stat.isFile()) { + await fs.copyFile(src, dst) + } else if (stat.isDirectory()) { + await fs.cp(src, dst, { recursive: true, dereference: false }) + } + } + process.stdout.write(` ✓ Installed "${skillName}" → ${path.relative(rootDir, dest)}` + EOL) + installedNames.push(skillName) + installed++ + } + + // Clean up tmp if cloned + if (skillDir.startsWith(Global.Path.cache)) { + await fs.rm(skillDir, { recursive: true, force: true }) + } + + process.stdout.write(EOL) + if (installed > 0) { + process.stdout.write(`${installed} skill(s) installed${isGlobal ? " globally" : ""}.` + EOL) + // altimate_change start — telemetry + try { + Telemetry.track({ + type: "skill_installed", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId || "", + install_source: source, + skill_count: installed, + skill_names: installedNames, + source: "cli", + }) + } catch {} + // altimate_change end + } else { + process.stdout.write(`No new skills installed.` + EOL) + } + }) + }, +}) + +const SkillRemoveCommand = cmd({ + command: "remove ", + describe: "remove an installed skill and its paired CLI tool", + builder: (yargs) => + yargs.positional("name", { + type: "string", + describe: "name of the skill to remove", + demandOption: true, + }), + async handler(args) { + const name = args.name as string + await bootstrap(process.cwd(), async () => { + const skill = await Skill.get(name) + if (!skill) { + process.stderr.write(`Error: Skill "${name}" not found.` + EOL) + process.exit(1) + } + + if (skill.location.startsWith("builtin:")) { + process.stderr.write(`Error: Cannot remove built-in skill "${name}".` + EOL) + process.exit(1) + } + + // Check if skill is tracked by git (part of the repo, not user-installed) + const skillDir = path.dirname(skill.location) + const gitCheck = Bun.spawnSync(["git", "ls-files", "--error-unmatch", skill.location], { + cwd: path.dirname(skillDir), + stdout: "pipe", + stderr: "pipe", + }) + if (gitCheck.exitCode === 0) { + process.stderr.write(`Error: Cannot remove "${name}" — it is tracked by git.` + EOL) + process.stderr.write(`This skill is part of the repository, not user-installed.` + EOL) + process.exit(1) + } + + // Remove skill directory + await fs.rm(skillDir, { recursive: true, force: true }) + process.stdout.write(` ✓ Removed skill: ${skillDir}` + EOL) + + // Remove paired CLI tool if it exists + const rootDir = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + const toolFile = path.join(rootDir, ".opencode", "tools", name) + try { + await fs.access(toolFile) + await fs.rm(toolFile, { force: true }) + process.stdout.write(` ✓ Removed tool: ${toolFile}` + EOL) + } catch { + // No paired tool, that's fine + } + + // altimate_change start — telemetry + try { + Telemetry.track({ + type: "skill_removed", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId || "", + skill_name: name, + source: "cli", + }) + } catch {} + // altimate_change end + + process.stdout.write(EOL + `Skill "${name}" removed.` + EOL) + }) + }, +}) + +// --------------------------------------------------------------------------- +// Top-level skill command +// --------------------------------------------------------------------------- + +export const SkillCommand = cmd({ + command: "skill", + describe: "manage skills and user CLI tools", + builder: (yargs) => + yargs + .command(SkillListCommand) + .command(SkillCreateCommand) + .command(SkillTestCommand) + .command(SkillShowCommand) + .command(SkillInstallCommand) + .command(SkillRemoveCommand) + .demandCommand(), + async handler() {}, +}) +// altimate_change end diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx index 4bcd3c7bde..ec95da2203 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -2,35 +2,544 @@ import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { createResource, createMemo } from "solid-js" import { useDialog } from "@tui/ui/dialog" import { useSDK } from "@tui/context/sdk" +// altimate_change start — import helpers for tool detection, keybind support, and prompt dialog +import { detectToolReferences } from "../../skill-helpers" +import { Keybind } from "@/util/keybind" +import { useToast } from "@tui/ui/toast" +import { spawn } from "child_process" +import { DialogPrompt } from "@tui/ui/dialog-prompt" +import os from "os" +import path from "path" +import fs from "fs/promises" +// altimate_change end export type DialogSkillProps = { onSelect: (skill: string) => void } +// altimate_change start — categorize skills by domain for cleaner grouping +const SKILL_CATEGORIES: Record = { + "dbt-develop": "dbt", + "dbt-test": "dbt", + "dbt-docs": "dbt", + "dbt-analyze": "dbt", + "dbt-troubleshoot": "dbt", + "sql-review": "SQL", + "sql-translate": "SQL", + "query-optimize": "SQL", + "schema-migration": "Schema", + "pii-audit": "Schema", + "cost-report": "FinOps", + "lineage-diff": "Lineage", + "data-viz": "Visualization", + "train": "Training", + "teach": "Training", + "training-status": "Training", + "altimate-setup": "Setup", +} + +// Cache dir for temporary git clones +function cacheDir(): string { + return path.join(os.homedir(), ".cache", "altimate-code") +} + +/** Resolve git worktree root from a directory, falling back to the directory itself. */ +function gitRoot(dir: string): string { + try { + const proc = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }) + if (proc.exitCode === 0) { + const root = new TextDecoder().decode(proc.stdout).trim() + if (root) return root + } + } catch {} + return dir +} +// altimate_change end + +// altimate_change start — inline skill operations (no subprocess spawning) + +/** Create a skill + tool pair directly via fs operations. */ +async function createSkillDirect(name: string, rootDir: string): Promise<{ ok: boolean; message: string }> { + if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name) || name.length < 2 || name.length > 64) { + return { ok: false, message: "Name must be lowercase alphanumeric with hyphens, 2-64 chars" } + } + const skillDir = path.join(rootDir, ".opencode", "skills", name) + const skillFile = path.join(skillDir, "SKILL.md") + try { + await fs.access(skillFile) + return { ok: false, message: `Skill "${name}" already exists` } + } catch { + // doesn't exist, good + } + await fs.mkdir(skillDir, { recursive: true }) + await fs.writeFile( + skillFile, + `---\nname: ${name}\ndescription: TODO — describe what this skill does\n---\n\n# ${name}\n\n## When to Use\nTODO\n\n## CLI Reference\n\`\`\`bash\n${name} --help\n\`\`\`\n\n## Workflow\n1. Understand what the user needs\n2. Run the appropriate CLI command\n3. Interpret the output\n`, + ) + // Create tool stub (skip if tool already exists) + const toolsDir = path.join(rootDir, ".opencode", "tools") + await fs.mkdir(toolsDir, { recursive: true }) + const toolFile = path.join(toolsDir, name) + try { + await fs.access(toolFile) + // Tool already exists, don't overwrite + } catch { + await fs.writeFile( + toolFile, + `#!/usr/bin/env bash\nset -euo pipefail\ncase "\${1:-help}" in\n help|--help|-h) echo "Usage: ${name} " ;;\n *) echo "Unknown: \${1}" >&2; exit 1 ;;\nesac\n`, + { mode: 0o755 }, + ) + } + return { ok: true, message: `Created skill + tool at .opencode/skills/${name}/` } +} + +/** Progress callback for live status updates. */ +type ProgressFn = (status: string) => void + +/** Install skills from a GitHub repo or local path directly. */ +async function installSkillDirect( + source: string, + rootDir: string, + onProgress?: ProgressFn, +): Promise<{ ok: boolean; message: string; installedNames?: string[] }> { + const trimmed = source.trim() + if (!trimmed) return { ok: false, message: "Source is required" } + const targetDir = path.join(rootDir, ".opencode", "skills") + let skillDir: string + let isTmp = false + + // Normalize GitHub web URLs (e.g. https://github.com/owner/repo/tree/main/path) + // to clonable repo URLs (https://github.com/owner/repo.git) + let normalized = trimmed + const ghWebMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/(?:tree|blob)\/.*)?$/) + if (ghWebMatch) { + normalized = `https://github.com/${ghWebMatch[1]}.git` + } + + if (normalized.startsWith("http://") || normalized.startsWith("https://") || normalized.match(/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/)) { + const url = normalized.startsWith("http") ? normalized : `https://github.com/${normalized}.git` + const label = url.replace(/https?:\/\/github\.com\//, "").replace(/\.git$/, "") + onProgress?.(`Cloning ${label}...`) + const cache = cacheDir() + await fs.mkdir(cache, { recursive: true }) + const tmpDir = path.join(cache, "skill-install-" + Date.now()) + isTmp = true + const proc = Bun.spawn(["git", "clone", "--depth", "1", url, tmpDir], { + stdout: "pipe", + stderr: "pipe", + }) + await proc.exited + if (proc.exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + return { ok: false, message: `Failed to clone: ${stderr.trim().slice(0, 150)}` } + } + onProgress?.(`Cloned. Scanning for skills...`) + skillDir = tmpDir + } else { + const resolved = path.isAbsolute(trimmed) ? trimmed : path.resolve(trimmed) + try { + await fs.access(resolved) + } catch { + return { ok: false, message: `Path not found: ${resolved}` } + } + onProgress?.(`Scanning ${resolved}...`) + skillDir = resolved + } + + // Find SKILL.md files + const glob = new Bun.Glob("**/SKILL.md") + const matches: string[] = [] + for await (const match of glob.scan({ cwd: skillDir, absolute: true })) { + if (!match.includes("/.git/")) matches.push(match) + } + if (matches.length === 0) { + if (isTmp) await fs.rm(skillDir, { recursive: true, force: true }) + return { ok: false, message: `No SKILL.md files found in ${source}` } + } + + onProgress?.(`Found ${matches.length} skill(s). Installing...`) + + let installed = 0 + const names: string[] = [] + for (const skillFile of matches) { + const skillParent = path.dirname(skillFile) + const skillName = path.basename(skillParent) + const dest = path.join(targetDir, skillName) + try { + await fs.access(dest) + continue // already exists, skip + } catch { + // not installed + } + await fs.mkdir(dest, { recursive: true }) + const files = await fs.readdir(skillParent) + for (const file of files) { + const src = path.join(skillParent, file) + const dst = path.join(dest, file) + const stat = await fs.lstat(src) + if (stat.isSymbolicLink()) continue + if (stat.isFile()) await fs.copyFile(src, dst) + else if (stat.isDirectory()) await fs.cp(src, dst, { recursive: true, dereference: false }) + } + names.push(skillName) + installed++ + onProgress?.(`Installed ${installed}/${matches.length}: ${skillName}`) + } + + if (isTmp) { + onProgress?.(`Cleaning up...`) + await fs.rm(skillDir, { recursive: true, force: true }) + } + if (installed === 0) return { ok: true, message: "No new skills installed (all already exist)" } + return { ok: true, message: `Installed ${installed} skill(s): ${names.join(", ")}`, installedNames: names } +} + +/** Test a skill by checking its tool responds to --help. */ +async function testSkillDirect(skillName: string, content: string, rootDir: string): Promise<{ ok: boolean; message: string }> { + const tools = detectToolReferences(content) + if (tools.length === 0) return { ok: true, message: `${skillName}: PASS (no CLI tools)` } + + const sep = process.platform === "win32" ? ";" : ":" + const toolPath = [ + process.env.ALTIMATE_BIN_DIR, + path.join(rootDir, ".opencode", "tools"), + path.join(os.homedir(), ".config", "altimate-code", "tools"), + process.env.PATH, + ] + .filter(Boolean) + .join(sep) + + for (const tool of tools) { + try { + const proc = Bun.spawn([tool, "--help"], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, PATH: toolPath }, + }) + const timeout = setTimeout(() => proc.kill(), 5000) + const exitCode = await proc.exited + clearTimeout(timeout) + if (exitCode !== 0) { + return { ok: false, message: `${skillName}: FAIL — "${tool} --help" exited with code ${exitCode}` } + } + } catch { + return { ok: false, message: `${skillName}: FAIL — "${tool}" not found or failed to execute` } + } + } + return { ok: true, message: `${skillName}: PASS` } +} +// altimate_change end + +// altimate_change start — sub-dialogs for create and install + +/** Reload skills on the server and verify new skills are visible. */ +async function reloadAndVerify(sdk: ReturnType, expectedNames: string[]): Promise { + try { + const resp = await sdk.fetch(`${sdk.url}/skill?reload=true`) + const skills = (await resp.json()) as Array<{ name: string; description: string }> + return expectedNames.filter((n) => skills.some((s) => s.name === n)) + } catch { + return [] + } +} + +function DialogSkillCreate() { + const dialog = useDialog() + const toast = useToast() + const sdk = useSDK() + + return ( + { + const name = rawName.trim() + dialog.clear() + if (!name) { + toast.show({ message: "No name provided.", variant: "error", duration: 4000 }) + return + } + toast.show({ message: `Creating "${name}"...`, variant: "info", duration: 30000 }) + try { + const result = await createSkillDirect(name, gitRoot(sdk.directory ?? process.cwd())) + if (!result.ok) { + toast.show({ message: `Create failed: ${result.message}`, variant: "error", duration: 6000 }) + return + } + const verified = await reloadAndVerify(sdk, [name]) + if (verified.length > 0) { + toast.show({ + message: `✓ Created "${name}"\n\nSkill + CLI tool at .opencode/skills/${name}/\nType /${name} in the prompt to use it.`, + variant: "success", + duration: 8000, + }) + } else { + toast.show({ + message: `✓ Created "${name}" files.\nReopen /skills to see it.`, + variant: "success", + duration: 6000, + }) + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + toast.show({ message: `Create error: ${msg.slice(0, 200)}`, variant: "error", duration: 8000 }) + } + }} + onCancel={() => dialog.clear()} + /> + ) +} + +function DialogSkillInstall() { + const dialog = useDialog() + const toast = useToast() + const sdk = useSDK() + + return ( + { + // Strip trailing dots, whitespace, and .git suffix that users might paste + const source = rawSource.trim().replace(/\.+$/, "").replace(/\.git$/, "") + dialog.clear() + if (!source) { + toast.show({ message: "No source provided.", variant: "error", duration: 4000 }) + return + } + const progress = (status: string) => { + toast.show({ message: `Installing from ${source}\n\n${status}`, variant: "info", duration: 600000 }) + } + progress("Preparing...") + try { + const result = await installSkillDirect(source, gitRoot(sdk.directory ?? process.cwd()), progress) + if (!result.ok) { + toast.show({ message: `Install failed: ${result.message}`, variant: "error", duration: 6000 }) + return + } + if (result.message.includes("all already exist")) { + toast.show({ message: "All skills from this source are already installed.", variant: "info", duration: 4000 }) + return + } + const names = result.installedNames ?? [] + progress("Verifying skills loaded...") + const verified = await reloadAndVerify(sdk, names) + const lines = [ + `✓ Installed ${verified.length} skill(s)`, + "", + ...verified.map((n) => ` • ${n}`), + "", + "Open /skills to browse, or type / to use.", + ] + toast.show({ message: lines.join("\n"), variant: "success", duration: 8000 }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + toast.show({ message: `Install error: ${msg.slice(0, 200)}`, variant: "error", duration: 8000 }) + } + }} + onCancel={() => dialog.clear()} + /> + ) +} +// altimate_change end + export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() + const toast = useToast() dialog.setSize("large") - const [skills] = createResource(async () => { + const [skills, { refetch }] = createResource(async () => { const result = await sdk.client.app.skills() return result.data ?? [] }) + // altimate_change start — build lookups from skill name → location/content for actions + const skillMap = createMemo(() => { + const map = new Map() + for (const skill of skills() ?? []) { + map.set(skill.name, { location: skill.location, content: skill.content, description: skill.description }) + } + return map + }) + // altimate_change end + + // altimate_change start — enrich skill list with domain categories and tool info const options = createMemo[]>(() => { const list = skills() ?? [] const maxWidth = Math.max(0, ...list.map((s) => s.name.length)) - return list.map((skill) => ({ - title: skill.name.padEnd(maxWidth), - description: skill.description?.replace(/\s+/g, " ").trim(), - value: skill.name, - category: "Skills", - onSelect: () => { - props.onSelect(skill.name) - dialog.clear() - }, - })) + return list.map((skill) => { + const tools = detectToolReferences(skill.content) + const category = SKILL_CATEGORIES[skill.name] ?? "Other" + const desc = skill.description?.replace(/\s+/g, " ").trim() + const shortDesc = desc && desc.length > 80 ? desc.slice(0, 77) + "..." : desc + return { + title: skill.name.padEnd(maxWidth), + description: shortDesc, + footer: tools.length > 0 ? `⚡ ${tools.slice(0, 2).join(", ")}` : undefined, + value: skill.name, + category, + onSelect: () => { + props.onSelect(skill.name) + dialog.clear() + }, + } + }) }) - return + // Re-open the main skills dialog (used after an action completes) + function reopenSkillList() { + dialog.replace(() => ( + + )) + } + + // Single keybind opens action picker for the selected skill + function openActionPicker(skillName: string) { + const info = skillMap().get(skillName) + const isBuiltin = !info || info.location.startsWith("builtin:") + const isRemovable = (() => { + if (isBuiltin) return false + const gitCheck = Bun.spawnSync(["git", "ls-files", "--error-unmatch", info!.location], { + cwd: path.dirname(path.dirname(info!.location)), + stdout: "pipe", + stderr: "pipe", + }) + return gitCheck.exitCode !== 0 // only removable if NOT git-tracked + })() + + const actions: DialogSelectOption[] = [ + { + title: "Show details", + value: "show", + description: "View skill info, tools, and location", + }, + { + title: "Edit", + value: "edit", + description: "Open SKILL.md in your default editor", + disabled: isBuiltin, // allow editing git-tracked skills, only block builtin + }, + { + title: "Test", + value: "test", + description: "Validate the paired CLI tool works", + }, + { + title: "Remove", + value: "remove", + description: "Delete this skill and its paired tool", + disabled: !isRemovable, + }, + ].filter((a) => !a.disabled) + + dialog.replace( + () => ( + { + switch (action.value) { + case "show": { + if (!info) return + const tools = detectToolReferences(info.content) + const lines = [ + `${skillName}: ${info.description}`, + tools.length > 0 ? `Tools: ${tools.join(", ")}` : null, + `Location: ${info.location}`, + ] + .filter((l) => l !== null) + .join("\n") + toast.show({ message: lines, variant: "info", duration: 8000 }) + reopenSkillList() + break + } + case "edit": { + if (!info) return + // Open in system editor (new window, doesn't conflict with TUI) + const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open" + spawn(openCmd, [info.location], { stdio: "ignore", detached: true }).unref() + toast.show({ + message: `Opening ${skillName}/SKILL.md in your editor.\n\nFile: ${info.location}`, + variant: "info", + duration: 5000, + }) + reopenSkillList() + break + } + case "test": { + if (!info) return + toast.show({ message: `Testing ${skillName}...`, variant: "info", duration: 600000 }) + const result = await testSkillDirect(skillName, info.content, gitRoot(sdk.directory ?? process.cwd())) + toast.show({ + message: result.ok ? `✓ ${result.message}` : `✗ ${result.message}`, + variant: result.ok ? "success" : "error", + duration: 4000, + }) + reopenSkillList() + break + } + case "remove": { + if (!info) return + try { + const skillDir = path.dirname(info.location) + await fs.rm(skillDir, { recursive: true, force: true }) + const root = gitRoot(sdk.directory ?? process.cwd()) + const toolFile = path.join(root, ".opencode", "tools", skillName) + await fs.rm(toolFile, { force: true }).catch(() => {}) + await reloadAndVerify(sdk, []) + toast.show({ message: `Removed "${skillName}".`, variant: "success", duration: 4000 }) + reopenSkillList() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + toast.show({ message: `Remove failed: ${msg.slice(0, 150)}`, variant: "error", duration: 5000 }) + } + break + } + } + }} + /> + ), + // When Esc is pressed on the action picker, go back to skill list + () => setTimeout(() => reopenSkillList(), 0), + ) + } + + const keybinds = createMemo(() => [ + { + keybind: Keybind.parse("ctrl+a")[0], + title: "actions", + onTrigger: async (option: DialogSelectOption) => { + openActionPicker(option.value) + }, + }, + { + keybind: Keybind.parse("ctrl+n")[0], + title: "new", + onTrigger: async () => { + dialog.replace(() => ) + }, + }, + { + keybind: Keybind.parse("ctrl+i")[0], + title: "install", + onTrigger: async () => { + dialog.replace(() => ) + }, + }, + ]) + // altimate_change end + + return ( + + ) } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ceeeff9bc5..26f788d51d 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -31,6 +31,9 @@ import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" import { DbCommand } from "./cli/cmd/db" import { TraceCommand } from "./cli/cmd/trace" +// altimate_change start — top-level skill command +import { SkillCommand } from "./cli/cmd/skill" +// altimate_change end import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" @@ -190,6 +193,9 @@ let cli = yargs(hideBin(process.argv)) .command(SessionCommand) .command(DbCommand) .command(TraceCommand) + // altimate_change start — top-level skill command + .command(SkillCommand) + // altimate_change end if (Installation.isLocal()) { cli = cli.command(WorkspaceServeCommand) diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index a9dce565b5..f25b3c789a 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -28,6 +28,14 @@ export namespace State { } } + // altimate_change start — allow invalidating a single state entry by its init function + export function invalidate(key: string, init: any) { + const entries = recordsByKey.get(key) + if (!entries) return + entries.delete(init) + } + // altimate_change end + export async function dispose(key: string) { const entries = recordsByKey.get(key) if (!entries) return diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index d6bc4973a0..1a63e9ad10 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -7,7 +7,9 @@ import { Instance } from "../project/instance" import { lazy } from "@opencode-ai/util/lazy" import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" +import { Global } from "@/global" import { PtyID } from "./schema" +import path from "path" export namespace Pty { const log = Log.create({ service: "pty" }) @@ -135,6 +137,27 @@ export namespace Pty { OPENCODE_TERMINAL: "1", } as Record + // altimate_change start — prepend user tools dirs to PATH in terminal sessions + const sep = process.platform === "win32" ? ";" : ":" + const basePath = env.PATH ?? env.Path ?? "" + const pathEntries = new Set(basePath.split(sep).filter(Boolean)) + const prependDirs: string[] = [] + const binDir = process.env.ALTIMATE_BIN_DIR + if (binDir && !pathEntries.has(binDir)) prependDirs.push(binDir) + const projectToolsDir = path.join(Instance.directory, ".opencode", "tools") + if (!pathEntries.has(projectToolsDir)) prependDirs.push(projectToolsDir) + if (Instance.worktree !== "/" && Instance.worktree !== Instance.directory) { + const worktreeToolsDir = path.join(Instance.worktree, ".opencode", "tools") + if (!pathEntries.has(worktreeToolsDir)) prependDirs.push(worktreeToolsDir) + } + const globalToolsDir = path.join(Global.Path.config, "tools") + if (!pathEntries.has(globalToolsDir)) prependDirs.push(globalToolsDir) + if (prependDirs.length > 0) { + const prefix = prependDirs.join(sep) + env.PATH = basePath ? `${prefix}${sep}${basePath}` : prefix + } + // altimate_change end + if (process.platform === "win32") { env.LC_ALL = "C.UTF-8" env.LC_CTYPE = "C.UTF-8" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index a75c0d6f3e..e3af5664be 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -450,6 +450,12 @@ export namespace Server { }, }), async (c) => { + // altimate_change start — support cache invalidation via query param + const reload = c.req.query("reload") + if (reload === "true") { + Skill.invalidate() + } + // altimate_change end const skills = await Skill.all() return c.json(skills) }, diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index ceba0e791e..e0027cdb1f 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -6,6 +6,9 @@ import matter from "gray-matter" // altimate_change end import { Config } from "../config/config" import { Instance } from "../project/instance" +// altimate_change start — import State for cache invalidation +import { State } from "../project/state" +// altimate_change end import { NamedError } from "@opencode-ai/util/error" import { ConfigMarkdown } from "../config/markdown" import { Log } from "../util/log" @@ -61,7 +64,9 @@ export namespace Skill { const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" const SKILL_PATTERN = "**/SKILL.md" - export const state = Instance.state(async () => { + // altimate_change start — extract init function so it can be referenced for cache invalidation + const stateInit = async () => { + // altimate_change end const skills: Record = {} const dirs = new Set() @@ -229,7 +234,18 @@ export namespace Skill { skills, dirs: Array.from(dirs), } - }) + } + // altimate_change start — wrap extracted init in Instance.state + export const state = Instance.state(stateInit) + // altimate_change end + + // altimate_change start — allow invalidating the skill cache so new skills are picked up + export function invalidate() { + // Clear the cached state for this init function so the next call + // to state() will re-scan all skill directories and pick up new skills. + State.invalidate(Instance.directory, stateInit) + } + // altimate_change end export async function get(name: string) { return state().then((x) => x.skills[name]) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 86ed7225cc..5f3405202d 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -17,6 +17,7 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncation" import { Plugin } from "@/plugin" +import { Global } from "@/global" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -165,17 +166,45 @@ export const BashTool = Tool.define("bash", async () => { { env: {} }, ) - // altimate_change start — prepend bundled tools dir (ALTIMATE_BIN_DIR) to PATH + // altimate_change start — prepend bundled tools dir (ALTIMATE_BIN_DIR) and user tools dirs to PATH const mergedEnv: Record = { ...process.env, ...shellEnv.env } + const sep = process.platform === "win32" ? ";" : ":" + const basePath = mergedEnv.PATH ?? mergedEnv.Path ?? "" + const pathEntries = new Set(basePath.split(sep).filter(Boolean)) + + // Collect directories to prepend (highest priority first) + const prependDirs: string[] = [] + + // 1. Bundled tools (altimate-dbt, etc.) — highest priority const binDir = process.env.ALTIMATE_BIN_DIR - if (binDir) { - const sep = process.platform === "win32" ? ";" : ":" - const basePath = mergedEnv.PATH ?? mergedEnv.Path ?? "" - const pathEntries = basePath.split(sep).filter(Boolean) - if (!pathEntries.some((entry) => entry === binDir)) { - mergedEnv.PATH = basePath ? `${binDir}${sep}${basePath}` : binDir + if (binDir && !pathEntries.has(binDir)) { + prependDirs.push(binDir) + } + + // 2. Project-level user tools (.opencode/tools/) — user extensions + // Anchored to Instance.directory (not cwd) so external_directory workdirs + // can't shadow project tools. Also check worktree root for monorepos. + const projectToolsDir = path.join(Instance.directory, ".opencode", "tools") + if (!pathEntries.has(projectToolsDir)) { + prependDirs.push(projectToolsDir) + } + if (Instance.worktree !== "/" && Instance.worktree !== Instance.directory) { + const worktreeToolsDir = path.join(Instance.worktree, ".opencode", "tools") + if (!pathEntries.has(worktreeToolsDir)) { + prependDirs.push(worktreeToolsDir) } } + + // 3. Global user tools (~/.config/altimate-code/tools/) — shared across projects + const globalToolsDir = path.join(Global.Path.config, "tools") + if (!pathEntries.has(globalToolsDir)) { + prependDirs.push(globalToolsDir) + } + + if (prependDirs.length > 0) { + const prefix = prependDirs.join(sep) + mergedEnv.PATH = basePath ? `${prefix}${sep}${basePath}` : prefix + } // altimate_change end const proc = spawn(params.command, { diff --git a/packages/opencode/test/cli/skill.test.ts b/packages/opencode/test/cli/skill.test.ts new file mode 100644 index 0000000000..46a45e3898 --- /dev/null +++ b/packages/opencode/test/cli/skill.test.ts @@ -0,0 +1,345 @@ +// altimate_change start — tests for skill CLI command (create, list, test) +import { describe, test, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { tmpdir } from "../fixture/fixture" +import { detectToolReferences, SHELL_BUILTINS } from "../../src/cli/cmd/skill-helpers" + +// --------------------------------------------------------------------------- +// Unit tests — import production code directly (no duplication) +// --------------------------------------------------------------------------- + +describe("detectToolReferences", () => { + test("detects tools from Tools used line", () => { + const content = `**Tools used:** bash (runs \`altimate-dbt\` commands), read, \`sql_analyze\`` + const tools = detectToolReferences(content) + expect(tools).toContain("altimate-dbt") + expect(tools).toContain("sql_analyze") + }) + + test("filters builtins from Tools used line", () => { + const content = `**Tools used:** \`bash\`, \`read\`, \`glob\`, \`altimate-dbt\`` + const tools = detectToolReferences(content) + expect(tools).toContain("altimate-dbt") + expect(tools).not.toContain("bash") + expect(tools).not.toContain("read") + expect(tools).not.toContain("glob") + }) + + test("detects tools from bash code blocks", () => { + const content = ` +\`\`\`bash +altimate-dbt info +altimate-dbt columns --model users +\`\`\` +` + const tools = detectToolReferences(content) + expect(tools).toContain("altimate-dbt") + expect(tools.length).toBe(1) // deduplicated + }) + + test("filters out shell builtins", () => { + const content = ` +\`\`\`bash +echo "hello" +cd /tmp +cat file.txt +my-custom-tool run +\`\`\` +` + const tools = detectToolReferences(content) + expect(tools).toContain("my-custom-tool") + expect(tools).not.toContain("echo") + expect(tools).not.toContain("cd") + expect(tools).not.toContain("cat") + }) + + test("handles content with no tools", () => { + const content = `# Just a plain skill\n\nDo some stuff.` + const tools = detectToolReferences(content) + expect(tools.length).toBe(0) + }) + + test("ignores comment lines in bash blocks", () => { + const content = ` +\`\`\`bash +# this is a comment +my-tool run +\`\`\` +` + const tools = detectToolReferences(content) + expect(tools).toContain("my-tool") + expect(tools.length).toBe(1) + }) + + test("handles $ prefix in bash blocks", () => { + const content = ` +\`\`\`bash +$ altimate-schema search --pattern "user*" +\`\`\` +` + const tools = detectToolReferences(content) + expect(tools).toContain("altimate-schema") + }) + + test("handles \\r\\n line endings in bash blocks", () => { + const content = "```bash\r\nmy-tool run\r\n```" + const tools = detectToolReferences(content) + expect(tools).toContain("my-tool") + }) + + test("filters common utilities (git, python, docker, etc.)", () => { + const content = ` +\`\`\`bash +git status +python3 script.py +docker build . +my-custom-cli run +\`\`\` +` + const tools = detectToolReferences(content) + expect(tools).toContain("my-custom-cli") + expect(tools).not.toContain("git") + expect(tools).not.toContain("python3") + expect(tools).not.toContain("docker") + }) +}) + +describe("SHELL_BUILTINS", () => { + test("contains expected shell builtins", () => { + for (const cmd of ["echo", "cd", "export", "if", "for", "case"]) { + expect(SHELL_BUILTINS.has(cmd)).toBe(true) + } + }) + + test("contains common utilities", () => { + for (const cmd of ["git", "python", "node", "docker", "curl", "make"]) { + expect(SHELL_BUILTINS.has(cmd)).toBe(true) + } + }) + + test("contains agent tool names", () => { + for (const cmd of ["glob", "write", "edit"]) { + expect(SHELL_BUILTINS.has(cmd)).toBe(true) + } + }) + + test("does not contain altimate tools", () => { + expect(SHELL_BUILTINS.has("altimate-dbt")).toBe(false) + expect(SHELL_BUILTINS.has("altimate-sql")).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Scaffold template tests — use tmpdir() fixture +// --------------------------------------------------------------------------- + +describe("altimate-code skill create", () => { + test("scaffold generates valid SKILL.md with tool reference", async () => { + await using tmp = await tmpdir({ git: true }) + const skillDir = path.join(tmp.path, ".opencode", "skills", "test-tool") + await fs.mkdir(skillDir, { recursive: true }) + + const name = "test-tool" + const content = `--- +name: ${name} +description: TODO — describe what this skill does +--- + +# ${name} + +## When to Use +TODO — describe when the agent should invoke this skill. + +## CLI Reference +\`\`\`bash +${name} --help +${name} [options] +\`\`\` + +## Workflow +1. Understand what the user needs +2. Run the appropriate CLI command +3. Interpret the output and act on it +` + const skillFile = path.join(skillDir, "SKILL.md") + await fs.writeFile(skillFile, content) + + const written = await fs.readFile(skillFile, "utf-8") + expect(written).toContain("name: test-tool") + expect(written).toContain("description: TODO") + expect(written).toContain("test-tool --help") + + // Verify tool detection works on the template + const tools = detectToolReferences(written) + expect(tools).toContain("test-tool") + }) + + test("scaffold generates valid SKILL.md without tool reference (skill-only)", async () => { + await using tmp = await tmpdir({ git: true }) + const skillDir = path.join(tmp.path, ".opencode", "skills", "prompt-only") + await fs.mkdir(skillDir, { recursive: true }) + + const content = `--- +name: prompt-only +description: TODO — describe what this skill does +--- + +# prompt-only + +## When to Use +TODO — describe when the agent should invoke this skill. + +## Workflow +1. Understand what the user needs +2. Provide guidance based on the instructions below +` + const skillFile = path.join(skillDir, "SKILL.md") + await fs.writeFile(skillFile, content) + + const tools = detectToolReferences(content) + expect(tools.length).toBe(0) + }) + + test("scaffold generates executable bash tool", async () => { + await using tmp = await tmpdir({ git: true }) + const toolsDir = path.join(tmp.path, ".opencode", "tools") + await fs.mkdir(toolsDir, { recursive: true }) + + const name = "test-tool" + const template = `#!/usr/bin/env bash +set -euo pipefail +case "\${1:-help}" in + help|--help|-h) echo "Usage: ${name} " ;; + *) echo "Unknown: \${1}" >&2; exit 1 ;; +esac +` + const toolFile = path.join(toolsDir, name) + await fs.writeFile(toolFile, template, { mode: 0o755 }) + + const stat = await fs.stat(toolFile) + expect(stat.mode & 0o100).toBeTruthy() + + const proc = Bun.spawnSync(["bash", toolFile, "--help"]) + expect(proc.exitCode).toBe(0) + expect(proc.stdout.toString()).toContain("Usage:") + }) + + test("scaffold generates executable python tool", async () => { + await using tmp = await tmpdir({ git: true }) + const toolsDir = path.join(tmp.path, ".opencode", "tools") + await fs.mkdir(toolsDir, { recursive: true }) + + const name = "py-test-tool" + const template = `#!/usr/bin/env python3 +"""${name}""" +import argparse, sys +def main(): + parser = argparse.ArgumentParser(prog="${name}") + parser.add_argument("command", nargs="?", default="help") + args = parser.parse_args() + if args.command == "help": + parser.print_help() + sys.exit(0) +if __name__ == "__main__": + main() +` + const toolFile = path.join(toolsDir, name) + await fs.writeFile(toolFile, template, { mode: 0o755 }) + + const proc = Bun.spawnSync(["python3", toolFile, "help"]) + expect(proc.exitCode).toBe(0) + }) + + test("scaffold generates executable node tool", async () => { + await using tmp = await tmpdir({ git: true }) + const toolsDir = path.join(tmp.path, ".opencode", "tools") + await fs.mkdir(toolsDir, { recursive: true }) + + const name = "node-test-tool" + const template = `#!/usr/bin/env node +const command = process.argv[2] || "help" +if (command === "help" || command === "--help") { + console.log("Usage: ${name} ") +} else { + console.error("Unknown: " + command) + process.exit(1) +} +` + const toolFile = path.join(toolsDir, name) + await fs.writeFile(toolFile, template, { mode: 0o755 }) + + const proc = Bun.spawnSync(["node", toolFile, "--help"]) + expect(proc.exitCode).toBe(0) + expect(proc.stdout.toString()).toContain("Usage:") + }) + + test("rejects invalid skill names", () => { + const valid = (n: string) => /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(n) && n.length >= 2 && n.length <= 64 + // Valid names + expect(valid("my-tool")).toBe(true) + expect(valid("freshness-check")).toBe(true) + expect(valid("tool123")).toBe(true) + expect(valid("ab")).toBe(true) + expect(valid("a-tool")).toBe(true) + expect(valid("x-ray")).toBe(true) + expect(valid("a-very-long-but-valid-name")).toBe(true) + expect(valid("dbt-custom-check")).toBe(true) + // Invalid: uppercase, numbers first, spaces, underscores + expect(valid("MyTool")).toBe(false) + expect(valid("123tool")).toBe(false) + expect(valid("my tool")).toBe(false) + expect(valid("my_tool")).toBe(false) + // Invalid: single char, trailing hyphen, leading hyphen, double hyphen + expect(valid("a")).toBe(false) + expect(valid("a-")).toBe(false) + expect(valid("-tool")).toBe(false) + expect(valid("tool-")).toBe(false) + expect(valid("my--tool")).toBe(false) + // Invalid: too long + expect(valid("a".repeat(65))).toBe(false) + // Valid edge cases + expect(valid("a".repeat(64))).toBe(true) + // Invalid: injection attempts + expect(valid("$(whoami)")).toBe(false) + expect(valid("../etc/passwd")).toBe(false) + expect(valid("`rm -rf /`")).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// PATH auto-discovery tests — use tmpdir() fixture +// --------------------------------------------------------------------------- + +describe("PATH auto-discovery for .opencode/tools/", () => { + test("tool in .opencode/tools/ is executable", async () => { + await using tmp = await tmpdir({ git: true }) + const toolsDir = path.join(tmp.path, ".opencode", "tools") + await fs.mkdir(toolsDir, { recursive: true }) + await fs.writeFile(path.join(toolsDir, "my-test-tool"), '#!/usr/bin/env bash\necho "hello from tool"', { + mode: 0o755, + }) + + const toolPath = path.join(toolsDir, "my-test-tool") + const proc = Bun.spawnSync(["bash", toolPath]) + expect(proc.exitCode).toBe(0) + expect(proc.stdout.toString().trim()).toBe("hello from tool") + }) + + test("tool is discoverable when .opencode/tools/ is on PATH", async () => { + await using tmp = await tmpdir({ git: true }) + const toolsDir = path.join(tmp.path, ".opencode", "tools") + await fs.mkdir(toolsDir, { recursive: true }) + await fs.writeFile(path.join(toolsDir, "my-test-tool"), '#!/usr/bin/env bash\necho "hello from tool"', { + mode: 0o755, + }) + + const sep = process.platform === "win32" ? ";" : ":" + const env = { ...process.env, PATH: `${toolsDir}${sep}${process.env.PATH}` } + + const proc = Bun.spawnSync(["my-test-tool"], { env, cwd: tmp.path }) + expect(proc.exitCode).toBe(0) + expect(proc.stdout.toString().trim()).toBe("hello from tool") + }) +}) +// altimate_change end