diff --git a/CLAUDE.md b/CLAUDE.md index 51852a1e..32d8a9d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,18 +22,24 @@ Super Multica is a distributed AI agent framework with a monorepo architecture. # Install dependencies pnpm install -# Development (all services concurrently: gateway:3000, console, web:3001) -pnpm dev +# Multica CLI (unified entry point) +multica # Interactive mode (default) +multica run "" # Run a single prompt +multica chat # Interactive REPL mode +multica session list # List sessions +multica profile list # List profiles +multica skills list # List skills +multica tools list # List tools +multica credentials init # Initialize credentials +multica dev # Start all dev services +multica help # Show help -# Individual services -pnpm dev:gateway # WebSocket gateway only -pnpm dev:console # NestJS console with agent -pnpm dev:web # Next.js web app -pnpm dev:desktop # Electron desktop app - -# Agent CLI -pnpm agent:cli # Non-interactive agent -pnpm agent:interactive # Interactive REPL mode +# Development servers +multica dev # All services (gateway:3000, console:4000, web:3001) +multica dev gateway # WebSocket gateway only +multica dev console # NestJS console with agent +multica dev web # Next.js web app +multica dev desktop # Electron desktop app # Build (turbo-orchestrated) pnpm build @@ -57,7 +63,7 @@ Frontend (web:3001 / desktop) → Agent Engine (LLM runner, sessions, skills, tools) ``` -**Agent Engine** (`src/agent/`): Orchestrates LLM interactions with multi-provider support (OpenAI, Anthropic, DeepSeek, Kimi, Groq, Mistral, Google, Together). Features session management (JSONL-based, UUIDv7 IDs), profile system (`~/.super-multica/agent-profiles/`), modular skills with hot-reload, and token-aware context window guards (compaction modes: tokens, count, summary). CLI tools are organized in `src/agent/cli/` (interactive, non-interactive, profile, skills, tools). +**Agent Engine** (`src/agent/`): Orchestrates LLM interactions with multi-provider support (OpenAI, Anthropic, DeepSeek, Kimi, Groq, Mistral, Google, Together). Features session management (JSONL-based, UUIDv7 IDs), profile system (`~/.super-multica/agent-profiles/`), modular skills with hot-reload, and token-aware context window guards (compaction modes: tokens, count, summary). Unified CLI in `src/agent/cli/index.ts` with subcommands in `src/agent/cli/commands/`. **Gateway** (`src/gateway/`): NestJS WebSocket server with Socket.io for real-time message passing, RPC request/response, and streaming. @@ -82,7 +88,7 @@ Frontend (web:3001 / desktop) Use JSON5 credential files instead of `.env`: ```bash -pnpm credentials:cli init +multica credentials init ``` This creates: diff --git a/README.md b/README.md index dfa8e373..e25d1936 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ The Agent reads credentials from JSON5 files (no `.env` required). Create empty templates: ```bash -pnpm credentials:cli init +multica credentials init ``` This creates: @@ -85,9 +85,9 @@ Example `skills.env.json5` (dynamic keys): Start services directly (no `source .env`): ```bash -pnpm dev:console -pnpm agent:cli "hello" -pnpm dev:gateway +multica dev console +multica run "hello" +multica dev gateway ``` Optional overrides: @@ -104,32 +104,52 @@ Each setting is resolved in order (first match wins): 3. **Session metadata** — restored from previous session 4. **Default** — `kimi-coding` provider with `kimi-k2-thinking` model -## Agent CLI +## Multica CLI -Use the agent module directly from the CLI for isolated testing. +The unified CLI provides access to all agent features through a single command. ```bash -# New sessions get a UUIDv7 ID (shown on start) -pnpm agent:cli "hello" -# [session: 019c0b0a-b111-765c-8bbd-f4149beac9c4] +# Interactive mode (default) +multica +multica chat +multica chat --profile my-agent + +# Run a single prompt +multica run "hello" +multica run --session demo "remember my name is Alice" + +# Session management +multica session list +multica session show abc12345 +multica session delete abc12345 # Continue a session -pnpm agent:cli --session 019c0b0a-b111-765c-8bbd-f4149beac9c4 "what did I say?" - -# Or use a custom session name -pnpm agent:cli --session demo "remember my name is Alice" -pnpm agent:cli --session demo "what's my name?" +multica --session abc12345 +multica run --session abc12345 "what did I say?" # Override provider/model -pnpm agent:cli --provider openai --model gpt-4o-mini "hi" +multica run --provider openai --model gpt-4o-mini "hi" # Use an agent profile -pnpm agent:cli --profile my-agent "hello" +multica chat --profile my-agent # Set thinking level -pnpm agent:cli --thinking high "solve this complex problem" +multica run --thinking high "solve this complex problem" + +# Development servers +multica dev # Start all services +multica dev gateway # Gateway only (:3000) +multica dev console # Console only (:4000) +multica dev web # Web app only (:3001) + +# Help +multica help +multica run --help +multica session --help ``` +Short alias: `mu` (same as `multica`) + ## Sessions Sessions persist conversation history to `~/.super-multica/sessions//`. Each session includes: @@ -156,16 +176,19 @@ Agent profiles define identity, personality, tools, and memory for an agent. Pro ```bash # Create a new profile with default templates -pnpm agent:profile new my-agent +multica profile new my-agent # List all profiles -pnpm agent:profile list +multica profile list # Show profile contents -pnpm agent:profile show my-agent +multica profile show my-agent # Open profile directory in file manager -pnpm agent:profile edit my-agent +multica profile edit my-agent + +# Delete a profile +multica profile delete my-agent ``` ### Profile Structure @@ -194,17 +217,20 @@ Skills are modular capabilities that extend agent functionality through `SKILL.m ```bash # List all skills -pnpm skills:cli list +multica skills list # Install skills from GitHub -pnpm skills:cli add anthropics/skills +multica skills add anthropics/skills # Check skill status with diagnostics -pnpm skills:cli status -pnpm skills:cli status pdf -v +multica skills status +multica skills status pdf -v + +# Install skill dependencies +multica skills install nano-pdf # Remove installed skills -pnpm skills:cli remove skills +multica skills remove skills ``` ### Built-in Skills @@ -320,22 +346,31 @@ The Hub manages multiple agents and gateway connections: ## Scripts -### Agent Commands +### Multica CLI Commands -- `pnpm agent:cli` - Run the agent CLI for module-level testing -- `pnpm agent:interactive` - Interactive REPL mode -- `pnpm agent:profile` - Manage agent profiles +- `multica` / `mu` - Unified CLI entry point +- `multica run ` - Run a single prompt +- `multica chat` - Interactive REPL mode +- `multica session ` - Session management +- `multica profile ` - Profile management +- `multica skills ` - Skills management +- `multica tools ` - Tool policy inspection +- `multica credentials ` - Credentials management +- `multica dev [service]` - Development servers +- `multica help` - Show help -### Development +### Development (shortcuts) -- `pnpm dev` - Run full stack in development mode +- `pnpm dev` - Run full stack (gateway + console + web) - `pnpm dev:gateway` - Run gateway only - `pnpm dev:console` - Run console only - `pnpm dev:web` - Run web app only +- `pnpm dev:desktop` - Run desktop app ### Build & Test - `pnpm build` - Build for production - `pnpm build:sdk` - Build SDK package +- `pnpm build:cli` - Build CLI binary - `pnpm start` - Run production build - `pnpm typecheck` - Type check without emitting diff --git a/package.json b/package.json index d1d4b720..66d593d0 100644 --- a/package.json +++ b/package.json @@ -5,24 +5,17 @@ "type": "module", "main": "dist/index.js", "bin": { - "multica": "./bin/multica-interactive.mjs", - "multica-interactive": "./bin/multica-interactive.mjs", - "multica-cli": "./bin/multica-cli.mjs", - "multica-profile": "./bin/multica-profile.mjs", - "multica-credentials": "./bin/multica-credentials.mjs" + "multica": "./bin/multica.mjs", + "mu": "./bin/multica.mjs" }, "scripts": { - "dev": "concurrently -n gateway,console,web -c blue,yellow,green \"pnpm dev:gateway\" \"pnpm dev:console\" \"pnpm dev:web\"", - "agent:cli": "tsx src/agent/cli/non-interactive.ts", - "agent:interactive": "tsx src/agent/cli/interactive.ts", - "agent:profile": "tsx src/agent/cli/profile.ts", - "credentials:cli": "tsx src/agent/credentials-cli.ts", - "skills:cli": "tsx src/agent/cli/skills.ts", - "tools:cli": "tsx src/agent/cli/tools.ts", - "dev:gateway": "tsx --watch src/gateway/main.ts", - "dev:console": "tsx --watch src/console/main.ts", - "dev:web": "pnpm --filter @multica/web dev", - "dev:desktop": "pnpm --filter @multica/desktop dev", + "multica": "tsx src/agent/cli/index.ts", + "mu": "tsx src/agent/cli/index.ts", + "dev": "tsx src/agent/cli/index.ts dev", + "dev:gateway": "tsx src/agent/cli/index.ts dev gateway", + "dev:console": "tsx src/agent/cli/index.ts dev console", + "dev:web": "tsx src/agent/cli/index.ts dev web", + "dev:desktop": "tsx src/agent/cli/index.ts dev desktop", "build": "turbo build", "build:sdk": "pnpm --filter @multica/sdk build", "build:cli": "node scripts/build-cli.js", diff --git a/scripts/build-cli.js b/scripts/build-cli.js index 8c85fa21..3df78e4b 100644 --- a/scripts/build-cli.js +++ b/scripts/build-cli.js @@ -28,44 +28,48 @@ const stripShebangPlugin = { }; async function build() { - const entryPoints = [ - { entry: "src/agent/cli/interactive.ts", outfile: "bin/multica-interactive.mjs" }, - { entry: "src/agent/cli/non-interactive.ts", outfile: "bin/multica-cli.mjs" }, - { entry: "src/agent/cli/profile.ts", outfile: "bin/multica-profile.mjs" }, - { entry: "src/agent/credentials-cli.ts", outfile: "bin/multica-credentials.mjs" }, - ]; + // Unified CLI entry point + const entryPoint = { + entry: "src/agent/cli/index.ts", + outfile: "bin/multica.mjs", + }; - for (const { entry, outfile } of entryPoints) { - console.log(`Building ${entry} -> ${outfile}...`); + console.log(`Building ${entryPoint.entry} -> ${entryPoint.outfile}...`); - await esbuild.build({ - entryPoints: [resolve(rootDir, entry)], - outfile: resolve(rootDir, outfile), - bundle: true, - platform: "node", - target: "node20", - format: "esm", - banner: { - js: "#!/usr/bin/env node", - }, - plugins: [stripShebangPlugin], - sourcemap: true, - minify: false, - // Externalize all dependencies - they will be loaded from node_modules at runtime - external: allDeps, - }); + await esbuild.build({ + entryPoints: [resolve(rootDir, entryPoint.entry)], + outfile: resolve(rootDir, entryPoint.outfile), + bundle: true, + platform: "node", + target: "node20", + format: "esm", + banner: { + js: "#!/usr/bin/env node", + }, + plugins: [stripShebangPlugin], + sourcemap: true, + minify: false, + // Externalize all dependencies - they will be loaded from node_modules at runtime + external: allDeps, + }); - // Make executable - chmodSync(resolve(rootDir, outfile), 0o755); - console.log(` ✓ ${outfile}`); - } + // Make executable + chmodSync(resolve(rootDir, entryPoint.outfile), 0o755); + console.log(` ✓ ${entryPoint.outfile}`); - console.log("\nBuild complete! Binaries are in ./bin/"); + console.log("\nBuild complete! Binary is in ./bin/"); console.log("\nUsage:"); - console.log(" node bin/multica-interactive.mjs # Interactive CLI"); - console.log(" node bin/multica-cli.mjs # Non-interactive CLI"); - console.log(" node bin/multica-profile.mjs # Profile management"); - console.log("\nNote: The built binaries require node_modules to be present."); + console.log(" multica # Interactive mode (default)"); + console.log(" multica run # Run a single prompt"); + console.log(" multica chat # Interactive mode"); + console.log(" multica session list # List sessions"); + console.log(" multica profile list # List profiles"); + console.log(" multica skills list # List skills"); + console.log(" multica tools list # List tools"); + console.log(" multica credentials init # Initialize credentials"); + console.log(" multica dev # Start dev servers"); + console.log(" multica help # Show help"); + console.log("\nNote: The built binary requires node_modules to be present."); console.log("Run 'pnpm install --prod' to install only production dependencies."); } diff --git a/src/agent/cli/commands/chat.ts b/src/agent/cli/commands/chat.ts new file mode 100644 index 00000000..b9eaf7d8 --- /dev/null +++ b/src/agent/cli/commands/chat.ts @@ -0,0 +1,503 @@ +/** + * Chat command - Interactive REPL mode + * + * Usage: + * multica chat [options] + * multica [options] (default command) + */ + +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 ChatOptions = { + profile?: string; + provider?: string; + model?: string; + system?: string; + thinking?: string; + cwd?: string; + session?: string; + help?: boolean; +}; + +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 printHelp() { + console.log(` +${cyan("Usage:")} multica chat [options] + multica [options] + +${cyan("Options:")} + ${yellow("--profile")} ID Load agent profile + ${yellow("--provider")} NAME LLM provider (openai, anthropic, kimi, etc.) + ${yellow("--model")} NAME Model name + ${yellow("--system")} TEXT System prompt (ignored if --profile set) + ${yellow("--thinking")} LEVEL Thinking level + ${yellow("--cwd")} DIR Working directory + ${yellow("--session")} ID Session ID to resume + ${yellow("--help")}, -h Show this help + +${cyan("Interactive Commands:")} +`); + for (const [cmd, desc] of Object.entries(COMMANDS)) { + console.log(` ${yellow(`/${cmd}`.padEnd(14))} ${dim(desc)}`); + } + console.log(); +} + +function parseArgs(argv: string[]): ChatOptions { + const args = [...argv]; + const opts: ChatOptions = {}; + + 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: ChatOptions) { + 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 printCommandHelp(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(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(); + } + + private render() { + if (!this.enabled || !this.currentStatus) return; + + const termWidth = this.stream.columns || 80; + const termHeight = this.stream.rows || 24; + + 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 + `\x1b[2K` + // Clear line + statusLine.padEnd(termWidth) + + `\x1b[0m` + // Reset + `\x1b[u` // Restore cursor + ); + } + + clear() { + if (!this.enabled) return; + + const termHeight = this.stream.rows || 24; + + this.stream.write( + `\x1b[s` + + `\x1b[${termHeight};1H` + + `\x1b[2K` + + `\x1b[u` + ); + this.currentStatus = ""; + } + + hide() { + this.clear(); + } + + show() { + this.render(); + } +} + +class InteractiveCLI { + private agent: Agent; + private opts: ChatOptions; + private rl: readline.Interface | null = null; + private multilineMode = false; + private multilineBuffer: string[] = []; + private running = true; + private skillManager: SkillManager; + private reservedNames: Set; + private statusBar: StatusBar; + + constructor(opts: ChatOptions) { + this.opts = opts; + this.agent = this.createAgent(opts.session); + this.statusBar = new StatusBar(); + + this.skillManager = new SkillManager({ + profileId: opts.profile, + }); + + this.reservedNames = new Set(Object.keys(COMMANDS)); + + process.on("SIGINT", () => { + this.statusBar.clear(); + console.log(`\n${dim("Goodbye!")}`); + process.exit(0); + }); + } + + 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; + } + + private closeReadline() { + if (this.rl) { + this.rl.close(); + this.rl = null; + } + } + + private getSuggestions(input: string): AutocompleteOption[] { + if (!input.startsWith("/")) { + return []; + } + + const prefix = input.slice(1).toLowerCase(); + const suggestions: AutocompleteOption[] = []; + + for (const [cmd, desc] of Object.entries(COMMANDS)) { + if (cmd.toLowerCase().startsWith(prefix)) { + suggestions.push({ + value: `/${cmd}`, + label: desc.slice(0, 40), + }); + } + } + + 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), + }); + } + } + + 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) { + const lineInput = await this.readline(this.prompt()); + if (lineInput === null) break; + input = lineInput; + + if (input === ".") { + const fullInput = this.multilineBuffer.join("\n"); + this.multilineBuffer = []; + this.multilineMode = false; + this.closeReadline(); + if (fullInput.trim()) { + await this.handleInput(fullInput); + } + } else { + this.multilineBuffer.push(input); + } + continue; + } + + 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 { + return new Promise((resolve) => { + this.getReadline().question(prompt, (answer) => { + resolve(answer); + }); + }); + } + + private async handleCommand(input: string): Promise { + const cmd = input.slice(1).toLowerCase().split(/\s+/)[0]; + + switch (cmd) { + case "help": + printCommandHelp(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 = []; + this.closeReadline(); + } + return true; + + default: + const invocation = this.skillManager.resolveCommand(input); + if (invocation) { + 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; + } + return false; + } + } + + private async handleInput(input: string) { + try { + console.log(""); + 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(""); + } catch (err) { + console.error(`\n${colors.error(`Error: ${err instanceof Error ? err.message : String(err)}`)}`); + console.log(""); + } + } +} + +export async function chatCommand(args: string[]): Promise { + const opts = parseArgs(args); + + if (opts.help) { + printHelp(); + return; + } + + if (!process.stdin.isTTY) { + console.error(colors.error("Error: Interactive mode requires a TTY. Use 'multica run' for non-interactive mode.")); + process.exit(1); + } + + const cli = new InteractiveCLI(opts); + await cli.run(); +} diff --git a/src/agent/cli/commands/credentials.ts b/src/agent/cli/commands/credentials.ts new file mode 100644 index 00000000..62a2b69f --- /dev/null +++ b/src/agent/cli/commands/credentials.ts @@ -0,0 +1,229 @@ +/** + * Credentials command - Manage credentials and environment files + * + * Usage: + * multica credentials init Create credential files + * multica credentials show Show credential paths + * multica credentials edit Open credentials in editor + */ + +import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs"; +import { dirname } from "node:path"; +import { getCredentialsPath, getSkillsEnvPath } from "../../credentials.js"; +import { cyan, yellow, green, dim, red } from "../colors.js"; + +type Command = "init" | "show" | "edit" | "help"; + +interface CredentialsOptions { + command: Command; + force: boolean; + coreOnly: boolean; + skillsOnly: boolean; + pathOverride?: string; + skillsPathOverride?: string; +} + +function printHelp() { + console.log(` +${cyan("Usage:")} multica credentials [options] + +${cyan("Commands:")} + ${yellow("init")} Create credentials.json5 and skills.env.json5 + ${yellow("show")} Show credential file paths + ${yellow("edit")} Open credentials directory in file manager + ${yellow("help")} Show this help + +${cyan("Options for 'init':")} + ${yellow("--force")} Overwrite existing files + ${yellow("--core-only")} Only create credentials.json5 + ${yellow("--skills-only")} Only create skills.env.json5 + ${yellow("--path")} PATH Override credentials path + ${yellow("--skills-path")} PATH Override skills env path + +${cyan("Files Created:")} + ~/.super-multica/credentials.json5 LLM providers + tools config + ~/.super-multica/skills.env.json5 Skills/plugins/integrations env vars + +${cyan("Examples:")} + ${dim("# Initialize credentials")} + multica credentials init + + ${dim("# Force overwrite")} + multica credentials init --force + + ${dim("# Only create core credentials")} + multica credentials init --core-only +`); +} + +function parseArgs(argv: string[]): CredentialsOptions { + const args = [...argv]; + const opts: CredentialsOptions = { + command: "help", + force: false, + coreOnly: false, + skillsOnly: false, + }; + + const positional: string[] = []; + + while (args.length > 0) { + const arg = args.shift(); + if (!arg) break; + + if (arg === "--help" || arg === "-h") { + opts.command = "help"; + return opts; + } + if (arg === "--force" || arg === "-f") { + opts.force = true; + continue; + } + if (arg === "--core-only") { + opts.coreOnly = true; + continue; + } + if (arg === "--skills-only") { + opts.skillsOnly = true; + continue; + } + if (arg === "--path") { + opts.pathOverride = args.shift(); + continue; + } + if (arg === "--skills-path") { + opts.skillsPathOverride = args.shift(); + continue; + } + positional.push(arg); + } + + opts.command = (positional[0] || "help") as Command; + return opts; +} + +function buildCoreTemplate(): string { + return `{ + version: 1, + llm: { + // provider: "openai", + providers: { + // openai: { apiKey: "sk-...", baseUrl: "https://api.openai.com/v1", model: "gpt-4.1" } + } + }, + tools: { + // brave: { apiKey: "brv-..." }, + // perplexity: { apiKey: "pplx-...", baseUrl: "https://api.perplexity.ai", model: "perplexity/sonar-pro" } + } +} +`; +} + +function buildSkillsTemplate(): string { + return `{ + env: { + // Dynamic keys (skills, plugins, integrations) + // LINEAR_API_KEY: "lin-..." + } +} +`; +} + +function cmdInit(opts: CredentialsOptions): void { + const createCore = !opts.skillsOnly; + const createSkills = !opts.coreOnly; + + if (!createCore && !createSkills) { + console.error(`${red("Error:")} Both --core-only and --skills-only were provided.`); + process.exit(1); + } + + if (createCore) { + const path = opts.pathOverride ?? getCredentialsPath(); + if (existsSync(path) && !opts.force) { + console.error(`${red("Error:")} Credentials file already exists at ${path}`); + console.error("Use --force to overwrite."); + process.exit(1); + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, buildCoreTemplate(), "utf8"); + chmodSync(path, 0o600); + console.log(`${green("Created:")} ${path}`); + } + + if (createSkills) { + const skillsPath = opts.skillsPathOverride ?? getSkillsEnvPath(); + if (existsSync(skillsPath) && !opts.force) { + console.error(`${red("Error:")} Skills env file already exists at ${skillsPath}`); + console.error("Use --force to overwrite."); + process.exit(1); + } + mkdirSync(dirname(skillsPath), { recursive: true }); + writeFileSync(skillsPath, buildSkillsTemplate(), "utf8"); + chmodSync(skillsPath, 0o600); + console.log(`${green("Created:")} ${skillsPath}`); + } + + console.log(""); + console.log("Edit these files to add your credentials."); +} + +function cmdShow(): void { + const credentialsPath = getCredentialsPath(); + const skillsEnvPath = getSkillsEnvPath(); + + console.log(`\n${cyan("Credential Files:")}\n`); + + console.log(`${yellow("credentials.json5")}`); + console.log(` Path: ${credentialsPath}`); + console.log(` Exists: ${existsSync(credentialsPath) ? green("Yes") : red("No")}`); + console.log(""); + + console.log(`${yellow("skills.env.json5")}`); + console.log(` Path: ${skillsEnvPath}`); + console.log(` Exists: ${existsSync(skillsEnvPath) ? green("Yes") : red("No")}`); + console.log(""); + + if (!existsSync(credentialsPath) || !existsSync(skillsEnvPath)) { + console.log(`${dim("Run 'multica credentials init' to create missing files.")}`); + } +} + +async function cmdEdit(): Promise { + const credentialsPath = getCredentialsPath(); + const dir = dirname(credentialsPath); + + if (!existsSync(dir)) { + console.error(`${red("Error:")} Credentials directory does not exist: ${dir}`); + console.error("Run 'multica credentials init' first."); + process.exit(1); + } + + 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(`${green("Opened:")} ${dir}`); +} + +export async function credentialsCommand(args: string[]): Promise { + const opts = parseArgs(args); + + switch (opts.command) { + case "init": + cmdInit(opts); + break; + case "show": + cmdShow(); + break; + case "edit": + await cmdEdit(); + break; + case "help": + default: + printHelp(); + break; + } +} diff --git a/src/agent/cli/commands/dev.ts b/src/agent/cli/commands/dev.ts new file mode 100644 index 00000000..88d608ca --- /dev/null +++ b/src/agent/cli/commands/dev.ts @@ -0,0 +1,195 @@ +/** + * Dev command - Start development servers + * + * Usage: + * multica dev Start all services (gateway + console + web) + * multica dev gateway Start gateway only (:3000) + * multica dev console Start console only (:4000) + * multica dev web Start web app only (:3001) + * multica dev desktop Start desktop app + */ + +import { spawn } from "node:child_process"; +import { cyan, yellow, green, dim, red } from "../colors.js"; + +type Service = "all" | "gateway" | "console" | "web" | "desktop" | "help"; + +function printHelp() { + console.log(` +${cyan("Usage:")} multica dev [service] + +${cyan("Services:")} + ${yellow("(default)")} Start all services (gateway + console + web) + ${yellow("gateway")} Start Gateway server (:3000) + ${yellow("console")} Start Console server (:4000) + ${yellow("web")} Start Web app (:3001) + ${yellow("desktop")} Start Desktop app + ${yellow("help")} Show this help + +${cyan("Architecture:")} + Frontend (web:3001 / desktop) + → Gateway (WebSocket, :3000) + → Console Hub (multi-agent coordination, :4000) + → Agent Engine + +${cyan("Examples:")} + ${dim("# Start all services")} + multica dev + + ${dim("# Start only the gateway")} + multica dev gateway + + ${dim("# Start web and gateway separately")} + multica dev gateway & + multica dev web +`); +} + +interface DevOptions { + service: Service; + watch: boolean; +} + +function parseArgs(argv: string[]): DevOptions { + const args = [...argv]; + let service: Service = "all"; + let watch = true; + + while (args.length > 0) { + const arg = args.shift(); + if (!arg) break; + + if (arg === "--help" || arg === "-h") { + return { service: "help", watch }; + } + if (arg === "--no-watch") { + watch = false; + continue; + } + + // Service name + if (["gateway", "console", "web", "desktop", "all", "help"].includes(arg)) { + service = arg as Service; + } + } + + return { service, watch }; +} + +function runCommand(command: string, args: string[], options: { name: string; color: string }) { + console.log(`${options.color}[${options.name}]${"\x1b[0m"} Starting...`); + + const child = spawn(command, args, { + stdio: "inherit", + shell: true, + }); + + child.on("error", (err) => { + console.error(`${red(`[${options.name}]`)} Error: ${err.message}`); + }); + + child.on("exit", (code) => { + if (code !== 0 && code !== null) { + console.error(`${red(`[${options.name}]`)} Exited with code ${code}`); + } + }); + + return child; +} + +async function startGateway(watch: boolean) { + const watchFlag = watch ? "--watch" : ""; + return runCommand("tsx", [watchFlag, "src/gateway/main.ts"].filter(Boolean), { + name: "gateway", + color: "\x1b[34m", // blue + }); +} + +async function startConsole(watch: boolean) { + const watchFlag = watch ? "--watch" : ""; + return runCommand("tsx", [watchFlag, "src/console/main.ts"].filter(Boolean), { + name: "console", + color: "\x1b[33m", // yellow + }); +} + +async function startWeb() { + return runCommand("pnpm", ["--filter", "@multica/web", "dev"], { + name: "web", + color: "\x1b[32m", // green + }); +} + +async function startDesktop() { + return runCommand("pnpm", ["--filter", "@multica/desktop", "dev"], { + name: "desktop", + color: "\x1b[35m", // magenta + }); +} + +async function startAll(watch: boolean) { + console.log(`\n${cyan("Starting all services...")}\n`); + console.log(` ${"\x1b[34m"}Gateway${"\x1b[0m"} → http://localhost:3000`); + console.log(` ${"\x1b[33m"}Console${"\x1b[0m"} → http://localhost:4000`); + console.log(` ${"\x1b[32m"}Web${"\x1b[0m"} → http://localhost:3001`); + console.log(""); + + // Start all services + const gateway = await startGateway(watch); + const console_ = await startConsole(watch); + const web = await startWeb(); + + // Handle Ctrl+C + const cleanup = () => { + console.log(`\n${dim("Stopping all services...")}`); + gateway.kill(); + console_.kill(); + web.kill(); + process.exit(0); + }; + + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + + // Wait for all to exit + await Promise.all([ + new Promise((resolve) => gateway.on("exit", resolve)), + new Promise((resolve) => console_.on("exit", resolve)), + new Promise((resolve) => web.on("exit", resolve)), + ]); +} + +export async function devCommand(args: string[]): Promise { + const opts = parseArgs(args); + + switch (opts.service) { + case "gateway": + console.log(`\n${cyan("Starting Gateway...")} → http://localhost:3000\n`); + await startGateway(opts.watch); + break; + + case "console": + console.log(`\n${cyan("Starting Console...")} → http://localhost:4000\n`); + await startConsole(opts.watch); + break; + + case "web": + console.log(`\n${cyan("Starting Web App...")} → http://localhost:3001\n`); + await startWeb(); + break; + + case "desktop": + console.log(`\n${cyan("Starting Desktop App...")}\n`); + await startDesktop(); + break; + + case "all": + await startAll(opts.watch); + break; + + case "help": + default: + printHelp(); + break; + } +} diff --git a/src/agent/cli/commands/profile.ts b/src/agent/cli/commands/profile.ts new file mode 100644 index 00000000..0800fcb6 --- /dev/null +++ b/src/agent/cli/commands/profile.ts @@ -0,0 +1,246 @@ +/** + * Profile command - Manage agent profiles + * + * Usage: + * multica profile list List all profiles + * multica profile new Create a new profile + * multica profile show Show profile contents + * multica profile edit Open profile in file manager + * multica profile delete Delete a profile + */ + +import { existsSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { + createAgentProfile, + loadAgentProfile, + getProfileDir, + profileExists, +} from "../../profile/index.js"; +import { DATA_DIR } from "../../../shared/index.js"; +import { cyan, yellow, green, dim, red } from "../colors.js"; + +const PROFILES_DIR = join(DATA_DIR, "agent-profiles"); + +type Command = "new" | "list" | "show" | "edit" | "delete" | "help"; + +function printHelp() { + console.log(` +${cyan("Usage:")} multica profile [options] + +${cyan("Commands:")} + ${yellow("list")} List all profiles + ${yellow("new")} Create a new profile + ${yellow("show")} Show profile contents + ${yellow("edit")} Open profile directory in file manager + ${yellow("delete")} Delete a profile + ${yellow("help")} Show this help + +${cyan("Profile Structure:")} + Each profile is a directory containing: + - soul.md Personality and constraints + - identity.md Name and role + - tools.md Tool usage instructions + - memory.md Persistent knowledge + - bootstrap.md Initial context + +${cyan("Examples:")} + ${dim("# Create a new profile")} + multica profile new my-agent + + ${dim("# List all profiles")} + multica profile list + + ${dim("# Use a profile")} + multica chat --profile my-agent +`); +} + +function cmdNew(profileId: string | undefined) { + if (!profileId) { + console.error("Error: Profile ID is required"); + console.error("Usage: multica profile new "); + 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(`${green("Created profile:")} ${yellow(profile.id)}`); + console.log(`${dim("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(` multica chat --profile ${profileId}`); +} + +function cmdList() { + if (!existsSync(PROFILES_DIR)) { + console.log("No profiles found."); + console.log(`Create one with: multica profile new `); + return; + } + + const entries = readdirSync(PROFILES_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: multica profile new `); + return; + } + + console.log(`\n${cyan("Available profiles:")}\n`); + for (const id of profiles) { + const dir = getProfileDir(id); + console.log(` ${yellow(id)}`); + console.log(` ${dim(dir)}`); + } + console.log(""); + console.log(`${dim(`Total: ${profiles.length} profile(s)`)}`); +} + +function cmdShow(profileId: string | undefined) { + if (!profileId) { + console.error("Error: Profile ID is required"); + console.error("Usage: multica profile show "); + process.exit(1); + } + + const profile = loadAgentProfile(profileId); + if (!profile) { + console.error(`Error: Profile "${profileId}" not found`); + console.error(`Create it with: multica profile new ${profileId}`); + process.exit(1); + } + + console.log(`\n${cyan("Profile:")} ${yellow(profile.id)}`); + console.log(`${dim("Location:")} ${getProfileDir(profileId)}`); + console.log(""); + + if (profile.identity) { + console.log(`${green("=== identity.md ===")}`); + console.log(profile.identity.trim()); + console.log(""); + } + + if (profile.soul) { + console.log(`${green("=== soul.md ===")}`); + console.log(profile.soul.trim()); + console.log(""); + } + + if (profile.tools) { + console.log(`${green("=== tools.md ===")}`); + console.log(profile.tools.trim()); + console.log(""); + } + + if (profile.memory) { + console.log(`${green("=== memory.md ===")}`); + console.log(profile.memory.trim()); + console.log(""); + } + + if (profile.bootstrap) { + console.log(`${green("=== 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: multica profile edit "); + process.exit(1); + } + + if (!profileExists(profileId)) { + console.error(`Error: Profile "${profileId}" not found`); + console.error(`Create it with: multica 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(`${green("Opened:")} ${dir}`); +} + +function cmdDelete(profileId: string | undefined) { + if (!profileId) { + console.error("Error: Profile ID is required"); + console.error("Usage: multica profile delete "); + process.exit(1); + } + + if (!profileExists(profileId)) { + console.error(`Error: Profile "${profileId}" not found`); + process.exit(1); + } + + const dir = getProfileDir(profileId); + + try { + rmSync(dir, { recursive: true }); + console.log(`${green("Deleted:")} ${profileId}`); + } catch (err) { + console.error(`${red("Error:")} Failed to delete profile: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } +} + +export async function profileCommand(args: string[]): Promise { + const command = (args[0] || "help") as Command; + const arg1 = args[1]; + + if (args.includes("--help") || args.includes("-h")) { + printHelp(); + return; + } + + switch (command) { + case "new": + cmdNew(arg1); + break; + case "list": + cmdList(); + break; + case "show": + cmdShow(arg1); + break; + case "edit": + await cmdEdit(arg1); + break; + case "delete": + cmdDelete(arg1); + break; + case "help": + default: + printHelp(); + break; + } +} diff --git a/src/agent/cli/commands/run.ts b/src/agent/cli/commands/run.ts new file mode 100644 index 00000000..23311e0a --- /dev/null +++ b/src/agent/cli/commands/run.ts @@ -0,0 +1,211 @@ +/** + * Run command - Execute a single prompt non-interactively + * + * Usage: + * multica run [options] + * echo "prompt" | multica run + */ + +import { Agent } from "../../runner.js"; +import type { ToolsConfig } from "../../tools/policy.js"; +import { cyan, yellow, dim } from "../colors.js"; + +type RunOptions = { + profile?: string; + provider?: string; + model?: string; + apiKey?: string; + baseUrl?: string; + system?: string; + thinking?: string; + cwd?: string; + session?: string; + debug?: boolean; + toolsProfile?: string; + toolsAllow?: string[]; + toolsDeny?: string[]; + help?: boolean; +}; + +function printHelp() { + console.log(` +${cyan("Usage:")} multica run [options] + echo "prompt" | multica run + +${cyan("Options:")} + ${yellow("--profile")} ID Load agent profile + ${yellow("--provider")} NAME LLM provider (openai, anthropic, kimi, etc.) + ${yellow("--model")} NAME Model name + ${yellow("--api-key")} KEY API key (overrides environment) + ${yellow("--base-url")} URL Custom base URL for provider + ${yellow("--system")} TEXT System prompt (ignored if --profile set) + ${yellow("--thinking")} LEVEL Thinking level + ${yellow("--cwd")} DIR Working directory + ${yellow("--session")} ID Session ID for persistence + ${yellow("--debug")} Enable debug logging + ${yellow("--help")}, -h Show this help + +${cyan("Tools Configuration:")} + ${yellow("--tools-profile")} P Tool profile (minimal, coding, web, full) + ${yellow("--tools-allow")} T Allow specific tools (comma-separated) + ${yellow("--tools-deny")} T Deny specific tools (comma-separated) + +${cyan("Examples:")} + ${dim("# Run with default settings")} + multica run "What is 2+2?" + + ${dim("# Use a specific profile")} + multica run --profile coder "List files in this directory" + + ${dim("# Pipe input")} + echo "Explain this code" | multica run + + ${dim("# Resume a session")} + multica run --session abc123 "Continue from where we left off" +`); +} + +function parseArgs(argv: string[]): { opts: RunOptions; prompt: string } { + const args = [...argv]; + const opts: RunOptions = {}; + 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(): Promise { + if (process.stdin.isTTY) return ""; + return new Promise((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); + }); +} + +export async function runCommand(args: string[]): Promise { + const { opts, prompt } = parseArgs(args); + + if (opts.help) { + printHelp(); + return; + } + + const stdinPrompt = await readStdin(); + const finalPrompt = prompt || stdinPrompt; + + if (!finalPrompt) { + printHelp(); + process.exit(1); + } + + // Build tools config if any tools options are set + let toolsConfig: ToolsConfig | undefined; + if (opts.toolsProfile || opts.toolsAllow || opts.toolsDeny) { + toolsConfig = {}; + if (opts.toolsProfile) { + toolsConfig.profile = opts.toolsProfile as ToolsConfig["profile"]; + } + 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; + } +} diff --git a/src/agent/cli/commands/session.ts b/src/agent/cli/commands/session.ts new file mode 100644 index 00000000..e726cf60 --- /dev/null +++ b/src/agent/cli/commands/session.ts @@ -0,0 +1,262 @@ +/** + * Session command - Manage conversation sessions + * + * Usage: + * multica session list List all sessions + * multica session show Show session details + * multica session delete Delete a session + */ + +import { existsSync, readdirSync, readFileSync, unlinkSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { DATA_DIR } from "../../../shared/index.js"; +import { cyan, yellow, green, dim, red } from "../colors.js"; + +const SESSIONS_DIR = join(DATA_DIR, "sessions"); + +type Command = "list" | "show" | "delete" | "help"; + +function printHelp() { + console.log(` +${cyan("Usage:")} multica session [options] + +${cyan("Commands:")} + ${yellow("list")} List all sessions + ${yellow("show")} Show session details + ${yellow("delete")} Delete a session + ${yellow("help")} Show this help + +${cyan("Examples:")} + ${dim("# List all sessions")} + multica session list + + ${dim("# Show session details")} + multica session show abc12345 + + ${dim("# Delete a session")} + multica session delete abc12345 + + ${dim("# Resume a session")} + multica --session abc12345 + multica chat --session abc12345 +`); +} + +function formatDate(date: Date): string { + return date.toLocaleString(); +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +interface SessionInfo { + id: string; + path: string; + size: number; + mtime: Date; + messageCount: number; +} + +function getSessionInfo(sessionId: string): SessionInfo | null { + const sessionPath = join(SESSIONS_DIR, `${sessionId}.jsonl`); + if (!existsSync(sessionPath)) { + return null; + } + + const stat = statSync(sessionPath); + const content = readFileSync(sessionPath, "utf8"); + const lines = content.trim().split("\n").filter(Boolean); + + return { + id: sessionId, + path: sessionPath, + size: stat.size, + mtime: stat.mtime, + messageCount: lines.length, + }; +} + +function listSessions(): SessionInfo[] { + if (!existsSync(SESSIONS_DIR)) { + return []; + } + + const files = readdirSync(SESSIONS_DIR); + const sessions: SessionInfo[] = []; + + for (const file of files) { + if (!file.endsWith(".jsonl")) continue; + const sessionId = file.replace(".jsonl", ""); + const info = getSessionInfo(sessionId); + if (info) { + sessions.push(info); + } + } + + // Sort by modification time, newest first + sessions.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + + return sessions; +} + +function cmdList() { + const sessions = listSessions(); + + if (sessions.length === 0) { + console.log("No sessions found."); + console.log(`${dim("Sessions are stored in:")} ${SESSIONS_DIR}`); + return; + } + + console.log(`\n${cyan("Sessions:")}\n`); + + for (const session of sessions) { + const shortId = session.id.slice(0, 8); + console.log(` ${yellow(shortId)} ${dim(formatDate(session.mtime))} ${dim(`${session.messageCount} msgs`)} ${dim(formatSize(session.size))}`); + } + + console.log(`\n${dim(`Total: ${sessions.length} session(s)`)}`); + console.log(`${dim("Resume with:")} multica --session `); +} + +function cmdShow(sessionId: string | undefined) { + if (!sessionId) { + console.error("Error: Session ID is required"); + console.error("Usage: multica session show "); + process.exit(1); + } + + // Support partial ID matching + const sessions = listSessions(); + const matches = sessions.filter((s) => s.id.startsWith(sessionId)); + + if (matches.length === 0) { + console.error(`Error: Session "${sessionId}" not found`); + process.exit(1); + } + + if (matches.length > 1) { + console.error(`Error: Multiple sessions match "${sessionId}":`); + for (const s of matches) { + console.error(` ${s.id.slice(0, 8)}`); + } + console.error("Please provide a more specific ID."); + process.exit(1); + } + + const session = matches[0]; + const content = readFileSync(session.path, "utf8"); + const lines = content.trim().split("\n").filter(Boolean); + + console.log(`\n${cyan("Session:")} ${yellow(session.id)}`); + console.log(`${dim("Path:")} ${session.path}`); + console.log(`${dim("Size:")} ${formatSize(session.size)}`); + console.log(`${dim("Modified:")} ${formatDate(session.mtime)}`); + console.log(`${dim("Messages:")} ${session.messageCount}`); + console.log(""); + console.log(cyan("─".repeat(60))); + console.log(""); + + // Parse and display messages + for (const line of lines) { + try { + const msg = JSON.parse(line); + const role = msg.role || "unknown"; + const roleColor = role === "user" ? green : role === "assistant" ? cyan : dim; + + console.log(`${roleColor(`[${role}]`)}`); + + if (typeof msg.content === "string") { + // Truncate long content + const preview = msg.content.length > 500 + ? msg.content.slice(0, 500) + "..." + : msg.content; + console.log(preview); + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "text") { + const preview = part.text.length > 500 + ? part.text.slice(0, 500) + "..." + : part.text; + console.log(preview); + } else if (part.type === "tool_use") { + console.log(`${dim(`[Tool: ${part.name}]`)}`); + } else if (part.type === "tool_result") { + console.log(`${dim(`[Tool Result]`)}`); + } + } + } + console.log(""); + } catch { + // Skip invalid JSON lines + } + } + + console.log(cyan("─".repeat(60))); + console.log(`\n${dim("Resume with:")} multica --session ${session.id.slice(0, 8)}`); +} + +function cmdDelete(sessionId: string | undefined) { + if (!sessionId) { + console.error("Error: Session ID is required"); + console.error("Usage: multica session delete "); + process.exit(1); + } + + // Support partial ID matching + const sessions = listSessions(); + const matches = sessions.filter((s) => s.id.startsWith(sessionId)); + + if (matches.length === 0) { + console.error(`Error: Session "${sessionId}" not found`); + process.exit(1); + } + + if (matches.length > 1) { + console.error(`Error: Multiple sessions match "${sessionId}":`); + for (const s of matches) { + console.error(` ${s.id.slice(0, 8)}`); + } + console.error("Please provide a more specific ID."); + process.exit(1); + } + + const session = matches[0]; + + try { + unlinkSync(session.path); + console.log(`${green("Deleted:")} ${session.id}`); + } catch (err) { + console.error(`${red("Error:")} Failed to delete session: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } +} + +export async function sessionCommand(args: string[]): Promise { + const command = (args[0] || "help") as Command; + const arg1 = args[1]; + + if (args.includes("--help") || args.includes("-h")) { + printHelp(); + return; + } + + switch (command) { + case "list": + cmdList(); + break; + case "show": + cmdShow(arg1); + break; + case "delete": + cmdDelete(arg1); + break; + case "help": + default: + printHelp(); + break; + } +} diff --git a/src/agent/cli/commands/skills.ts b/src/agent/cli/commands/skills.ts new file mode 100644 index 00000000..9ec6126f --- /dev/null +++ b/src/agent/cli/commands/skills.ts @@ -0,0 +1,511 @@ +/** + * Skills command - Manage agent skills + * + * Usage: + * multica skills list List all skills + * multica skills status [id] Show skill status + * multica skills install Install skill dependencies + * multica skills add Add skill from GitHub + * multica skills remove Remove a skill + */ + +import { + SkillManager, + installSkill, + getInstallOptions, + addSkill, + removeSkill, + listInstalledSkills, + checkEligibilityDetailed, + type DiagnosticItem, +} from "../../skills/index.js"; +import { credentialManager } from "../../credentials.js"; +import { cyan, yellow, green, dim, red } from "../colors.js"; + +type Command = "list" | "status" | "install" | "add" | "remove" | "help"; + +interface ParsedArgs { + command: Command; + args: string[]; + verbose: boolean; + force: boolean; +} + +function printHelp() { + console.log(` +${cyan("Usage:")} multica skills [options] + +${cyan("Commands:")} + ${yellow("list")} List all available skills + ${yellow("status")} [id] Show skill status (detailed diagnostics) + ${yellow("install")} Install dependencies for a skill + ${yellow("add")} Add skill from GitHub + ${yellow("remove")} Remove an installed skill + ${yellow("help")} Show this help + +${cyan("Options:")} + ${yellow("-v, --verbose")} Show more details + ${yellow("-f, --force")} Force overwrite existing skill + +${cyan("Source Formats:")} ${dim("(for add command)")} + owner/repo Clone entire repository + owner/repo/skill-name Clone single skill directory + owner/repo@branch Clone specific branch/tag + +${cyan("Examples:")} + ${dim("# List all skills")} + multica skills list + + ${dim("# Check skill status")} + multica skills status commit + + ${dim("# Install skill dependencies")} + multica skills install nano-pdf + + ${dim("# Add skills from GitHub")} + multica skills add vercel-labs/agent-skills + multica skills add vercel-labs/agent-skills/perplexity + + ${dim("# Remove a skill")} + multica skills remove agent-skills +`); +} + +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 }; +} + +function cmdList(manager: SkillManager, verbose: boolean): void { + const skills = manager.listAllSkillsWithStatus(); + + if (skills.length === 0) { + console.log("No skills found."); + return; + } + + console.log(`\n${cyan("Available Skills:")}\n`); + + for (const skill of skills) { + const status = skill.eligible ? "✓" : "✗"; + const statusColor = skill.eligible ? green : red; + + console.log(` ${statusColor(status)} ${skill.emoji} ${skill.name} (${skill.id})`); + console.log(` ${dim(skill.description)}`); + console.log(` ${dim(`Source: ${skill.source}`)}`); + + if (!skill.eligible && skill.reasons) { + for (const reason of skill.reasons) { + console.log(` ${red(`└ ${reason}`)}`); + } + } + + if (verbose) { + console.log(); + } + } + + console.log(); + const eligibleCount = skills.filter((s) => s.eligible).length; + console.log(`${dim(`Total: ${skills.length} skills (${eligibleCount} eligible)`)}`); +} + +function cmdStatus(manager: SkillManager, skillId?: string, verbose?: boolean): void { + if (!skillId) { + cmdStatusSummary(manager, verbose); + return; + } + 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(`\n${cyan("Skills Status Summary:")}\n`); + console.log(` Total: ${skills.length}`); + console.log(` ${green(`Eligible: ${eligible.length}`)}`); + console.log(` ${red(`Ineligible: ${ineligible.length}`)}`); + + if (ineligible.length > 0) { + console.log("\n" + dim("─".repeat(45))); + console.log("Ineligible Skills:"); + + const byIssue: Map = 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); + } + } + + const issueLabels: Record = { + 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 ${yellow(label + ":")}`); + 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(` ${cyan(`Hint: ${diag.hint}`)}`); + } + } else { + console.log(` - ${id}`); + } + } + } + + console.log("\n" + dim("─".repeat(45))); + console.log(`${cyan("Tip:")} Run 'multica skills status ' for detailed diagnostics`); + } +} + +function cmdStatusDetail(manager: SkillManager, skillId: string, verbose?: boolean): void { + const skill = manager.getSkillFromAll(skillId); + if (!skill) { + console.error(`${red("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 ? green("✓ ELIGIBLE") : red("✗ NOT ELIGIBLE")}`); + + if (!detailed.eligible && detailed.diagnostics) { + console.log("\nDiagnostics:"); + for (const diag of detailed.diagnostics) { + printDiagnostic(diag); + } + } + + 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); + } + } + + 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 ? green("✓") : red("✗"); + console.log(` ${status} [${opt.id}] ${opt.label}`); + if (!opt.available && opt.reason) { + console.log(` └ ${opt.reason}`); + } + } + } + + if (!detailed.eligible) { + console.log("\n" + "─".repeat(50)); + console.log(`${yellow("Quick Actions:")}`); + + for (const diag of detailed.diagnostics ?? []) { + if (diag.hint) { + console.log(` → ${diag.hint}`); + } + } + + if (installOptions.length > 0) { + console.log(` → multica skills install ${skillId}`); + } + } +} + +function printDiagnostic(diag: DiagnosticItem): void { + const typeColors: Record string> = { + disabled: yellow, + not_in_allowlist: yellow, + platform: dim, + binary: red, + any_binary: red, + env: cyan, + config: cyan, + }; + + const color = typeColors[diag.type] ?? dim; + + console.log(`\n ${color(`[${diag.type.toUpperCase()}]`)}`); + console.log(` ${diag.message}`); + + if (diag.values && diag.values.length > 0) { + console.log(` Values: ${diag.values.join(", ")}`); + } + + if (diag.hint) { + console.log(` ${cyan(`💡 ${diag.hint}`)}`); + } +} + +function printRequirementStatus( + label: string, + items: string[], + checker: (items: string[]) => Map, + 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 ? green("✓") : red("✗"); + + console.log(`\n ${statusIcon} ${label}:`); + for (const [name, ok] of status) { + const icon = ok ? green("✓") : red("✗"); + console.log(` ${icon} ${name}`); + } +} + +function checkBinaries(bins: string[]): Map { + const result = new Map(); + 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 { + const result = new Map(); + for (const env of envs) { + result.set(env, credentialManager.hasEnv(env)); + } + return result; +} + +async function cmdInstall(manager: SkillManager, skillId: string, installId?: string): Promise { + const skill = manager.getSkillFromAll(skillId); + if (!skill) { + console.error(`${red("Error:")} Skill not found: ${skillId}`); + process.exit(1); + } + + const installOptions = getInstallOptions(skill); + if (installOptions.length === 0) { + console.error(`${red("Error:")} Skill '${skillId}' has no install specifications.`); + process.exit(1); + } + + 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: multica skills install ${skillId} `); + return; + } + + console.log(`\nInstalling dependencies for '${skillId}'...`); + + const result = await installSkill({ + skill, + installId, + }); + + if (result.ok) { + console.log(`\n${green(`✓ ${result.message}`)}`); + } else { + console.error(`\n${red(`✗ ${result.message}`)}`); + if (result.stderr) { + console.error("\nError output:"); + console.error(result.stderr); + } + process.exit(1); + } +} + +async function cmdAdd(source: string, force: boolean): Promise { + console.log(`\nAdding skill from '${source}'...`); + + const result = await addSkill({ + source, + force, + }); + + if (result.ok) { + console.log(`\n${green(`✓ ${result.message}`)}`); + 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${red(`✗ ${result.message}`)}`); + process.exit(1); + } +} + +async function cmdRemove(name: string): Promise { + console.log(`\nRemoving skill '${name}'...`); + + const result = await removeSkill(name); + + if (result.ok) { + console.log(`\n${green(`✓ ${result.message}`)}`); + } else { + console.error(`\n${red(`✗ ${result.message}`)}`); + process.exit(1); + } +} + +async function cmdListInstalled(): Promise { + const skills = await listInstalledSkills(); + + if (skills.length === 0) { + console.log("\nNo skills installed in ~/.super-multica/skills/"); + console.log("Use 'multica skills add ' to add skills."); + return; + } + + console.log("\nInstalled skills (~/.super-multica/skills/):\n"); + for (const name of skills) { + console.log(` - ${name}`); + } + console.log(`\n${dim(`Total: ${skills.length} installed`)}`); +} + +export async function skillsCommand(args: string[]): Promise { + const { command, args: cmdArgs, verbose, force } = parseArgs(args); + + if (command === "help") { + printHelp(); + return; + } + + switch (command) { + case "add": + if (!cmdArgs[0]) { + console.error("Usage: multica skills add [--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(cmdArgs[0], force); + return; + + case "remove": + if (!cmdArgs[0]) { + console.error("Usage: multica skills remove "); + await cmdListInstalled(); + process.exit(1); + } + await cmdRemove(cmdArgs[0]); + return; + } + + const manager = new SkillManager(); + + switch (command) { + case "list": + cmdList(manager, verbose); + break; + + case "status": + cmdStatus(manager, cmdArgs[0], verbose); + break; + + case "install": + if (!cmdArgs[0]) { + console.error("Usage: multica skills install [install-id]"); + process.exit(1); + } + await cmdInstall(manager, cmdArgs[0], cmdArgs[1]); + break; + + default: + console.error(`Unknown command: ${command}`); + printHelp(); + process.exit(1); + } +} diff --git a/src/agent/cli/commands/tools.ts b/src/agent/cli/commands/tools.ts new file mode 100644 index 00000000..2b8c998d --- /dev/null +++ b/src/agent/cli/commands/tools.ts @@ -0,0 +1,210 @@ +/** + * Tools command - Inspect and test tool policies + * + * Usage: + * multica tools list [options] List available tools + * multica tools groups Show all tool groups + * multica tools profiles Show all tool profiles + */ + +import { createAllTools } from "../../tools.js"; +import { filterTools, type ToolsConfig } from "../../tools/policy.js"; +import { TOOL_GROUPS, TOOL_PROFILES, expandToolGroups } from "../../tools/groups.js"; +import { cyan, yellow, green, dim } from "../colors.js"; + +type Command = "list" | "groups" | "profiles" | "help"; + +interface ToolsOptions { + command: Command; + profile?: string; + allow?: string[]; + deny?: string[]; + provider?: string; + isSubagent?: boolean; +} + +function printHelp() { + console.log(` +${cyan("Usage:")} multica tools [options] + +${cyan("Commands:")} + ${yellow("list")} List available tools (with optional filtering) + ${yellow("groups")} Show all tool groups + ${yellow("profiles")} Show all tool profiles + ${yellow("help")} Show this help + +${cyan("Options for 'list':")} + ${yellow("--profile")} PROFILE Apply profile filter (minimal, coding, web, full) + ${yellow("--allow")} TOOLS Allow specific tools (comma-separated) + ${yellow("--deny")} TOOLS Deny specific tools (comma-separated) + ${yellow("--provider")} NAME Apply provider-specific rules + ${yellow("--subagent")} Apply subagent restrictions + +${cyan("Examples:")} + ${dim("# List all tools")} + multica tools list + + ${dim("# List tools with profile")} + multica tools list --profile coding + + ${dim("# List tools with allow/deny")} + multica tools list --profile coding --deny exec + multica tools list --allow group:fs,web_fetch + + ${dim("# Show tool groups")} + multica tools groups +`); +} + +function parseArgs(argv: string[]): ToolsOptions { + const args = [...argv]; + const command = (args.shift() || "help") as Command; + + if (command === "--help" || command === "-h") { + return { command: "help" }; + } + + const opts: ToolsOptions = { command }; + + while (args.length > 0) { + const arg = args.shift(); + if (!arg) break; + + if (arg === "--help" || arg === "-h") { + return { command: "help" }; + } + if (arg === "--profile") { + opts.profile = args.shift(); + 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") { + opts.provider = args.shift(); + continue; + } + if (arg === "--subagent") { + opts.isSubagent = true; + continue; + } + } + + return opts; +} + +function cmdList(opts: ToolsOptions) { + const allTools = createAllTools(process.cwd()); + + console.log(`\n${cyan("Tools Overview")}`); + console.log(`Total tools available: ${allTools.length}\n`); + + // Build config + let config: ToolsConfig | undefined; + if (opts.profile || opts.allow || opts.deny) { + config = {}; + if (opts.profile) { + config.profile = opts.profile as ToolsConfig["profile"]; + } + 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(` ${dim("Profile:")} ${yellow(opts.profile)}`); + if (opts.allow) console.log(` ${dim("Allow:")} ${opts.allow.join(", ")}`); + if (opts.deny) console.log(` ${dim("Deny:")} ${opts.deny.join(", ")}`); + if (opts.provider) console.log(` ${dim("Provider:")} ${opts.provider}`); + if (opts.isSubagent) console.log(` ${dim("Subagent:")} true`); + console.log(""); + console.log(`Tools after filtering: ${green(String(filtered.length))}`); + console.log(""); + } + + console.log("Tools:"); + for (const tool of filtered) { + const desc = tool.description?.slice(0, 55) || ""; + console.log(` ${yellow(tool.name.padEnd(15))} ${dim(desc)}${desc.length >= 55 ? "..." : ""}`); + } + + if (filtered.length < allTools.length) { + const removed = allTools.filter((t) => !filtered.find((f) => f.name === t.name)); + console.log(""); + console.log(`${dim(`Filtered out (${removed.length}):`)}`); + for (const tool of removed) { + console.log(` ${dim(tool.name)}`); + } + } +} + +function cmdGroups() { + console.log(`\n${cyan("Tool Groups:")}\n`); + for (const [name, tools] of Object.entries(TOOL_GROUPS)) { + console.log(` ${yellow(name)}:`); + console.log(` ${dim(tools.join(", "))}`); + console.log(""); + } +} + +function cmdProfiles() { + console.log(`\n${cyan("Tool Profiles:")}\n`); + for (const [name, policy] of Object.entries(TOOL_PROFILES)) { + console.log(` ${yellow(name)}:`); + if (policy.allow) { + const expanded = expandToolGroups(policy.allow); + console.log(` ${dim("Allow:")} ${policy.allow.join(", ")}`); + console.log(` ${dim("Expands to:")} ${expanded.join(", ")}`); + } else { + console.log(` ${dim("Allow:")} (all tools)`); + } + if (policy.deny) { + console.log(` ${dim("Deny:")} ${policy.deny.join(", ")}`); + } + console.log(""); + } +} + +export async function toolsCommand(args: string[]): Promise { + const opts = parseArgs(args); + + switch (opts.command) { + case "list": + cmdList(opts); + break; + case "groups": + cmdGroups(); + break; + case "profiles": + cmdProfiles(); + break; + case "help": + default: + printHelp(); + break; + } +} diff --git a/src/agent/cli/index.ts b/src/agent/cli/index.ts new file mode 100644 index 00000000..3389a8a5 --- /dev/null +++ b/src/agent/cli/index.ts @@ -0,0 +1,178 @@ +#!/usr/bin/env node +/** + * Multica CLI - Unified command-line interface + * + * Usage: + * multica Interactive mode (default) + * multica run Run a single prompt + * multica chat Interactive mode (explicit) + * multica session Session management + * multica profile Profile management + * multica skills Skills management + * multica tools Tool policy inspection + * multica credentials Credentials management + * multica dev [service] Development servers + * multica help Show help + */ + +import { cyan, yellow, green, dim, brightCyan } from "./colors.js"; + +// Subcommand handlers (lazy imports for faster startup) +type SubcommandHandler = (args: string[]) => Promise; + +const subcommands: Record Promise> = { + run: async () => (await import("./commands/run.js")).runCommand, + chat: async () => (await import("./commands/chat.js")).chatCommand, + session: async () => (await import("./commands/session.js")).sessionCommand, + profile: async () => (await import("./commands/profile.js")).profileCommand, + skills: async () => (await import("./commands/skills.js")).skillsCommand, + tools: async () => (await import("./commands/tools.js")).toolsCommand, + credentials: async () => (await import("./commands/credentials.js")).credentialsCommand, + dev: async () => (await import("./commands/dev.js")).devCommand, +}; + +function printHelp() { + console.log(` +${brightCyan("Multica CLI")} - AI Agent Framework + +${cyan("Usage:")} + ${yellow("multica")} Start interactive mode (default) + ${yellow("multica run")} Run a single prompt + ${yellow("multica chat")} [options] Start interactive mode + ${yellow("multica session")} Manage sessions + ${yellow("multica profile")} Manage agent profiles + ${yellow("multica skills")} Manage skills + ${yellow("multica tools")} Inspect tool policies + ${yellow("multica credentials")} Manage credentials + ${yellow("multica dev")} [service] Start development servers + ${yellow("multica help")} Show this help + +${cyan("Agent Options:")} ${dim("(for run/chat)")} + ${yellow("--profile")} ID Load agent profile + ${yellow("--provider")} NAME LLM provider (openai, anthropic, kimi, etc.) + ${yellow("--model")} NAME Model name + ${yellow("--system")} TEXT System prompt + ${yellow("--session")} ID Resume session + ${yellow("--cwd")} DIR Working directory + +${cyan("Commands:")} + ${green("session")} + list List all sessions + show Show session details + delete Delete a session + + ${green("profile")} + list List all profiles + new Create a new profile + show Show profile contents + edit Open profile in file manager + delete Delete a profile + + ${green("skills")} + list List all skills + status [id] Show skill status + install Install skill dependencies + add Add skill from GitHub + remove Remove a skill + + ${green("tools")} + list [--profile P] List tools (with optional filter) + groups Show tool groups + profiles Show tool profiles + + ${green("credentials")} + init [--force] Create credential files + show Show credential paths + edit Open credentials in editor + + ${green("dev")} + ${dim("(default)")} Start all services (gateway + console + web) + gateway Start gateway only (:3000) + console Start console only (:4000) + web Start web app only (:3001) + desktop Start desktop app + +${cyan("Examples:")} + ${dim("# Start interactive mode")} + multica + + ${dim("# Run a single prompt")} + multica run "What files are in this directory?" + + ${dim("# Use a specific profile")} + multica chat --profile coder + + ${dim("# Resume a session")} + multica --session abc123 + + ${dim("# Start development servers")} + multica dev + multica dev gateway +`); +} + +function printVersion() { + // Read version from package.json would be ideal, but for now just print a placeholder + console.log("multica 1.0.0"); +} + +async function main() { + const args = process.argv.slice(2); + + // Handle global flags + if (args.includes("--help") || args.includes("-h")) { + // If help is requested with a subcommand, delegate to that subcommand + const firstArg = args[0]; + if (firstArg && !firstArg.startsWith("-") && subcommands[firstArg]) { + const handler = await subcommands[firstArg](); + await handler(["--help"]); + return; + } + printHelp(); + return; + } + + if (args.includes("--version") || args.includes("-V")) { + printVersion(); + return; + } + + // Determine command + const firstArg = args[0]; + + // No args or starts with -- means interactive mode + if (!firstArg || firstArg.startsWith("-")) { + const chatHandler = await subcommands.chat!(); + await chatHandler(args); + return; + } + + // Check if it's "help" command + if (firstArg === "help") { + const subcommand = args[1]; + if (subcommand && subcommands[subcommand]) { + const handler = await subcommands[subcommand](); + await handler(["--help"]); + return; + } + printHelp(); + return; + } + + // Check if it's a known subcommand + if (subcommands[firstArg]) { + const handler = await subcommands[firstArg](); + await handler(args.slice(1)); + return; + } + + // Unknown command - show error and help + console.error(`Unknown command: ${firstArg}`); + console.error(`Run 'multica help' for usage information.`); + process.exit(1); +} + +main().catch((err) => { + console.error(err?.stack || String(err)); + process.exit(1); +});