diff --git a/package-lock.json b/package-lock.json index 8d1775c7..33a404a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6074,11 +6074,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.11.4", + "version": "0.11.5", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.11.3", - "@mariozechner/pi-tui": "^0.11.3" + "@mariozechner/pi-ai": "^0.11.4", + "@mariozechner/pi-tui": "^0.11.4" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6108,7 +6108,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.11.4", + "version": "0.11.5", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -6149,12 +6149,12 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.11.4", + "version": "0.11.5", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.11.3", - "@mariozechner/pi-ai": "^0.11.3", - "@mariozechner/pi-tui": "^0.11.3", + "@mariozechner/pi-agent-core": "^0.11.4", + "@mariozechner/pi-ai": "^0.11.4", + "@mariozechner/pi-tui": "^0.11.4", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6191,12 +6191,12 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.11.4", + "version": "0.11.5", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.11.3", - "@mariozechner/pi-ai": "^0.11.3", + "@mariozechner/pi-agent-core": "^0.11.4", + "@mariozechner/pi-ai": "^0.11.4", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6234,10 +6234,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.11.4", + "version": "0.11.5", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.11.3", + "@mariozechner/pi-agent-core": "^0.11.4", "chalk": "^5.5.0" }, "bin": { @@ -6250,7 +6250,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.11.4", + "version": "0.11.5", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -6266,7 +6266,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.11.4", + "version": "0.11.5", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -6310,12 +6310,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.11.4", + "version": "0.11.5", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.11.3", - "@mariozechner/pi-tui": "^0.11.3", + "@mariozechner/pi-ai": "^0.11.4", + "@mariozechner/pi-tui": "^0.11.4", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", diff --git a/packages/agent/package.json b/packages/agent/package.json index db049c05..164903ef 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.11.4", + "version": "0.11.5", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.11.4", - "@mariozechner/pi-tui": "^0.11.4" + "@mariozechner/pi-ai": "^0.11.5", + "@mariozechner/pi-tui": "^0.11.5" }, "keywords": [ "ai", diff --git a/packages/ai/package.json b/packages/ai/package.json index 05ed974f..5afe6844 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.11.4", + "version": "0.11.5", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 634906cb..ddc92025 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.11.5] - 2025-12-01 + +### Added + +- **Custom Slash Commands**: Define reusable prompt templates as Markdown files. Place files in `~/.pi/agent/commands/` (global) or `.pi/commands/` (project-specific). Commands appear in `/` autocomplete with source indicators like `(user)` or `(project)`. Supports bash-style arguments (`$1`, `$2`, `$@`) with quote-aware parsing. Subdirectories create namespaced commands (e.g., `.pi/commands/frontend/component.md` shows as `(project:frontend)`). Optional `description` field in YAML frontmatter. Works from CLI as well (`pi -p "/review"`). ([#86](https://github.com/badlogic/pi-mono/issues/86)) + ## [0.11.4] - 2025-12-01 ### Improved diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 2ada927e..8724e07d 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -456,6 +456,71 @@ Clear the conversation context and start a fresh session: Aborts any in-flight agent work, clears all messages, and creates a new session file. +### Custom Slash Commands + +Define reusable prompt templates as Markdown files that appear in the `/` autocomplete. + +**Locations:** +- **Global:** `~/.pi/agent/commands/*.md` - available in all sessions +- **Project:** `.pi/commands/*.md` - project-specific commands + +**File format:** + +```markdown +--- +description: Review staged git changes +--- +Review the staged changes (`git diff --cached`). Focus on: +- Bugs and logic errors +- Security issues +- Error handling gaps +- Code style per AGENTS.md +``` + +The filename (without `.md`) becomes the command name. The optional `description` frontmatter field is shown in autocomplete. If omitted, the first line of content is used. + +**Arguments (bash-style):** + +Commands support positional arguments with quote-aware parsing: + +```markdown +--- +description: Create a component with features +--- +Create a React component named $1 with these features: $@ +``` + +Usage: `/component Button "has onClick handler" "supports disabled"` +- `$1` = `Button` +- `$2` = `has onClick handler` +- `$@` = `Button has onClick handler supports disabled` + +**Namespacing:** + +Subdirectories create namespaced commands. A file at `.pi/commands/frontend/component.md` creates `/component` with description showing `(project:frontend)`. + +**Source indicators:** + +Commands show their source in autocomplete: +- `(user)` - from `~/.pi/agent/commands/` +- `(project)` - from `.pi/commands/` +- `(project:subdir)` - from `.pi/commands/subdir/` + +**CLI usage:** + +Custom slash commands also work from the command line: + +```bash +# Non-interactive mode +pi -p "/review" + +# With arguments +pi -p '/component Button "handles click events"' + +# Interactive mode with initial command +pi "/review" +``` + ## Editor Features The interactive input editor includes several productivity features: diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 06f49314..336db37b 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.11.4", + "version": "0.11.5", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "bin": { @@ -22,9 +22,9 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.11.4", - "@mariozechner/pi-ai": "^0.11.4", - "@mariozechner/pi-tui": "^0.11.4", + "@mariozechner/pi-agent-core": "^0.11.5", + "@mariozechner/pi-ai": "^0.11.5", + "@mariozechner/pi-tui": "^0.11.5", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a303ca93..456ddee2 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -11,6 +11,7 @@ import { exportFromFile } from "./export-html.js"; import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js"; import { SessionManager } from "./session-manager.js"; import { SettingsManager } from "./settings-manager.js"; +import { expandSlashCommand, loadSlashCommands } from "./slash-commands.js"; import { initTheme } from "./theme/theme.js"; import { allTools, codingTools, type ToolName } from "./tools/index.js"; import { ensureTool } from "./tools-manager.js"; @@ -732,10 +733,13 @@ async function runInteractiveMode( renderer.showWarning(modelFallbackMessage); } + // Load file-based slash commands for expansion + const fileCommands = loadSlashCommands(); + // Process initial message with attachments if provided (from @file args) if (initialMessage) { try { - await agent.prompt(initialMessage, initialAttachments); + await agent.prompt(expandSlashCommand(initialMessage, fileCommands), initialAttachments); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; renderer.showError(errorMessage); @@ -745,7 +749,7 @@ async function runInteractiveMode( // Process remaining initial messages if provided (from CLI args) for (const message of initialMessages) { try { - await agent.prompt(message); + await agent.prompt(expandSlashCommand(message, fileCommands)); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; renderer.showError(errorMessage); @@ -775,6 +779,9 @@ async function runSingleShotMode( initialMessage?: string, initialAttachments?: Attachment[], ): Promise { + // Load file-based slash commands for expansion + const fileCommands = loadSlashCommands(); + if (mode === "json") { // Subscribe to all events and output as JSON agent.subscribe((event) => { @@ -785,12 +792,12 @@ async function runSingleShotMode( // Send initial message with attachments if provided if (initialMessage) { - await agent.prompt(initialMessage, initialAttachments); + await agent.prompt(expandSlashCommand(initialMessage, fileCommands), initialAttachments); } // Send remaining messages for (const message of messages) { - await agent.prompt(message); + await agent.prompt(expandSlashCommand(message, fileCommands)); } // In text mode, only output the final assistant message diff --git a/packages/coding-agent/src/slash-commands.ts b/packages/coding-agent/src/slash-commands.ts new file mode 100644 index 00000000..52384198 --- /dev/null +++ b/packages/coding-agent/src/slash-commands.ts @@ -0,0 +1,206 @@ +import { existsSync, readdirSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { join, resolve } from "path"; + +/** + * Represents a custom slash command loaded from a file + */ +export interface FileSlashCommand { + name: string; + description: string; + content: string; + source: string; // e.g., "(user)", "(project)", "(project:frontend)" +} + +/** + * Parse YAML frontmatter from markdown content + * Returns { frontmatter, content } where content has frontmatter stripped + */ +function parseFrontmatter(content: string): { frontmatter: Record; content: string } { + const frontmatter: Record = {}; + + if (!content.startsWith("---")) { + return { frontmatter, content }; + } + + const endIndex = content.indexOf("\n---", 3); + if (endIndex === -1) { + return { frontmatter, content }; + } + + const frontmatterBlock = content.slice(4, endIndex); + const remainingContent = content.slice(endIndex + 4).trim(); + + // Simple YAML parsing - just key: value pairs + for (const line of frontmatterBlock.split("\n")) { + const match = line.match(/^(\w+):\s*(.*)$/); + if (match) { + frontmatter[match[1]] = match[2].trim(); + } + } + + return { frontmatter, content: remainingContent }; +} + +/** + * Parse command arguments respecting quoted strings (bash-style) + * Returns array of arguments + */ +export function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + + if (inQuote) { + if (char === inQuote) { + inQuote = null; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = char; + } else if (char === " " || char === "\t") { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + + if (current) { + args.push(current); + } + + return args; +} + +/** + * Substitute argument placeholders in command content + * Supports $1, $2, ... for positional args and $@ for all args + */ +export function substituteArgs(content: string, args: string[]): string { + let result = content; + + // Replace $@ with all args joined + result = result.replace(/\$@/g, args.join(" ")); + + // Replace $1, $2, etc. with positional args + result = result.replace(/\$(\d+)/g, (_, num) => { + const index = parseInt(num, 10) - 1; + return args[index] ?? ""; + }); + + return result; +} + +/** + * Recursively scan a directory for .md files and load them as slash commands + */ +function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: string = ""): FileSlashCommand[] { + const commands: FileSlashCommand[] = []; + + if (!existsSync(dir)) { + return commands; + } + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + // Recurse into subdirectory + const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name; + commands.push(...loadCommandsFromDir(fullPath, source, newSubdir)); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + try { + const rawContent = readFileSync(fullPath, "utf-8"); + const { frontmatter, content } = parseFrontmatter(rawContent); + + const name = entry.name.slice(0, -3); // Remove .md extension + + // Build source string + let sourceStr: string; + if (source === "user") { + sourceStr = subdir ? `(user:${subdir})` : "(user)"; + } else { + sourceStr = subdir ? `(project:${subdir})` : "(project)"; + } + + // Get description from frontmatter or first non-empty line + let description = frontmatter.description || ""; + if (!description) { + const firstLine = content.split("\n").find((line) => line.trim()); + if (firstLine) { + // Truncate if too long + description = firstLine.slice(0, 60); + if (firstLine.length > 60) description += "..."; + } + } + + // Append source to description + description = description ? `${description} ${sourceStr}` : sourceStr; + + commands.push({ + name, + description, + content, + source: sourceStr, + }); + } catch (error) { + // Silently skip files that can't be read + } + } + } + } catch (error) { + // Silently skip directories that can't be read + } + + return commands; +} + +/** + * Load all custom slash commands from: + * 1. Global: ~/.pi/agent/commands/ + * 2. Project: ./.pi/commands/ + */ +export function loadSlashCommands(): FileSlashCommand[] { + const commands: FileSlashCommand[] = []; + + // 1. Load global commands from ~/.pi/agent/commands/ + const homeDir = homedir(); + const globalCommandsDir = resolve(process.env.PI_CODING_AGENT_DIR || join(homeDir, ".pi/agent/"), "commands"); + commands.push(...loadCommandsFromDir(globalCommandsDir, "user")); + + // 2. Load project commands from ./.pi/commands/ + const projectCommandsDir = resolve(process.cwd(), ".pi/commands"); + commands.push(...loadCommandsFromDir(projectCommandsDir, "project")); + + return commands; +} + +/** + * Expand a slash command if it matches a file-based command. + * Returns the expanded content or the original text if not a slash command. + */ +export function expandSlashCommand(text: string, fileCommands: FileSlashCommand[]): string { + if (!text.startsWith("/")) return text; + + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); + + const fileCommand = fileCommands.find((cmd) => cmd.name === commandName); + if (fileCommand) { + const args = parseCommandArgs(argsString); + return substituteArgs(fileCommand.content, args); + } + + return text; +} diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 887fc18a..c0bdb16d 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -21,6 +21,7 @@ import { getApiKeyForModel, getAvailableModels } from "../model-config.js"; import { listOAuthProviders, login, logout } from "../oauth/index.js"; import type { SessionManager } from "../session-manager.js"; import type { SettingsManager } from "../settings-manager.js"; +import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js"; import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js"; import { AssistantMessageComponent } from "./assistant-message.js"; import { CustomEditor } from "./custom-editor.js"; @@ -97,6 +98,9 @@ export class TuiRenderer { // Agent subscription unsubscribe function private unsubscribe?: () => void; + // File-based slash commands + private fileCommands: FileSlashCommand[] = []; + constructor( agent: Agent, sessionManager: SessionManager, @@ -179,6 +183,15 @@ export class TuiRenderer { description: "Clear context and start a fresh session", }; + // Load file-based slash commands + this.fileCommands = loadSlashCommands(); + + // Convert file commands to SlashCommand format + const fileSlashCommands: SlashCommand[] = this.fileCommands.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + })); + // Setup autocomplete for file paths and slash commands const autocompleteProvider = new CombinedAutocompleteProvider( [ @@ -193,6 +206,7 @@ export class TuiRenderer { logoutCommand, queueCommand, clearCommand, + ...fileSlashCommands, ], process.cwd(), fdPath, @@ -401,6 +415,9 @@ export class TuiRenderer { return; } + // Check for file-based slash commands + text = expandSlashCommand(text, this.fileCommands); + // Normal message submission - validate model and API key first const currentModel = this.agent.state.model; if (!currentModel) { diff --git a/packages/mom/package.json b/packages/mom/package.json index 6e306481..02f0c5c4 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.11.4", + "version": "0.11.5", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -21,8 +21,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.11.4", - "@mariozechner/pi-ai": "^0.11.4", + "@mariozechner/pi-agent-core": "^0.11.5", + "@mariozechner/pi-ai": "^0.11.5", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/pods/package.json b/packages/pods/package.json index 631893c9..39866c61 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.11.4", + "version": "0.11.5", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.11.4", + "@mariozechner/pi-agent-core": "^0.11.5", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index ff8c1cdb..42c20f42 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.11.4", + "version": "0.11.5", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index 43ab9144..95e3c009 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.11.4", + "version": "0.11.5", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 51175ed2..8858a1e3 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.11.4", + "version": "0.11.5", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.11.4", - "@mariozechner/pi-tui": "^0.11.4", + "@mariozechner/pi-ai": "^0.11.5", + "@mariozechner/pi-tui": "^0.11.5", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0",