feat(agent): enhance interactive CLI with colors, spinner, and status bar (#43)
* feat(agent): improve interactive CLI with colors, spinner, and status bar - Add colors.ts module with ANSI terminal color utilities - Add spinner animation for tool execution feedback - Add persistent status bar showing session/provider/model - Apply colors to welcome banner, prompts, commands, and suggestions - Support NO_COLOR env for accessibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(agent): correct cursor position with ANSI-colored prompts Strip ANSI escape codes when calculating visual length of prompt to ensure cursor is positioned correctly after colored text. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(agent): prevent duplicate input echo in interactive CLI Lazy-initialize readline.Interface only when multiline mode is active. This prevents readline from interfering with autocomplete's raw mode, which was causing user input to be echoed twice. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(agent): move CLI files to dedicated cli/ directory Reorganize CLI-related files into src/agent/cli/ for better separation: - interactive.ts (was interactive-cli.ts) - non-interactive.ts (was cli.ts) - profile.ts, skills.ts, tools.ts (was *-cli.ts) - autocomplete.ts, colors.ts, output.ts (CLI utilities) Update all imports, package.json scripts, and build configuration. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b7e1063b3a
commit
b1d80f29ae
12 changed files with 437 additions and 87 deletions
313
src/agent/cli/autocomplete.ts
Normal file
313
src/agent/cli/autocomplete.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
/**
|
||||
* Autocomplete Input
|
||||
*
|
||||
* Real-time dropdown autocomplete for terminal input
|
||||
* No external dependencies - uses raw terminal control
|
||||
*/
|
||||
|
||||
import * as readline from "readline";
|
||||
import { colors } from "./colors.js";
|
||||
|
||||
export interface AutocompleteOption {
|
||||
value: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface AutocompleteConfig {
|
||||
/** Function to get suggestions based on current input */
|
||||
getSuggestions: (input: string) => AutocompleteOption[];
|
||||
/** Prompt string */
|
||||
prompt?: string;
|
||||
/** Max suggestions to show */
|
||||
maxSuggestions?: number;
|
||||
}
|
||||
|
||||
// ANSI escape codes
|
||||
const ESC = "\x1b";
|
||||
const CLEAR_LINE = `${ESC}[2K`;
|
||||
const CURSOR_UP = (n: number) => (n > 0 ? `${ESC}[${n}A` : "");
|
||||
const CURSOR_TO_COL = (n: number) => `${ESC}[${n}G`;
|
||||
const RESET = `${ESC}[0m`;
|
||||
const INVERSE = `${ESC}[7m`;
|
||||
const SHOW_CURSOR = `${ESC}[?25h`;
|
||||
const SAVE_CURSOR = `${ESC}[s`;
|
||||
const RESTORE_CURSOR = `${ESC}[u`;
|
||||
const CLEAR_TO_END = `${ESC}[J`;
|
||||
const CURSOR_DOWN = (n: number) => (n > 0 ? `${ESC}[${n}B` : "");
|
||||
|
||||
// Strip ANSI escape codes to get visual length
|
||||
const ANSI_REGEX = /\x1b\[[0-9;]*m/g;
|
||||
function stripAnsi(str: string): string {
|
||||
return str.replace(ANSI_REGEX, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a line with real-time autocomplete dropdown
|
||||
*/
|
||||
export function autocompleteInput(config: AutocompleteConfig): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const { getSuggestions, prompt = "> ", maxSuggestions = 5 } = config;
|
||||
|
||||
const stdin = process.stdin;
|
||||
const stdout = process.stdout;
|
||||
|
||||
let input = "";
|
||||
let cursorPos = 0;
|
||||
let suggestions: AutocompleteOption[] = [];
|
||||
let selectedIndex = -1;
|
||||
let initialized = false;
|
||||
|
||||
// Enable raw mode
|
||||
if (stdin.isTTY) {
|
||||
stdin.setRawMode(true);
|
||||
}
|
||||
readline.emitKeypressEvents(stdin);
|
||||
|
||||
const cleanup = () => {
|
||||
stdout.write(SHOW_CURSOR);
|
||||
if (stdin.isTTY) {
|
||||
stdin.setRawMode(false);
|
||||
}
|
||||
stdin.removeListener("keypress", onKeypress);
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
if (!initialized) {
|
||||
// First render - save cursor position as anchor
|
||||
stdout.write(SAVE_CURSOR);
|
||||
initialized = true;
|
||||
} else {
|
||||
// Restore to anchor and clear everything after it
|
||||
stdout.write(RESTORE_CURSOR);
|
||||
stdout.write(CLEAR_TO_END);
|
||||
// Re-save in case terminal scrolled
|
||||
stdout.write(SAVE_CURSOR);
|
||||
}
|
||||
|
||||
// Write prompt and input
|
||||
stdout.write(`${prompt}${input}`);
|
||||
|
||||
// Calculate cursor position accounting for line wrapping
|
||||
const termWidth = stdout.columns || 80;
|
||||
const promptVisualLen = stripAnsi(prompt).length;
|
||||
const cursorOffset = promptVisualLen + cursorPos;
|
||||
|
||||
// Handle edge case: when cursor is exactly at line boundary,
|
||||
// show it at end of current line, not start of next line
|
||||
let cursorRow: number;
|
||||
let cursorCol: number;
|
||||
if (cursorOffset > 0 && cursorOffset % termWidth === 0) {
|
||||
cursorRow = cursorOffset / termWidth - 1;
|
||||
cursorCol = termWidth;
|
||||
} else {
|
||||
cursorRow = Math.floor(cursorOffset / termWidth);
|
||||
cursorCol = (cursorOffset % termWidth) + 1;
|
||||
}
|
||||
|
||||
// Calculate total lines for suggestions positioning
|
||||
const totalLength = promptVisualLen + input.length;
|
||||
const totalLines = Math.ceil(totalLength / termWidth) || 1;
|
||||
|
||||
// Get and display suggestions if input starts with /
|
||||
if (input.startsWith("/") && input.length > 1) {
|
||||
suggestions = getSuggestions(input).slice(0, maxSuggestions);
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
// Ensure selectedIndex is valid
|
||||
if (selectedIndex >= suggestions.length) {
|
||||
selectedIndex = suggestions.length - 1;
|
||||
}
|
||||
|
||||
// Move to end of input text before showing suggestions
|
||||
// Cursor is currently at end of text, just go to new line
|
||||
stdout.write("\n");
|
||||
|
||||
for (let i = 0; i < suggestions.length; i++) {
|
||||
const opt = suggestions[i]!;
|
||||
const isSelected = i === selectedIndex;
|
||||
const value = isSelected
|
||||
? `${INVERSE} ${opt.value}${RESET}`
|
||||
: ` ${colors.suggestionDim(opt.value)}`;
|
||||
const label = opt.label ? ` ${colors.suggestionLabel(opt.label)}` : "";
|
||||
const line = `${value}${label}`;
|
||||
|
||||
stdout.write(`${CLEAR_LINE}${line}`);
|
||||
if (i < suggestions.length - 1) {
|
||||
stdout.write("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Move cursor back up to input line (accounting for wrapped lines)
|
||||
const linesFromEnd = totalLines - 1 - cursorRow;
|
||||
stdout.write(CURSOR_UP(suggestions.length + linesFromEnd));
|
||||
stdout.write(CURSOR_TO_COL(cursorCol));
|
||||
}
|
||||
} else {
|
||||
suggestions = [];
|
||||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
// Position cursor for wrapped text
|
||||
// After writing, cursor is at end of text. Move to correct position.
|
||||
// Go back to start of input block, then move to target row/col
|
||||
const endRow = totalLines - 1;
|
||||
if (endRow > cursorRow) {
|
||||
stdout.write(CURSOR_UP(endRow - cursorRow));
|
||||
}
|
||||
stdout.write(CURSOR_TO_COL(cursorCol));
|
||||
};
|
||||
|
||||
const submit = (value: string) => {
|
||||
// Clear suggestions before submitting
|
||||
stdout.write(RESTORE_CURSOR);
|
||||
stdout.write(CLEAR_TO_END);
|
||||
stdout.write(`${prompt}${value}\n`);
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const onKeypress = (_char: string, key: readline.Key) => {
|
||||
if (!key) return;
|
||||
|
||||
// Handle Ctrl+C
|
||||
if (key.ctrl && key.name === "c") {
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle Ctrl+D (EOF)
|
||||
if (key.ctrl && key.name === "d") {
|
||||
cleanup();
|
||||
stdout.write("\n");
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Enter
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
// Use selected suggestion
|
||||
const selected = suggestions[selectedIndex]!;
|
||||
submit(selected.value);
|
||||
} else {
|
||||
submit(input);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Tab - cycle through suggestions or complete selected one
|
||||
if (key.name === "tab") {
|
||||
if (suggestions.length > 0) {
|
||||
if (selectedIndex >= 0) {
|
||||
// Already have a selection - complete it to input
|
||||
const selected = suggestions[selectedIndex]!;
|
||||
input = selected.value + " ";
|
||||
cursorPos = input.length;
|
||||
selectedIndex = -1;
|
||||
render();
|
||||
} else {
|
||||
// No selection yet - select first item
|
||||
if (key.shift) {
|
||||
selectedIndex = suggestions.length - 1;
|
||||
} else {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
render();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle arrow keys
|
||||
if (key.name === "up") {
|
||||
if (suggestions.length > 0) {
|
||||
selectedIndex = selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "down") {
|
||||
if (suggestions.length > 0) {
|
||||
selectedIndex = selectedIndex >= suggestions.length - 1 ? 0 : selectedIndex + 1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Escape - clear selection
|
||||
if (key.name === "escape") {
|
||||
selectedIndex = -1;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle backspace
|
||||
if (key.name === "backspace") {
|
||||
if (cursorPos > 0) {
|
||||
input = input.slice(0, cursorPos - 1) + input.slice(cursorPos);
|
||||
cursorPos--;
|
||||
selectedIndex = -1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle delete
|
||||
if (key.name === "delete") {
|
||||
if (cursorPos < input.length) {
|
||||
input = input.slice(0, cursorPos) + input.slice(cursorPos + 1);
|
||||
selectedIndex = -1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle left arrow
|
||||
if (key.name === "left") {
|
||||
if (cursorPos > 0) {
|
||||
cursorPos--;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle right arrow
|
||||
if (key.name === "right") {
|
||||
if (cursorPos < input.length) {
|
||||
cursorPos++;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle home
|
||||
if (key.name === "home" || (key.ctrl && key.name === "a")) {
|
||||
cursorPos = 0;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle end
|
||||
if (key.name === "end" || (key.ctrl && key.name === "e")) {
|
||||
cursorPos = input.length;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle printable characters
|
||||
if (key.sequence && !key.ctrl && !key.meta) {
|
||||
const char = key.sequence;
|
||||
if (char.length === 1 && char.charCodeAt(0) >= 32) {
|
||||
input = input.slice(0, cursorPos) + char + input.slice(cursorPos);
|
||||
cursorPos++;
|
||||
selectedIndex = -1;
|
||||
render();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
stdin.on("keypress", onKeypress);
|
||||
render();
|
||||
});
|
||||
}
|
||||
151
src/agent/cli/colors.ts
Normal file
151
src/agent/cli/colors.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Terminal Colors and Styling
|
||||
*
|
||||
* Simple ANSI color utilities for terminal output
|
||||
*/
|
||||
|
||||
// Check if colors should be disabled
|
||||
const NO_COLOR = process.env.NO_COLOR !== undefined || process.env.TERM === "dumb";
|
||||
|
||||
type StyleFn = (s: string) => string;
|
||||
|
||||
const identity: StyleFn = (s) => s;
|
||||
|
||||
function style(code: number, reset: number = 0): StyleFn {
|
||||
if (NO_COLOR) return identity;
|
||||
return (s: string) => `\x1b[${code}m${s}\x1b[${reset}m`;
|
||||
}
|
||||
|
||||
// Basic styles
|
||||
export const reset = "\x1b[0m";
|
||||
export const bold = style(1, 22);
|
||||
export const dim = style(2, 22);
|
||||
export const italic = style(3, 23);
|
||||
export const underline = style(4, 24);
|
||||
export const inverse = style(7, 27);
|
||||
|
||||
// Foreground colors
|
||||
export const black = style(30, 39);
|
||||
export const red = style(31, 39);
|
||||
export const green = style(32, 39);
|
||||
export const yellow = style(33, 39);
|
||||
export const blue = style(34, 39);
|
||||
export const magenta = style(35, 39);
|
||||
export const cyan = style(36, 39);
|
||||
export const white = style(37, 39);
|
||||
export const gray = style(90, 39);
|
||||
|
||||
// Bright colors
|
||||
export const brightRed = style(91, 39);
|
||||
export const brightGreen = style(92, 39);
|
||||
export const brightYellow = style(93, 39);
|
||||
export const brightBlue = style(94, 39);
|
||||
export const brightMagenta = style(95, 39);
|
||||
export const brightCyan = style(96, 39);
|
||||
|
||||
// Background colors
|
||||
export const bgRed = style(41, 49);
|
||||
export const bgGreen = style(42, 49);
|
||||
export const bgYellow = style(43, 49);
|
||||
export const bgBlue = style(44, 49);
|
||||
|
||||
// Semantic colors for the CLI
|
||||
export const colors = {
|
||||
// UI elements
|
||||
prompt: cyan,
|
||||
promptSymbol: brightCyan,
|
||||
sessionId: dim,
|
||||
|
||||
// Tool output
|
||||
toolName: yellow,
|
||||
toolArgs: dim,
|
||||
toolBullet: cyan,
|
||||
toolArrow: dim,
|
||||
toolError: red,
|
||||
|
||||
// Messages
|
||||
error: red,
|
||||
warning: yellow,
|
||||
success: green,
|
||||
info: blue,
|
||||
|
||||
// Status bar
|
||||
statusBg: inverse,
|
||||
statusLabel: dim,
|
||||
statusValue: white,
|
||||
|
||||
// Welcome banner
|
||||
bannerBorder: cyan,
|
||||
bannerText: brightCyan,
|
||||
|
||||
// Suggestions
|
||||
suggestionSelected: inverse,
|
||||
suggestionDim: dim,
|
||||
suggestionLabel: gray,
|
||||
};
|
||||
|
||||
// Spinner frames for thinking indicator
|
||||
export const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
// Alternative spinner styles
|
||||
export const spinnerStyles = {
|
||||
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
||||
line: ["-", "\\", "|", "/"],
|
||||
arc: ["◜", "◠", "◝", "◞", "◡", "◟"],
|
||||
bounce: ["⠁", "⠂", "⠄", "⠂"],
|
||||
pulse: ["◯", "◔", "◑", "◕", "●", "◕", "◑", "◔"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a spinner instance
|
||||
*/
|
||||
export function createSpinner(options: {
|
||||
stream?: NodeJS.WritableStream;
|
||||
frames?: string[];
|
||||
interval?: number;
|
||||
} = {}) {
|
||||
const {
|
||||
stream = process.stderr,
|
||||
frames = spinnerFrames,
|
||||
interval = 80,
|
||||
} = options;
|
||||
|
||||
let frameIndex = 0;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let currentText = "";
|
||||
|
||||
const render = () => {
|
||||
const frame = colors.toolBullet(frames[frameIndex % frames.length]!);
|
||||
stream.write(`\r\x1b[K${frame} ${currentText}`);
|
||||
frameIndex++;
|
||||
};
|
||||
|
||||
return {
|
||||
start(text: string) {
|
||||
currentText = text;
|
||||
frameIndex = 0;
|
||||
if (timer) clearInterval(timer);
|
||||
render();
|
||||
timer = setInterval(render, interval);
|
||||
},
|
||||
|
||||
update(text: string) {
|
||||
currentText = text;
|
||||
},
|
||||
|
||||
stop(finalText?: string) {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
stream.write("\r\x1b[K");
|
||||
if (finalText) {
|
||||
stream.write(finalText + "\n");
|
||||
}
|
||||
},
|
||||
|
||||
isSpinning() {
|
||||
return timer !== null;
|
||||
},
|
||||
};
|
||||
}
|
||||
537
src/agent/cli/interactive.ts
Normal file
537
src/agent/cli/interactive.ts
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
#!/usr/bin/env node
|
||||
import * as readline from "readline";
|
||||
import { Agent } from "../runner.js";
|
||||
import type { AgentOptions } from "../types.js";
|
||||
import { SkillManager } from "../skills/index.js";
|
||||
import { autocompleteInput, type AutocompleteOption } from "./autocomplete.js";
|
||||
import { colors, dim, cyan, brightCyan, yellow, green, gray } from "./colors.js";
|
||||
|
||||
type CliOptions = {
|
||||
profile?: string | undefined;
|
||||
provider?: string | undefined;
|
||||
model?: string | undefined;
|
||||
system?: string | undefined;
|
||||
thinking?: string | undefined;
|
||||
cwd?: string | undefined;
|
||||
session?: string | undefined;
|
||||
help?: boolean | undefined;
|
||||
};
|
||||
|
||||
const COMMANDS = {
|
||||
help: "Show this help message",
|
||||
exit: "Exit the CLI (aliases: quit, q)",
|
||||
clear: "Clear the current session and start fresh",
|
||||
session: "Show current session ID",
|
||||
new: "Start a new session",
|
||||
multiline: "Toggle multi-line input mode (end with a line containing only '.')",
|
||||
};
|
||||
|
||||
function printUsage() {
|
||||
console.log(`${cyan("Usage:")} pnpm agent:interactive [options]`);
|
||||
console.log("");
|
||||
console.log(`${cyan("Options:")}`);
|
||||
console.log(` ${yellow("--profile")} ID Load agent profile (identity, soul, tools, memory)`);
|
||||
console.log(` ${yellow("--provider")} NAME LLM provider (e.g., openai, anthropic, kimi)`);
|
||||
console.log(` ${yellow("--model")} NAME Model name`);
|
||||
console.log(` ${yellow("--system")} TEXT System prompt (ignored if --profile is set)`);
|
||||
console.log(` ${yellow("--thinking")} LEVEL Thinking level`);
|
||||
console.log(` ${yellow("--cwd")} DIR Working directory for commands`);
|
||||
console.log(` ${yellow("--session")} ID Session ID to resume`);
|
||||
console.log(` ${yellow("--help")}, -h Show this help`);
|
||||
console.log("");
|
||||
console.log(`${cyan("Commands")} (use during interaction):`);
|
||||
for (const [cmd, desc] of Object.entries(COMMANDS)) {
|
||||
console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const args = [...argv];
|
||||
const opts: CliOptions = {};
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
opts.help = true;
|
||||
break;
|
||||
}
|
||||
if (arg === "--profile") {
|
||||
opts.profile = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--provider") {
|
||||
opts.provider = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--model") {
|
||||
opts.model = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--system") {
|
||||
opts.system = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--thinking") {
|
||||
opts.thinking = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--cwd") {
|
||||
opts.cwd = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--session") {
|
||||
opts.session = args.shift();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
function printWelcome(sessionId: string, opts: CliOptions) {
|
||||
const border = cyan("│");
|
||||
const topBorder = cyan("╭─────────────────────────────────────────╮");
|
||||
const bottomBorder = cyan("╰─────────────────────────────────────────╯");
|
||||
|
||||
console.log(topBorder);
|
||||
console.log(`${border} ${brightCyan("Super Multica Interactive CLI")} ${border}`);
|
||||
console.log(bottomBorder);
|
||||
|
||||
// Show configuration
|
||||
const configLines: string[] = [];
|
||||
configLines.push(`${dim("Session:")} ${gray(sessionId.slice(0, 8))}...`);
|
||||
if (opts.profile) {
|
||||
configLines.push(`${dim("Profile:")} ${yellow(opts.profile)}`);
|
||||
}
|
||||
if (opts.provider) {
|
||||
configLines.push(`${dim("Provider:")} ${green(opts.provider)}`);
|
||||
}
|
||||
if (opts.model) {
|
||||
configLines.push(`${dim("Model:")} ${green(opts.model)}`);
|
||||
}
|
||||
|
||||
console.log(configLines.join(" "));
|
||||
console.log(`${dim("Type")} ${cyan("/help")} ${dim("for commands,")} ${cyan("/exit")} ${dim("to quit.")}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function printHelp(skillManager?: SkillManager) {
|
||||
console.log(`\n${cyan("Built-in commands:")}`);
|
||||
for (const [cmd, desc] of Object.entries(COMMANDS)) {
|
||||
console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`);
|
||||
}
|
||||
|
||||
// Show skill commands if available
|
||||
if (skillManager) {
|
||||
const reservedNames = new Set(Object.keys(COMMANDS));
|
||||
const skillCommands = skillManager.getSkillCommands({ reservedNames });
|
||||
if (skillCommands.length > 0) {
|
||||
console.log(`\n${cyan("Skill commands:")}`);
|
||||
for (const cmd of skillCommands) {
|
||||
console.log(` ${yellow(`/${cmd.name}`.padEnd(14))} ${dim(cmd.description)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${dim("Just type your message and press Enter to chat with the agent.")}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Status Bar - renders a persistent status line at the bottom of the terminal
|
||||
*/
|
||||
class StatusBar {
|
||||
private enabled: boolean;
|
||||
private currentStatus: string = "";
|
||||
private stream: NodeJS.WriteStream;
|
||||
|
||||
constructor(stream: NodeJS.WriteStream = process.stdout) {
|
||||
this.stream = stream;
|
||||
this.enabled = stream.isTTY === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status bar content
|
||||
*/
|
||||
update(parts: { session?: string; provider?: string; model?: string; tokens?: number }) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const segments: string[] = [];
|
||||
|
||||
if (parts.session) {
|
||||
segments.push(`${dim("session:")}${gray(parts.session.slice(0, 8))}`);
|
||||
}
|
||||
if (parts.provider) {
|
||||
segments.push(`${dim("provider:")}${green(parts.provider)}`);
|
||||
}
|
||||
if (parts.model) {
|
||||
segments.push(`${dim("model:")}${yellow(parts.model)}`);
|
||||
}
|
||||
if (parts.tokens !== undefined) {
|
||||
segments.push(`${dim("tokens:")}${cyan(String(parts.tokens))}`);
|
||||
}
|
||||
|
||||
this.currentStatus = segments.join(" ");
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the status bar
|
||||
*/
|
||||
private render() {
|
||||
if (!this.enabled || !this.currentStatus) return;
|
||||
|
||||
const termWidth = this.stream.columns || 80;
|
||||
const termHeight = this.stream.rows || 24;
|
||||
|
||||
// Save cursor, move to bottom, clear line, write status, restore cursor
|
||||
const statusLine = ` ${this.currentStatus} `.slice(0, termWidth);
|
||||
|
||||
this.stream.write(
|
||||
`\x1b[s` + // Save cursor
|
||||
`\x1b[${termHeight};1H` + // Move to last row
|
||||
`\x1b[7m` + // Inverse video (highlight)
|
||||
`\x1b[2K` + // Clear line
|
||||
statusLine.padEnd(termWidth) + // Write status padded to terminal width
|
||||
`\x1b[0m` + // Reset
|
||||
`\x1b[u` // Restore cursor
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the status bar
|
||||
*/
|
||||
clear() {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const termHeight = this.stream.rows || 24;
|
||||
|
||||
this.stream.write(
|
||||
`\x1b[s` + // Save cursor
|
||||
`\x1b[${termHeight};1H` + // Move to last row
|
||||
`\x1b[2K` + // Clear line
|
||||
`\x1b[u` // Restore cursor
|
||||
);
|
||||
this.currentStatus = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily hide status bar (for clean output)
|
||||
*/
|
||||
hide() {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show status bar again
|
||||
*/
|
||||
show() {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
class InteractiveCLI {
|
||||
private agent: Agent;
|
||||
private opts: CliOptions;
|
||||
private rl: readline.Interface | null = null;
|
||||
private multilineMode = false;
|
||||
private multilineBuffer: string[] = [];
|
||||
private running = true;
|
||||
private skillManager: SkillManager;
|
||||
private reservedNames: Set<string>;
|
||||
private statusBar: StatusBar;
|
||||
|
||||
constructor(opts: CliOptions) {
|
||||
this.opts = opts;
|
||||
this.agent = this.createAgent(opts.session);
|
||||
this.statusBar = new StatusBar();
|
||||
|
||||
// Initialize SkillManager for tab completion
|
||||
this.skillManager = new SkillManager({
|
||||
profileId: opts.profile,
|
||||
});
|
||||
|
||||
// Build list of reserved command names (built-in CLI commands)
|
||||
this.reservedNames = new Set(Object.keys(COMMANDS));
|
||||
|
||||
// Handle Ctrl+C gracefully
|
||||
process.on("SIGINT", () => {
|
||||
this.statusBar.clear();
|
||||
console.log(`\n${dim("Goodbye!")}`);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create readline interface (lazy initialization)
|
||||
* Only created when needed for multiline mode to avoid interfering with autocomplete
|
||||
*/
|
||||
private getReadline(): readline.Interface {
|
||||
if (!this.rl) {
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
this.rl.on("close", () => {
|
||||
this.running = false;
|
||||
this.statusBar.clear();
|
||||
console.log(`\n${dim("Goodbye!")}`);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
return this.rl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close readline interface when not needed
|
||||
*/
|
||||
private closeReadline() {
|
||||
if (this.rl) {
|
||||
this.rl.close();
|
||||
this.rl = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete suggestions for input
|
||||
*/
|
||||
private getSuggestions(input: string): AutocompleteOption[] {
|
||||
if (!input.startsWith("/")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const prefix = input.slice(1).toLowerCase();
|
||||
const suggestions: AutocompleteOption[] = [];
|
||||
|
||||
// Add built-in command suggestions
|
||||
for (const [cmd, desc] of Object.entries(COMMANDS)) {
|
||||
if (cmd.toLowerCase().startsWith(prefix)) {
|
||||
suggestions.push({
|
||||
value: `/${cmd}`,
|
||||
label: desc.slice(0, 40),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add skill command suggestions
|
||||
const skillCommands = this.skillManager.getSkillCommands({ reservedNames: this.reservedNames });
|
||||
for (const cmd of skillCommands) {
|
||||
if (cmd.name.toLowerCase().startsWith(prefix)) {
|
||||
suggestions.push({
|
||||
value: `/${cmd.name}`,
|
||||
label: cmd.description.slice(0, 40),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: shorter first, then alphabetically
|
||||
suggestions.sort((a, b) => {
|
||||
if (a.value.length !== b.value.length) return a.value.length - b.value.length;
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
private createAgent(sessionId?: string): Agent {
|
||||
return new Agent({
|
||||
profileId: this.opts.profile,
|
||||
provider: this.opts.provider,
|
||||
model: this.opts.model,
|
||||
systemPrompt: this.opts.system,
|
||||
thinkingLevel: this.opts.thinking as AgentOptions["thinkingLevel"],
|
||||
cwd: this.opts.cwd,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
private prompt(): string {
|
||||
if (this.multilineMode) {
|
||||
return this.multilineBuffer.length === 0 ? cyan(">>> ") : cyan("... ");
|
||||
}
|
||||
return `${brightCyan("You:")} `;
|
||||
}
|
||||
|
||||
private updateStatusBar() {
|
||||
const statusUpdate: { session?: string; provider?: string; model?: string; tokens?: number } = {
|
||||
session: this.agent.sessionId,
|
||||
provider: this.opts.provider ?? "default",
|
||||
};
|
||||
if (this.opts.model) {
|
||||
statusUpdate.model = this.opts.model;
|
||||
}
|
||||
this.statusBar.update(statusUpdate);
|
||||
}
|
||||
|
||||
async run() {
|
||||
printWelcome(this.agent.sessionId, this.opts);
|
||||
this.updateStatusBar();
|
||||
await this.loop();
|
||||
}
|
||||
|
||||
private async loop() {
|
||||
while (this.running) {
|
||||
let input: string;
|
||||
|
||||
if (this.multilineMode) {
|
||||
// Use simple readline for multiline mode
|
||||
const lineInput = await this.readline(this.prompt());
|
||||
if (lineInput === null) break;
|
||||
input = lineInput;
|
||||
|
||||
if (input === ".") {
|
||||
// End of multiline input
|
||||
const fullInput = this.multilineBuffer.join("\n");
|
||||
this.multilineBuffer = [];
|
||||
this.multilineMode = false;
|
||||
// Close readline to avoid interfering with autocomplete
|
||||
this.closeReadline();
|
||||
if (fullInput.trim()) {
|
||||
await this.handleInput(fullInput);
|
||||
}
|
||||
} else {
|
||||
this.multilineBuffer.push(input);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use autocomplete input for normal mode
|
||||
try {
|
||||
this.statusBar.hide();
|
||||
input = await autocompleteInput({
|
||||
prompt: this.prompt(),
|
||||
getSuggestions: (text) => this.getSuggestions(text),
|
||||
maxSuggestions: 8,
|
||||
});
|
||||
this.statusBar.show();
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
if (trimmed.startsWith("/")) {
|
||||
const handled = await this.handleCommand(trimmed);
|
||||
if (!handled) {
|
||||
await this.handleInput(trimmed);
|
||||
}
|
||||
} else {
|
||||
await this.handleInput(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readline(prompt: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.getReadline().question(prompt, (answer) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handleCommand(input: string): Promise<boolean> {
|
||||
const cmd = input.slice(1).toLowerCase().split(/\s+/)[0];
|
||||
|
||||
switch (cmd) {
|
||||
case "help":
|
||||
printHelp(this.skillManager);
|
||||
return true;
|
||||
|
||||
case "exit":
|
||||
case "quit":
|
||||
case "q":
|
||||
this.statusBar.clear();
|
||||
console.log(dim("Goodbye!"));
|
||||
this.running = false;
|
||||
this.closeReadline();
|
||||
process.exit(0);
|
||||
return true;
|
||||
|
||||
case "clear":
|
||||
this.agent = this.createAgent();
|
||||
this.updateStatusBar();
|
||||
console.log(`${green("Session cleared.")} ${dim("New session:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
|
||||
return true;
|
||||
|
||||
case "session":
|
||||
console.log(`${dim("Current session:")} ${cyan(this.agent.sessionId)}\n`);
|
||||
return true;
|
||||
|
||||
case "new":
|
||||
this.agent = this.createAgent();
|
||||
this.updateStatusBar();
|
||||
console.log(`${green("Started new session:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
|
||||
return true;
|
||||
|
||||
case "multiline":
|
||||
this.multilineMode = !this.multilineMode;
|
||||
if (this.multilineMode) {
|
||||
console.log(`${green("Multi-line mode enabled.")} ${dim("End input with a line containing only '.'")}`);
|
||||
this.multilineBuffer = [];
|
||||
} else {
|
||||
console.log(dim("Multi-line mode disabled."));
|
||||
this.multilineBuffer = [];
|
||||
// Close readline to avoid interfering with autocomplete
|
||||
this.closeReadline();
|
||||
}
|
||||
return true;
|
||||
|
||||
default:
|
||||
// Check if it's a skill command
|
||||
const invocation = this.skillManager.resolveCommand(input);
|
||||
if (invocation) {
|
||||
// Skill command found - send to agent with skill instructions as context
|
||||
const skillPrompt = invocation.args
|
||||
? `[Skill: ${invocation.command.name}]\n\n${invocation.instructions}\n\nUser request: ${invocation.args}`
|
||||
: `[Skill: ${invocation.command.name}]\n\n${invocation.instructions}`;
|
||||
await this.handleInput(skillPrompt);
|
||||
return true;
|
||||
}
|
||||
// Unknown command - let the agent handle it as-is
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleInput(input: string) {
|
||||
try {
|
||||
console.log(""); // Add spacing before response
|
||||
this.statusBar.hide();
|
||||
const result = await this.agent.run(input);
|
||||
this.statusBar.show();
|
||||
if (result.error) {
|
||||
console.error(`\n${colors.error(`Error: ${result.error}`)}`);
|
||||
}
|
||||
console.log(""); // Add spacing after response
|
||||
} catch (err) {
|
||||
console.error(`\n${colors.error(`Error: ${err instanceof Error ? err.message : String(err)}`)}`);
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (opts.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if running in a TTY
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(colors.error("Error: Interactive CLI requires a TTY. Use agent:cli for non-interactive mode."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cli = new InteractiveCLI(opts);
|
||||
await cli.run();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
195
src/agent/cli/non-interactive.ts
Normal file
195
src/agent/cli/non-interactive.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
#!/usr/bin/env node
|
||||
import { Agent } from "../runner.js";
|
||||
|
||||
type CliOptions = {
|
||||
profile?: string | undefined;
|
||||
provider?: string | undefined;
|
||||
model?: string | undefined;
|
||||
apiKey?: string | undefined;
|
||||
baseUrl?: string | undefined;
|
||||
system?: string | undefined;
|
||||
thinking?: string | undefined;
|
||||
cwd?: string | undefined;
|
||||
session?: string | undefined;
|
||||
debug?: boolean | undefined;
|
||||
help?: boolean | undefined;
|
||||
// Tools configuration
|
||||
toolsProfile?: string | undefined;
|
||||
toolsAllow?: string[] | undefined;
|
||||
toolsDeny?: string[] | undefined;
|
||||
};
|
||||
|
||||
function printUsage() {
|
||||
console.log("Usage: pnpm agent:cli [options] <prompt>");
|
||||
console.log(" echo \"your prompt\" | pnpm agent:cli");
|
||||
console.log("");
|
||||
console.log("Options:");
|
||||
console.log(" --profile ID Load agent profile (identity, soul, tools, memory)");
|
||||
console.log(" --provider NAME LLM provider (e.g., openai, anthropic, kimi)");
|
||||
console.log(" --model NAME Model name");
|
||||
console.log(" --api-key KEY API key (overrides environment variable)");
|
||||
console.log(" --base-url URL Custom base URL for the provider");
|
||||
console.log(" --system TEXT System prompt (ignored if --profile is set)");
|
||||
console.log(" --thinking LEVEL Thinking level");
|
||||
console.log(" --cwd DIR Working directory for commands");
|
||||
console.log(" --session ID Session ID for conversation persistence");
|
||||
console.log(" --debug Enable debug logging");
|
||||
console.log(" --help, -h Show this help");
|
||||
console.log("");
|
||||
console.log("Tools Configuration:");
|
||||
console.log(" --tools-profile PROFILE Tool profile (minimal, coding, web, full)");
|
||||
console.log(" --tools-allow TOOLS Allow specific tools (comma-separated, supports group:*)");
|
||||
console.log(" --tools-deny TOOLS Deny specific tools (comma-separated)");
|
||||
console.log("");
|
||||
console.log("Examples:");
|
||||
console.log(' pnpm agent:cli --tools-profile coding "list files"');
|
||||
console.log(' pnpm agent:cli --tools-profile minimal --tools-allow exec "run ls"');
|
||||
console.log(' pnpm agent:cli --tools-deny exec,process "read file.txt"');
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const args = [...argv];
|
||||
const opts: CliOptions = {};
|
||||
const promptParts: string[] = [];
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
opts.help = true;
|
||||
break;
|
||||
}
|
||||
if (arg === "--profile") {
|
||||
opts.profile = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--provider") {
|
||||
opts.provider = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--model") {
|
||||
opts.model = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--api-key") {
|
||||
opts.apiKey = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--base-url") {
|
||||
opts.baseUrl = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--system") {
|
||||
opts.system = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--thinking") {
|
||||
opts.thinking = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--cwd") {
|
||||
opts.cwd = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--session") {
|
||||
opts.session = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--debug") {
|
||||
opts.debug = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tools-profile") {
|
||||
opts.toolsProfile = args.shift();
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tools-allow") {
|
||||
const value = args.shift();
|
||||
opts.toolsAllow = value?.split(",").map((s) => s.trim()) ?? [];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tools-deny") {
|
||||
const value = args.shift();
|
||||
opts.toolsDeny = value?.split(",").map((s) => s.trim()) ?? [];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--") {
|
||||
promptParts.push(...args);
|
||||
break;
|
||||
}
|
||||
promptParts.push(arg);
|
||||
}
|
||||
|
||||
return { opts, prompt: promptParts.join(" ") };
|
||||
}
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return "";
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => (data += chunk));
|
||||
process.stdin.on("end", () => resolve(data.trim()));
|
||||
process.stdin.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { opts, prompt } = parseArgs(process.argv.slice(2));
|
||||
if (opts.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const stdinPrompt = await readStdin();
|
||||
const finalPrompt = prompt || stdinPrompt;
|
||||
if (!finalPrompt) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build tools config if any tools options are set
|
||||
let toolsConfig: import("../tools/policy.js").ToolsConfig | undefined;
|
||||
if (opts.toolsProfile || opts.toolsAllow || opts.toolsDeny) {
|
||||
toolsConfig = {};
|
||||
if (opts.toolsProfile) {
|
||||
toolsConfig.profile = opts.toolsProfile as any;
|
||||
}
|
||||
if (opts.toolsAllow) {
|
||||
toolsConfig.allow = opts.toolsAllow;
|
||||
}
|
||||
if (opts.toolsDeny) {
|
||||
toolsConfig.deny = opts.toolsDeny;
|
||||
}
|
||||
}
|
||||
|
||||
const agent = new Agent({
|
||||
profileId: opts.profile,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
apiKey: opts.apiKey,
|
||||
baseUrl: opts.baseUrl,
|
||||
systemPrompt: opts.system,
|
||||
thinkingLevel: opts.thinking as any,
|
||||
cwd: opts.cwd,
|
||||
sessionId: opts.session,
|
||||
debug: opts.debug,
|
||||
tools: toolsConfig,
|
||||
});
|
||||
|
||||
// If it's a newly created session, notify user of sessionId
|
||||
if (!opts.session) {
|
||||
console.error(`[session: ${agent.sessionId}]`);
|
||||
}
|
||||
|
||||
const result = await agent.run(finalPrompt);
|
||||
if (result.error) {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
173
src/agent/cli/output.ts
Normal file
173
src/agent/cli/output.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { colors, createSpinner } from "./colors.js";
|
||||
|
||||
export type AgentOutputState = {
|
||||
lastAssistantText: string;
|
||||
printedLen: number;
|
||||
streaming: boolean;
|
||||
};
|
||||
|
||||
export type AgentOutput = {
|
||||
state: AgentOutputState;
|
||||
handleEvent: (event: AgentEvent) => void;
|
||||
};
|
||||
|
||||
function extractText(message: AgentMessage | undefined): string {
|
||||
if (!message || typeof message !== "object" || !("content" in message)) return "";
|
||||
const content = (message as { content?: Array<{ type: string; text?: string }> }).content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
function toolDisplayName(name: string): string {
|
||||
const map: Record<string, string> = {
|
||||
read: "ReadFile",
|
||||
write: "WriteFile",
|
||||
edit: "EditFile",
|
||||
exec: "Exec",
|
||||
process: "Process",
|
||||
grep: "Grep",
|
||||
find: "FindFiles",
|
||||
ls: "ListDir",
|
||||
};
|
||||
return map[name] || name;
|
||||
}
|
||||
|
||||
function formatToolArgs(name: string, args: unknown): string {
|
||||
if (!args || typeof args !== "object") return "";
|
||||
const record = args as Record<string, unknown>;
|
||||
const get = (key: string) => (record[key] !== undefined ? String(record[key]) : "");
|
||||
switch (name) {
|
||||
case "read":
|
||||
return get("path") || get("file");
|
||||
case "write":
|
||||
return get("path") || get("file");
|
||||
case "edit":
|
||||
return get("path") || get("file");
|
||||
case "grep":
|
||||
return [get("pattern"), get("path") || get("directory")].filter(Boolean).join(" ");
|
||||
case "find":
|
||||
return [get("glob") || get("pattern"), get("path") || get("directory")].filter(Boolean).join(" ");
|
||||
case "ls":
|
||||
return get("path") || get("directory");
|
||||
case "exec":
|
||||
return get("command");
|
||||
case "process":
|
||||
return [get("action"), get("id")].filter(Boolean).join(" ");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatToolLine(name: string, args: unknown): string {
|
||||
const title = colors.toolName(toolDisplayName(name));
|
||||
const argText = formatToolArgs(name, args);
|
||||
const bullet = colors.toolBullet("•");
|
||||
if (argText) {
|
||||
return `${bullet} ${title} ${colors.toolArgs(`(${argText})`)}`;
|
||||
}
|
||||
return `${bullet} ${title}`;
|
||||
}
|
||||
|
||||
export function createAgentOutput(params: {
|
||||
stdout: NodeJS.WritableStream;
|
||||
stderr: NodeJS.WritableStream;
|
||||
}): AgentOutput {
|
||||
const state: AgentOutputState = {
|
||||
lastAssistantText: "",
|
||||
printedLen: 0,
|
||||
streaming: false,
|
||||
};
|
||||
|
||||
// Create spinner for thinking indicator
|
||||
const spinner = createSpinner({ stream: params.stderr });
|
||||
let pendingToolName = "";
|
||||
let pendingToolArgs: unknown = null;
|
||||
|
||||
const handleEvent = (event: AgentEvent) => {
|
||||
switch (event.type) {
|
||||
case "message_start": {
|
||||
const msg = event.message;
|
||||
if (msg.role === "assistant") {
|
||||
// Stop any running spinner when assistant starts responding
|
||||
if (spinner.isSpinning()) {
|
||||
spinner.stop();
|
||||
}
|
||||
state.streaming = true;
|
||||
state.printedLen = 0;
|
||||
const text = extractText(msg);
|
||||
if (text.length > 0) {
|
||||
params.stdout.write(text);
|
||||
state.printedLen = text.length;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "message_update": {
|
||||
const msg = event.message;
|
||||
if (msg.role === "assistant") {
|
||||
const text = extractText(msg);
|
||||
if (text.length > state.printedLen) {
|
||||
params.stdout.write(text.slice(state.printedLen));
|
||||
state.printedLen = text.length;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "message_end": {
|
||||
const msg = event.message;
|
||||
if (msg.role === "assistant") {
|
||||
const text = extractText(msg);
|
||||
if (text.length > state.printedLen) {
|
||||
params.stdout.write(text.slice(state.printedLen));
|
||||
state.printedLen = text.length;
|
||||
}
|
||||
if (state.streaming) params.stdout.write("\n");
|
||||
state.streaming = false;
|
||||
state.lastAssistantText = text;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_execution_start": {
|
||||
pendingToolName = event.toolName;
|
||||
pendingToolArgs = event.args;
|
||||
const title = colors.toolName(toolDisplayName(event.toolName));
|
||||
const argText = formatToolArgs(event.toolName, event.args);
|
||||
const displayText = argText ? `${title} ${colors.toolArgs(`(${argText})`)}` : title;
|
||||
spinner.start(displayText);
|
||||
break;
|
||||
}
|
||||
case "tool_execution_update": {
|
||||
// Show real-time output updates (e.g., from exec tool)
|
||||
const updateText = extractText(event.partialResult);
|
||||
if (updateText && pendingToolName) {
|
||||
const title = colors.toolName(toolDisplayName(pendingToolName));
|
||||
const preview = colors.toolArgs(updateText.slice(-50).replace(/\n/g, " "));
|
||||
spinner.update(`${title} ${colors.toolArrow("→")} ${preview}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
// Stop spinner and show final result
|
||||
if (event.isError) {
|
||||
const errorText = extractText(event.result) || "Tool failed";
|
||||
const bullet = colors.toolError("✗");
|
||||
const title = colors.toolName(toolDisplayName(event.toolName));
|
||||
spinner.stop(`${bullet} ${title}: ${colors.toolError(errorText)}`);
|
||||
} else {
|
||||
spinner.stop(formatToolLine(event.toolName, pendingToolArgs));
|
||||
}
|
||||
pendingToolName = "";
|
||||
pendingToolArgs = null;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return { state, handleEvent };
|
||||
}
|
||||
205
src/agent/cli/profile.ts
Normal file
205
src/agent/cli/profile.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Agent Profile CLI
|
||||
*
|
||||
* Commands:
|
||||
* new <id> Create a new profile with default templates
|
||||
* list List all profiles
|
||||
* show <id> Show profile contents
|
||||
* edit <id> Open profile directory
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createAgentProfile,
|
||||
loadAgentProfile,
|
||||
getProfileDir,
|
||||
profileExists,
|
||||
} from "../profile/index.js";
|
||||
import { DATA_DIR } from "../../shared/index.js";
|
||||
|
||||
const DEFAULT_BASE_DIR = join(DATA_DIR, "agent-profiles");
|
||||
|
||||
type Command = "new" | "list" | "show" | "edit" | "help";
|
||||
|
||||
function printUsage() {
|
||||
console.log("Usage: pnpm profile <command> [options]");
|
||||
console.log("");
|
||||
console.log("Commands:");
|
||||
console.log(" new <id> Create a new profile with default templates");
|
||||
console.log(" list List all profiles");
|
||||
console.log(" show <id> Show profile contents");
|
||||
console.log(" edit <id> Open profile directory in Finder/file manager");
|
||||
console.log(" help Show this help");
|
||||
console.log("");
|
||||
console.log("Examples:");
|
||||
console.log(" pnpm profile new my-agent");
|
||||
console.log(" pnpm profile list");
|
||||
console.log(" pnpm profile show my-agent");
|
||||
}
|
||||
|
||||
function cmdNew(profileId: string | undefined) {
|
||||
if (!profileId) {
|
||||
console.error("Error: Profile ID is required");
|
||||
console.error("Usage: pnpm profile new <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate profile ID
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(profileId)) {
|
||||
console.error("Error: Profile ID can only contain letters, numbers, hyphens, and underscores");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (profileExists(profileId)) {
|
||||
console.error(`Error: Profile "${profileId}" already exists`);
|
||||
console.error(`Location: ${getProfileDir(profileId)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const profile = createAgentProfile(profileId);
|
||||
const dir = getProfileDir(profileId);
|
||||
|
||||
console.log(`Created profile: ${profile.id}`);
|
||||
console.log(`Location: ${dir}`);
|
||||
console.log("");
|
||||
console.log("Files created:");
|
||||
console.log(" - soul.md (personality and constraints)");
|
||||
console.log(" - identity.md (name and role)");
|
||||
console.log(" - tools.md (tool usage instructions)");
|
||||
console.log(" - memory.md (persistent knowledge)");
|
||||
console.log(" - bootstrap.md (initial context)");
|
||||
console.log("");
|
||||
console.log("Edit these files to customize your agent, then run:");
|
||||
console.log(` pnpm agent:cli --profile ${profileId} "Hello"`);
|
||||
}
|
||||
|
||||
function cmdList() {
|
||||
if (!existsSync(DEFAULT_BASE_DIR)) {
|
||||
console.log("No profiles found.");
|
||||
console.log(`Create one with: pnpm profile new <id>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = readdirSync(DEFAULT_BASE_DIR, { withFileTypes: true });
|
||||
const profiles = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
console.log("No profiles found.");
|
||||
console.log(`Create one with: pnpm profile new <id>`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Available profiles:");
|
||||
console.log("");
|
||||
for (const id of profiles) {
|
||||
const dir = getProfileDir(id);
|
||||
console.log(` ${id}`);
|
||||
console.log(` ${dir}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log(`Total: ${profiles.length} profile(s)`);
|
||||
}
|
||||
|
||||
function cmdShow(profileId: string | undefined) {
|
||||
if (!profileId) {
|
||||
console.error("Error: Profile ID is required");
|
||||
console.error("Usage: pnpm profile show <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const profile = loadAgentProfile(profileId);
|
||||
if (!profile) {
|
||||
console.error(`Error: Profile "${profileId}" not found`);
|
||||
console.error(`Create it with: pnpm profile new ${profileId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Profile: ${profile.id}`);
|
||||
console.log(`Location: ${getProfileDir(profileId)}`);
|
||||
console.log("");
|
||||
|
||||
if (profile.identity) {
|
||||
console.log("=== identity.md ===");
|
||||
console.log(profile.identity.trim());
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (profile.soul) {
|
||||
console.log("=== soul.md ===");
|
||||
console.log(profile.soul.trim());
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (profile.tools) {
|
||||
console.log("=== tools.md ===");
|
||||
console.log(profile.tools.trim());
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (profile.memory) {
|
||||
console.log("=== memory.md ===");
|
||||
console.log(profile.memory.trim());
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (profile.bootstrap) {
|
||||
console.log("=== bootstrap.md ===");
|
||||
console.log(profile.bootstrap.trim());
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdEdit(profileId: string | undefined) {
|
||||
if (!profileId) {
|
||||
console.error("Error: Profile ID is required");
|
||||
console.error("Usage: pnpm profile edit <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!profileExists(profileId)) {
|
||||
console.error(`Error: Profile "${profileId}" not found`);
|
||||
console.error(`Create it with: pnpm profile new ${profileId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dir = getProfileDir(profileId);
|
||||
const { spawn } = await import("node:child_process");
|
||||
|
||||
// Open in default file manager
|
||||
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
||||
spawn(cmd, [dir], { detached: true, stdio: "ignore" }).unref();
|
||||
|
||||
console.log(`Opened: ${dir}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = (args[0] || "help") as Command;
|
||||
const arg1 = args[1];
|
||||
|
||||
switch (command) {
|
||||
case "new":
|
||||
cmdNew(arg1);
|
||||
break;
|
||||
case "list":
|
||||
cmdList();
|
||||
break;
|
||||
case "show":
|
||||
cmdShow(arg1);
|
||||
break;
|
||||
case "edit":
|
||||
await cmdEdit(arg1);
|
||||
break;
|
||||
case "help":
|
||||
default:
|
||||
printUsage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
539
src/agent/cli/skills.ts
Normal file
539
src/agent/cli/skills.ts
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Skills CLI
|
||||
*
|
||||
* Command-line interface for managing skills
|
||||
*
|
||||
* Usage:
|
||||
* pnpm skills:cli list List all skills
|
||||
* pnpm skills:cli status [id] Show skill status
|
||||
* pnpm skills:cli install <id> Install skill dependencies
|
||||
* pnpm skills:cli add <source> Add skill from GitHub
|
||||
* pnpm skills:cli remove <name> Remove an installed skill
|
||||
*/
|
||||
|
||||
import {
|
||||
SkillManager,
|
||||
installSkill,
|
||||
getInstallOptions,
|
||||
addSkill,
|
||||
removeSkill,
|
||||
listInstalledSkills,
|
||||
checkEligibilityDetailed,
|
||||
type DiagnosticItem,
|
||||
} from "../skills/index.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type Command = "list" | "status" | "install" | "add" | "remove" | "help";
|
||||
|
||||
interface ParsedArgs {
|
||||
command: Command;
|
||||
args: string[];
|
||||
verbose: boolean;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Argument Parsing
|
||||
// ============================================================================
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const args = [...argv];
|
||||
let verbose = false;
|
||||
let force = false;
|
||||
const positional: string[] = [];
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
|
||||
if (arg === "--verbose" || arg === "-v") {
|
||||
verbose = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--force" || arg === "-f") {
|
||||
force = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return { command: "help", args: [], verbose, force };
|
||||
}
|
||||
|
||||
positional.push(arg);
|
||||
}
|
||||
|
||||
const command = (positional[0] ?? "help") as Command;
|
||||
const commandArgs = positional.slice(1);
|
||||
|
||||
return { command, args: commandArgs, verbose, force };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Commands
|
||||
// ============================================================================
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`
|
||||
Skills CLI - Manage super-multica skills
|
||||
|
||||
Usage:
|
||||
pnpm skills:cli <command> [options]
|
||||
|
||||
Commands:
|
||||
list List all available skills
|
||||
status [id] Show detailed status of a skill (or all skills)
|
||||
install <id> Install dependencies for a skill
|
||||
add <source> Add skill from GitHub (owner/repo or owner/repo/skill)
|
||||
remove <name> Remove an installed skill
|
||||
|
||||
Options:
|
||||
-v, --verbose Show more details
|
||||
-f, --force Force overwrite existing skill
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
pnpm skills:cli list
|
||||
pnpm skills:cli status commit
|
||||
pnpm skills:cli install nano-pdf
|
||||
pnpm skills:cli add vercel-labs/agent-skills
|
||||
pnpm skills:cli add vercel-labs/agent-skills/perplexity
|
||||
pnpm skills:cli remove agent-skills
|
||||
`);
|
||||
}
|
||||
|
||||
function cmdList(manager: SkillManager, verbose: boolean): void {
|
||||
const skills = manager.listAllSkillsWithStatus();
|
||||
|
||||
if (skills.length === 0) {
|
||||
console.log("No skills found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\nAvailable Skills:\n");
|
||||
|
||||
for (const skill of skills) {
|
||||
const status = skill.eligible ? "✓" : "✗";
|
||||
const statusColor = skill.eligible ? "\x1b[32m" : "\x1b[31m";
|
||||
const reset = "\x1b[0m";
|
||||
|
||||
console.log(` ${statusColor}${status}${reset} ${skill.emoji} ${skill.name} (${skill.id})`);
|
||||
console.log(` ${skill.description}`);
|
||||
console.log(` Source: ${skill.source}`);
|
||||
|
||||
if (!skill.eligible && skill.reasons) {
|
||||
for (const reason of skill.reasons) {
|
||||
console.log(` ${statusColor}└ ${reason}${reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
const eligibleCount = skills.filter((s) => s.eligible).length;
|
||||
console.log(`Total: ${skills.length} skills (${eligibleCount} eligible)`);
|
||||
}
|
||||
|
||||
function cmdStatus(manager: SkillManager, skillId?: string, verbose?: boolean): void {
|
||||
if (!skillId) {
|
||||
// Show summary status with diagnostics
|
||||
cmdStatusSummary(manager, verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show specific skill status with detailed diagnostics
|
||||
cmdStatusDetail(manager, skillId, verbose);
|
||||
}
|
||||
|
||||
function cmdStatusSummary(manager: SkillManager, verbose?: boolean): void {
|
||||
const skills = manager.listAllSkillsWithStatus();
|
||||
const eligible = skills.filter((s) => s.eligible);
|
||||
const ineligible = skills.filter((s) => !s.eligible);
|
||||
|
||||
console.log("\nSkills Status Summary:\n");
|
||||
console.log(` Total: ${skills.length}`);
|
||||
console.log(` \x1b[32mEligible: ${eligible.length}\x1b[0m`);
|
||||
console.log(` \x1b[31mIneligible: ${ineligible.length}\x1b[0m`);
|
||||
|
||||
if (ineligible.length > 0) {
|
||||
console.log("\n─────────────────────────────────────────");
|
||||
console.log("Ineligible Skills:");
|
||||
|
||||
// Group by issue type
|
||||
const byIssue: Map<string, string[]> = new Map();
|
||||
for (const s of ineligible) {
|
||||
const skill = manager.getSkillFromAll(s.id);
|
||||
if (skill) {
|
||||
const detailed = checkEligibilityDetailed(skill);
|
||||
const mainIssue = detailed.diagnostics?.[0]?.type ?? "unknown";
|
||||
const existing = byIssue.get(mainIssue) ?? [];
|
||||
existing.push(s.id);
|
||||
byIssue.set(mainIssue, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Print grouped issues
|
||||
const issueLabels: Record<string, string> = {
|
||||
disabled: "Disabled in config",
|
||||
not_in_allowlist: "Not in allowlist",
|
||||
platform: "Platform mismatch",
|
||||
binary: "Missing binaries",
|
||||
any_binary: "Missing binaries (any)",
|
||||
env: "Missing environment variables",
|
||||
config: "Missing config values",
|
||||
unknown: "Unknown issues",
|
||||
};
|
||||
|
||||
for (const [issue, skillIds] of byIssue) {
|
||||
const label = issueLabels[issue] ?? issue;
|
||||
console.log(`\n \x1b[33m${label}:\x1b[0m`);
|
||||
for (const id of skillIds) {
|
||||
const skill = manager.getSkillFromAll(id);
|
||||
if (skill && verbose) {
|
||||
const detailed = checkEligibilityDetailed(skill);
|
||||
const diag = detailed.diagnostics?.[0];
|
||||
console.log(` - ${id}`);
|
||||
if (diag?.hint) {
|
||||
console.log(` \x1b[36mHint: ${diag.hint}\x1b[0m`);
|
||||
}
|
||||
} else {
|
||||
console.log(` - ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n─────────────────────────────────────────");
|
||||
console.log(`\x1b[36mTip: Run 'pnpm skills:cli status <skill-id>' for detailed diagnostics\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
function cmdStatusDetail(manager: SkillManager, skillId: string, verbose?: boolean): void {
|
||||
const skill = manager.getSkillFromAll(skillId);
|
||||
if (!skill) {
|
||||
console.error(`Skill not found: ${skillId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const detailed = checkEligibilityDetailed(skill);
|
||||
const metadata = skill.frontmatter.metadata;
|
||||
|
||||
console.log(`\n${metadata?.emoji ?? "🔧"} ${skill.frontmatter.name}`);
|
||||
console.log("═".repeat(50));
|
||||
console.log(`ID: ${skill.id}`);
|
||||
console.log(`Description: ${skill.frontmatter.description ?? "N/A"}`);
|
||||
console.log(`Version: ${skill.frontmatter.version ?? "N/A"}`);
|
||||
console.log(`Source: ${skill.source}`);
|
||||
console.log(`Path: ${skill.filePath}`);
|
||||
console.log(`Homepage: ${skill.frontmatter.homepage ?? metadata?.homepage ?? "N/A"}`);
|
||||
|
||||
console.log();
|
||||
console.log("─".repeat(50));
|
||||
console.log(`Status: ${detailed.eligible ? "\x1b[32m✓ ELIGIBLE\x1b[0m" : "\x1b[31m✗ NOT ELIGIBLE\x1b[0m"}`);
|
||||
|
||||
// Show detailed diagnostics
|
||||
if (!detailed.eligible && detailed.diagnostics) {
|
||||
console.log("\nDiagnostics:");
|
||||
for (const diag of detailed.diagnostics) {
|
||||
printDiagnostic(diag);
|
||||
}
|
||||
}
|
||||
|
||||
// Show requirements summary
|
||||
const requirements = metadata?.requires;
|
||||
const hasBins = requirements?.bins?.length ?? metadata?.requiresBinaries?.length ?? 0;
|
||||
const hasAnyBins = requirements?.anyBins?.length ?? 0;
|
||||
const hasEnvs = requirements?.env?.length ?? metadata?.requiresEnv?.length ?? 0;
|
||||
|
||||
if (hasBins > 0 || hasAnyBins > 0 || hasEnvs > 0) {
|
||||
console.log("\n─".repeat(50));
|
||||
console.log("Requirements:");
|
||||
|
||||
if (hasBins > 0) {
|
||||
const bins = requirements?.bins ?? metadata?.requiresBinaries ?? [];
|
||||
printRequirementStatus("Binaries (all required)", bins, checkBinaries);
|
||||
}
|
||||
|
||||
if (hasAnyBins > 0) {
|
||||
const anyBins = requirements?.anyBins ?? [];
|
||||
printRequirementStatus("Binaries (any one)", anyBins, checkBinaries, true);
|
||||
}
|
||||
|
||||
if (hasEnvs > 0) {
|
||||
const envs = requirements?.env ?? metadata?.requiresEnv ?? [];
|
||||
printRequirementStatus("Environment vars", envs, checkEnvVars);
|
||||
}
|
||||
}
|
||||
|
||||
// Show install options
|
||||
const installOptions = getInstallOptions(skill);
|
||||
if (installOptions.length > 0) {
|
||||
console.log("\n─".repeat(50));
|
||||
console.log("Install Options:");
|
||||
for (const opt of installOptions) {
|
||||
const status = opt.available ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
|
||||
console.log(` ${status} [${opt.id}] ${opt.label}`);
|
||||
if (!opt.available && opt.reason) {
|
||||
console.log(` └ ${opt.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show quick actions if not eligible
|
||||
if (!detailed.eligible) {
|
||||
console.log("\n─".repeat(50));
|
||||
console.log("\x1b[33mQuick Actions:\x1b[0m");
|
||||
|
||||
for (const diag of detailed.diagnostics ?? []) {
|
||||
if (diag.hint) {
|
||||
console.log(` → ${diag.hint}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (installOptions.length > 0) {
|
||||
console.log(` → pnpm skills:cli install ${skillId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printDiagnostic(diag: DiagnosticItem): void {
|
||||
const typeColors: Record<string, string> = {
|
||||
disabled: "\x1b[33m",
|
||||
not_in_allowlist: "\x1b[33m",
|
||||
platform: "\x1b[35m",
|
||||
binary: "\x1b[31m",
|
||||
any_binary: "\x1b[31m",
|
||||
env: "\x1b[34m",
|
||||
config: "\x1b[36m",
|
||||
};
|
||||
|
||||
const color = typeColors[diag.type] ?? "\x1b[37m";
|
||||
const reset = "\x1b[0m";
|
||||
|
||||
console.log(`\n ${color}[${diag.type.toUpperCase()}]${reset}`);
|
||||
console.log(` ${diag.message}`);
|
||||
|
||||
if (diag.values && diag.values.length > 0) {
|
||||
console.log(` Values: ${diag.values.join(", ")}`);
|
||||
}
|
||||
|
||||
if (diag.hint) {
|
||||
console.log(` \x1b[36m💡 ${diag.hint}${reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printRequirementStatus(
|
||||
label: string,
|
||||
items: string[],
|
||||
checker: (items: string[]) => Map<string, boolean>,
|
||||
anyMode: boolean = false,
|
||||
): void {
|
||||
const status = checker(items);
|
||||
const found = Array.from(status.entries()).filter(([, ok]) => ok).map(([name]) => name);
|
||||
const missing = Array.from(status.entries()).filter(([, ok]) => !ok).map(([name]) => name);
|
||||
|
||||
const allOk = anyMode ? found.length > 0 : missing.length === 0;
|
||||
const statusIcon = allOk ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
|
||||
|
||||
console.log(`\n ${statusIcon} ${label}:`);
|
||||
for (const [name, ok] of status) {
|
||||
const icon = ok ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
|
||||
console.log(` ${icon} ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkBinaries(bins: string[]): Map<string, boolean> {
|
||||
const result = new Map<string, boolean>();
|
||||
for (const bin of bins) {
|
||||
try {
|
||||
const cmd = process.platform === "win32" ? `where ${bin}` : `which ${bin}`;
|
||||
require("child_process").execSync(cmd, { stdio: "ignore" });
|
||||
result.set(bin, true);
|
||||
} catch {
|
||||
result.set(bin, false);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function checkEnvVars(envs: string[]): Map<string, boolean> {
|
||||
const result = new Map<string, boolean>();
|
||||
for (const env of envs) {
|
||||
result.set(env, env in process.env);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function cmdInstall(manager: SkillManager, skillId: string, installId?: string): Promise<void> {
|
||||
const skill = manager.getSkillFromAll(skillId);
|
||||
if (!skill) {
|
||||
console.error(`Skill not found: ${skillId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const installOptions = getInstallOptions(skill);
|
||||
if (installOptions.length === 0) {
|
||||
console.error(`Skill '${skillId}' has no install specifications.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Show available options if multiple
|
||||
if (!installId && installOptions.length > 1) {
|
||||
console.log(`\nMultiple install options available for '${skillId}':\n`);
|
||||
for (const opt of installOptions) {
|
||||
const status = opt.available ? "available" : `unavailable: ${opt.reason}`;
|
||||
console.log(` [${opt.id}] ${opt.label} (${status})`);
|
||||
}
|
||||
console.log(`\nUse: pnpm skills:cli install ${skillId} <install-id>`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nInstalling dependencies for '${skillId}'...`);
|
||||
|
||||
const result = await installSkill({
|
||||
skill,
|
||||
installId,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
console.log(`\n\x1b[32m✓ ${result.message}\x1b[0m`);
|
||||
} else {
|
||||
console.error(`\n\x1b[31m✗ ${result.message}\x1b[0m`);
|
||||
if (result.stderr) {
|
||||
console.error("\nError output:");
|
||||
console.error(result.stderr);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Add/Remove Commands
|
||||
// ============================================================================
|
||||
|
||||
async function cmdAdd(source: string, force: boolean): Promise<void> {
|
||||
console.log(`\nAdding skill from '${source}'...`);
|
||||
|
||||
const result = await addSkill({
|
||||
source,
|
||||
force,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
console.log(`\n\x1b[32m✓ ${result.message}\x1b[0m`);
|
||||
if (result.skills && result.skills.length > 1) {
|
||||
console.log("\nSkills found:");
|
||||
for (const name of result.skills) {
|
||||
console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
if (result.path) {
|
||||
console.log(`\nInstalled to: ${result.path}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`\n\x1b[31m✗ ${result.message}\x1b[0m`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdRemove(name: string): Promise<void> {
|
||||
console.log(`\nRemoving skill '${name}'...`);
|
||||
|
||||
const result = await removeSkill(name);
|
||||
|
||||
if (result.ok) {
|
||||
console.log(`\n\x1b[32m✓ ${result.message}\x1b[0m`);
|
||||
} else {
|
||||
console.error(`\n\x1b[31m✗ ${result.message}\x1b[0m`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdListInstalled(): Promise<void> {
|
||||
const skills = await listInstalledSkills();
|
||||
|
||||
if (skills.length === 0) {
|
||||
console.log("\nNo skills installed in ~/.super-multica/skills/");
|
||||
console.log("Use 'pnpm skills:cli add <source>' to add skills.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\nInstalled skills (~/.super-multica/skills/):\n");
|
||||
for (const name of skills) {
|
||||
console.log(` - ${name}`);
|
||||
}
|
||||
console.log(`\nTotal: ${skills.length} installed`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main
|
||||
// ============================================================================
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { command, args, verbose, force } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (command === "help") {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case "add":
|
||||
if (!args[0]) {
|
||||
console.error("Usage: pnpm skills:cli add <source> [--force]");
|
||||
console.error("\nSource formats:");
|
||||
console.error(" owner/repo Clone entire repository");
|
||||
console.error(" owner/repo/skill-name Clone single skill directory");
|
||||
console.error(" owner/repo@branch Clone specific branch/tag");
|
||||
process.exit(1);
|
||||
}
|
||||
await cmdAdd(args[0], force);
|
||||
return;
|
||||
|
||||
case "remove":
|
||||
if (!args[0]) {
|
||||
console.error("Usage: pnpm skills:cli remove <skill-name>");
|
||||
await cmdListInstalled();
|
||||
process.exit(1);
|
||||
}
|
||||
await cmdRemove(args[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Commands that need SkillManager
|
||||
const manager = new SkillManager();
|
||||
|
||||
switch (command) {
|
||||
case "list":
|
||||
cmdList(manager, verbose);
|
||||
break;
|
||||
|
||||
case "status":
|
||||
cmdStatus(manager, args[0], verbose);
|
||||
break;
|
||||
|
||||
case "install":
|
||||
if (!args[0]) {
|
||||
console.error("Usage: pnpm skills:cli install <skill-id> [install-id]");
|
||||
process.exit(1);
|
||||
}
|
||||
await cmdInstall(manager, args[0], args[1]);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
205
src/agent/cli/tools.ts
Normal file
205
src/agent/cli/tools.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI tool to inspect and test tool policy configuration.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tools:cli list # List all available tools
|
||||
* pnpm tools:cli list --profile coding # List tools after applying profile
|
||||
* pnpm tools:cli list --deny exec # List tools after denying exec
|
||||
* pnpm tools:cli groups # Show all tool groups
|
||||
* pnpm tools:cli profiles # Show all profiles
|
||||
*/
|
||||
|
||||
import { createAllTools } from "../tools.js";
|
||||
import { filterTools, type ToolsConfig } from "../tools/policy.js";
|
||||
import { TOOL_GROUPS, TOOL_PROFILES, expandToolGroups } from "../tools/groups.js";
|
||||
|
||||
type Command = "list" | "groups" | "profiles" | "help";
|
||||
|
||||
interface CliOptions {
|
||||
command: Command;
|
||||
profile?: string;
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
provider?: string;
|
||||
isSubagent?: boolean;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log("Usage: pnpm tools:cli <command> [options]");
|
||||
console.log("");
|
||||
console.log("Commands:");
|
||||
console.log(" list List available tools (with optional filtering)");
|
||||
console.log(" groups Show all tool groups");
|
||||
console.log(" profiles Show all profiles");
|
||||
console.log(" help Show this help");
|
||||
console.log("");
|
||||
console.log("Options for 'list':");
|
||||
console.log(" --profile PROFILE Apply profile filter (minimal, coding, web, full)");
|
||||
console.log(" --allow TOOLS Allow specific tools (comma-separated)");
|
||||
console.log(" --deny TOOLS Deny specific tools (comma-separated)");
|
||||
console.log(" --provider NAME Apply provider-specific rules");
|
||||
console.log(" --subagent Apply subagent restrictions");
|
||||
console.log("");
|
||||
console.log("Examples:");
|
||||
console.log(" pnpm tools:cli list");
|
||||
console.log(" pnpm tools:cli list --profile coding");
|
||||
console.log(" pnpm tools:cli list --profile coding --deny exec");
|
||||
console.log(" pnpm tools:cli list --allow group:fs,web_fetch");
|
||||
console.log(" pnpm tools:cli groups");
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const args = [...argv];
|
||||
const command = (args.shift() || "help") as Command;
|
||||
|
||||
const opts: CliOptions = { command };
|
||||
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (!arg) break;
|
||||
|
||||
if (arg === "--profile") {
|
||||
const value = args.shift();
|
||||
if (value) opts.profile = value;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--allow") {
|
||||
const value = args.shift();
|
||||
opts.allow = value?.split(",").map((s) => s.trim()) ?? [];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--deny") {
|
||||
const value = args.shift();
|
||||
opts.deny = value?.split(",").map((s) => s.trim()) ?? [];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--provider") {
|
||||
const value = args.shift();
|
||||
if (value) opts.provider = value;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--subagent") {
|
||||
opts.isSubagent = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
function listTools(opts: CliOptions) {
|
||||
const allTools = createAllTools(process.cwd());
|
||||
|
||||
console.log(`Total tools available: ${allTools.length}`);
|
||||
console.log("");
|
||||
|
||||
// Build config
|
||||
let config: ToolsConfig | undefined;
|
||||
if (opts.profile || opts.allow || opts.deny) {
|
||||
config = {};
|
||||
if (opts.profile) {
|
||||
config.profile = opts.profile as any;
|
||||
}
|
||||
if (opts.allow) {
|
||||
config.allow = opts.allow;
|
||||
}
|
||||
if (opts.deny) {
|
||||
config.deny = opts.deny;
|
||||
}
|
||||
}
|
||||
|
||||
const filterOpts: import("../tools/policy.js").FilterToolsOptions = {};
|
||||
if (config) {
|
||||
filterOpts.config = config;
|
||||
}
|
||||
if (opts.provider) {
|
||||
filterOpts.provider = opts.provider;
|
||||
}
|
||||
if (opts.isSubagent) {
|
||||
filterOpts.isSubagent = opts.isSubagent;
|
||||
}
|
||||
|
||||
const filtered = filterTools(allTools, filterOpts);
|
||||
|
||||
if (config || opts.provider || opts.isSubagent) {
|
||||
console.log("Applied filters:");
|
||||
if (opts.profile) console.log(` Profile: ${opts.profile}`);
|
||||
if (opts.allow) console.log(` Allow: ${opts.allow.join(", ")}`);
|
||||
if (opts.deny) console.log(` Deny: ${opts.deny.join(", ")}`);
|
||||
if (opts.provider) console.log(` Provider: ${opts.provider}`);
|
||||
if (opts.isSubagent) console.log(` Subagent: true`);
|
||||
console.log("");
|
||||
console.log(`Tools after filtering: ${filtered.length}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log("Tools:");
|
||||
for (const tool of filtered) {
|
||||
const desc = tool.description?.slice(0, 60) || "";
|
||||
console.log(` ${tool.name.padEnd(15)} ${desc}${desc.length >= 60 ? "..." : ""}`);
|
||||
}
|
||||
|
||||
if (filtered.length < allTools.length) {
|
||||
const removed = allTools.filter((t) => !filtered.find((f) => f.name === t.name));
|
||||
console.log("");
|
||||
console.log(`Filtered out (${removed.length}):`);
|
||||
for (const tool of removed) {
|
||||
console.log(` ${tool.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showGroups() {
|
||||
console.log("Tool Groups:");
|
||||
console.log("");
|
||||
for (const [name, tools] of Object.entries(TOOL_GROUPS)) {
|
||||
console.log(` ${name}:`);
|
||||
console.log(` ${tools.join(", ")}`);
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
function showProfiles() {
|
||||
console.log("Tool Profiles:");
|
||||
console.log("");
|
||||
for (const [name, policy] of Object.entries(TOOL_PROFILES)) {
|
||||
console.log(` ${name}:`);
|
||||
if (policy.allow) {
|
||||
const expanded = expandToolGroups(policy.allow);
|
||||
console.log(` Allow: ${policy.allow.join(", ")}`);
|
||||
console.log(` Expands to: ${expanded.join(", ")}`);
|
||||
} else {
|
||||
console.log(` Allow: (all tools)`);
|
||||
}
|
||||
if (policy.deny) {
|
||||
console.log(` Deny: ${policy.deny.join(", ")}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
|
||||
switch (opts.command) {
|
||||
case "list":
|
||||
listTools(opts);
|
||||
break;
|
||||
case "groups":
|
||||
showGroups();
|
||||
break;
|
||||
case "profiles":
|
||||
showProfiles();
|
||||
break;
|
||||
case "help":
|
||||
default:
|
||||
printUsage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue