From 1c5d57540ec60c8c3c964a395bd6e56ed4bb5765 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 20 Mar 2026 16:11:05 -0700 Subject: [PATCH 01/33] feat: add `skill` CLI command and `.opencode/tools/` auto-discovery (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a top-level `altimate-code skill` command with `list`, `create`, and `test` subcommands, plus auto-discovery of user CLI tools on PATH. - `skill list` — shows skills with paired CLI tools, source, description - `skill create ` — scaffolds SKILL.md + CLI tool stub (bash/python/node) - `skill test ` — validates frontmatter, checks tool on PATH, runs `--help` - Auto-prepend `.opencode/tools/` (project) and `~/.config/altimate-code/tools/` (global) to PATH in `BashTool` and PTY sessions - Worktree-aware: `skill create` uses git root, PATH includes worktree tools - Input validation: regex + length limits, 5s timeout on tool `--help` - 14 unit tests (42 assertions) covering tool detection, templates, adversarial inputs - Updated docs: skills.md, tools/custom.md Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/docs/configure/skills.md | 73 ++- docs/docs/configure/tools/custom.md | 68 ++- packages/opencode/src/cli/cmd/skill.ts | 554 +++++++++++++++++++++++ packages/opencode/src/index.ts | 6 + packages/opencode/src/pty/index.ts | 23 + packages/opencode/src/tool/bash.ts | 42 +- packages/opencode/test/cli/skill.test.ts | 313 +++++++++++++ 7 files changed, 1068 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/skill.ts create mode 100644 packages/opencode/test/cli/skill.test.ts diff --git a/docs/docs/configure/skills.md b/docs/docs/configure/skills.md index 6a807cce3f..b2340bde9d 100644 --- a/docs/docs/configure/skills.md +++ b/docs/docs/configure/skills.md @@ -88,9 +88,76 @@ 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 +# List all skills with their paired CLI tools +altimate-code skill list + +# List as JSON (for scripting) +altimate-code skill list --json + +# Scaffold a new skill + CLI tool pair +altimate-code skill create my-tool +altimate-code skill create my-tool --language python +altimate-code skill create my-tool --language node +altimate-code skill create my-tool --skill-only # skill only, no CLI stub + +# Validate a skill and its paired tool +altimate-code skill test my-tool +``` + ## 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 +171,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..a1b8fd7e3a 100644 --- a/docs/docs/configure/tools/custom.md +++ b/docs/docs/configure/tools/custom.md @@ -1,8 +1,72 @@ # 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. + +### 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/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts new file mode 100644 index 0000000000..0cec4ec693 --- /dev/null +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -0,0 +1,554 @@ +// 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" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Shell builtins, common utilities, and agent tool names to filter when detecting CLI tool references. */ +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). */ +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] + // Filter out common shell builtins and generic commands + if (!SHELL_BUILTINS.has(cmd)) { + tools.add(cmd) + } + } + } + } + + return Array.from(tools) +} + +/** Determine the source label for a skill based on its location. */ +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/). */ +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 +} + +// --------------------------------------------------------------------------- +// 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 + + 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 sourceWidth = 8 + + const header = `${"SKILL".padEnd(nameWidth)} ${"SOURCE".padEnd(sourceWidth)} ${"TOOLS".padEnd(20)} 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 source = skillSource(skill.location) + const rawToolStr = tools.length > 0 ? tools.join(", ") : "—" + const toolStr = rawToolStr.length > 20 ? rawToolStr.slice(0, 17) + "..." : rawToolStr + const desc = skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description + + process.stdout.write( + `${skill.name.padEnd(nameWidth)} ${source.padEnd(sourceWidth)} ${toolStr.padEnd(20)} ${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)) { + 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) + } + } + + 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 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, cwd) + if (available) { + pass(`"${tool}" found on PATH`) + + // Try running --help (with 5s timeout to prevent hangs) + try { + const worktreeDir = Instance.worktree !== "/" ? Instance.worktree : cwd + const toolEnv = { + ...process.env, + PATH: [ + process.env.ALTIMATE_BIN_DIR, + path.join(worktreeDir, ".opencode", "tools"), + path.join(cwd, ".opencode", "tools"), + path.join(Global.Path.config, "tools"), + process.env.PATH, + ] + .filter(Boolean) + .join(process.platform === "win32" ? ";" : ":"), + } + const proc = Bun.spawn([tool, "--help"], { + cwd, + 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) { + warn(`"${tool} --help" timed out after 5s`) + } else { + warn(`"${tool} --help" exited with code ${exitCode}`) + } + } catch { + warn(`"${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) + } + }) + }, +}) + +// --------------------------------------------------------------------------- +// 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) + .demandCommand(), + async handler() {}, +}) +// altimate_change end 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/pty/index.ts b/packages/opencode/src/pty/index.ts index d6bc4973a0..e907774994 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(cwd, ".opencode", "tools") + if (!pathEntries.has(projectToolsDir)) prependDirs.push(projectToolsDir) + if (Instance.worktree !== "/") { + const worktreeToolsDir = path.join(Instance.worktree, ".opencode", "tools") + if (worktreeToolsDir !== projectToolsDir && !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/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 86ed7225cc..9a9ff100be 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,44 @@ 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 + // Check both the cwd and the worktree root (they may differ in monorepos or subdirs) + const projectToolsDir = path.join(cwd, ".opencode", "tools") + if (!pathEntries.has(projectToolsDir)) { + prependDirs.push(projectToolsDir) + } + if (Instance.worktree !== "/") { + const worktreeToolsDir = path.join(Instance.worktree, ".opencode", "tools") + if (worktreeToolsDir !== projectToolsDir && !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..e87aa88096 --- /dev/null +++ b/packages/opencode/test/cli/skill.test.ts @@ -0,0 +1,313 @@ +// altimate_change start — tests for skill CLI command (create, list, test) +import { describe, test, expect, beforeAll, afterAll } from "bun:test" +import fs from "fs/promises" +import path from "path" +import os from "os" + +// --------------------------------------------------------------------------- +// Unit tests for the helper functions extracted from skill.ts +// We import the module indirectly by testing the CLI output. +// For pure unit tests we replicate the helper logic here (same source). +// --------------------------------------------------------------------------- + +/** Shell builtins to filter — mirrors SHELL_BUILTINS in skill.ts */ +const SHELL_BUILTINS = new Set([ + "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", + "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", + "glob", "write", "edit", +]) + +/** Detect CLI tool references inside a skill's content. */ +function detectToolReferences(content: string): string[] { + const tools = new Set() + + const toolsUsedMatch = content.match(/Tools used:\s*(.+)/i) + if (toolsUsedMatch) { + const refs = toolsUsedMatch[1].matchAll(/`([a-z][\w-]*)`/gi) + for (const m of refs) tools.add(m[1]) + } + + const bashBlocks = content.matchAll(/```(?:bash|sh)\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 + 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) +} + +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("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") + }) +}) + +// --------------------------------------------------------------------------- +// Integration tests — run the actual CLI commands +// --------------------------------------------------------------------------- + +describe("altimate-code skill create", () => { + let tmpDir: string + + beforeAll(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "skill-test-")) + }) + + afterAll(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test("creates skill and bash tool", async () => { + const result = Bun.spawnSync(["bun", "run", "src/cli/cmd/skill.ts", "--help"], { + cwd: path.join(import.meta.dir, "../../"), + }) + // Just verify the module parses without errors + // Full CLI integration requires bootstrap which needs a git repo + }) + + test("scaffold generates valid SKILL.md", async () => { + const skillDir = path.join(tmpDir, ".opencode", "skills", "test-tool") + await fs.mkdir(skillDir, { recursive: true }) + + // Generate template content (same as in skill.ts) + 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 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") + }) + + test("scaffold generates executable bash tool", async () => { + const toolsDir = path.join(tmpDir, ".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) + // Check executable bit (owner) + expect(stat.mode & 0o100).toBeTruthy() + + // Run the tool + 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 () => { + const toolsDir = path.join(tmpDir, ".opencode", "tools") + 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 () => { + const toolsDir = path.join(tmpDir, ".opencode", "tools") + 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", () => { + // Names must match /^[a-z][a-z0-9]+(-[a-z0-9]+)*$/ (min 2 chars, no trailing hyphens) + const valid = (n: string) => /^[a-z][a-z0-9]+(-[a-z0-9]+)*$/.test(n) && n.length <= 64 + expect(valid("my-tool")).toBe(true) + expect(valid("freshness-check")).toBe(true) + expect(valid("tool123")).toBe(true) + expect(valid("ab")).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 + expect(valid("a")).toBe(false) + expect(valid("a-")).toBe(false) + expect(valid("-tool")).toBe(false) + expect(valid("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 +// --------------------------------------------------------------------------- + +describe("PATH auto-discovery for .opencode/tools/", () => { + let tmpDir: string + + beforeAll(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tools-path-test-")) + // Create .opencode/tools/ with an executable + const toolsDir = path.join(tmpDir, ".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, + }) + }) + + afterAll(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test("tool in .opencode/tools/ is executable", async () => { + const toolPath = path.join(tmpDir, ".opencode", "tools", "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 () => { + const toolsDir = path.join(tmpDir, ".opencode", "tools") + const sep = process.platform === "win32" ? ";" : ":" + const env = { ...process.env, PATH: `${toolsDir}${sep}${process.env.PATH}` } + + const proc = Bun.spawnSync(["my-test-tool"], { env, cwd: tmpDir }) + expect(proc.exitCode).toBe(0) + expect(proc.stdout.toString().trim()).toBe("hello from tool") + }) +}) +// altimate_change end From ca6ad231508cdd738ea43f143bb6e95cbb41ba5e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 20 Mar 2026 17:24:04 -0700 Subject: [PATCH 02/33] fix: address code review findings for skill CLI (#341) - Extract `detectToolReferences`, `SHELL_BUILTINS`, `skillSource`, `isToolOnPath` into `skill-helpers.ts` so tests import production code instead of duplicating it - Anchor PATH tool dirs to `Instance.directory` (not `cwd`) to prevent external_directory workdirs from shadowing project tools - Change `skill test` to FAIL (not warn) on broken paired tools: timeouts, non-zero exits, and spawn failures now set `hasErrors` - Add `.opencode/skills/` to the Discovery Paths doc section for consistency with `skill create` and the Skill Paths section - Use `tmpdir()` fixture from `test/fixture/fixture.ts` in tests instead of manual `fs.mkdtemp` + `afterAll` cleanup Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/docs/configure/skills.md | 3 +- .../opencode/src/cli/cmd/skill-helpers.ts | 108 +++++++++ packages/opencode/src/cli/cmd/skill.ts | 123 +---------- packages/opencode/src/pty/index.ts | 6 +- packages/opencode/src/tool/bash.ts | 9 +- packages/opencode/test/cli/skill.test.ts | 209 ++++++++++-------- 6 files changed, 242 insertions(+), 216 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/skill-helpers.ts diff --git a/docs/docs/configure/skills.md b/docs/docs/configure/skills.md index b2340bde9d..684cfe5463 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/` 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 index 0cec4ec693..8f0bd93dc4 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -7,113 +7,7 @@ import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" import { Instance } from "../../project/instance" import { Global } from "@/global" - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Shell builtins, common utilities, and agent tool names to filter when detecting CLI tool references. */ -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). */ -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] - // Filter out common shell builtins and generic commands - if (!SHELL_BUILTINS.has(cmd)) { - tools.add(cmd) - } - } - } - } - - return Array.from(tools) -} - -/** Determine the source label for a skill based on its location. */ -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/). */ -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 -} +import { detectToolReferences, skillSource, isToolOnPath } from "./skill-helpers" // --------------------------------------------------------------------------- // Templates @@ -475,25 +369,26 @@ const SkillTestCommand = cmd({ } // 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, cwd) + 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 : cwd + const worktreeDir = Instance.worktree !== "/" ? Instance.worktree : projectDir const toolEnv = { ...process.env, PATH: [ process.env.ALTIMATE_BIN_DIR, path.join(worktreeDir, ".opencode", "tools"), - path.join(cwd, ".opencode", "tools"), + path.join(projectDir, ".opencode", "tools"), path.join(Global.Path.config, "tools"), process.env.PATH, ] @@ -501,7 +396,7 @@ const SkillTestCommand = cmd({ .join(process.platform === "win32" ? ";" : ":"), } const proc = Bun.spawn([tool, "--help"], { - cwd, + cwd: projectDir, stdout: "pipe", stderr: "pipe", env: toolEnv, @@ -512,12 +407,12 @@ const SkillTestCommand = cmd({ if (exitCode === 0) { pass(`"${tool} --help" exits cleanly`) } else if (exitCode === null || exitCode === 137 || exitCode === 143) { - warn(`"${tool} --help" timed out after 5s`) + fail(`"${tool} --help" timed out after 5s`) } else { - warn(`"${tool} --help" exited with code ${exitCode}`) + fail(`"${tool} --help" exited with code ${exitCode}`) } } catch { - warn(`"${tool} --help" failed to execute`) + fail(`"${tool} --help" failed to execute`) } } else { fail(`"${tool}" not found on PATH`) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index e907774994..1a63e9ad10 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -144,11 +144,11 @@ export namespace Pty { const prependDirs: string[] = [] const binDir = process.env.ALTIMATE_BIN_DIR if (binDir && !pathEntries.has(binDir)) prependDirs.push(binDir) - const projectToolsDir = path.join(cwd, ".opencode", "tools") + const projectToolsDir = path.join(Instance.directory, ".opencode", "tools") if (!pathEntries.has(projectToolsDir)) prependDirs.push(projectToolsDir) - if (Instance.worktree !== "/") { + if (Instance.worktree !== "/" && Instance.worktree !== Instance.directory) { const worktreeToolsDir = path.join(Instance.worktree, ".opencode", "tools") - if (worktreeToolsDir !== projectToolsDir && !pathEntries.has(worktreeToolsDir)) prependDirs.push(worktreeToolsDir) + if (!pathEntries.has(worktreeToolsDir)) prependDirs.push(worktreeToolsDir) } const globalToolsDir = path.join(Global.Path.config, "tools") if (!pathEntries.has(globalToolsDir)) prependDirs.push(globalToolsDir) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 9a9ff100be..5f3405202d 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -182,14 +182,15 @@ export const BashTool = Tool.define("bash", async () => { } // 2. Project-level user tools (.opencode/tools/) — user extensions - // Check both the cwd and the worktree root (they may differ in monorepos or subdirs) - const projectToolsDir = path.join(cwd, ".opencode", "tools") + // 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 !== "/") { + if (Instance.worktree !== "/" && Instance.worktree !== Instance.directory) { const worktreeToolsDir = path.join(Instance.worktree, ".opencode", "tools") - if (worktreeToolsDir !== projectToolsDir && !pathEntries.has(worktreeToolsDir)) { + if (!pathEntries.has(worktreeToolsDir)) { prependDirs.push(worktreeToolsDir) } } diff --git a/packages/opencode/test/cli/skill.test.ts b/packages/opencode/test/cli/skill.test.ts index e87aa88096..b97a4880cc 100644 --- a/packages/opencode/test/cli/skill.test.ts +++ b/packages/opencode/test/cli/skill.test.ts @@ -1,57 +1,14 @@ // altimate_change start — tests for skill CLI command (create, list, test) -import { describe, test, expect, beforeAll, afterAll } from "bun:test" +import { describe, test, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import os from "os" +import { tmpdir } from "../fixture/fixture" +import { detectToolReferences, SHELL_BUILTINS } from "../../src/cli/cmd/skill-helpers" // --------------------------------------------------------------------------- -// Unit tests for the helper functions extracted from skill.ts -// We import the module indirectly by testing the CLI output. -// For pure unit tests we replicate the helper logic here (same source). +// Unit tests — import production code directly (no duplication) // --------------------------------------------------------------------------- -/** Shell builtins to filter — mirrors SHELL_BUILTINS in skill.ts */ -const SHELL_BUILTINS = new Set([ - "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", - "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", - "glob", "write", "edit", -]) - -/** Detect CLI tool references inside a skill's content. */ -function detectToolReferences(content: string): string[] { - const tools = new Set() - - const toolsUsedMatch = content.match(/Tools used:\s*(.+)/i) - if (toolsUsedMatch) { - const refs = toolsUsedMatch[1].matchAll(/`([a-z][\w-]*)`/gi) - for (const m of refs) tools.add(m[1]) - } - - const bashBlocks = content.matchAll(/```(?:bash|sh)\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 - 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) -} - describe("detectToolReferences", () => { test("detects tools from Tools used line", () => { const content = `**Tools used:** bash (runs \`altimate-dbt\` commands), read, \`sql_analyze\`` @@ -60,6 +17,15 @@ describe("detectToolReferences", () => { 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 @@ -115,36 +81,65 @@ $ altimate-schema search --pattern "user*" const tools = detectToolReferences(content) expect(tools).toContain("altimate-schema") }) -}) -// --------------------------------------------------------------------------- -// Integration tests — run the actual CLI commands -// --------------------------------------------------------------------------- + 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") + }) -describe("altimate-code skill create", () => { - let tmpDir: string + 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") + }) +}) - beforeAll(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "skill-test-")) +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) + } }) - afterAll(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }) + test("contains common utilities", () => { + for (const cmd of ["git", "python", "node", "docker", "curl", "make"]) { + expect(SHELL_BUILTINS.has(cmd)).toBe(true) + } }) - test("creates skill and bash tool", async () => { - const result = Bun.spawnSync(["bun", "run", "src/cli/cmd/skill.ts", "--help"], { - cwd: path.join(import.meta.dir, "../../"), - }) - // Just verify the module parses without errors - // Full CLI integration requires bootstrap which needs a git repo + test("contains agent tool names", () => { + for (const cmd of ["glob", "write", "edit"]) { + expect(SHELL_BUILTINS.has(cmd)).toBe(true) + } }) - test("scaffold generates valid SKILL.md", async () => { - const skillDir = path.join(tmpDir, ".opencode", "skills", "test-tool") + 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 }) - // Generate template content (same as in skill.ts) const name = "test-tool" const content = `--- name: ${name} @@ -164,7 +159,7 @@ ${name} [options] ## Workflow 1. Understand what the user needs -2. Run the appropriate command +2. Run the appropriate CLI command 3. Interpret the output and act on it ` const skillFile = path.join(skillDir, "SKILL.md") @@ -174,10 +169,41 @@ ${name} [options] 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 () => { - const toolsDir = path.join(tmpDir, ".opencode", "tools") + 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" @@ -192,17 +218,18 @@ esac await fs.writeFile(toolFile, template, { mode: 0o755 }) const stat = await fs.stat(toolFile) - // Check executable bit (owner) expect(stat.mode & 0o100).toBeTruthy() - // Run the tool 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 () => { - const toolsDir = path.join(tmpDir, ".opencode", "tools") + 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}""" @@ -225,7 +252,10 @@ if __name__ == "__main__": }) test("scaffold generates executable node tool", async () => { - const toolsDir = path.join(tmpDir, ".opencode", "tools") + 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" @@ -245,27 +275,21 @@ if (command === "help" || command === "--help") { }) test("rejects invalid skill names", () => { - // Names must match /^[a-z][a-z0-9]+(-[a-z0-9]+)*$/ (min 2 chars, no trailing hyphens) const valid = (n: string) => /^[a-z][a-z0-9]+(-[a-z0-9]+)*$/.test(n) && n.length <= 64 expect(valid("my-tool")).toBe(true) expect(valid("freshness-check")).toBe(true) expect(valid("tool123")).toBe(true) expect(valid("ab")).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 expect(valid("a")).toBe(false) expect(valid("a-")).toBe(false) expect(valid("-tool")).toBe(false) expect(valid("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) @@ -273,39 +297,36 @@ if (command === "help" || command === "--help") { }) // --------------------------------------------------------------------------- -// PATH auto-discovery tests +// PATH auto-discovery tests — use tmpdir() fixture // --------------------------------------------------------------------------- describe("PATH auto-discovery for .opencode/tools/", () => { - let tmpDir: string - - beforeAll(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tools-path-test-")) - // Create .opencode/tools/ with an executable - const toolsDir = path.join(tmpDir, ".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, }) - }) - afterAll(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }) - }) - - test("tool in .opencode/tools/ is executable", async () => { - const toolPath = path.join(tmpDir, ".opencode", "tools", "my-test-tool") + 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 () => { - const toolsDir = path.join(tmpDir, ".opencode", "tools") + 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: tmpDir }) + 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") }) From 4ff95dfbdfb6a3c21eaca1eaa2223494d4ead22c Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 12:00:32 -0700 Subject: [PATCH 03/33] feat: enrich TUI skill dialog with source and paired tools (#341) Enhance the `/skills` dialog in the TUI to show: - Source category (Project / Global / Built-in) instead of flat "Skills" - Paired CLI tools in the footer (e.g., "Tools: altimate-dbt") - Reuses `detectToolReferences` and `skillSource` from `skill-helpers.ts` Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) 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..e743d9f592 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -2,6 +2,9 @@ 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 and source classification +import { detectToolReferences, skillSource } from "../../skill-helpers" +// altimate_change end export type DialogSkillProps = { onSelect: (skill: string) => void @@ -17,20 +20,28 @@ export function DialogSkill(props: DialogSkillProps) { return result.data ?? [] }) + // altimate_change start — enrich skill list with source and paired tools 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 source = skillSource(skill.location) + const tools = detectToolReferences(skill.content) + const toolStr = tools.length > 0 ? tools.join(", ") : undefined + return { + title: skill.name.padEnd(maxWidth), + description: skill.description?.replace(/\s+/g, " ").trim(), + footer: toolStr ? `Tools: ${toolStr}` : undefined, + value: skill.name, + category: source === "builtin" ? "Built-in" : source === "global" ? "Global" : "Project", + onSelect: () => { + props.onSelect(skill.name) + dialog.clear() + }, + } + }) }) + // altimate_change end return } From 0beca8d9ec4e73784ad44a5d5d3d60381307b6da Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 15:31:30 -0700 Subject: [PATCH 04/33] fix: improve skill list UX and TUI dialog (#341) CLI `skill list`: - Sort skills alphabetically - Remove SOURCE column (noisy, not useful) - Truncate descriptions on word boundaries instead of mid-word TUI `/skills` dialog: - Group skills by domain (dbt, SQL, Schema, FinOps, etc.) instead of source - Truncate descriptions to 80 chars for readability - Show paired tools compactly in footer (max 2) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/skill.ts | 21 +++++++--- .../cli/cmd/tui/component/dialog-skill.tsx | 40 +++++++++++++++---- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index 8f0bd93dc4..a1973031db 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -161,6 +161,9 @@ const SkillListCommand = cmd({ 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) => { @@ -190,9 +193,9 @@ const SkillListCommand = cmd({ // Calculate column widths const nameWidth = Math.max(6, ...skills.map((s) => s.name.length)) - const sourceWidth = 8 + const toolsWidth = 20 - const header = `${"SKILL".padEnd(nameWidth)} ${"SOURCE".padEnd(sourceWidth)} ${"TOOLS".padEnd(20)} DESCRIPTION` + const header = `${"SKILL".padEnd(nameWidth)} ${"TOOLS".padEnd(toolsWidth)} DESCRIPTION` const separator = "─".repeat(header.length) process.stdout.write(EOL) @@ -201,13 +204,19 @@ const SkillListCommand = cmd({ for (const skill of skills) { const tools = detectToolReferences(skill.content) - const source = skillSource(skill.location) const rawToolStr = tools.length > 0 ? tools.join(", ") : "—" - const toolStr = rawToolStr.length > 20 ? rawToolStr.slice(0, 17) + "..." : rawToolStr - const desc = skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description + 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)} ${source.padEnd(sourceWidth)} ${toolStr.padEnd(20)} ${desc}` + EOL, + `${skill.name.padEnd(nameWidth)} ${toolStr.padEnd(toolsWidth)} ${desc}` + EOL, ) } 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 e743d9f592..0a19a8c7e6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -2,14 +2,36 @@ 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 and source classification -import { detectToolReferences, skillSource } from "../../skill-helpers" +// altimate_change start — import helpers for tool detection +import { detectToolReferences } from "../../skill-helpers" // 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", +} +// altimate_change end + export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() @@ -20,20 +42,22 @@ export function DialogSkill(props: DialogSkillProps) { return result.data ?? [] }) - // altimate_change start — enrich skill list with source and paired tools + // 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) => { - const source = skillSource(skill.location) const tools = detectToolReferences(skill.content) - const toolStr = tools.length > 0 ? tools.join(", ") : undefined + const category = SKILL_CATEGORIES[skill.name] ?? "Other" + // Truncate description to keep it readable in the dialog + 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: skill.description?.replace(/\s+/g, " ").trim(), - footer: toolStr ? `Tools: ${toolStr}` : undefined, + description: shortDesc, + footer: tools.length > 0 ? `⚡ ${tools.slice(0, 2).join(", ")}` : undefined, value: skill.name, - category: source === "builtin" ? "Built-in" : source === "global" ? "Global" : "Project", + category, onSelect: () => { props.onSelect(skill.name) dialog.clear() From 50b7098affa89c382c19988894a3c50cfc8c626d Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 15:35:41 -0700 Subject: [PATCH 05/33] feat: add edit and test actions to TUI skills dialog (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/skills` dialog now supports keybind actions on the selected skill: - `ctrl+e` — open the SKILL.md in `$EDITOR` (skips built-in skills) - `ctrl+t` — run `skill test` and show PASS/FAIL result as a toast This makes the TUI a complete skill management surface (browse, use, edit, test) without dropping to the CLI. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 67 ++++++++++++++++++- 1 file changed, 64 insertions(+), 3 deletions(-) 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 0a19a8c7e6..69f3173448 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -2,8 +2,11 @@ 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 +// altimate_change start — import helpers for tool detection and keybind support import { detectToolReferences } from "../../skill-helpers" +import { Keybind } from "@/util/keybind" +import { useToast } from "@tui/ui/toast" +import { spawn } from "child_process" // altimate_change end export type DialogSkillProps = { @@ -35,6 +38,7 @@ const SKILL_CATEGORIES: Record = { export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() + const toast = useToast() dialog.setSize("large") const [skills] = createResource(async () => { @@ -42,6 +46,16 @@ export function DialogSkill(props: DialogSkillProps) { return result.data ?? [] }) + // altimate_change start — build a lookup from skill name → location for editor/test actions + const skillMap = createMemo(() => { + const map = new Map() + for (const skill of skills() ?? []) { + map.set(skill.name, skill.location) + } + return map + }) + // altimate_change end + // altimate_change start — enrich skill list with domain categories and tool info const options = createMemo[]>(() => { const list = skills() ?? [] @@ -49,7 +63,6 @@ export function DialogSkill(props: DialogSkillProps) { return list.map((skill) => { const tools = detectToolReferences(skill.content) const category = SKILL_CATEGORIES[skill.name] ?? "Other" - // Truncate description to keep it readable in the dialog const desc = skill.description?.replace(/\s+/g, " ").trim() const shortDesc = desc && desc.length > 80 ? desc.slice(0, 77) + "..." : desc return { @@ -65,7 +78,55 @@ export function DialogSkill(props: DialogSkillProps) { } }) }) + + // Keybind actions: edit skill in $EDITOR, test skill + const keybinds = createMemo(() => [ + { + keybind: Keybind.parse("ctrl+e")[0], + title: "edit", + onTrigger: async (option: DialogSelectOption) => { + const location = skillMap().get(option.value) + if (!location || location.startsWith("builtin:")) { + toast.show({ message: "Cannot edit built-in skills", variant: "info" }) + return + } + const editor = process.env.EDITOR || process.env.VISUAL || "vi" + dialog.clear() + spawn(editor, [location], { stdio: "inherit", detached: true }).unref() + }, + }, + { + keybind: Keybind.parse("ctrl+t")[0], + title: "test", + onTrigger: async (option: DialogSelectOption) => { + toast.show({ message: `Testing ${option.value}...`, variant: "info" }) + try { + const proc = Bun.spawn(["altimate-code", "skill", "test", option.value], { + stdout: "pipe", + stderr: "pipe", + }) + const exitCode = await proc.exited + const output = await new Response(proc.stdout).text() + const passed = output.includes("PASS") + toast.show({ + message: passed ? `✓ ${option.value}: PASS` : `✗ ${option.value}: FAIL`, + variant: passed ? "success" : "error", + duration: 4000, + }) + } catch { + toast.show({ message: `Failed to test ${option.value}`, variant: "error" }) + } + }, + }, + ]) // altimate_change end - return + return ( + + ) } From 4bb54743e0e826e5ca0668d301fb8cb50e696f52 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 15:48:16 -0700 Subject: [PATCH 06/33] feat: add `skill install` and `skill show` commands (#341) New subcommands for the skill CLI: `skill install `: - Install skills from GitHub repos: `altimate-code skill install anthropics/skills` - Install from URL: `altimate-code skill install https://github.com/owner/repo.git` - Install from local path: `altimate-code skill install ./my-skills` - `--global` / `-g` flag for user-wide installation - Duplicate protection (skips already-installed skills) - Copies full skill directories (SKILL.md + references/ + scripts/) - Cleans up temp clones after install `skill show `: - Display full skill content (name, description, location, tools, content) - Quick inspection without opening an editor Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/skill.ts | 170 +++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index a1973031db..64613c0f50 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -440,6 +440,174 @@ const SkillTestCommand = cmd({ }, }) +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) { + const source = args.source as string + const isGlobal = args.global as boolean + + 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 + + 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 + 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) + 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.stat(src) + if (stat.isFile()) { + await fs.copyFile(src, dst) + } else if (stat.isDirectory()) { + await fs.cp(src, dst, { recursive: true }) + } + } + process.stdout.write(` ✓ Installed "${skillName}" → ${path.relative(rootDir, dest)}` + EOL) + 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) + } else { + process.stdout.write(`No new skills installed.` + EOL) + } + }) + }, +}) + // --------------------------------------------------------------------------- // Top-level skill command // --------------------------------------------------------------------------- @@ -452,6 +620,8 @@ export const SkillCommand = cmd({ .command(SkillListCommand) .command(SkillCreateCommand) .command(SkillTestCommand) + .command(SkillShowCommand) + .command(SkillInstallCommand) .demandCommand(), async handler() {}, }) From 91dccd13198e2a1a3ebe48ed05768124e9a72464 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 15:56:59 -0700 Subject: [PATCH 07/33] feat: align TUI skill operations with CLI commands (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add all CLI skill operations to the TUI for feature parity: `/skills` dialog keybinds: - `ctrl+o` — view skill content (maps to `skill show`) - `ctrl+e` — edit SKILL.md in $EDITOR - `ctrl+t` — test skill and show PASS/FAIL toast (maps to `skill test`) Slash commands (command palette): - `/skill-create ` — scaffolds a new skill (maps to `skill create`) - `/skill-install ` — install from GitHub/URL (maps to `skill install`) CLI ↔ TUI mapping: skill list → /skills dialog skill show → ctrl+o in dialog skill create → /skill-create skill test → ctrl+t in dialog skill install → /skill-install Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 37 +++++++++++++---- .../cli/cmd/tui/component/prompt/index.tsx | 40 +++++++++++++++++++ 2 files changed, 70 insertions(+), 7 deletions(-) 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 69f3173448..8f7b97cbc0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -46,11 +46,11 @@ export function DialogSkill(props: DialogSkillProps) { return result.data ?? [] }) - // altimate_change start — build a lookup from skill name → location for editor/test actions + // altimate_change start — build lookups from skill name → location/content for actions const skillMap = createMemo(() => { - const map = new Map() + const map = new Map() for (const skill of skills() ?? []) { - map.set(skill.name, skill.location) + map.set(skill.name, { location: skill.location, content: skill.content, description: skill.description }) } return map }) @@ -79,20 +79,43 @@ export function DialogSkill(props: DialogSkillProps) { }) }) - // Keybind actions: edit skill in $EDITOR, test skill + // Keybind actions: view, edit, test const keybinds = createMemo(() => [ + { + keybind: Keybind.parse("ctrl+o")[0], + title: "view", + onTrigger: async (option: DialogSelectOption) => { + const info = skillMap().get(option.value) + if (!info) return + const tools = detectToolReferences(info.content) + const lines = [ + `Skill: ${option.value}`, + info.description, + "", + `Location: ${info.location}`, + tools.length > 0 ? `Tools: ${tools.join(", ")}` : null, + "", + "Content:", + "─".repeat(40), + info.content.slice(0, 800) + (info.content.length > 800 ? "\n..." : ""), + ] + .filter((l) => l !== null) + .join("\n") + toast.show({ message: lines, variant: "info", duration: 10000 }) + }, + }, { keybind: Keybind.parse("ctrl+e")[0], title: "edit", onTrigger: async (option: DialogSelectOption) => { - const location = skillMap().get(option.value) - if (!location || location.startsWith("builtin:")) { + const info = skillMap().get(option.value) + if (!info || info.location.startsWith("builtin:")) { toast.show({ message: "Cannot edit built-in skills", variant: "info" }) return } const editor = process.env.EDITOR || process.env.VISUAL || "vi" dialog.clear() - spawn(editor, [location], { stdio: "inherit", detached: true }).unref() + spawn(editor, [info.location], { stdio: "inherit", detached: true }).unref() }, }, { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 93d7016d37..4ef9096ca6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -408,6 +408,46 @@ export function Prompt(props: PromptProps) { )) }, }, + // altimate_change start — skill management commands + { + title: "Create skill", + value: "prompt.skill-create", + category: "Skills", + slash: { + name: "skill-create", + arguments: "", + }, + onSelect: () => { + input.setText("/skill-create ") + setStore("prompt", { input: "/skill-create ", parts: [] }) + input.gotoBufferEnd() + toast.show({ + message: "Type a name, e.g.: /skill-create my-tool\nOr use CLI: altimate-code skill create ", + variant: "info", + duration: 5000, + }) + }, + }, + { + title: "Install skill", + value: "prompt.skill-install", + category: "Skills", + slash: { + name: "skill-install", + arguments: "", + }, + onSelect: () => { + input.setText("/skill-install ") + setStore("prompt", { input: "/skill-install ", parts: [] }) + input.gotoBufferEnd() + toast.show({ + message: "Type a source, e.g.: /skill-install anthropics/skills\nSupports: owner/repo, URL, or local path", + variant: "info", + duration: 5000, + }) + }, + }, + // altimate_change end ] }) From 16050f3bf3276b3ee7f4a8ad42427b74ec7a8adc Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 16:05:47 -0700 Subject: [PATCH 08/33] fix: final validation fixes from release testing (#341) - Fix name regex: allow single-char first segment (e.g., `a-tool`, `x-ray`) by changing `[a-z][a-z0-9]+` to `[a-z][a-z0-9]*` with separate length check - Validate empty/whitespace source in `skill install` (was treating `""` as cwd) - Add test cases for valid hyphenated names and adversarial install inputs Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/skill.ts | 9 +++++++-- packages/opencode/test/cli/skill.test.ts | 13 ++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index 64613c0f50..4468ada86f 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -256,7 +256,7 @@ const SkillCreateCommand = cmd({ const noTool = args["skill-only"] as boolean // Validate name before bootstrap (fast fail) - if (!/^[a-z][a-z0-9]+(-[a-z0-9]+)*$/.test(name)) { + 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) } @@ -490,9 +490,14 @@ const SkillInstallCommand = cmd({ default: false, }), async handler(args) { - const source = args.source as string + const source = (args.source as string).trim() 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 diff --git a/packages/opencode/test/cli/skill.test.ts b/packages/opencode/test/cli/skill.test.ts index b97a4880cc..46a45e3898 100644 --- a/packages/opencode/test/cli/skill.test.ts +++ b/packages/opencode/test/cli/skill.test.ts @@ -275,21 +275,32 @@ if (command === "help" || command === "--help") { }) test("rejects invalid skill names", () => { - const valid = (n: string) => /^[a-z][a-z0-9]+(-[a-z0-9]+)*$/.test(n) && n.length <= 64 + 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) From c5e6055e5d5d919e739dfaeeb95a9b50b6f46ab8 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 17:22:05 -0700 Subject: [PATCH 09/33] fix: replace fake slash commands with real TUI dialogs for create/install (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove non-functional `/skill-create` and `/skill-install` slash commands that only pre-filled input text. Replace with real keybind actions in the `/skills` dialog: - `ctrl+n` (create) — opens `DialogPrompt` for skill name, then runs `altimate-code skill create` and shows result as toast - `ctrl+i` (install) — opens `DialogPrompt` for source (owner/repo, URL, or path), then runs `altimate-code skill install` and shows result Full CLI ↔ TUI parity: skill list → /skills dialog skill show → ctrl+o in dialog skill create → ctrl+n in dialog (input prompt) skill test → ctrl+t in dialog skill install → ctrl+i in dialog (input prompt) skill edit → ctrl+e in dialog Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 87 ++++++++++++++++++- .../cli/cmd/tui/component/prompt/index.tsx | 40 --------- 2 files changed, 85 insertions(+), 42 deletions(-) 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 8f7b97cbc0..f8468df9d6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -2,11 +2,12 @@ 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 and keybind support +// 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" // altimate_change end export type DialogSkillProps = { @@ -35,6 +36,74 @@ const SKILL_CATEGORIES: Record = { } // altimate_change end +// altimate_change start — sub-dialogs for create and install +function DialogSkillCreate() { + const dialog = useDialog() + const toast = useToast() + + return ( + { + dialog.clear() + toast.show({ message: `Creating ${name}...`, variant: "info" }) + try { + const proc = Bun.spawn(["altimate-code", "skill", "create", name], { + stdout: "pipe", + stderr: "pipe", + }) + await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + if (proc.exitCode === 0) { + toast.show({ message: `✓ Created skill "${name}"\n${stdout.trim()}`, variant: "success", duration: 5000 }) + } else { + toast.show({ message: stderr.trim() || `Failed to create "${name}"`, variant: "error", duration: 5000 }) + } + } catch { + toast.show({ message: `Failed to create "${name}"`, variant: "error" }) + } + }} + onCancel={() => dialog.clear()} + /> + ) +} + +function DialogSkillInstall() { + const dialog = useDialog() + const toast = useToast() + + return ( + { + dialog.clear() + toast.show({ message: `Installing from ${source}...`, variant: "info" }) + try { + const proc = Bun.spawn(["altimate-code", "skill", "install", source], { + stdout: "pipe", + stderr: "pipe", + }) + await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + if (proc.exitCode === 0) { + toast.show({ message: stdout.trim(), variant: "success", duration: 6000 }) + } else { + toast.show({ message: stderr.trim() || `Failed to install from "${source}"`, variant: "error", duration: 5000 }) + } + } catch { + toast.show({ message: `Failed to install from "${source}"`, variant: "error" }) + } + }} + onCancel={() => dialog.clear()} + /> + ) +} +// altimate_change end + export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() @@ -79,7 +148,7 @@ export function DialogSkill(props: DialogSkillProps) { }) }) - // Keybind actions: view, edit, test + // Keybind actions: view, edit, test, create, install const keybinds = createMemo(() => [ { keybind: Keybind.parse("ctrl+o")[0], @@ -141,6 +210,20 @@ export function DialogSkill(props: DialogSkillProps) { } }, }, + { + keybind: Keybind.parse("ctrl+n")[0], + title: "create", + onTrigger: async () => { + dialog.replace(() => ) + }, + }, + { + keybind: Keybind.parse("ctrl+i")[0], + title: "install", + onTrigger: async () => { + dialog.replace(() => ) + }, + }, ]) // altimate_change end diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 4ef9096ca6..93d7016d37 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -408,46 +408,6 @@ export function Prompt(props: PromptProps) { )) }, }, - // altimate_change start — skill management commands - { - title: "Create skill", - value: "prompt.skill-create", - category: "Skills", - slash: { - name: "skill-create", - arguments: "", - }, - onSelect: () => { - input.setText("/skill-create ") - setStore("prompt", { input: "/skill-create ", parts: [] }) - input.gotoBufferEnd() - toast.show({ - message: "Type a name, e.g.: /skill-create my-tool\nOr use CLI: altimate-code skill create ", - variant: "info", - duration: 5000, - }) - }, - }, - { - title: "Install skill", - value: "prompt.skill-install", - category: "Skills", - slash: { - name: "skill-install", - arguments: "", - }, - onSelect: () => { - input.setText("/skill-install ") - setStore("prompt", { input: "/skill-install ", parts: [] }) - input.gotoBufferEnd() - toast.show({ - message: "Type a source, e.g.: /skill-install anthropics/skills\nSupports: owner/repo, URL, or local path", - variant: "info", - duration: 5000, - }) - }, - }, - // altimate_change end ] }) From 461fefa9fa66deeb13de781fd6f96b88609ad84f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 17:51:22 -0700 Subject: [PATCH 10/33] fix: use `process.argv[0]` instead of `"altimate-code"` in TUI subprocess spawns (#341) TUI keybind actions (create, test, install) were spawning `"altimate-code"` which resolves to the system-installed binary, not the currently running one. This causes failures when running a local build or a different version than what's installed globally. Fix: use `process.argv[0]` which is the path to the current running binary in all three spawn sites. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/tui/component/dialog-skill.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 f8468df9d6..96249b206e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -49,7 +49,7 @@ function DialogSkillCreate() { dialog.clear() toast.show({ message: `Creating ${name}...`, variant: "info" }) try { - const proc = Bun.spawn(["altimate-code", "skill", "create", name], { + const proc = Bun.spawn([process.argv[0], "skill", "create", name], { stdout: "pipe", stderr: "pipe", }) @@ -82,7 +82,7 @@ function DialogSkillInstall() { dialog.clear() toast.show({ message: `Installing from ${source}...`, variant: "info" }) try { - const proc = Bun.spawn(["altimate-code", "skill", "install", source], { + const proc = Bun.spawn([process.argv[0], "skill", "install", source], { stdout: "pipe", stderr: "pipe", }) @@ -193,7 +193,7 @@ export function DialogSkill(props: DialogSkillProps) { onTrigger: async (option: DialogSelectOption) => { toast.show({ message: `Testing ${option.value}...`, variant: "info" }) try { - const proc = Bun.spawn(["altimate-code", "skill", "test", option.value], { + const proc = Bun.spawn([process.argv[0], "skill", "test", option.value], { stdout: "pipe", stderr: "pipe", }) From f2196640783e27810e789507311590dda1748f1b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 17:55:58 -0700 Subject: [PATCH 11/33] fix: improve TUI error reporting for skill create/install/test (#341) - Add explicit `env: { ...process.env }` to all Bun.spawn calls to ensure NODE_PATH and other env vars are forwarded to subprocess - Improve error messages: show both stderr and stdout on failure, truncate to 200 chars, include error class name for catch blocks - Consistent error format across create, install, and test actions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) 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 96249b206e..6d344c8a21 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -52,6 +52,7 @@ function DialogSkillCreate() { const proc = Bun.spawn([process.argv[0], "skill", "create", name], { stdout: "pipe", stderr: "pipe", + env: { ...process.env }, }) await proc.exited const stdout = await new Response(proc.stdout).text() @@ -59,10 +60,12 @@ function DialogSkillCreate() { if (proc.exitCode === 0) { toast.show({ message: `✓ Created skill "${name}"\n${stdout.trim()}`, variant: "success", duration: 5000 }) } else { - toast.show({ message: stderr.trim() || `Failed to create "${name}"`, variant: "error", duration: 5000 }) + const errMsg = (stderr.trim() || stdout.trim() || `Exit code ${proc.exitCode}`).slice(0, 200) + toast.show({ message: `Create failed: ${errMsg}`, variant: "error", duration: 6000 }) } - } catch { - toast.show({ message: `Failed to create "${name}"`, variant: "error" }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + toast.show({ message: `Create error: ${msg}`, variant: "error", duration: 6000 }) } }} onCancel={() => dialog.clear()} @@ -85,6 +88,7 @@ function DialogSkillInstall() { const proc = Bun.spawn([process.argv[0], "skill", "install", source], { stdout: "pipe", stderr: "pipe", + env: { ...process.env }, }) await proc.exited const stdout = await new Response(proc.stdout).text() @@ -92,10 +96,12 @@ function DialogSkillInstall() { if (proc.exitCode === 0) { toast.show({ message: stdout.trim(), variant: "success", duration: 6000 }) } else { - toast.show({ message: stderr.trim() || `Failed to install from "${source}"`, variant: "error", duration: 5000 }) + const errMsg = (stderr.trim() || stdout.trim() || `Exit code ${proc.exitCode}`).slice(0, 200) + toast.show({ message: `Install failed: ${errMsg}`, variant: "error", duration: 6000 }) } - } catch { - toast.show({ message: `Failed to install from "${source}"`, variant: "error" }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + toast.show({ message: `Install error: ${msg}`, variant: "error", duration: 6000 }) } }} onCancel={() => dialog.clear()} @@ -196,8 +202,9 @@ export function DialogSkill(props: DialogSkillProps) { const proc = Bun.spawn([process.argv[0], "skill", "test", option.value], { stdout: "pipe", stderr: "pipe", + env: { ...process.env }, }) - const exitCode = await proc.exited + await proc.exited const output = await new Response(proc.stdout).text() const passed = output.includes("PASS") toast.show({ @@ -205,8 +212,9 @@ export function DialogSkill(props: DialogSkillProps) { variant: passed ? "success" : "error", duration: 4000, }) - } catch { - toast.show({ message: `Failed to test ${option.value}`, variant: "error" }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + toast.show({ message: `Test error: ${msg}`, variant: "error", duration: 6000 }) } }, }, From 1301169deb1aed1135e814cc23ce92f3a0c3351c Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 18:00:16 -0700 Subject: [PATCH 12/33] fix: inline skill operations in TUI instead of spawning subprocess (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI dialog was spawning `process.argv[0] skill install ...` which failed because `process.argv[0]` in the compiled Bun binary doesn't resolve correctly for self-invocation (error: Script not found "skill"). Fix: inline all skill operations directly in the TUI component: - `createSkillDirect()` — creates SKILL.md + tool via `fs` operations - `installSkillDirect()` — clones repo via `git clone`, copies SKILL.md files via `fs`, no subprocess self-invocation needed - `testSkillDirect()` — spawns the tool's `--help` directly (not via the altimate-code binary) This eliminates the subprocess spawning problem entirely. The TUI now uses the same `Instance`, `Global`, and `fs` APIs as the CLI commands. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 246 ++++++++++++++---- 1 file changed, 189 insertions(+), 57 deletions(-) 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 6d344c8a21..38040be5d4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -8,6 +8,10 @@ import { Keybind } from "@/util/keybind" import { useToast } from "@tui/ui/toast" import { spawn } from "child_process" import { DialogPrompt } from "@tui/ui/dialog-prompt" +import { Instance } from "@/project/instance" +import { Global } from "@/global" +import path from "path" +import fs from "fs/promises" // altimate_change end export type DialogSkillProps = { @@ -34,6 +38,170 @@ const SKILL_CATEGORIES: Record = { "training-status": "Training", "altimate-setup": "Setup", } + +/** Resolve the project root for skill operations. */ +function projectRoot(): string { + return Instance.worktree !== "/" ? Instance.worktree : Instance.directory +} +// 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): 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 rootDir = projectRoot() + 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 + const toolsDir = path.join(rootDir, ".opencode", "tools") + await fs.mkdir(toolsDir, { recursive: true }) + const toolFile = path.join(toolsDir, name) + 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}/` } +} + +/** Install skills from a GitHub repo or local path directly. */ +async function installSkillDirect(source: string): Promise<{ ok: boolean; message: string }> { + const trimmed = source.trim() + if (!trimmed) return { ok: false, message: "Source is required" } + + const rootDir = projectRoot() + const targetDir = path.join(rootDir, ".opencode", "skills") + let skillDir: string + let isTmp = false + + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + // URL + const tmpDir = path.join(Global.Path.cache, "skill-install-" + Date.now()) + isTmp = true + const proc = Bun.spawnSync(["git", "clone", "--depth", "1", trimmed, tmpDir], { + stdout: "pipe", + stderr: "pipe", + }) + if (proc.exitCode !== 0) { + return { ok: false, message: `Failed to clone: ${proc.stderr.toString().trim().slice(0, 150)}` } + } + skillDir = tmpDir + } else if (trimmed.match(/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/)) { + // GitHub shorthand + const url = `https://github.com/${trimmed}.git` + const tmpDir = path.join(Global.Path.cache, "skill-install-" + Date.now()) + isTmp = true + const proc = Bun.spawnSync(["git", "clone", "--depth", "1", url, tmpDir], { + stdout: "pipe", + stderr: "pipe", + }) + if (proc.exitCode !== 0) { + return { ok: false, message: `Failed to clone ${trimmed}: ${proc.stderr.toString().trim().slice(0, 150)}` } + } + skillDir = tmpDir + } else { + // Local path + const resolved = path.isAbsolute(trimmed) ? trimmed : path.resolve(trimmed) + try { + await fs.access(resolved) + } catch { + return { ok: false, message: `Path not found: ${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}` } + } + + 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.stat(src) + if (stat.isFile()) await fs.copyFile(src, dst) + else if (stat.isDirectory()) await fs.cp(src, dst, { recursive: true }) + } + names.push(skillName) + installed++ + } + + if (isTmp) 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(", ")}` } +} + +/** Test a skill by checking its tool responds to --help. */ +async function testSkillDirect(skillName: string, location: string, content: string): Promise<{ ok: boolean; message: string }> { + const tools = detectToolReferences(content) + if (tools.length === 0) return { ok: true, message: `${skillName}: PASS (no CLI tools)` } + + const rootDir = projectRoot() + const worktreeDir = Instance.worktree !== "/" ? Instance.worktree : rootDir + const sep = process.platform === "win32" ? ";" : ":" + const toolPath = [ + process.env.ALTIMATE_BIN_DIR, + path.join(worktreeDir, ".opencode", "tools"), + path.join(rootDir, ".opencode", "tools"), + path.join(Global.Path.config, "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 @@ -48,25 +216,12 @@ function DialogSkillCreate() { onConfirm={async (name) => { dialog.clear() toast.show({ message: `Creating ${name}...`, variant: "info" }) - try { - const proc = Bun.spawn([process.argv[0], "skill", "create", name], { - stdout: "pipe", - stderr: "pipe", - env: { ...process.env }, - }) - await proc.exited - const stdout = await new Response(proc.stdout).text() - const stderr = await new Response(proc.stderr).text() - if (proc.exitCode === 0) { - toast.show({ message: `✓ Created skill "${name}"\n${stdout.trim()}`, variant: "success", duration: 5000 }) - } else { - const errMsg = (stderr.trim() || stdout.trim() || `Exit code ${proc.exitCode}`).slice(0, 200) - toast.show({ message: `Create failed: ${errMsg}`, variant: "error", duration: 6000 }) - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - toast.show({ message: `Create error: ${msg}`, variant: "error", duration: 6000 }) - } + const result = await createSkillDirect(name) + toast.show({ + message: result.ok ? `✓ ${result.message}` : result.message, + variant: result.ok ? "success" : "error", + duration: 5000, + }) }} onCancel={() => dialog.clear()} /> @@ -84,25 +239,12 @@ function DialogSkillInstall() { onConfirm={async (source) => { dialog.clear() toast.show({ message: `Installing from ${source}...`, variant: "info" }) - try { - const proc = Bun.spawn([process.argv[0], "skill", "install", source], { - stdout: "pipe", - stderr: "pipe", - env: { ...process.env }, - }) - await proc.exited - const stdout = await new Response(proc.stdout).text() - const stderr = await new Response(proc.stderr).text() - if (proc.exitCode === 0) { - toast.show({ message: stdout.trim(), variant: "success", duration: 6000 }) - } else { - const errMsg = (stderr.trim() || stdout.trim() || `Exit code ${proc.exitCode}`).slice(0, 200) - toast.show({ message: `Install failed: ${errMsg}`, variant: "error", duration: 6000 }) - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - toast.show({ message: `Install error: ${msg}`, variant: "error", duration: 6000 }) - } + const result = await installSkillDirect(source) + toast.show({ + message: result.ok ? `✓ ${result.message}` : result.message, + variant: result.ok ? "success" : "error", + duration: 6000, + }) }} onCancel={() => dialog.clear()} /> @@ -116,7 +258,7 @@ export function DialogSkill(props: DialogSkillProps) { 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 ?? [] }) @@ -197,25 +339,15 @@ export function DialogSkill(props: DialogSkillProps) { keybind: Keybind.parse("ctrl+t")[0], title: "test", onTrigger: async (option: DialogSelectOption) => { + const info = skillMap().get(option.value) + if (!info) return toast.show({ message: `Testing ${option.value}...`, variant: "info" }) - try { - const proc = Bun.spawn([process.argv[0], "skill", "test", option.value], { - stdout: "pipe", - stderr: "pipe", - env: { ...process.env }, - }) - await proc.exited - const output = await new Response(proc.stdout).text() - const passed = output.includes("PASS") - toast.show({ - message: passed ? `✓ ${option.value}: PASS` : `✗ ${option.value}: FAIL`, - variant: passed ? "success" : "error", - duration: 4000, - }) - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - toast.show({ message: `Test error: ${msg}`, variant: "error", duration: 6000 }) - } + const result = await testSkillDirect(option.value, info.location, info.content) + toast.show({ + message: result.ok ? `✓ ${result.message}` : `✗ ${result.message}`, + variant: result.ok ? "success" : "error", + duration: 4000, + }) }, }, { From 4cf58009020af5af08aaf11b1bc186dc36fc6a37 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 18:04:59 -0700 Subject: [PATCH 13/33] fix: use async `Bun.spawn` for git clone in TUI install (#341) `Bun.spawnSync` blocks the TUI event loop during `git clone`, causing the install toast to show briefly then disappear with nothing installed. Fix: switch to async `Bun.spawn` with `await proc.exited` so the TUI stays responsive while cloning. Read stderr via `new Response().text()` for error messages. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/cli/cmd/tui/component/dialog-skill.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 38040be5d4..6ae340c6a0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -89,28 +89,32 @@ async function installSkillDirect(source: string): Promise<{ ok: boolean; messag let isTmp = false if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { - // URL + // URL — use async spawn so TUI doesn't freeze const tmpDir = path.join(Global.Path.cache, "skill-install-" + Date.now()) isTmp = true - const proc = Bun.spawnSync(["git", "clone", "--depth", "1", trimmed, tmpDir], { + const proc = Bun.spawn(["git", "clone", "--depth", "1", trimmed, tmpDir], { stdout: "pipe", stderr: "pipe", }) + await proc.exited if (proc.exitCode !== 0) { - return { ok: false, message: `Failed to clone: ${proc.stderr.toString().trim().slice(0, 150)}` } + const stderr = await new Response(proc.stderr).text() + return { ok: false, message: `Failed to clone: ${stderr.trim().slice(0, 150)}` } } skillDir = tmpDir } else if (trimmed.match(/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/)) { - // GitHub shorthand + // GitHub shorthand — use async spawn const url = `https://github.com/${trimmed}.git` const tmpDir = path.join(Global.Path.cache, "skill-install-" + Date.now()) isTmp = true - const proc = Bun.spawnSync(["git", "clone", "--depth", "1", url, tmpDir], { + const proc = Bun.spawn(["git", "clone", "--depth", "1", url, tmpDir], { stdout: "pipe", stderr: "pipe", }) + await proc.exited if (proc.exitCode !== 0) { - return { ok: false, message: `Failed to clone ${trimmed}: ${proc.stderr.toString().trim().slice(0, 150)}` } + const stderr = await new Response(proc.stderr).text() + return { ok: false, message: `Failed to clone ${trimmed}: ${stderr.trim().slice(0, 150)}` } } skillDir = tmpDir } else { From a581e6fd7e00931d051ee2c0ad30bda9a55b3e2d Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 18:16:28 -0700 Subject: [PATCH 14/33] fix: invalidate skill cache after TUI create/install so new skills appear (#341) The TUI skill dialog showed stale data after installing or creating skills because `Skill.state` is a singleton cache (via `Instance.state`) that never re-scans. Fix: - Add `State.invalidate(key, init)` to clear a single cached state entry without disposing all state for the directory - Add `Skill.invalidate()` that clears the skill cache, so the next call to `Skill.all()` re-scans all skill directories - Call `Skill.invalidate()` after successful create/install in the TUI dialog, so reopening `/skills` shows the newly installed skills Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 5 +++++ packages/opencode/src/project/state.ts | 8 ++++++++ packages/opencode/src/skill/skill.ts | 20 +++++++++++++++++-- 3 files changed, 31 insertions(+), 2 deletions(-) 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 6ae340c6a0..27dd03d2b7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -10,6 +10,7 @@ import { spawn } from "child_process" import { DialogPrompt } from "@tui/ui/dialog-prompt" import { Instance } from "@/project/instance" import { Global } from "@/global" +import { Skill } from "@/skill" import path from "path" import fs from "fs/promises" // altimate_change end @@ -75,6 +76,8 @@ async function createSkillDirect(name: string): Promise<{ ok: boolean; message: `#!/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 }, ) + // Invalidate cached skill list so new skill appears immediately + Skill.invalidate() return { ok: true, message: `Created skill + tool at .opencode/skills/${name}/` } } @@ -166,6 +169,8 @@ async function installSkillDirect(source: string): Promise<{ ok: boolean; messag if (isTmp) await fs.rm(skillDir, { recursive: true, force: true }) if (installed === 0) return { ok: true, message: "No new skills installed (all already exist)" } + // Invalidate cached skill list so new skills appear immediately + Skill.invalidate() return { ok: true, message: `Installed ${installed} skill(s): ${names.join(", ")}` } } 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/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]) From c5c6acf24de3ab26003682db58aa291d4d392806 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 18:24:34 -0700 Subject: [PATCH 15/33] fix: invalidate server-side skill cache via API after TUI install/create (#341) The TUI runs in the main thread but the server (which serves the skills API) runs in a worker thread. Calling `Skill.invalidate()` from the TUI component had no effect because it cleared the cache in the wrong JS context. Fix: - Add `?reload=true` query param support to `GET /skill` endpoint that calls `Skill.invalidate()` in the server/worker context - TUI calls `GET /skill?reload=true` via `sdk.fetch()` after successful install/create to invalidate the worker's cache - Next `/skills` dialog open fetches fresh data from server Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 20 ++++++++++++++----- packages/opencode/src/server/server.ts | 6 ++++++ 2 files changed, 21 insertions(+), 5 deletions(-) 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 27dd03d2b7..015d77c361 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -10,7 +10,6 @@ import { spawn } from "child_process" import { DialogPrompt } from "@tui/ui/dialog-prompt" import { Instance } from "@/project/instance" import { Global } from "@/global" -import { Skill } from "@/skill" import path from "path" import fs from "fs/promises" // altimate_change end @@ -76,8 +75,6 @@ async function createSkillDirect(name: string): Promise<{ ok: boolean; message: `#!/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 }, ) - // Invalidate cached skill list so new skill appears immediately - Skill.invalidate() return { ok: true, message: `Created skill + tool at .opencode/skills/${name}/` } } @@ -169,8 +166,6 @@ async function installSkillDirect(source: string): Promise<{ ok: boolean; messag if (isTmp) await fs.rm(skillDir, { recursive: true, force: true }) if (installed === 0) return { ok: true, message: "No new skills installed (all already exist)" } - // Invalidate cached skill list so new skills appear immediately - Skill.invalidate() return { ok: true, message: `Installed ${installed} skill(s): ${names.join(", ")}` } } @@ -214,9 +209,21 @@ async function testSkillDirect(skillName: string, location: string, content: str // altimate_change end // altimate_change start — sub-dialogs for create and install +// Reload skills on the server so the cache is invalidated in the worker thread. +// Calls GET /skill?reload=true via the SDK's fetch to invalidate the cache +// and force a re-scan of all skill directories. +async function reloadSkillsOnServer(sdk: ReturnType) { + try { + await sdk.fetch(`${sdk.url}/skill?reload=true`) + } catch { + // Best-effort — if it fails, skills will show on next TUI restart + } +} + function DialogSkillCreate() { const dialog = useDialog() const toast = useToast() + const sdk = useSDK() return ( { + // 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) }, From eb0b88cd91018b38b5d92f5499958ee3f22c1484 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 18:30:31 -0700 Subject: [PATCH 16/33] fix: improve TUI install/create confirmation UX (#341) Problems: - "Installing..." toast disappeared immediately (short default duration) - No verification that skills actually loaded after install - No guidance on what to do next Fix: - Set 30s duration on "Installing..." toast so it persists during clone - After install/create, call `GET /skill?reload=true` and verify each installed skill appears in the returned list - Show detailed confirmation toast (8s duration) with: - Count and bullet list of installed skill names - Guidance: "Open /skills to browse, or type / to use" - Show explicit error if install succeeded but skills didn't load - Same pattern for create: confirms name and location Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 71 +++++++++++++------ 1 file changed, 49 insertions(+), 22 deletions(-) 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 015d77c361..59dd7e3013 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -79,7 +79,7 @@ async function createSkillDirect(name: string): Promise<{ ok: boolean; message: } /** Install skills from a GitHub repo or local path directly. */ -async function installSkillDirect(source: string): Promise<{ ok: boolean; message: string }> { +async function installSkillDirect(source: string): Promise<{ ok: boolean; message: string; installedNames?: string[] }> { const trimmed = source.trim() if (!trimmed) return { ok: false, message: "Source is required" } @@ -166,7 +166,7 @@ async function installSkillDirect(source: string): Promise<{ ok: boolean; messag if (isTmp) 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(", ")}` } + return { ok: true, message: `Installed ${installed} skill(s): ${names.join(", ")}`, installedNames: names } } /** Test a skill by checking its tool responds to --help. */ @@ -209,14 +209,15 @@ async function testSkillDirect(skillName: string, location: string, content: str // altimate_change end // altimate_change start — sub-dialogs for create and install -// Reload skills on the server so the cache is invalidated in the worker thread. -// Calls GET /skill?reload=true via the SDK's fetch to invalidate the cache -// and force a re-scan of all skill directories. -async function reloadSkillsOnServer(sdk: ReturnType) { + +/** Reload skills on the server and verify new skills are visible. */ +async function reloadAndVerify(sdk: ReturnType, expectedNames: string[]): Promise { try { - await sdk.fetch(`${sdk.url}/skill?reload=true`) + 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 { - // Best-effort — if it fails, skills will show on next TUI restart + return [] } } @@ -231,14 +232,27 @@ function DialogSkillCreate() { placeholder="my-tool" onConfirm={async (name) => { dialog.clear() - toast.show({ message: `Creating ${name}...`, variant: "info" }) + toast.show({ message: `Creating "${name}"...`, variant: "info", duration: 30000 }) const result = await createSkillDirect(name) - if (result.ok) await reloadSkillsOnServer(sdk) - toast.show({ - message: result.ok ? `✓ ${result.message}` : result.message, - variant: result.ok ? "success" : "error", - duration: 5000, - }) + if (!result.ok) { + toast.show({ message: result.message, variant: "error", duration: 6000 }) + return + } + // Verify the skill loaded on the server + const verified = await reloadAndVerify(sdk, [name]) + if (verified.length > 0) { + toast.show({ + message: `✓ Created "${name}"\n\nSkill and CLI tool ready at .opencode/skills/${name}/\nUse /skills to find it, or type /${name} in the prompt.`, + variant: "success", + duration: 8000, + }) + } else { + toast.show({ + message: `Created files but skill not loaded. Try reopening /skills.`, + variant: "warning", + duration: 6000, + }) + } }} onCancel={() => dialog.clear()} /> @@ -256,14 +270,27 @@ function DialogSkillInstall() { placeholder="anthropics/skills" onConfirm={async (source) => { dialog.clear() - toast.show({ message: `Installing from ${source}...`, variant: "info" }) + toast.show({ message: `Installing from ${source}...`, variant: "info", duration: 30000 }) const result = await installSkillDirect(source) - if (result.ok) await reloadSkillsOnServer(sdk) - toast.show({ - message: result.ok ? `✓ ${result.message}` : result.message, - variant: result.ok ? "success" : "error", - duration: 6000, - }) + if (!result.ok) { + toast.show({ message: result.message, variant: "error", duration: 6000 }) + return + } + if (result.message.includes("all already exist")) { + toast.show({ message: "All skills already installed.", variant: "info", duration: 4000 }) + return + } + // Extract installed names and verify they loaded + const names = result.installedNames ?? [] + 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 }) }} onCancel={() => dialog.clear()} /> From 72f342bd24b61021ab5931e2fe9276cfbdcb1729 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 18:36:54 -0700 Subject: [PATCH 17/33] fix: add try/catch and input sanitization to TUI install/create (#341) Root cause of silent failures: `onConfirm` async callbacks had no try/catch, so any thrown error was swallowed and no result toast shown. Fixes: - Wrap all install/create logic in try/catch with error toast - Strip trailing dots from input (textarea was appending `.`) - Strip `.git` suffix from URLs (users paste from browser) - Trim whitespace and validate before proceeding - "Installing..." toast now shows 60s duration with helpful text ("This may take a moment while the repo is cloned") - Empty input shows immediate error instead of proceeding Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 95 +++++++++++-------- 1 file changed, 57 insertions(+), 38 deletions(-) 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 59dd7e3013..557859c77f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -230,28 +230,37 @@ function DialogSkillCreate() { { + onConfirm={async (rawName) => { + const name = rawName.trim() dialog.clear() - toast.show({ message: `Creating "${name}"...`, variant: "info", duration: 30000 }) - const result = await createSkillDirect(name) - if (!result.ok) { - toast.show({ message: result.message, variant: "error", duration: 6000 }) + if (!name) { + toast.show({ message: "No name provided.", variant: "error", duration: 4000 }) return } - // Verify the skill loaded on the server - const verified = await reloadAndVerify(sdk, [name]) - if (verified.length > 0) { - toast.show({ - message: `✓ Created "${name}"\n\nSkill and CLI tool ready at .opencode/skills/${name}/\nUse /skills to find it, or type /${name} in the prompt.`, - variant: "success", - duration: 8000, - }) - } else { - toast.show({ - message: `Created files but skill not loaded. Try reopening /skills.`, - variant: "warning", - duration: 6000, - }) + toast.show({ message: `Creating "${name}"...`, variant: "info", duration: 30000 }) + try { + const result = await createSkillDirect(name) + 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()} @@ -268,29 +277,39 @@ function DialogSkillInstall() { { + onConfirm={async (rawSource) => { + // Strip trailing dots, whitespace, and .git suffix that users might paste + const source = rawSource.trim().replace(/\.+$/, "").replace(/\.git$/, "") dialog.clear() - toast.show({ message: `Installing from ${source}...`, variant: "info", duration: 30000 }) - const result = await installSkillDirect(source) - if (!result.ok) { - toast.show({ message: result.message, variant: "error", duration: 6000 }) + if (!source) { + toast.show({ message: "No source provided.", variant: "error", duration: 4000 }) return } - if (result.message.includes("all already exist")) { - toast.show({ message: "All skills already installed.", variant: "info", duration: 4000 }) - return + toast.show({ message: `Installing from ${source}...\nThis may take a moment while the repo is cloned.`, variant: "info", duration: 60000 }) + try { + const result = await installSkillDirect(source) + 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 ?? [] + 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 }) } - // Extract installed names and verify they loaded - const names = result.installedNames ?? [] - 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 }) }} onCancel={() => dialog.clear()} /> From f31d0da7deeef158df1e6604b6a119d9e35dadeb Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 18:39:22 -0700 Subject: [PATCH 18/33] fix: use 10min timeout for in-progress toast so it never disappears before completion (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Installing..." toast used a 60s timeout which could expire on slow connections. Changed to 600s (10 min) — effectively infinite since the result toast (`toast.show`) always replaces it on completion, clearing the old timeout automatically. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 557859c77f..90bfffc961 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -285,7 +285,7 @@ function DialogSkillInstall() { toast.show({ message: "No source provided.", variant: "error", duration: 4000 }) return } - toast.show({ message: `Installing from ${source}...\nThis may take a moment while the repo is cloned.`, variant: "info", duration: 60000 }) + toast.show({ message: `Installing from ${source}...\nThis may take a moment while the repo is cloned.`, variant: "info", duration: 600000 }) try { const result = await installSkillDirect(source) if (!result.ok) { From 8e4870154804686b6221c9692ebd600560de1ac4 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 18:41:03 -0700 Subject: [PATCH 19/33] feat: show live progress log in TUI install toast (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of a static "Installing..." message, the toast now updates in real-time as each step completes: Installing from dagster-io/skills Cloning dagster-io/skills... → Cloned. Scanning for skills... → Found 2 skill(s). Installing... → Installed 1/2: dignified-python → Installed 2/2: dagster-expert → Verifying skills loaded... Each step calls `toast.show()` which replaces the previous message, keeping the toast visible throughout. The user always knows what's happening and how far along the operation is. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) 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 90bfffc961..a5a49278cd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -78,8 +78,14 @@ async function createSkillDirect(name: string): Promise<{ ok: boolean; message: 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): Promise<{ ok: boolean; message: string; installedNames?: string[] }> { +async function installSkillDirect( + source: string, + onProgress?: ProgressFn, +): Promise<{ ok: boolean; message: string; installedNames?: string[] }> { const trimmed = source.trim() if (!trimmed) return { ok: false, message: "Source is required" } @@ -88,23 +94,10 @@ async function installSkillDirect(source: string): Promise<{ ok: boolean; messag let skillDir: string let isTmp = false - if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { - // URL — use async spawn so TUI doesn't freeze - const tmpDir = path.join(Global.Path.cache, "skill-install-" + Date.now()) - isTmp = true - const proc = Bun.spawn(["git", "clone", "--depth", "1", trimmed, 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)}` } - } - skillDir = tmpDir - } else if (trimmed.match(/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/)) { - // GitHub shorthand — use async spawn - const url = `https://github.com/${trimmed}.git` + if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.match(/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/)) { + const url = trimmed.startsWith("http") ? trimmed : `https://github.com/${trimmed}.git` + const label = trimmed.startsWith("http") ? trimmed.replace(/https?:\/\/github\.com\//, "") : trimmed + onProgress?.(`Cloning ${label}...`) const tmpDir = path.join(Global.Path.cache, "skill-install-" + Date.now()) isTmp = true const proc = Bun.spawn(["git", "clone", "--depth", "1", url, tmpDir], { @@ -114,17 +107,18 @@ async function installSkillDirect(source: string): Promise<{ ok: boolean; messag await proc.exited if (proc.exitCode !== 0) { const stderr = await new Response(proc.stderr).text() - return { ok: false, message: `Failed to clone ${trimmed}: ${stderr.trim().slice(0, 150)}` } + return { ok: false, message: `Failed to clone: ${stderr.trim().slice(0, 150)}` } } + onProgress?.(`Cloned. Scanning for skills...`) skillDir = tmpDir } else { - // Local path 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 } @@ -139,6 +133,8 @@ async function installSkillDirect(source: string): Promise<{ ok: boolean; messag 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) { @@ -162,9 +158,13 @@ async function installSkillDirect(source: string): Promise<{ ok: boolean; messag } names.push(skillName) installed++ + onProgress?.(`Installed ${installed}/${matches.length}: ${skillName}`) } - if (isTmp) await fs.rm(skillDir, { recursive: true, force: true }) + 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 } } @@ -285,9 +285,12 @@ function DialogSkillInstall() { toast.show({ message: "No source provided.", variant: "error", duration: 4000 }) return } - toast.show({ message: `Installing from ${source}...\nThis may take a moment while the repo is cloned.`, variant: "info", duration: 600000 }) + 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) + const result = await installSkillDirect(source, progress) if (!result.ok) { toast.show({ message: `Install failed: ${result.message}`, variant: "error", duration: 6000 }) return @@ -297,6 +300,7 @@ function DialogSkillInstall() { return } const names = result.installedNames ?? [] + progress("Verifying skills loaded...") const verified = await reloadAndVerify(sdk, names) const lines = [ `✓ Installed ${verified.length} skill(s)`, From 6a27faa028f47513b557544008997e37e9b5f79e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 18:59:04 -0700 Subject: [PATCH 20/33] =?UTF-8?q?fix:=20remove=20Instance/Global=20usage?= =?UTF-8?q?=20from=20TUI=20dialog=20=E2=80=94=20use=20sdk.directory=20(#34?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Instance` and `Global` only exist in the worker thread context. The TUI dialog runs in the main thread, so accessing them throws "No context found for instance". Fix: pass `sdk.directory` (available from the SDK context in the main thread) to all skill operations instead of calling `Instance.worktree` or `Global.Path.cache`. Use `os.homedir()` for cache directory. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) 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 a5a49278cd..c151c87c6a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -8,8 +8,7 @@ import { Keybind } from "@/util/keybind" import { useToast } from "@tui/ui/toast" import { spawn } from "child_process" import { DialogPrompt } from "@tui/ui/dialog-prompt" -import { Instance } from "@/project/instance" -import { Global } from "@/global" +import os from "os" import path from "path" import fs from "fs/promises" // altimate_change end @@ -39,20 +38,19 @@ const SKILL_CATEGORIES: Record = { "altimate-setup": "Setup", } -/** Resolve the project root for skill operations. */ -function projectRoot(): string { - return Instance.worktree !== "/" ? Instance.worktree : Instance.directory +// Cache dir for temporary git clones +function cacheDir(): string { + return path.join(os.homedir(), ".cache", "altimate-code") } // 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): Promise<{ ok: boolean; message: string }> { +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 rootDir = projectRoot() const skillDir = path.join(rootDir, ".opencode", "skills", name) const skillFile = path.join(skillDir, "SKILL.md") try { @@ -84,12 +82,11 @@ 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 rootDir = projectRoot() const targetDir = path.join(rootDir, ".opencode", "skills") let skillDir: string let isTmp = false @@ -98,7 +95,7 @@ async function installSkillDirect( const url = trimmed.startsWith("http") ? trimmed : `https://github.com/${trimmed}.git` const label = trimmed.startsWith("http") ? trimmed.replace(/https?:\/\/github\.com\//, "") : trimmed onProgress?.(`Cloning ${label}...`) - const tmpDir = path.join(Global.Path.cache, "skill-install-" + Date.now()) + const tmpDir = path.join(cacheDir(), "skill-install-" + Date.now()) isTmp = true const proc = Bun.spawn(["git", "clone", "--depth", "1", url, tmpDir], { stdout: "pipe", @@ -170,18 +167,14 @@ async function installSkillDirect( } /** Test a skill by checking its tool responds to --help. */ -async function testSkillDirect(skillName: string, location: string, content: string): Promise<{ ok: boolean; message: string }> { +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 rootDir = projectRoot() - const worktreeDir = Instance.worktree !== "/" ? Instance.worktree : rootDir const sep = process.platform === "win32" ? ";" : ":" const toolPath = [ process.env.ALTIMATE_BIN_DIR, - path.join(worktreeDir, ".opencode", "tools"), path.join(rootDir, ".opencode", "tools"), - path.join(Global.Path.config, "tools"), process.env.PATH, ] .filter(Boolean) @@ -239,7 +232,7 @@ function DialogSkillCreate() { } toast.show({ message: `Creating "${name}"...`, variant: "info", duration: 30000 }) try { - const result = await createSkillDirect(name) + const result = await createSkillDirect(name, sdk.directory ?? process.cwd()) if (!result.ok) { toast.show({ message: `Create failed: ${result.message}`, variant: "error", duration: 6000 }) return @@ -290,7 +283,7 @@ function DialogSkillInstall() { } progress("Preparing...") try { - const result = await installSkillDirect(source, progress) + const result = await installSkillDirect(source, sdk.directory ?? process.cwd(), progress) if (!result.ok) { toast.show({ message: `Install failed: ${result.message}`, variant: "error", duration: 6000 }) return @@ -411,7 +404,7 @@ export function DialogSkill(props: DialogSkillProps) { const info = skillMap().get(option.value) if (!info) return toast.show({ message: `Testing ${option.value}...`, variant: "info" }) - const result = await testSkillDirect(option.value, info.location, info.content) + const result = await testSkillDirect(option.value, info.content, sdk.directory ?? process.cwd()) toast.show({ message: result.ok ? `✓ ${result.message}` : `✗ ${result.message}`, variant: result.ok ? "success" : "error", From 0096aebbd934de821e1054566bc01b65cda59eb7 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 19:06:19 -0700 Subject: [PATCH 21/33] fix: use git root for TUI skill install target directory (#341) `sdk.directory` is the cwd where the TUI was launched (e.g. `packages/opencode/`), not the git worktree root. Skills installed via TUI went to the wrong `.opencode/skills/` directory. Fix: add `gitRoot()` helper that runs `git rev-parse --show-toplevel` to resolve the actual project root, matching what Instance.worktree does in the worker thread. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) 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 c151c87c6a..7b70971e3e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -42,6 +42,22 @@ const SKILL_CATEGORIES: Record = { 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) @@ -232,7 +248,7 @@ function DialogSkillCreate() { } toast.show({ message: `Creating "${name}"...`, variant: "info", duration: 30000 }) try { - const result = await createSkillDirect(name, sdk.directory ?? process.cwd()) + 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 @@ -283,7 +299,7 @@ function DialogSkillInstall() { } progress("Preparing...") try { - const result = await installSkillDirect(source, sdk.directory ?? process.cwd(), progress) + 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 @@ -404,7 +420,7 @@ export function DialogSkill(props: DialogSkillProps) { const info = skillMap().get(option.value) if (!info) return toast.show({ message: `Testing ${option.value}...`, variant: "info" }) - const result = await testSkillDirect(option.value, info.content, sdk.directory ?? process.cwd()) + const result = await testSkillDirect(option.value, info.content, gitRoot(sdk.directory ?? process.cwd())) toast.show({ message: result.ok ? `✓ ${result.message}` : `✗ ${result.message}`, variant: result.ok ? "success" : "error", From 4c7f19b63e7648bf2f5a0c9fad447c9e719cc9c7 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 21:00:50 -0700 Subject: [PATCH 22/33] feat: add `skill remove` command and ctrl+d in TUI (#341) New CLI command: altimate-code skill remove TUI keybind: ctrl+d in /skills dialog Safety: - Cannot remove skills tracked by git (part of the repo) - Cannot remove built-in skills (builtin: prefix) - Removes both skill directory and paired CLI tool Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/skill.ts | 57 +++++++++++++++++++ .../cli/cmd/tui/component/dialog-skill.tsx | 38 +++++++++++++ 2 files changed, 95 insertions(+) diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index 4468ada86f..ed9d3888a2 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -613,6 +613,62 @@ const SkillInstallCommand = cmd({ }, }) +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 + } + + process.stdout.write(EOL + `Skill "${name}" removed.` + EOL) + }) + }, +}) + // --------------------------------------------------------------------------- // Top-level skill command // --------------------------------------------------------------------------- @@ -627,6 +683,7 @@ export const SkillCommand = cmd({ .command(SkillTestCommand) .command(SkillShowCommand) .command(SkillInstallCommand) + .command(SkillRemoveCommand) .demandCommand(), async handler() {}, }) 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 7b70971e3e..32558967c2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -442,6 +442,44 @@ export function DialogSkill(props: DialogSkillProps) { dialog.replace(() => ) }, }, + { + keybind: Keybind.parse("ctrl+d")[0], + title: "remove", + onTrigger: async (option: DialogSelectOption) => { + const info = skillMap().get(option.value) + if (!info) return + if (info.location.startsWith("builtin:")) { + toast.show({ message: "Cannot remove built-in skills.", variant: "info", duration: 3000 }) + return + } + // Check if tracked by git (part of the repo) + const gitCheck = Bun.spawnSync(["git", "ls-files", "--error-unmatch", info.location], { + cwd: path.dirname(path.dirname(info.location)), + stdout: "pipe", + stderr: "pipe", + }) + if (gitCheck.exitCode === 0) { + toast.show({ message: `Cannot remove "${option.value}" — it is part of the repository.`, variant: "info", duration: 4000 }) + return + } + try { + const skillDir = path.dirname(info.location) + await fs.rm(skillDir, { recursive: true, force: true }) + // Also remove paired tool + const root = gitRoot(sdk.directory ?? process.cwd()) + const toolFile = path.join(root, ".opencode", "tools", option.value) + await fs.rm(toolFile, { force: true }).catch(() => {}) + // Reload server cache + await reloadAndVerify(sdk, []) + toast.show({ message: `Removed "${option.value}".`, variant: "success", duration: 4000 }) + // Close and let user reopen to see updated list + dialog.clear() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + toast.show({ message: `Remove failed: ${msg.slice(0, 150)}`, variant: "error", duration: 5000 }) + } + }, + }, ]) // altimate_change end From 8b1d44d18d1860678d77bf7ca9478c9e482e1df4 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 21:14:59 -0700 Subject: [PATCH 23/33] fix: parse GitHub web URLs and reduce TUI keybind clutter (#341) 1. GitHub web URLs like `https://github.com/owner/repo/tree/main/skills/foo` now correctly extract `owner/repo` and clone the full repo. Previously tried to clone the `/tree/main` path which isn't a repo. 2. Reduced TUI dialog keybinds from 7 to 4 (edit, create, install, remove) so the footer isn't cramped. View and test are available via CLI (`skill show`, `skill test`). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/skill.ts | 8 ++- .../cli/cmd/tui/component/dialog-skill.tsx | 54 +++++-------------- 2 files changed, 19 insertions(+), 43 deletions(-) diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index ed9d3888a2..e75d66adad 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -490,7 +490,7 @@ const SkillInstallCommand = cmd({ default: false, }), async handler(args) { - const source = (args.source as string).trim() + let source = (args.source as string).trim() const isGlobal = args.global as boolean if (!source) { @@ -507,6 +507,12 @@ const SkillInstallCommand = cmd({ // 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) 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 32558967c2..d00fff6abe 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -107,9 +107,17 @@ async function installSkillDirect( let skillDir: string let isTmp = false - if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.match(/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/)) { - const url = trimmed.startsWith("http") ? trimmed : `https://github.com/${trimmed}.git` - const label = trimmed.startsWith("http") ? trimmed.replace(/https?:\/\/github\.com\//, "") : trimmed + // 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 tmpDir = path.join(cacheDir(), "skill-install-" + Date.now()) isTmp = true @@ -374,31 +382,8 @@ export function DialogSkill(props: DialogSkillProps) { }) }) - // Keybind actions: view, edit, test, create, install + // Keybind actions: edit, create, install, remove const keybinds = createMemo(() => [ - { - keybind: Keybind.parse("ctrl+o")[0], - title: "view", - onTrigger: async (option: DialogSelectOption) => { - const info = skillMap().get(option.value) - if (!info) return - const tools = detectToolReferences(info.content) - const lines = [ - `Skill: ${option.value}`, - info.description, - "", - `Location: ${info.location}`, - tools.length > 0 ? `Tools: ${tools.join(", ")}` : null, - "", - "Content:", - "─".repeat(40), - info.content.slice(0, 800) + (info.content.length > 800 ? "\n..." : ""), - ] - .filter((l) => l !== null) - .join("\n") - toast.show({ message: lines, variant: "info", duration: 10000 }) - }, - }, { keybind: Keybind.parse("ctrl+e")[0], title: "edit", @@ -413,21 +398,6 @@ export function DialogSkill(props: DialogSkillProps) { spawn(editor, [info.location], { stdio: "inherit", detached: true }).unref() }, }, - { - keybind: Keybind.parse("ctrl+t")[0], - title: "test", - onTrigger: async (option: DialogSelectOption) => { - const info = skillMap().get(option.value) - if (!info) return - toast.show({ message: `Testing ${option.value}...`, variant: "info" }) - const result = await testSkillDirect(option.value, info.content, gitRoot(sdk.directory ?? process.cwd())) - toast.show({ - message: result.ok ? `✓ ${result.message}` : `✗ ${result.message}`, - variant: result.ok ? "success" : "error", - duration: 4000, - }) - }, - }, { keybind: Keybind.parse("ctrl+n")[0], title: "create", From 01c9f9b766fd2bad47a40e31fecb745d92fbefd7 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 21:28:33 -0700 Subject: [PATCH 24/33] fix: restore show/test keybinds and wrap footer to fit (#341) Add `flexWrap="wrap"` to the DialogSelect keybind footer so it flows to two rows when there are many keybinds, instead of overflowing off-screen. Restore all 6 keybinds in /skills dialog: show ctrl+o | edit ctrl+e | test ctrl+t create ctrl+n | install ctrl+i | remove ctrl+d Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 34 ++++++++++++++++++- .../src/cli/cmd/tui/ui/dialog-select.tsx | 2 +- 2 files changed, 34 insertions(+), 2 deletions(-) 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 d00fff6abe..c4b36337c2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -382,8 +382,25 @@ export function DialogSkill(props: DialogSkillProps) { }) }) - // Keybind actions: edit, create, install, remove + // Keybind actions: show, edit, test, create, install, remove const keybinds = createMemo(() => [ + { + keybind: Keybind.parse("ctrl+o")[0], + title: "show", + onTrigger: async (option: DialogSelectOption) => { + const info = skillMap().get(option.value) + if (!info) return + const tools = detectToolReferences(info.content) + const lines = [ + `${option.value}: ${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 }) + }, + }, { keybind: Keybind.parse("ctrl+e")[0], title: "edit", @@ -398,6 +415,21 @@ export function DialogSkill(props: DialogSkillProps) { spawn(editor, [info.location], { stdio: "inherit", detached: true }).unref() }, }, + { + keybind: Keybind.parse("ctrl+t")[0], + title: "test", + onTrigger: async (option: DialogSelectOption) => { + const info = skillMap().get(option.value) + if (!info) return + toast.show({ message: `Testing ${option.value}...`, variant: "info", duration: 600000 }) + const result = await testSkillDirect(option.value, info.content, gitRoot(sdk.directory ?? process.cwd())) + toast.show({ + message: result.ok ? `✓ ${result.message}` : `✗ ${result.message}`, + variant: result.ok ? "success" : "error", + duration: 4000, + }) + }, + }, { keybind: Keybind.parse("ctrl+n")[0], title: "create", diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 151f73cf7c..3f4606cb0a 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -337,7 +337,7 @@ export function DialogSelect(props: DialogSelectProps) { }> - + {(item) => ( From 879f84c36deab7761f0a7fa8b016ea4022692964 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 21:33:02 -0700 Subject: [PATCH 25/33] feat: replace 6 keybinds with single ctrl+a action picker (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of 6 individual keybinds cramped in the footer, the /skills dialog now shows a single clean footer: actions ctrl+a Pressing ctrl+a on any skill opens a second-level action picker: ┌ Actions: dbt-develop ──────────────────────┐ │ Show details view info, tools, location │ │ Edit in $EDITOR open SKILL.md │ │ Test paired tool run --help on CLI tool │ │ Create new skill scaffold skill + tool │ │ Install from GitHub │ │ Remove skill delete skill + tool │ └────────────────────────────────────────────────┘ - Edit and Remove are hidden for git-tracked/builtin skills - Enter on the main list still inserts / (most common action) - Revert flexWrap on DialogSelect (no longer needed) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 209 ++++++++++-------- .../src/cli/cmd/tui/ui/dialog-select.tsx | 2 +- 2 files changed, 119 insertions(+), 92 deletions(-) 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 c4b36337c2..39adf12ae4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -382,104 +382,131 @@ export function DialogSkill(props: DialogSkillProps) { }) }) - // Keybind actions: show, edit, test, create, install, remove - const keybinds = createMemo(() => [ - { - keybind: Keybind.parse("ctrl+o")[0], - title: "show", - onTrigger: async (option: DialogSelectOption) => { - const info = skillMap().get(option.value) - if (!info) return - const tools = detectToolReferences(info.content) - const lines = [ - `${option.value}: ${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 }) + // Single keybind opens action picker for the selected skill + function openActionPicker(skillName: string) { + const info = skillMap().get(skillName) + const isBuiltinOrTracked = (() => { + if (!info) return true + if (info.location.startsWith("builtin:")) return true + 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 + })() + + const actions: DialogSelectOption[] = [ + { + title: "Show details", + value: "show", + description: "View skill info, tools, and location", }, - }, - { - keybind: Keybind.parse("ctrl+e")[0], - title: "edit", - onTrigger: async (option: DialogSelectOption) => { - const info = skillMap().get(option.value) - if (!info || info.location.startsWith("builtin:")) { - toast.show({ message: "Cannot edit built-in skills", variant: "info" }) - return - } - const editor = process.env.EDITOR || process.env.VISUAL || "vi" - dialog.clear() - spawn(editor, [info.location], { stdio: "inherit", detached: true }).unref() + { + title: "Edit in $EDITOR", + value: "edit", + description: "Open SKILL.md in your editor", + disabled: isBuiltinOrTracked, }, - }, - { - keybind: Keybind.parse("ctrl+t")[0], - title: "test", - onTrigger: async (option: DialogSelectOption) => { - const info = skillMap().get(option.value) - if (!info) return - toast.show({ message: `Testing ${option.value}...`, variant: "info", duration: 600000 }) - const result = await testSkillDirect(option.value, info.content, gitRoot(sdk.directory ?? process.cwd())) - toast.show({ - message: result.ok ? `✓ ${result.message}` : `✗ ${result.message}`, - variant: result.ok ? "success" : "error", - duration: 4000, - }) + { + title: "Test paired tool", + value: "test", + description: "Run --help on the paired CLI tool", }, - }, - { - keybind: Keybind.parse("ctrl+n")[0], - title: "create", - onTrigger: async () => { - dialog.replace(() => ) + { + title: "Create new skill", + value: "create", + description: "Scaffold a new skill + CLI tool", }, - }, - { - keybind: Keybind.parse("ctrl+i")[0], - title: "install", - onTrigger: async () => { - dialog.replace(() => ) + { + title: "Install from GitHub", + value: "install", + description: "Install skills from a repo or URL", }, - }, + { + title: "Remove skill", + value: "remove", + description: "Delete this skill and its paired tool", + disabled: isBuiltinOrTracked, + }, + ].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") + dialog.clear() + toast.show({ message: lines, variant: "info", duration: 8000 }) + break + } + case "edit": { + if (!info) return + const editor = process.env.EDITOR || process.env.VISUAL || "vi" + dialog.clear() + spawn(editor, [info.location], { stdio: "inherit", detached: true }).unref() + break + } + case "test": { + if (!info) return + dialog.clear() + 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, + }) + break + } + case "create": { + dialog.replace(() => ) + break + } + case "install": { + dialog.replace(() => ) + 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, []) + dialog.clear() + toast.show({ message: `Removed "${skillName}".`, variant: "success", duration: 4000 }) + } 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 + } + } + }} + /> + )) + } + + const keybinds = createMemo(() => [ { - keybind: Keybind.parse("ctrl+d")[0], - title: "remove", + keybind: Keybind.parse("ctrl+a")[0], + title: "actions", onTrigger: async (option: DialogSelectOption) => { - const info = skillMap().get(option.value) - if (!info) return - if (info.location.startsWith("builtin:")) { - toast.show({ message: "Cannot remove built-in skills.", variant: "info", duration: 3000 }) - return - } - // Check if tracked by git (part of the repo) - const gitCheck = Bun.spawnSync(["git", "ls-files", "--error-unmatch", info.location], { - cwd: path.dirname(path.dirname(info.location)), - stdout: "pipe", - stderr: "pipe", - }) - if (gitCheck.exitCode === 0) { - toast.show({ message: `Cannot remove "${option.value}" — it is part of the repository.`, variant: "info", duration: 4000 }) - return - } - try { - const skillDir = path.dirname(info.location) - await fs.rm(skillDir, { recursive: true, force: true }) - // Also remove paired tool - const root = gitRoot(sdk.directory ?? process.cwd()) - const toolFile = path.join(root, ".opencode", "tools", option.value) - await fs.rm(toolFile, { force: true }).catch(() => {}) - // Reload server cache - await reloadAndVerify(sdk, []) - toast.show({ message: `Removed "${option.value}".`, variant: "success", duration: 4000 }) - // Close and let user reopen to see updated list - dialog.clear() - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - toast.show({ message: `Remove failed: ${msg.slice(0, 150)}`, variant: "error", duration: 5000 }) - } + openActionPicker(option.value) }, }, ]) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 3f4606cb0a..151f73cf7c 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -337,7 +337,7 @@ export function DialogSelect(props: DialogSelectProps) { }> - + {(item) => ( From 1c14ff7d1064c7aa0ead966086afa203a91afc7e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 21:38:59 -0700 Subject: [PATCH 26/33] fix: return to skill list after action picker completes (#341) Actions (show, test, remove) were calling `dialog.clear()` which closed everything. Now they call `reopenSkillList()` which replaces the action picker with a fresh skill list dialog, so the user stays in the /skills flow. - Show: toast shows, skill list reopens behind it - Test: toast shows result, skill list reopens - Remove: skill removed, skill list reopens (with updated list) - Edit: still closes dialog (external editor takes over) - Create/Install: already used dialog.replace() (unchanged) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/cli/cmd/tui/component/dialog-skill.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 39adf12ae4..6c7a71c6d9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -382,6 +382,13 @@ export function DialogSkill(props: DialogSkillProps) { }) }) + // 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) @@ -447,8 +454,8 @@ export function DialogSkill(props: DialogSkillProps) { ] .filter((l) => l !== null) .join("\n") - dialog.clear() toast.show({ message: lines, variant: "info", duration: 8000 }) + reopenSkillList() break } case "edit": { @@ -460,7 +467,6 @@ export function DialogSkill(props: DialogSkillProps) { } case "test": { if (!info) return - dialog.clear() toast.show({ message: `Testing ${skillName}...`, variant: "info", duration: 600000 }) const result = await testSkillDirect(skillName, info.content, gitRoot(sdk.directory ?? process.cwd())) toast.show({ @@ -468,6 +474,7 @@ export function DialogSkill(props: DialogSkillProps) { variant: result.ok ? "success" : "error", duration: 4000, }) + reopenSkillList() break } case "create": { @@ -487,8 +494,8 @@ export function DialogSkill(props: DialogSkillProps) { const toolFile = path.join(root, ".opencode", "tools", skillName) await fs.rm(toolFile, { force: true }).catch(() => {}) await reloadAndVerify(sdk, []) - dialog.clear() 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 }) From 3355b6e6b30f754b8e6778e3b67cd219934c3b29 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 21:52:17 -0700 Subject: [PATCH 27/33] fix: open skill editor in system app instead of inline terminal (#341) Opening $EDITOR with `stdio: "inherit"` conflicts with the TUI rendering, corrupts the display, and leaves the user stranded. Fix: use `open` (macOS) / `xdg-open` (Linux) to open the SKILL.md in the system default editor as a separate window. The TUI stays intact, shows a toast with the file path, and returns to the skill list. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/cli/cmd/tui/component/dialog-skill.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 6c7a71c6d9..b11edf7576 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -460,9 +460,15 @@ export function DialogSkill(props: DialogSkillProps) { } case "edit": { if (!info) return - const editor = process.env.EDITOR || process.env.VISUAL || "vi" - dialog.clear() - spawn(editor, [info.location], { stdio: "inherit", detached: true }).unref() + // 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": { From 0cb082954ecf55bc1f1ee9f3691a363baeb0816c Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 21:56:25 -0700 Subject: [PATCH 28/33] fix: separate per-skill actions from global actions in TUI (#341) ctrl+a action picker now only shows per-skill actions: Show details, Edit, Test, Remove Global actions moved to main skill list footer keybinds: actions ctrl+a | new ctrl+n | install ctrl+i This avoids confusion where "Create new skill" and "Install from GitHub" appeared under "Actions: dbt-develop" even though they have nothing to do with that skill. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/cmd/tui/component/dialog-skill.tsx | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) 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 b11edf7576..038beb5c52 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -410,28 +410,18 @@ export function DialogSkill(props: DialogSkillProps) { description: "View skill info, tools, and location", }, { - title: "Edit in $EDITOR", + title: "Edit", value: "edit", - description: "Open SKILL.md in your editor", + description: "Open SKILL.md in your default editor", disabled: isBuiltinOrTracked, }, { - title: "Test paired tool", + title: "Test", value: "test", - description: "Run --help on the paired CLI tool", + description: "Validate the paired CLI tool works", }, { - title: "Create new skill", - value: "create", - description: "Scaffold a new skill + CLI tool", - }, - { - title: "Install from GitHub", - value: "install", - description: "Install skills from a repo or URL", - }, - { - title: "Remove skill", + title: "Remove", value: "remove", description: "Delete this skill and its paired tool", disabled: isBuiltinOrTracked, @@ -483,14 +473,6 @@ export function DialogSkill(props: DialogSkillProps) { reopenSkillList() break } - case "create": { - dialog.replace(() => ) - break - } - case "install": { - dialog.replace(() => ) - break - } case "remove": { if (!info) return try { @@ -522,6 +504,20 @@ export function DialogSkill(props: DialogSkillProps) { 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 From 21e00b1350eee47b2393f28aea9812980bbac24b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 22:32:43 -0700 Subject: [PATCH 29/33] fix: Esc on action picker goes back to skill list (#341) Pressing Esc on the action picker was closing the dialog entirely. Now uses the `onClose` callback of `dialog.replace()` to reopen the skill list when the action picker is dismissed. Uses `setTimeout(0)` to defer the reopen so the dialog stack pop completes first. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/cli/cmd/tui/component/dialog-skill.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 038beb5c52..f3391c1bfd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -428,12 +428,13 @@ export function DialogSkill(props: DialogSkillProps) { }, ].filter((a) => !a.disabled) - dialog.replace(() => ( - { - switch (action.value) { + dialog.replace( + () => ( + { + switch (action.value) { case "show": { if (!info) return const tools = detectToolReferences(info.content) @@ -493,7 +494,10 @@ export function DialogSkill(props: DialogSkillProps) { } }} /> - )) + ), + // When Esc is pressed on the action picker, go back to skill list + () => setTimeout(() => reopenSkillList(), 0), + ) } const keybinds = createMemo(() => [ From fce05fd9e99ac845056f38f7ff74d6b1fc6ea520 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 22:38:03 -0700 Subject: [PATCH 30/33] docs: update skills and custom tools docs with all new commands (#341) skills.md: - Add `skill show`, `skill install`, `skill remove` to CLI section - Add TUI keybind reference table (ctrl+a actions, ctrl+n, ctrl+i) - Document GitHub URL support (web URLs, shorthand, --global) tools/custom.md: - Add "Installing Community Skills" section with install/remove examples - Document TUI install/remove flow Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/docs/configure/skills.md | 44 +++++++++++++++++++++-------- docs/docs/configure/tools/custom.md | 18 ++++++++++++ 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/docs/docs/configure/skills.md b/docs/docs/configure/skills.md index 684cfe5463..9a55caa6dd 100644 --- a/docs/docs/configure/skills.md +++ b/docs/docs/configure/skills.md @@ -94,21 +94,41 @@ altimate ships with built-in skills for common data engineering tasks. Type `/` Manage skills from the command line: ```bash -# List all skills with their paired CLI tools -altimate-code skill list +# 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 +``` -# List as JSON (for scripting) -altimate-code skill list --json +### TUI -# Scaffold a new skill + CLI tool pair -altimate-code skill create my-tool -altimate-code skill create my-tool --language python -altimate-code skill create my-tool --language node -altimate-code skill create my-tool --skill-only # skill only, no CLI stub +Type `/skills` in the TUI prompt to open the skill browser. From there: -# Validate a skill and its paired tool -altimate-code skill test my-tool -``` +| 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 diff --git a/docs/docs/configure/tools/custom.md b/docs/docs/configure/tools/custom.md index a1b8fd7e3a..3e7dddd13d 100644 --- a/docs/docs/configure/tools/custom.md +++ b/docs/docs/configure/tools/custom.md @@ -52,6 +52,24 @@ 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: From 8f918d9e28aca87928cdcc53c53ee4a2a05ecdef Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 22:53:39 -0700 Subject: [PATCH 31/33] fix: address code review findings from 6-model consensus review (#341) Security: - Use `fs.lstat` instead of `fs.stat` during skill install to skip symlinks (prevents file disclosure from malicious repos) - Pass `dereference: false` to `fs.cp` for directory copies Bugs: - Create cache directory (`~/.cache/altimate-code`) before git clone so installs work on fresh systems - TUI `createSkillDirect` now checks if tool already exists before writing (matches CLI behavior, prevents clobbering user tools) - Add global tools dir to TUI test PATH (fixes false negatives for globally installed tools) UX: - Allow editing git-tracked skills (only block Remove, not Edit) - Split `isBuiltinOrTracked` into `isBuiltin` + `isRemovable` Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/skill.ts | 6 ++- .../cli/cmd/tui/component/dialog-skill.tsx | 43 +++++++++++-------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index e75d66adad..4f30b45e70 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -588,16 +588,18 @@ const SkillInstallCommand = cmd({ } // 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.stat(src) + 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 }) + await fs.cp(src, dst, { recursive: true, dereference: false }) } } process.stdout.write(` ✓ Installed "${skillName}" → ${path.relative(rootDir, dest)}` + EOL) 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 f3391c1bfd..ec95da2203 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -80,15 +80,20 @@ async function createSkillDirect(name: string, rootDir: string): Promise<{ ok: b 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 + // 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) - 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 }, - ) + 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}/` } } @@ -119,7 +124,9 @@ async function installSkillDirect( const url = normalized.startsWith("http") ? normalized : `https://github.com/${normalized}.git` const label = url.replace(/https?:\/\/github\.com\//, "").replace(/\.git$/, "") onProgress?.(`Cloning ${label}...`) - const tmpDir = path.join(cacheDir(), "skill-install-" + Date.now()) + 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", @@ -173,9 +180,10 @@ async function installSkillDirect( for (const file of files) { const src = path.join(skillParent, file) const dst = path.join(dest, file) - const stat = await fs.stat(src) + 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 }) + else if (stat.isDirectory()) await fs.cp(src, dst, { recursive: true, dereference: false }) } names.push(skillName) installed++ @@ -199,6 +207,7 @@ async function testSkillDirect(skillName: string, content: string, rootDir: stri 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) @@ -392,15 +401,15 @@ export function DialogSkill(props: DialogSkillProps) { // Single keybind opens action picker for the selected skill function openActionPicker(skillName: string) { const info = skillMap().get(skillName) - const isBuiltinOrTracked = (() => { - if (!info) return true - if (info.location.startsWith("builtin:")) return true - const gitCheck = Bun.spawnSync(["git", "ls-files", "--error-unmatch", info.location], { - cwd: path.dirname(path.dirname(info.location)), + 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 + return gitCheck.exitCode !== 0 // only removable if NOT git-tracked })() const actions: DialogSelectOption[] = [ @@ -413,7 +422,7 @@ export function DialogSkill(props: DialogSkillProps) { title: "Edit", value: "edit", description: "Open SKILL.md in your default editor", - disabled: isBuiltinOrTracked, + disabled: isBuiltin, // allow editing git-tracked skills, only block builtin }, { title: "Test", @@ -424,7 +433,7 @@ export function DialogSkill(props: DialogSkillProps) { title: "Remove", value: "remove", description: "Delete this skill and its paired tool", - disabled: isBuiltinOrTracked, + disabled: !isRemovable, }, ].filter((a) => !a.disabled) From 8a21f5db0a011a8e8367b31aa0360a3d26e9f1c1 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 23:00:33 -0700 Subject: [PATCH 32/33] feat: add telemetry for skill create/install/remove (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New telemetry event types: - `skill_created` — tracks name, language, source (cli/tui) - `skill_installed` — tracks install_source, skill_count, skill_names - `skill_removed` — tracks skill_name, source All events follow existing patterns: - Wrapped in try/catch (telemetry never breaks operations) - Use Telemetry.getContext().sessionId (empty for CLI-only) - Include timestamp and source discriminator CLI commands instrumented: create, install, remove. TUI operations not instrumented (Telemetry runs in worker thread, TUI in main thread — would need a server endpoint to bridge). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/altimate/telemetry/index.ts | 26 +++++++++++ packages/opencode/src/cli/cmd/skill.ts | 43 +++++++++++++++++++ 2 files changed, 69 insertions(+) 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.ts b/packages/opencode/src/cli/cmd/skill.ts index 4f30b45e70..2f0ee1bc49 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -8,6 +8,9 @@ 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 @@ -314,6 +317,19 @@ const SkillCreateCommand = cmd({ } } + // 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) @@ -573,6 +589,7 @@ const SkillInstallCommand = cmd({ } let installed = 0 + const installedNames: string[] = [] for (const skillFile of matches) { const skillParent = path.dirname(skillFile) const skillName = path.basename(skillParent) @@ -603,6 +620,7 @@ const SkillInstallCommand = cmd({ } } process.stdout.write(` ✓ Installed "${skillName}" → ${path.relative(rootDir, dest)}` + EOL) + installedNames.push(skillName) installed++ } @@ -614,6 +632,19 @@ const SkillInstallCommand = cmd({ 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) } @@ -672,6 +703,18 @@ const SkillRemoveCommand = cmd({ // 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) }) }, From e9f7603a36e6f9caa48bffdd9dff4d6af0058a19 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 21 Mar 2026 23:03:34 -0700 Subject: [PATCH 33/33] fix: strip .git suffix from CLI install source to prevent double-append (#341) `altimate-code skill install owner/repo.git` produced `https://github.com/owner/repo.git.git`. Now strips `.git` suffix from the source string before processing. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/skill.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index 2f0ee1bc49..3bad700398 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -506,7 +506,7 @@ const SkillInstallCommand = cmd({ default: false, }), async handler(args) { - let source = (args.source as string).trim() + let source = (args.source as string).trim().replace(/\.git$/, "") const isGlobal = args.global as boolean if (!source) {