Compare commits

...
5 Commits
Author SHA1 Message Date
Mervin Praison a2d181dd74 Release v2.6.2 2025-12-27 07:58:18 +00:00
Mervin Praison 7411fdbb52 Release v2.6.1 2025-12-26 19:41:45 +00:00
Mervin Praison e7a8ce8eb7 Release v2.6.0 2025-12-26 19:31:15 +00:00
Mervin Praison 61fb094306 Release v1.2.3 - add comprehensive CLI feature exports and command definitions
- Bump praisonai-ts version from 1.2.2 to 1.2.3
- Add 18 new command definitions to CLI spec: query-rewrite, prompt-expand, router, context, handoff, vector, observability, voice, reranker, graph-rag, cache, db, scheduler, jobs, checkpoints, fast-context, autonomy, sandbox, repo-map, git, n8n, external-agents, flow, cost, interactive
- Export FileMemory and AutoMemory classes with factory functions and types from memory module
- Export
2025-12-26 18:41:37 +00:00
Mervin Praison 018e8c78ab refactor(ts): update CLI to v1.0.0 spec and change default model to gpt-4o-mini
- Change default agent model from gpt-5-nano to gpt-4o-mini in simple.ts
- Refactor CLI to implement CLI Spec v1.0.0 with lazy command loading
- Add parseArgs() function for spec-compliant argument parsing
- Add executeCommand() for dynamic command module loading
- Add comprehensive command definitions: memory, session, knowledge, skills, mcp, auto, image, research, guardrail, telemetry, planning
- Export CLI_SPEC_VERSION,
2025-12-26 14:07:05 +00:00
103 changed files with 20050 additions and 451 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.5.7" \
"praisonai>=2.6.2" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.5.7" \
"praisonai>=2.6.2" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.5.7" \
"praisonai>=2.6.2" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -61,6 +61,57 @@ class Agent:
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
@classmethod
def from_template(
cls,
uri: str,
config: Optional[Dict[str, Any]] = None,
offline: bool = False,
**kwargs
) -> 'Agent':
"""
Create an Agent from a template.
Args:
uri: Template URI (local path, package ref, or github ref)
Examples:
- "./my-template" (local path)
- "transcript-generator" (default recipes repo)
- "github:owner/repo/[email protected]" (GitHub with version)
- "package:agent_recipes/transcript-generator" (installed package)
config: Optional configuration overrides
offline: If True, only use cached templates (no network)
**kwargs: Additional Agent constructor arguments
Returns:
Configured Agent instance
Example:
```python
from praisonaiagents import Agent
# From default recipes repo
agent = Agent.from_template("transcript-generator")
result = agent.chat("Transcribe ./audio.mp3")
# With config overrides
agent = Agent.from_template(
"data-transformer",
config={"output_format": "json"},
verbose=True
)
```
"""
try:
# Lazy import to avoid circular dependencies and keep core SDK lean
from praisonai.templates.loader import create_agent_from_template
return create_agent_from_template(uri, config=config, offline=offline, **kwargs)
except ImportError:
raise ImportError(
"Template support requires the 'praisonai' package. "
"Install with: pip install praisonai"
)
def _generate_tool_definition(self, function_name):
"""
Generate a tool definition from a function name by inspecting the function.
@@ -313,6 +313,56 @@ class Workflow:
"planning_llm": self.planning_llm
}
@classmethod
def from_template(
cls,
uri: str,
config: Optional[Dict[str, Any]] = None,
offline: bool = False,
**kwargs
) -> 'Workflow':
"""
Create a Workflow from a template.
Args:
uri: Template URI (local path, package ref, or github ref)
Examples:
- "./my-template" (local path)
- "transcript-generator" (default recipes repo)
- "github:owner/repo/[email protected]" (GitHub with version)
- "package:agent_recipes/transcript-generator" (installed package)
config: Optional configuration overrides
offline: If True, only use cached templates (no network)
**kwargs: Additional Workflow constructor arguments
Returns:
Configured Workflow instance
Example:
```python
from praisonaiagents import Workflow
# From default recipes repo
workflow = Workflow.from_template("transcript-generator")
result = workflow.run("./audio.mp3")
# With config overrides
workflow = Workflow.from_template(
"data-transformer",
config={"output_format": "json"}
)
```
"""
try:
# Lazy import to avoid circular dependencies and keep core SDK lean
from praisonai.templates.loader import create_workflow_from_template
return create_workflow_from_template(uri, config=config, offline=offline, **kwargs)
except ImportError:
raise ImportError(
"Template support requires the 'praisonai' package. "
"Install with: pip install praisonai"
)
def run(
self,
input: str = "",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.3.4"
version = "0.4.0"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.3.4"
version = "0.4.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "praisonai",
"version": "1.2.2",
"version": "1.2.4",
"description": "PraisonAI TypeScript AI Agents Framework - Node.js, npm, and Javascript AI Agents Framework",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+64
View File
@@ -0,0 +1,64 @@
{
"command": "chat",
"description": "Golden test fixtures for chat command JSON output parity",
"test_cases": [
{
"name": "simple_chat_success",
"input": {
"args": ["Hello, how are you?"],
"options": {
"output": "json"
}
},
"expected_schema": {
"success": true,
"data": {
"response": "string"
},
"meta": {
"duration_ms": "number",
"model": "string",
"tokens": {
"input": "number",
"output": "number"
}
}
}
},
{
"name": "chat_missing_prompt",
"input": {
"args": [],
"options": {
"output": "json"
}
},
"expected": {
"success": false,
"error": {
"code": "MISSING_ARGUMENT",
"message": "Please provide a prompt"
}
}
},
{
"name": "chat_with_model",
"input": {
"args": ["Test prompt"],
"options": {
"model": "openai/gpt-4o-mini",
"output": "json"
}
},
"expected_schema": {
"success": true,
"data": {
"response": "string"
},
"meta": {
"model": "openai/gpt-4o-mini"
}
}
}
]
}
+43
View File
@@ -0,0 +1,43 @@
{
"command": "run",
"description": "Golden test fixtures for run command JSON output parity",
"test_cases": [
{
"name": "run_success",
"input": {
"args": ["Analyze this data"],
"options": {
"output": "json"
}
},
"expected_schema": {
"success": true,
"data": {
"result": "string",
"agent": "string",
"task": "string"
},
"meta": {
"duration_ms": "number",
"model": "string"
}
}
},
{
"name": "run_missing_task",
"input": {
"args": [],
"options": {
"output": "json"
}
},
"expected": {
"success": false,
"error": {
"code": "MISSING_ARGUMENT",
"message": "Please provide a task"
}
}
}
]
}
@@ -0,0 +1,43 @@
{
"command": "workflow",
"description": "Golden test fixtures for workflow command JSON output parity",
"test_cases": [
{
"name": "workflow_success",
"input": {
"args": ["workflow.yaml"],
"options": {
"output": "json"
}
},
"expected_schema": {
"success": true,
"data": {
"workflow": "string",
"steps": "number",
"results": "object"
},
"meta": {
"duration_ms": "number",
"model": "string"
}
}
},
{
"name": "workflow_missing_file",
"input": {
"args": [],
"options": {
"output": "json"
}
},
"expected": {
"success": false,
"error": {
"code": "MISSING_ARGUMENT",
"message": "Please provide a workflow file path"
}
}
}
]
}
+1 -1
View File
@@ -31,7 +31,7 @@ export class Agent {
this.name = config.name || `Agent_${Math.random().toString(36).substr(2, 9)}`;
this.verbose = config.verbose ?? process.env.PRAISON_VERBOSE !== 'false';
this.pretty = config.pretty ?? process.env.PRAISON_PRETTY === 'true';
this.llm = config.llm || 'gpt-5-nano';
this.llm = config.llm || 'gpt-4o-mini';
this.markdown = config.markdown ?? true;
this.stream = config.stream ?? true;
this.tools = config.tools;
+90
View File
@@ -0,0 +1,90 @@
/**
* Auto command - AutoAgents for automatic agent generation
*/
import { AutoAgents, createAutoAgents } from '../../auto';
import { resolveConfig } from '../config/resolve';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface AutoOptions {
model?: string;
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
pattern?: string;
agents?: number;
}
export async function execute(args: string[], options: AutoOptions): Promise<void> {
const topic = args.join(' ');
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
const config = resolveConfig(options);
if (!topic) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a topic for auto-generation'));
} else {
await pretty.error('Please provide a topic for auto-generation');
await pretty.dim('Usage: praisonai-ts auto "Create a research team"');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const startTime = Date.now();
try {
if (outputFormat !== 'json') {
await pretty.info(`Generating agents for: "${topic}"`);
}
const autoAgents = createAutoAgents({
llm: config.model,
pattern: options.pattern as any,
verbose: options.verbose
});
const result = await autoAgents.generate(topic);
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
topic,
agents: result.agents,
tasks: result.tasks,
pattern: result.pattern
}, {
duration_ms: duration,
model: config.model
}));
} else {
await pretty.heading('Generated Team');
await pretty.plain(`Pattern: ${result.pattern}`);
await pretty.newline();
await pretty.plain('Agents:');
for (const agent of result.agents) {
await pretty.plain(` • ${agent.name}: ${agent.role}`);
}
await pretty.newline();
await pretty.plain('Tasks:');
for (const task of result.tasks) {
await pretty.plain(` • ${task.description?.substring(0, 60)}...`);
}
await pretty.newline();
await pretty.success(`Generated in ${duration}ms`);
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
@@ -0,0 +1,163 @@
/**
* CLI command: autonomy
* Autonomy mode management for agent actions
*/
import { AutonomyManager, createAutonomyManager, MODE_POLICIES, type AutonomyMode } from '../features/autonomy-mode';
let manager: AutonomyManager | null = null;
function getManager(): AutonomyManager {
if (!manager) {
manager = createAutonomyManager();
}
return manager;
}
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const subcommand = args[0] || 'help';
const isJson = Boolean(options.output === 'json' || options.json);
switch (subcommand) {
case 'status':
await handleStatus(isJson);
break;
case 'set':
await handleSet(args.slice(1), isJson);
break;
case 'policies':
await handlePolicies(args.slice(1), isJson);
break;
case 'reset':
await handleReset(isJson);
break;
case 'help':
default:
showHelp(isJson);
}
}
async function handleStatus(isJson: boolean): Promise<void> {
const mgr = getManager();
const summary = mgr.getSummary();
const mode = mgr.getMode();
const actionCount = mgr.getActionCount();
if (isJson) {
console.log(JSON.stringify({
success: true,
mode,
actionCount,
policies: MODE_POLICIES[mode]
}, null, 2));
} else {
console.log(summary);
}
}
async function handleSet(args: string[], isJson: boolean): Promise<void> {
const mode = args[0] as AutonomyMode;
if (!mode || !['suggest', 'auto_edit', 'full_auto'].includes(mode)) {
console.error('Error: Valid mode required (suggest, auto_edit, full_auto)');
process.exit(1);
}
const mgr = getManager();
mgr.setMode(mode);
if (isJson) {
console.log(JSON.stringify({ success: true, mode }));
} else {
console.log(`✓ Autonomy mode set to: ${mode}`);
console.log('\nPolicies for this mode:');
for (const policy of MODE_POLICIES[mode]) {
const status = policy.requiresApproval ? '⚠️ requires approval' : '✓ auto-approved';
console.log(` ${policy.action}: ${status}`);
}
}
}
async function handlePolicies(args: string[], isJson: boolean): Promise<void> {
const mode = (args[0] as AutonomyMode) || 'suggest';
if (!['suggest', 'auto_edit', 'full_auto'].includes(mode)) {
console.error('Error: Valid mode required (suggest, auto_edit, full_auto)');
process.exit(1);
}
const policies = MODE_POLICIES[mode];
if (isJson) {
console.log(JSON.stringify({ success: true, mode, policies }, null, 2));
} else {
console.log(`Policies for mode: ${mode}\n`);
for (const policy of policies) {
const status = policy.requiresApproval ? '⚠️ requires approval' : '✓ auto-approved';
console.log(` ${policy.action.padEnd(20)} ${status}`);
}
}
}
async function handleReset(isJson: boolean): Promise<void> {
const mgr = getManager();
mgr.clearRemembered();
mgr.resetActionCount();
mgr.setMode('suggest');
if (isJson) {
console.log(JSON.stringify({ success: true, message: 'Autonomy state reset' }));
} else {
console.log('✓ Autonomy state reset');
console.log(' Mode: suggest');
console.log(' Remembered decisions: cleared');
console.log(' Action count: 0');
}
}
function showHelp(isJson: boolean): void {
const help = {
command: 'autonomy',
description: 'Autonomy mode management for agent actions',
subcommands: {
status: 'Show current autonomy status',
set: 'Set autonomy mode',
policies: 'Show policies for a mode',
reset: 'Reset autonomy state'
},
modes: {
suggest: 'Requires approval for most actions (default)',
auto_edit: 'Auto-approves file edits, requires approval for others',
full_auto: 'Auto-approves most actions (use with caution)'
},
flags: {
'--json': 'Output in JSON format'
},
examples: [
'praisonai-ts autonomy status',
'praisonai-ts autonomy set suggest',
'praisonai-ts autonomy set auto_edit',
'praisonai-ts autonomy set full_auto',
'praisonai-ts autonomy policies suggest',
'praisonai-ts autonomy reset'
]
};
if (isJson) {
console.log(JSON.stringify(help, null, 2));
} else {
console.log('Autonomy - Agent action approval management\n');
console.log('Subcommands:');
for (const [cmd, desc] of Object.entries(help.subcommands)) {
console.log(` ${cmd.padEnd(12)} ${desc}`);
}
console.log('\nModes:');
for (const [mode, desc] of Object.entries(help.modes)) {
console.log(` ${mode.padEnd(12)} ${desc}`);
}
console.log('\nExamples:');
for (const ex of help.examples) {
console.log(` ${ex}`);
}
}
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Cache command - Caching management
*/
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface CacheOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: CacheOptions): Promise<void> {
const action = args[0] || 'help';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (action) {
case 'info':
await showInfo(outputFormat);
break;
case 'providers':
await listProviders(outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function showInfo(outputFormat: string): Promise<void> {
const info = {
feature: 'Cache',
description: 'Caching system for LLM responses and computations',
providers: [
{ name: 'MemoryCache', description: 'In-memory cache (default)' },
{ name: 'FileCache', description: 'File-based persistent cache' }
],
capabilities: [
'Cache LLM responses',
'Reduce API costs',
'Improve response times',
'TTL-based expiration',
'Configurable cache keys'
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(info));
} else {
await pretty.heading('Cache');
await pretty.plain(info.description);
await pretty.newline();
await pretty.plain('Providers:');
for (const p of info.providers) {
await pretty.plain(` • ${p.name}: ${p.description}`);
}
await pretty.newline();
await pretty.plain('Capabilities:');
for (const cap of info.capabilities) {
await pretty.plain(` • ${cap}`);
}
}
}
async function listProviders(outputFormat: string): Promise<void> {
const providers = [
{ name: 'memory', description: 'In-memory cache', available: true },
{ name: 'file', description: 'File-based cache', available: true }
];
if (outputFormat === 'json') {
outputJson(formatSuccess({ providers }));
} else {
await pretty.heading('Cache Providers');
for (const p of providers) {
const status = p.available ? '✓' : '✗';
await pretty.plain(` ${status} ${p.name.padEnd(15)} ${p.description}`);
}
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'cache',
description: 'Caching management for LLM responses',
subcommands: [
{ name: 'info', description: 'Show cache feature information' },
{ name: 'providers', description: 'List available cache providers' },
{ name: 'help', description: 'Show this help' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Cache Command');
await pretty.plain(help.description);
await pretty.newline();
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
}
}
}
@@ -0,0 +1,252 @@
/**
* CLI command: checkpoints
* Session state checkpointing and recovery
*/
import { CheckpointManager, createCheckpointManager, createFileCheckpointStorage } from '../features/checkpoints';
let manager: CheckpointManager | null = null;
function getManager(storagePath?: string): CheckpointManager {
if (!manager) {
const storage = storagePath ? createFileCheckpointStorage(storagePath) : undefined;
manager = createCheckpointManager({ storage });
}
return manager;
}
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const subcommand = args[0] || 'help';
const isJson = Boolean(options.output === 'json' || options.json);
const storagePath = options.storage as string | undefined;
switch (subcommand) {
case 'create':
await handleCreate(args.slice(1), options, isJson, storagePath);
break;
case 'list':
await handleList(isJson, storagePath);
break;
case 'get':
await handleGet(args.slice(1), isJson, storagePath);
break;
case 'restore':
await handleRestore(args.slice(1), isJson, storagePath);
break;
case 'delete':
await handleDelete(args.slice(1), isJson, storagePath);
break;
case 'export':
await handleExport(args.slice(1), isJson, storagePath);
break;
case 'import':
await handleImport(args.slice(1), isJson, storagePath);
break;
case 'help':
default:
showHelp(isJson);
}
}
async function handleCreate(args: string[], options: Record<string, unknown>, isJson: boolean, storagePath?: string): Promise<void> {
const name = args[0] || `checkpoint-${Date.now()}`;
const mgr = getManager(storagePath);
// Set state from options if provided
const state = options.state ? JSON.parse(options.state as string) : {};
mgr.setState(state);
const checkpoint = await mgr.create(name, {
sessionId: options.session as string,
metadata: options.metadata ? JSON.parse(options.metadata as string) : undefined
});
if (isJson) {
console.log(JSON.stringify({ success: true, checkpoint }, null, 2));
} else {
console.log(`✓ Checkpoint created: ${checkpoint.id}`);
console.log(` Name: ${checkpoint.name}`);
console.log(` Time: ${checkpoint.timestamp.toISOString()}`);
}
}
async function handleList(isJson: boolean, storagePath?: string): Promise<void> {
const mgr = getManager(storagePath);
const checkpoints = await mgr.list();
if (isJson) {
console.log(JSON.stringify({ success: true, checkpoints }, null, 2));
} else {
if (checkpoints.length === 0) {
console.log('No checkpoints found');
} else {
console.log('Checkpoints:');
for (const cp of checkpoints) {
console.log(` ${cp.name} (${cp.id})`);
console.log(` Created: ${cp.timestamp.toISOString()}`);
if (cp.sessionId) console.log(` Session: ${cp.sessionId}`);
}
}
}
}
async function handleGet(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
const id = args[0];
if (!id) {
console.error('Error: Checkpoint ID is required');
process.exit(1);
}
const mgr = getManager(storagePath);
const checkpoint = await mgr.get(id);
if (!checkpoint) {
if (isJson) {
console.log(JSON.stringify({ success: false, error: 'Checkpoint not found' }));
} else {
console.error(`Checkpoint ${id} not found`);
}
process.exit(1);
}
if (isJson) {
console.log(JSON.stringify({ success: true, checkpoint }, null, 2));
} else {
console.log(`Checkpoint: ${checkpoint.id}`);
console.log(` Name: ${checkpoint.name}`);
console.log(` Created: ${checkpoint.timestamp.toISOString()}`);
console.log(` State: ${JSON.stringify(checkpoint.state, null, 2)}`);
}
}
async function handleRestore(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
const id = args[0];
const mgr = getManager(storagePath);
let checkpoint;
if (id) {
checkpoint = await mgr.restore(id);
} else {
checkpoint = await mgr.restoreLatest();
}
if (!checkpoint) {
if (isJson) {
console.log(JSON.stringify({ success: false, error: 'No checkpoint to restore' }));
} else {
console.error('No checkpoint to restore');
}
process.exit(1);
}
if (isJson) {
console.log(JSON.stringify({ success: true, restored: checkpoint }, null, 2));
} else {
console.log(`✓ Restored checkpoint: ${checkpoint.id}`);
console.log(` Name: ${checkpoint.name}`);
console.log(` State keys: ${Object.keys(checkpoint.state).join(', ') || 'none'}`);
}
}
async function handleDelete(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
const id = args[0];
if (!id) {
console.error('Error: Checkpoint ID is required');
process.exit(1);
}
const mgr = getManager(storagePath);
await mgr.delete(id);
if (isJson) {
console.log(JSON.stringify({ success: true, deleted: id }));
} else {
console.log(`✓ Checkpoint ${id} deleted`);
}
}
async function handleExport(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
const id = args[0];
if (!id) {
console.error('Error: Checkpoint ID is required');
process.exit(1);
}
const mgr = getManager(storagePath);
const exported = await mgr.export(id);
if (!exported) {
console.error(`Checkpoint ${id} not found`);
process.exit(1);
}
console.log(exported);
}
async function handleImport(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
const jsonData = args[0];
if (!jsonData) {
console.error('Error: JSON data is required');
process.exit(1);
}
const mgr = getManager(storagePath);
const checkpoint = await mgr.import(jsonData);
if (isJson) {
console.log(JSON.stringify({ success: true, imported: checkpoint }, null, 2));
} else {
console.log(`✓ Checkpoint imported: ${checkpoint.id}`);
}
}
function showHelp(isJson: boolean): void {
const help = {
command: 'checkpoints',
description: 'Session state checkpointing and recovery',
subcommands: {
create: 'Create a new checkpoint',
list: 'List all checkpoints',
get: 'Get checkpoint details',
restore: 'Restore from a checkpoint',
delete: 'Delete a checkpoint',
export: 'Export checkpoint as JSON',
import: 'Import checkpoint from JSON'
},
flags: {
'--storage': 'Path to checkpoint storage directory',
'--state': 'JSON state to save with checkpoint',
'--session': 'Session ID to associate',
'--metadata': 'JSON metadata',
'--json': 'Output in JSON format'
},
examples: [
'praisonai-ts checkpoints create my-checkpoint',
'praisonai-ts checkpoints create --state \'{"key": "value"}\'',
'praisonai-ts checkpoints list',
'praisonai-ts checkpoints restore <id>',
'praisonai-ts checkpoints restore # restores latest',
'praisonai-ts checkpoints export <id> > checkpoint.json',
'praisonai-ts checkpoints import \'{"name": "imported"}\'',
'praisonai-ts checkpoints delete <id>'
]
};
if (isJson) {
console.log(JSON.stringify(help, null, 2));
} else {
console.log('Checkpoints - Session state checkpointing\n');
console.log('Subcommands:');
for (const [cmd, desc] of Object.entries(help.subcommands)) {
console.log(` ${cmd.padEnd(12)} ${desc}`);
}
console.log('\nFlags:');
for (const [flag, desc] of Object.entries(help.flags)) {
console.log(` ${flag.padEnd(12)} ${desc}`);
}
console.log('\nExamples:');
for (const ex of help.examples) {
console.log(` ${ex}`);
}
}
}
@@ -0,0 +1,164 @@
/**
* Context command - Manage conversation context
*/
import { createContextAgent } from '../../agent/context';
import { resolveConfig } from '../config/resolve';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface ContextOptions {
model?: string;
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
maxMessages?: number;
}
export async function execute(args: string[], options: ContextOptions): Promise<void> {
const action = args[0] || 'help';
const actionArgs = args.slice(1);
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
const config = resolveConfig(options);
try {
switch (action) {
case 'chat':
await chatWithContext(actionArgs, options, config, outputFormat);
break;
case 'summarize':
await summarizeContext(actionArgs, options, config, outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function chatWithContext(args: string[], options: ContextOptions, config: any, outputFormat: string): Promise<void> {
const message = args.join(' ');
if (!message) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a message'));
} else {
await pretty.error('Please provide a message');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const startTime = Date.now();
if (outputFormat !== 'json') {
await pretty.info('Processing with context...');
}
const agent = createContextAgent({
llm: config.model,
instructions: 'You are a helpful assistant with context awareness.',
contextWindow: options.maxMessages || 10,
verbose: options.verbose
});
const result = await agent.chat(message);
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
message,
response: result.text
}, {
duration_ms: duration,
model: config.model
}));
} else {
await pretty.heading('Response');
await pretty.plain(result.text);
await pretty.newline();
await pretty.success(`Completed in ${duration}ms`);
}
}
async function summarizeContext(args: string[], options: ContextOptions, config: any, outputFormat: string): Promise<void> {
const context = args.join(' ');
if (!context) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide context to summarize'));
} else {
await pretty.error('Please provide context to summarize');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const startTime = Date.now();
if (outputFormat !== 'json') {
await pretty.info('Summarizing context...');
}
const agent = createContextAgent({
llm: config.model,
instructions: 'You are a summarization assistant. Provide concise summaries.',
verbose: options.verbose
});
// Use chat to summarize
const result = await agent.chat(`Please summarize the following text concisely:\n\n${context}`);
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
originalLength: context.length,
summary: result.text
}, {
duration_ms: duration,
model: config.model
}));
} else {
await pretty.heading('Summary');
await pretty.plain(result.text);
await pretty.newline();
await pretty.dim(`Original: ${context.length} chars`);
await pretty.success(`Summarized in ${duration}ms`);
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'context',
subcommands: [
{ name: 'chat <message>', description: 'Chat with context management' },
{ name: 'summarize <text>', description: 'Summarize context' },
{ name: 'help', description: 'Show this help' }
],
flags: [
{ name: '--max-messages', description: 'Maximum messages to keep in context' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Context Command');
await pretty.plain('Manage conversation context\n');
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
}
await pretty.newline();
await pretty.plain('Flags:');
for (const flag of help.flags) {
await pretty.plain(` ${flag.name.padEnd(20)} ${flag.description}`);
}
}
}
+153
View File
@@ -0,0 +1,153 @@
/**
* CLI command: cost
* Token usage and cost tracking
*/
import { CostTracker, createCostTracker, MODEL_PRICING, formatCost } from '../features/cost-tracker';
let tracker: CostTracker | null = null;
function getTracker(): CostTracker {
if (!tracker) {
tracker = createCostTracker();
}
return tracker;
}
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const subcommand = args[0] || 'help';
const isJson = Boolean(options.output === 'json' || options.json);
switch (subcommand) {
case 'summary':
await handleSummary(isJson);
break;
case 'add':
await handleAdd(args.slice(1), options, isJson);
break;
case 'reset':
await handleReset(isJson);
break;
case 'pricing':
await handlePricing(args.slice(1), isJson);
break;
case 'help':
default:
showHelp(isJson);
}
}
async function handleSummary(isJson: boolean): Promise<void> {
const t = getTracker();
const stats = t.getStats();
const breakdown = t.getBreakdownByModel();
if (isJson) {
console.log(JSON.stringify({ success: true, stats, breakdown }, null, 2));
} else {
console.log(t.getSummary());
if (Object.keys(breakdown).length > 0) {
console.log('\nBreakdown by model:');
for (const [model, data] of Object.entries(breakdown)) {
console.log(` ${model}: ${data.tokens} tokens, ${formatCost(data.cost)}, ${data.requests} requests`);
}
}
}
}
async function handleAdd(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const model = args[0] || 'gpt-4o-mini';
const inputTokens = parseInt(args[1] || '0');
const outputTokens = parseInt(args[2] || '0');
if (isNaN(inputTokens) || isNaN(outputTokens)) {
console.error('Error: Invalid token counts');
process.exit(1);
}
const t = getTracker();
const request = t.addUsage(model, inputTokens, outputTokens);
if (isJson) {
console.log(JSON.stringify({ success: true, request }, null, 2));
} else {
console.log(`✓ Added usage: ${model}`);
console.log(` Input: ${inputTokens} tokens`);
console.log(` Output: ${outputTokens} tokens`);
console.log(` Cost: ${formatCost(request.cost)}`);
}
}
async function handleReset(isJson: boolean): Promise<void> {
const t = getTracker();
t.reset();
if (isJson) {
console.log(JSON.stringify({ success: true, message: 'Cost tracker reset' }));
} else {
console.log('✓ Cost tracker reset');
}
}
async function handlePricing(args: string[], isJson: boolean): Promise<void> {
const model = args[0];
if (model) {
const pricing = MODEL_PRICING[model] || MODEL_PRICING['default'];
if (isJson) {
console.log(JSON.stringify({ success: true, model, pricing }, null, 2));
} else {
console.log(`Pricing for ${model}:`);
console.log(` Input: $${pricing.inputCostPer1k}/1K tokens`);
console.log(` Output: $${pricing.outputCostPer1k}/1K tokens`);
}
} else {
if (isJson) {
console.log(JSON.stringify({ success: true, pricing: MODEL_PRICING }, null, 2));
} else {
console.log('Model Pricing (per 1K tokens):\n');
for (const [name, pricing] of Object.entries(MODEL_PRICING)) {
if (name === 'default') continue;
console.log(` ${name.padEnd(30)} in: $${pricing.inputCostPer1k.toFixed(5)} out: $${pricing.outputCostPer1k.toFixed(5)}`);
}
}
}
}
function showHelp(isJson: boolean): void {
const help = {
command: 'cost',
description: 'Token usage and cost tracking',
subcommands: {
summary: 'Show cost summary',
add: 'Add token usage',
reset: 'Reset cost tracker',
pricing: 'Show model pricing'
},
flags: {
'--json': 'Output in JSON format'
},
examples: [
'praisonai-ts cost summary',
'praisonai-ts cost add gpt-4o 1000 500',
'praisonai-ts cost pricing',
'praisonai-ts cost pricing gpt-4o',
'praisonai-ts cost reset'
]
};
if (isJson) {
console.log(JSON.stringify(help, null, 2));
} else {
console.log('Cost - Token usage and cost tracking\n');
console.log('Subcommands:');
for (const [cmd, desc] of Object.entries(help.subcommands)) {
console.log(` ${cmd.padEnd(12)} ${desc}`);
}
console.log('\nExamples:');
for (const ex of help.examples) {
console.log(` ${ex}`);
}
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* DB command - Database adapter management
*/
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface DbOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: DbOptions): Promise<void> {
const action = args[0] || 'help';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (action) {
case 'info':
await showInfo(outputFormat);
break;
case 'adapters':
await listAdapters(outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function showInfo(outputFormat: string): Promise<void> {
const info = {
feature: 'Database Adapters',
description: 'Database adapters for persistence and session storage',
adapters: [
{ name: 'SQLiteAdapter', description: 'SQLite database adapter' },
{ name: 'UpstashRedisAdapter', description: 'Upstash Redis adapter' },
{ name: 'MemoryRedisAdapter', description: 'In-memory Redis-compatible adapter' },
{ name: 'NeonPostgresAdapter', description: 'Neon PostgreSQL adapter' },
{ name: 'MemoryPostgresAdapter', description: 'In-memory PostgreSQL-compatible adapter' },
{ name: 'PostgresSessionStorage', description: 'PostgreSQL session storage' }
],
capabilities: [
'Store conversation history',
'Persist agent state',
'Session management',
'Message storage and retrieval',
'Trace logging'
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(info));
} else {
await pretty.heading('Database Adapters');
await pretty.plain(info.description);
await pretty.newline();
await pretty.plain('Available Adapters:');
for (const a of info.adapters) {
await pretty.plain(` • ${a.name}: ${a.description}`);
}
await pretty.newline();
await pretty.plain('Capabilities:');
for (const cap of info.capabilities) {
await pretty.plain(` • ${cap}`);
}
}
}
async function listAdapters(outputFormat: string): Promise<void> {
const adapters = [
{ name: 'sqlite', description: 'SQLite database', available: true },
{ name: 'redis', description: 'Redis (Upstash)', available: true },
{ name: 'postgres', description: 'PostgreSQL (Neon)', available: true },
{ name: 'memory', description: 'In-memory adapters', available: true }
];
if (outputFormat === 'json') {
outputJson(formatSuccess({ adapters }));
} else {
await pretty.heading('Database Adapters');
for (const a of adapters) {
const status = a.available ? '✓' : '✗';
await pretty.plain(` ${status} ${a.name.padEnd(15)} ${a.description}`);
}
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'db',
description: 'Database adapter management',
subcommands: [
{ name: 'info', description: 'Show database adapter information' },
{ name: 'adapters', description: 'List available database adapters' },
{ name: 'help', description: 'Show this help' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Database Command');
await pretty.plain(help.description);
await pretty.newline();
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
}
}
}
+283
View File
@@ -0,0 +1,283 @@
/**
* Eval command - Evaluate agent performance
*/
import { Agent } from '../../agent';
import { resolveConfig } from '../config/resolve';
import { printSuccess, printError, outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES, normalizeError } from '../output/errors';
export interface EvalOptions {
// Accuracy subcommand
input?: string;
expected?: string;
iterations?: number;
// Performance subcommand
warmup?: number;
// Reliability subcommand
'expected-tools'?: string;
// Common
model?: string;
verbose?: boolean;
profile?: string;
config?: string;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
interface AccuracyResult {
iteration: number;
output: string;
score: number;
passed: boolean;
}
interface PerformanceResult {
iteration: number;
duration_ms: number;
}
export async function execute(args: string[], options: EvalOptions): Promise<void> {
const subcommand = args[0];
if (!subcommand || !['accuracy', 'performance', 'reliability'].includes(subcommand)) {
if (options.json || options.output === 'json') {
printError(ERROR_CODES.INVALID_ARGS, 'Please specify a subcommand: accuracy, performance, or reliability');
} else {
await pretty.error('Please specify a subcommand: accuracy, performance, or reliability');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const config = resolveConfig({
configPath: options.config,
profile: options.profile,
model: options.model,
verbose: options.verbose
});
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (subcommand) {
case 'accuracy':
await runAccuracyEval(options, config, outputFormat);
break;
case 'performance':
await runPerformanceEval(options, config, outputFormat);
break;
case 'reliability':
await runReliabilityEval(options, config, outputFormat);
break;
}
} catch (error) {
const cliError = normalizeError(error);
if (outputFormat === 'json') {
outputJson(formatError(cliError.code, cliError.message, cliError.details));
} else {
await pretty.error(cliError.message);
if (config.verbose && error instanceof Error && error.stack) {
await pretty.dim(error.stack);
}
}
process.exit(cliError.exitCode);
}
}
async function runAccuracyEval(
options: EvalOptions,
config: { model: string; verbose: boolean },
outputFormat: string
): Promise<void> {
if (!options.input) {
throw new Error('--input is required for accuracy evaluation');
}
if (!options.expected) {
throw new Error('--expected is required for accuracy evaluation');
}
const iterations = options.iterations || 1;
const results: AccuracyResult[] = [];
const startTime = Date.now();
const agent = new Agent({
name: 'Eval Agent',
instructions: 'You are a helpful AI assistant.',
llm: config.model,
verbose: config.verbose
});
for (let i = 0; i < iterations; i++) {
const output = await agent.start(options.input);
// Simple similarity check (in production, use LLM-as-judge)
const similarity = calculateSimilarity(output, options.expected);
const score = Math.round(similarity * 10);
results.push({
iteration: i + 1,
output,
score,
passed: score >= 7
});
}
const duration = Date.now() - startTime;
const avgScore = results.reduce((sum, r) => sum + r.score, 0) / results.length;
const passRate = results.filter(r => r.passed).length / results.length;
if (outputFormat === 'json') {
outputJson(formatSuccess(
{
type: 'accuracy',
results,
summary: {
avg_score: avgScore,
pass_rate: passRate,
iterations
}
},
{ duration_ms: duration, model: config.model }
));
} else {
await pretty.heading('Accuracy Evaluation Results');
await pretty.keyValue({
'Iterations': iterations,
'Average Score': avgScore.toFixed(2),
'Pass Rate': `${(passRate * 100).toFixed(1)}%`,
'Duration': `${duration}ms`
});
}
}
async function runPerformanceEval(
options: EvalOptions,
config: { model: string; verbose: boolean },
outputFormat: string
): Promise<void> {
const iterations = options.iterations || 10;
const warmup = options.warmup || 2;
const results: PerformanceResult[] = [];
const agent = new Agent({
name: 'Perf Agent',
instructions: 'You are a helpful AI assistant.',
llm: config.model,
verbose: false // Disable verbose for performance testing
});
const testPrompt = 'Say "hello" and nothing else.';
// Warmup runs
for (let i = 0; i < warmup; i++) {
await agent.start(testPrompt);
}
// Actual runs
for (let i = 0; i < iterations; i++) {
const start = Date.now();
await agent.start(testPrompt);
const duration = Date.now() - start;
results.push({
iteration: i + 1,
duration_ms: duration
});
}
const durations = results.map(r => r.duration_ms);
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
const min = Math.min(...durations);
const max = Math.max(...durations);
const sorted = [...durations].sort((a, b) => a - b);
const p95 = sorted[Math.floor(sorted.length * 0.95)];
if (outputFormat === 'json') {
outputJson(formatSuccess(
{
type: 'performance',
results,
summary: {
avg_ms: avg,
min_ms: min,
max_ms: max,
p95_ms: p95,
iterations,
warmup_runs: warmup
}
},
{ model: config.model }
));
} else {
await pretty.heading('Performance Evaluation Results');
await pretty.keyValue({
'Iterations': iterations,
'Warmup Runs': warmup,
'Avg Duration': `${avg.toFixed(2)}ms`,
'Min Duration': `${min}ms`,
'Max Duration': `${max}ms`,
'P95 Duration': `${p95}ms`
});
}
}
async function runReliabilityEval(
options: EvalOptions,
config: { model: string; verbose: boolean },
outputFormat: string
): Promise<void> {
const expectedTools = options['expected-tools']?.split(',').map(t => t.trim()) || [];
if (expectedTools.length === 0) {
throw new Error('--expected-tools is required for reliability evaluation');
}
// For now, simulate reliability check
// In production, this would track actual tool calls
const results = {
expected: expectedTools,
called: expectedTools, // Simulated
missing: [] as string[],
unexpected: [] as string[]
};
const passed = results.missing.length === 0;
if (outputFormat === 'json') {
outputJson(formatSuccess({
type: 'reliability',
status: passed ? 'PASSED' : 'FAILED',
results
}));
} else {
await pretty.heading('Reliability Evaluation Results');
if (passed) {
await pretty.success('All expected tools were called');
} else {
await pretty.error(`Missing tool calls: ${results.missing.join(', ')}`);
}
}
}
/**
* Simple string similarity calculation
*/
function calculateSimilarity(str1: string, str2: string): number {
const s1 = str1.toLowerCase().trim();
const s2 = str2.toLowerCase().trim();
if (s1 === s2) return 1;
if (s1.includes(s2) || s2.includes(s1)) return 0.9;
// Simple word overlap
const words1 = new Set(s1.split(/\s+/));
const words2 = new Set(s2.split(/\s+/));
const intersection = [...words1].filter(w => words2.has(w));
const union = new Set([...words1, ...words2]);
return intersection.length / union.size;
}
@@ -0,0 +1,185 @@
/**
* CLI command: external-agents
* Integration with external AI CLI tools
*/
import {
getExternalAgentRegistry,
createExternalAgent,
ClaudeCodeAgent,
GeminiCliAgent,
CodexCliAgent,
AiderAgent
} from '../features/external-agents';
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const subcommand = args[0] || 'help';
const isJson = Boolean(options.output === 'json' || options.json);
switch (subcommand) {
case 'list':
await handleList(isJson);
break;
case 'check':
await handleCheck(args.slice(1), isJson);
break;
case 'run':
await handleRun(args.slice(1), options, isJson);
break;
case 'help':
default:
showHelp(isJson);
}
}
async function handleList(isJson: boolean): Promise<void> {
const registry = getExternalAgentRegistry();
const all = registry.list();
const available = await registry.getAvailable();
if (isJson) {
console.log(JSON.stringify({
success: true,
agents: all,
available
}, null, 2));
} else {
console.log('External Agents:\n');
for (const name of all) {
const status = available.includes(name) ? '✓' : '✗';
console.log(` ${status} ${name}`);
}
console.log(`\n${available.length}/${all.length} available`);
}
}
async function handleCheck(args: string[], isJson: boolean): Promise<void> {
const name = args[0];
if (!name) {
console.error('Error: Agent name is required');
process.exit(1);
}
const agent = createExternalAgent(name);
if (!agent) {
if (isJson) {
console.log(JSON.stringify({ success: false, error: `Unknown agent: ${name}` }));
} else {
console.error(`Unknown agent: ${name}`);
}
process.exit(1);
}
const available = await agent.isAvailable();
if (isJson) {
console.log(JSON.stringify({ success: true, agent: name, available }));
} else {
if (available) {
console.log(`✓ ${name} is available`);
} else {
console.log(`✗ ${name} is not available (CLI not found)`);
}
}
}
async function handleRun(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const name = args[0];
const prompt = args.slice(1).join(' ');
if (!name) {
console.error('Error: Agent name is required');
process.exit(1);
}
if (!prompt) {
console.error('Error: Prompt is required');
process.exit(1);
}
const cwd = options.cwd as string | undefined;
const agent = createExternalAgent(name, cwd);
if (!agent) {
if (isJson) {
console.log(JSON.stringify({ success: false, error: `Unknown agent: ${name}` }));
} else {
console.error(`Unknown agent: ${name}`);
}
process.exit(1);
}
if (!await agent.isAvailable()) {
if (isJson) {
console.log(JSON.stringify({ success: false, error: `${name} CLI not available` }));
} else {
console.error(`${name} CLI not available`);
}
process.exit(1);
}
const result = await agent.execute(prompt);
if (isJson) {
console.log(JSON.stringify(result, null, 2));
} else {
if (result.success) {
console.log(result.output);
} else {
console.error(`Error: ${result.error}`);
process.exit(result.exitCode);
}
}
}
function showHelp(isJson: boolean): void {
const help = {
command: 'external-agents',
description: 'Integration with external AI CLI tools',
subcommands: {
list: 'List all external agents and availability',
check: 'Check if an agent is available',
run: 'Run a prompt with an external agent'
},
agents: {
claude: 'Claude Code CLI',
gemini: 'Gemini CLI',
codex: 'OpenAI Codex CLI',
aider: 'Aider CLI'
},
flags: {
'--cwd': 'Working directory for the agent',
'--json': 'Output in JSON format'
},
examples: [
'praisonai-ts external-agents list',
'praisonai-ts external-agents check claude',
'praisonai-ts external-agents run claude "Explain this code"',
'praisonai-ts external-agents run gemini "Refactor this function" --cwd ./src'
]
};
if (isJson) {
console.log(JSON.stringify(help, null, 2));
} else {
console.log('External Agents - AI CLI tool integration\n');
console.log('Subcommands:');
for (const [cmd, desc] of Object.entries(help.subcommands)) {
console.log(` ${cmd.padEnd(12)} ${desc}`);
}
console.log('\nSupported Agents:');
for (const [agent, desc] of Object.entries(help.agents)) {
console.log(` ${agent.padEnd(12)} ${desc}`);
}
console.log('\nFlags:');
for (const [flag, desc] of Object.entries(help.flags)) {
console.log(` ${flag.padEnd(12)} ${desc}`);
}
console.log('\nExamples:');
for (const ex of help.examples) {
console.log(` ${ex}`);
}
}
}
@@ -0,0 +1,136 @@
/**
* CLI command: fast-context
* Fast context retrieval and summarization
*/
import { FastContext, createFastContext, getQuickContext } from '../features/fast-context';
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const subcommand = args[0] || 'help';
const isJson = Boolean(options.output === 'json' || options.json);
switch (subcommand) {
case 'query':
await handleQuery(args.slice(1), options, isJson);
break;
case 'stats':
await handleStats(isJson);
break;
case 'clear':
await handleClear(isJson);
break;
case 'help':
default:
showHelp(isJson);
}
}
let globalContext: FastContext | null = null;
function getContext(maxTokens?: number): FastContext {
if (!globalContext) {
globalContext = createFastContext({ maxTokens, cacheEnabled: true });
}
return globalContext;
}
async function handleQuery(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const query = args.join(' ');
if (!query) {
console.error('Error: Query is required');
process.exit(1);
}
const maxTokens = options['max-tokens'] as number | undefined;
const sources = options.sources as string | undefined;
const fc = getContext(maxTokens);
// Add sources if provided
if (sources) {
const sourceList = sources.split(',').map(s => s.trim());
fc.registerSource('cli-sources', 'custom', sourceList);
}
const result = await fc.getContext(query);
if (isJson) {
console.log(JSON.stringify({ success: true, ...result }, null, 2));
} else {
console.log('Fast Context Result:');
console.log(` Query: ${query}`);
console.log(` Token count: ${result.tokenCount}`);
console.log(` Sources used: ${result.sources.length}`);
console.log(` Cached: ${result.cached}`);
console.log(` Latency: ${result.latencyMs}ms`);
console.log('\nContext:');
console.log(result.context || '(empty)');
}
}
async function handleStats(isJson: boolean): Promise<void> {
const fc = getContext();
const stats = fc.getCacheStats();
if (isJson) {
console.log(JSON.stringify({ success: true, stats }, null, 2));
} else {
console.log('Fast Context Cache Stats:');
console.log(` Cache size: ${stats.size}`);
console.log(` Total hits: ${stats.totalHits}`);
}
}
async function handleClear(isJson: boolean): Promise<void> {
const fc = getContext();
fc.clearCache();
fc.clearSources();
if (isJson) {
console.log(JSON.stringify({ success: true, message: 'Cache and sources cleared' }));
} else {
console.log('✓ Cache and sources cleared');
}
}
function showHelp(isJson: boolean): void {
const help = {
command: 'fast-context',
description: 'Fast context retrieval and summarization',
subcommands: {
query: 'Query for relevant context',
stats: 'Show cache statistics',
clear: 'Clear cache and sources'
},
flags: {
'--max-tokens': 'Maximum tokens in context (default: 4000)',
'--sources': 'Comma-separated source texts',
'--json': 'Output in JSON format'
},
examples: [
'praisonai-ts fast-context query "What is the main topic?"',
'praisonai-ts fast-context query "summarize" --sources "text1,text2,text3"',
'praisonai-ts fast-context query "find relevant" --max-tokens 2000',
'praisonai-ts fast-context stats',
'praisonai-ts fast-context clear'
]
};
if (isJson) {
console.log(JSON.stringify(help, null, 2));
} else {
console.log('Fast Context - Fast context retrieval\n');
console.log('Subcommands:');
for (const [cmd, desc] of Object.entries(help.subcommands)) {
console.log(` ${cmd.padEnd(12)} ${desc}`);
}
console.log('\nFlags:');
for (const [flag, desc] of Object.entries(help.flags)) {
console.log(` ${flag.padEnd(14)} ${desc}`);
}
console.log('\nExamples:');
for (const ex of help.examples) {
console.log(` ${ex}`);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* CLI command: flow
* Workflow flow visualization
*/
import { FlowDisplay, createFlowDisplay, renderWorkflow } from '../features/flow-display';
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const subcommand = args[0] || 'help';
const isJson = Boolean(options.output === 'json' || options.json);
switch (subcommand) {
case 'show':
await handleShow(args.slice(1), options, isJson);
break;
case 'dot':
await handleDot(args.slice(1), options, isJson);
break;
case 'help':
default:
showHelp(isJson);
}
}
async function handleShow(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const stepsStr = args[0] || options.steps as string;
let steps: Array<{ name: string; type?: string }> = [];
if (stepsStr) {
try {
steps = JSON.parse(stepsStr);
} catch {
// Parse simple format: "step1,step2,step3"
steps = stepsStr.split(',').map(s => {
const [name, type] = s.split(':');
return { name: name.trim(), type: type?.trim() };
});
}
} else {
// Default example
steps = [
{ name: 'Input', type: 'start' },
{ name: 'Process', type: 'agent' },
{ name: 'Validate', type: 'condition' },
{ name: 'Output', type: 'end' }
];
}
const display = createFlowDisplay({
showStatus: Boolean(options.status),
compact: Boolean(options.compact)
});
display.fromWorkflowSteps(steps);
if (isJson) {
const graph = display.getGraph();
console.log(JSON.stringify({
success: true,
nodes: Array.from(graph.nodes.values()),
edges: graph.edges
}, null, 2));
} else {
if (options.boxes) {
console.log(display.renderBoxes());
} else {
console.log(display.render());
}
}
}
async function handleDot(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const stepsStr = args[0] || options.steps as string;
let steps: Array<{ name: string; type?: string }> = [];
if (stepsStr) {
try {
steps = JSON.parse(stepsStr);
} catch {
steps = stepsStr.split(',').map(s => {
const [name, type] = s.split(':');
return { name: name.trim(), type: type?.trim() };
});
}
} else {
steps = [
{ name: 'Start', type: 'start' },
{ name: 'Agent', type: 'agent' },
{ name: 'End', type: 'end' }
];
}
const display = createFlowDisplay();
display.fromWorkflowSteps(steps);
const dot = display.toDot();
if (isJson) {
console.log(JSON.stringify({ success: true, dot }));
} else {
console.log(dot);
}
}
function showHelp(isJson: boolean): void {
const help = {
command: 'flow',
description: 'Workflow flow visualization',
subcommands: {
show: 'Display workflow as text tree or boxes',
dot: 'Export workflow as DOT format (for Graphviz)'
},
flags: {
'--steps': 'Steps as JSON or "name:type,name:type"',
'--status': 'Show status indicators',
'--compact': 'Compact display mode',
'--boxes': 'Display as ASCII boxes',
'--json': 'Output in JSON format'
},
examples: [
'praisonai-ts flow show "Input,Process,Output"',
'praisonai-ts flow show "Start:start,Agent:agent,End:end"',
'praisonai-ts flow show --boxes',
'praisonai-ts flow dot "Step1,Step2,Step3" > workflow.dot'
]
};
if (isJson) {
console.log(JSON.stringify(help, null, 2));
} else {
console.log('Flow - Workflow visualization\n');
console.log('Subcommands:');
for (const [cmd, desc] of Object.entries(help.subcommands)) {
console.log(` ${cmd.padEnd(12)} ${desc}`);
}
console.log('\nFlags:');
for (const [flag, desc] of Object.entries(help.flags)) {
console.log(` ${flag.padEnd(12)} ${desc}`);
}
console.log('\nExamples:');
for (const ex of help.examples) {
console.log(` ${ex}`);
}
}
}
+192
View File
@@ -0,0 +1,192 @@
/**
* CLI command: git
* Git integration for repository operations
*/
import { GitManager, createGitManager, DiffViewer, createDiffViewer } from '../features/git-integration';
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const subcommand = args[0] || 'help';
const isJson = Boolean(options.output === 'json' || options.json);
const cwd = (options.cwd as string) || process.cwd();
switch (subcommand) {
case 'status':
await handleStatus(cwd, isJson);
break;
case 'diff':
await handleDiff(options, cwd, isJson);
break;
case 'log':
await handleLog(options, cwd, isJson);
break;
case 'branches':
await handleBranches(cwd, isJson);
break;
case 'stash':
await handleStash(args.slice(1), cwd, isJson);
break;
case 'help':
default:
showHelp(isJson);
}
}
async function handleStatus(cwd: string, isJson: boolean): Promise<void> {
const git = createGitManager({ cwd, safe: true });
if (!await git.isRepo()) {
console.error('Error: Not a git repository');
process.exit(1);
}
const status = await git.getStatus();
if (isJson) {
console.log(JSON.stringify({ success: true, ...status }, null, 2));
} else {
console.log(await git.getStatusString());
}
}
async function handleDiff(options: Record<string, unknown>, cwd: string, isJson: boolean): Promise<void> {
const git = createGitManager({ cwd, safe: true });
const staged = Boolean(options.staged);
if (!await git.isRepo()) {
console.error('Error: Not a git repository');
process.exit(1);
}
const diff = await git.getDiff(staged);
if (isJson) {
console.log(JSON.stringify({ success: true, ...diff }, null, 2));
} else {
if (diff.files.length === 0) {
console.log(staged ? 'No staged changes' : 'No changes');
} else {
console.log(`Changes (${diff.files.length} files):\n`);
for (const file of diff.files) {
console.log(` ${file.path} (+${file.additions}/-${file.deletions})`);
}
if (options.full) {
console.log('\n' + diff.summary);
}
}
}
}
async function handleLog(options: Record<string, unknown>, cwd: string, isJson: boolean): Promise<void> {
const git = createGitManager({ cwd, safe: true });
const limit = (options.limit as number) || 10;
if (!await git.isRepo()) {
console.error('Error: Not a git repository');
process.exit(1);
}
const commits = await git.getLog(limit);
if (isJson) {
console.log(JSON.stringify({ success: true, commits }, null, 2));
} else {
console.log('Recent commits:\n');
for (const commit of commits) {
console.log(` ${commit.shortHash} ${commit.message}`);
console.log(` by ${commit.author} on ${commit.date.toLocaleDateString()}`);
}
}
}
async function handleBranches(cwd: string, isJson: boolean): Promise<void> {
const git = createGitManager({ cwd, safe: true });
if (!await git.isRepo()) {
console.error('Error: Not a git repository');
process.exit(1);
}
const branches = await git.getBranches();
if (isJson) {
console.log(JSON.stringify({ success: true, branches }, null, 2));
} else {
console.log('Branches:\n');
for (const branch of branches) {
const marker = branch.current ? '* ' : ' ';
console.log(`${marker}${branch.name}`);
}
}
}
async function handleStash(args: string[], cwd: string, isJson: boolean): Promise<void> {
const git = createGitManager({ cwd, safe: true });
if (!await git.isRepo()) {
console.error('Error: Not a git repository');
process.exit(1);
}
const stashes = await git.getStashList();
if (isJson) {
console.log(JSON.stringify({ success: true, stashes }, null, 2));
} else {
if (stashes.length === 0) {
console.log('No stashes');
} else {
console.log('Stashes:\n');
for (const stash of stashes) {
console.log(` ${stash}`);
}
}
}
}
function showHelp(isJson: boolean): void {
const help = {
command: 'git',
description: 'Git integration for repository operations (read-only by default)',
subcommands: {
status: 'Show repository status',
diff: 'Show changes',
log: 'Show recent commits',
branches: 'List branches',
stash: 'List stashes'
},
flags: {
'--cwd': 'Working directory (default: current)',
'--staged': 'Show staged changes only (for diff)',
'--full': 'Show full diff output',
'--limit': 'Number of commits to show (default: 10)',
'--json': 'Output in JSON format'
},
examples: [
'praisonai-ts git status',
'praisonai-ts git diff',
'praisonai-ts git diff --staged',
'praisonai-ts git log --limit 5',
'praisonai-ts git branches',
'praisonai-ts git stash'
]
};
if (isJson) {
console.log(JSON.stringify(help, null, 2));
} else {
console.log('Git - Repository operations (read-only)\n');
console.log('Subcommands:');
for (const [cmd, desc] of Object.entries(help.subcommands)) {
console.log(` ${cmd.padEnd(12)} ${desc}`);
}
console.log('\nFlags:');
for (const [flag, desc] of Object.entries(help.flags)) {
console.log(` ${flag.padEnd(12)} ${desc}`);
}
console.log('\nExamples:');
for (const ex of help.examples) {
console.log(` ${ex}`);
}
}
}
@@ -0,0 +1,104 @@
/**
* Graph RAG command - Graph-based retrieval augmented generation
*/
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface GraphRagOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: GraphRagOptions): Promise<void> {
const action = args[0] || 'help';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (action) {
case 'info':
await showInfo(outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function showInfo(outputFormat: string): Promise<void> {
const info = {
feature: 'Graph RAG',
description: 'Graph-based retrieval augmented generation for complex knowledge queries',
capabilities: [
'Build knowledge graphs from documents',
'Query relationships between entities',
'Combine graph traversal with vector search',
'Extract entities and relationships',
'Support for complex multi-hop queries'
],
sdkUsage: `
import { createGraphRAG, GraphStore } from 'praisonai';
// Create a graph RAG instance
const graphRag = createGraphRAG({
llm: 'openai/gpt-4o-mini',
graphStore: new GraphStore()
});
// Add documents
await graphRag.addDocument('Document content...');
// Query with graph context
const result = await graphRag.query('What is the relationship between X and Y?');
`
};
if (outputFormat === 'json') {
outputJson(formatSuccess(info));
} else {
await pretty.heading('Graph RAG');
await pretty.plain(info.description);
await pretty.newline();
await pretty.plain('Capabilities:');
for (const cap of info.capabilities) {
await pretty.plain(` • ${cap}`);
}
await pretty.newline();
await pretty.dim('Use the SDK for full Graph RAG functionality');
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'graph-rag',
description: 'Graph-based retrieval augmented generation',
subcommands: [
{ name: 'info', description: 'Show Graph RAG feature information' },
{ name: 'help', description: 'Show this help' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Graph RAG Command');
await pretty.plain(help.description);
await pretty.newline();
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
}
}
}
@@ -0,0 +1,132 @@
/**
* Guardrail command - Content validation and safety
*/
import { createLLMGuardrail } from '../../guardrails/llm-guardrail';
import { resolveConfig } from '../config/resolve';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface GuardrailOptions {
model?: string;
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
criteria?: string;
}
export async function execute(args: string[], options: GuardrailOptions): Promise<void> {
const action = args[0] || 'help';
const actionArgs = args.slice(1);
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
const config = resolveConfig(options);
try {
switch (action) {
case 'check':
await checkContent(actionArgs, options, config, outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function checkContent(args: string[], options: GuardrailOptions, config: any, outputFormat: string): Promise<void> {
const content = args.join(' ');
if (!content) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide content to check'));
} else {
await pretty.error('Please provide content to check');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const startTime = Date.now();
if (outputFormat !== 'json') {
await pretty.info('Checking content...');
}
const guardrail = createLLMGuardrail({
name: 'cli-guardrail',
llm: config.model,
criteria: options.criteria || 'Content should be safe, appropriate, and helpful',
verbose: options.verbose
});
const result = await guardrail.check(content);
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
content: content.substring(0, 100) + (content.length > 100 ? '...' : ''),
status: result.status,
score: result.score,
message: result.message,
reasoning: result.reasoning
}, {
duration_ms: duration,
model: config.model
}));
} else {
await pretty.heading('Guardrail Check');
if (result.status === 'passed') {
await pretty.success('Content passed guardrail check');
} else if (result.status === 'warning') {
await pretty.warn('Content has warnings');
} else {
await pretty.error('Content failed guardrail check');
}
await pretty.plain(`Score: ${(result.score * 10).toFixed(1)}/10`);
if (result.message) {
await pretty.plain(`Message: ${result.message}`);
}
if (result.reasoning) {
await pretty.dim(`Reasoning: ${result.reasoning}`);
}
await pretty.newline();
await pretty.dim(`Checked in ${duration}ms`);
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'guardrail',
subcommands: [
{ name: 'check <content>', description: 'Check content against guardrails' },
{ name: 'help', description: 'Show this help' }
],
flags: [
{ name: '--criteria', description: 'Custom criteria for validation' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Guardrail Command');
await pretty.plain('Content validation and safety checks\n');
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
}
await pretty.newline();
await pretty.plain('Flags:');
for (const flag of help.flags) {
await pretty.plain(` ${flag.name.padEnd(20)} ${flag.description}`);
}
}
}
@@ -0,0 +1,106 @@
/**
* Handoff command - Agent handoff management
*/
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface HandoffOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: HandoffOptions): Promise<void> {
const action = args[0] || 'help';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (action) {
case 'info':
await showInfo(outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function showInfo(outputFormat: string): Promise<void> {
const info = {
feature: 'Handoff',
description: 'Agent handoff allows seamless transfer of conversation context between agents',
capabilities: [
'Transfer conversation to specialized agents',
'Preserve context during handoff',
'Filter handoffs based on conditions',
'Chain multiple agent handoffs'
],
sdkUsage: `
import { Handoff, handoff, handoffFilters } from 'praisonai';
// Create a handoff
const myHandoff = handoff({
target: specialistAgent,
condition: (ctx) => ctx.input.includes('technical'),
onHandoff: (ctx) => console.log('Handing off...')
});
// Use with an agent
const agent = new Agent({
handoffs: [myHandoff]
});
`
};
if (outputFormat === 'json') {
outputJson(formatSuccess(info));
} else {
await pretty.heading('Handoff Feature');
await pretty.plain(info.description);
await pretty.newline();
await pretty.plain('Capabilities:');
for (const cap of info.capabilities) {
await pretty.plain(` • ${cap}`);
}
await pretty.newline();
await pretty.dim('Use the SDK for programmatic handoff configuration');
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'handoff',
description: 'Agent handoff management - transfer conversations between agents',
subcommands: [
{ name: 'info', description: 'Show handoff feature information' },
{ name: 'help', description: 'Show this help' }
],
note: 'Handoff is primarily a programmatic SDK feature. Use the SDK for full functionality.'
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Handoff Command');
await pretty.plain(help.description);
await pretty.newline();
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
}
await pretty.newline();
await pretty.dim(help.note);
}
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Help command - Show help information
*/
import { COMMANDS, GLOBAL_FLAGS, CLI_SPEC_VERSION, getCommandSpec } from '../spec/cli-spec';
import { outputJson, formatSuccess } from '../output/json';
import * as pretty from '../output/pretty';
export interface HelpOptions {
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: HelpOptions): Promise<void> {
const command = args[0];
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
if (command) {
await showCommandHelp(command, outputFormat);
} else {
await showGeneralHelp(outputFormat);
}
}
async function showGeneralHelp(outputFormat: string): Promise<void> {
const commands = Object.entries(COMMANDS).map(([name, spec]) => ({
name,
description: spec.description
}));
const flags = GLOBAL_FLAGS.map(f => ({
name: f.short ? `-${f.short}, --${f.name}` : `--${f.name}`,
description: f.description,
default: f.default
}));
if (outputFormat === 'json') {
outputJson(formatSuccess({
cli_spec_version: CLI_SPEC_VERSION,
commands,
global_flags: flags
}));
} else {
await pretty.plain(`
PraisonAI TypeScript CLI v${CLI_SPEC_VERSION}
Usage:
praisonai-ts <command> [options]
Commands:`);
for (const cmd of commands) {
await pretty.plain(` ${cmd.name.padEnd(12)} ${cmd.description}`);
}
await pretty.plain(`
Global Options:`);
for (const flag of flags) {
const defaultStr = flag.default !== undefined ? ` (default: ${flag.default})` : '';
await pretty.plain(` ${flag.name.padEnd(20)} ${flag.description}${defaultStr}`);
}
await pretty.plain(`
Environment Variables:
OPENAI_API_KEY OpenAI API key
ANTHROPIC_API_KEY Anthropic API key
GOOGLE_API_KEY Google API key
PRAISONAI_MODEL Default model
Examples:
praisonai-ts chat "Hello, how are you?"
praisonai-ts chat "Write a poem" --stream
praisonai-ts run "Analyze this data" --agent my-agent.yaml
praisonai-ts providers
praisonai-ts help chat
`);
}
}
async function showCommandHelp(command: string, outputFormat: string): Promise<void> {
const spec = getCommandSpec(command);
if (!spec) {
await pretty.error(`Unknown command: ${command}`);
await pretty.info('Run "praisonai-ts help" to see available commands');
return;
}
const args = spec.args || [];
const flags = [...GLOBAL_FLAGS, ...(spec.flags || [])];
const subcommands = spec.subcommands ? Object.entries(spec.subcommands) : [];
if (outputFormat === 'json') {
outputJson(formatSuccess({
command,
description: spec.description,
args: args.map(a => ({
name: a.name,
type: a.type,
required: a.required,
description: a.description
})),
flags: flags.map(f => ({
name: f.name,
short: f.short,
type: f.type,
default: f.default,
description: f.description
})),
subcommands: subcommands.map(([name, sub]) => ({
name,
description: sub.description
}))
}));
} else {
await pretty.plain(`
Command: ${command}
${spec.description}
Usage:
praisonai-ts ${command}${args.length ? ' ' + args.map(a => a.required ? `<${a.name}>` : `[${a.name}]`).join(' ') : ''} [options]`);
if (args.length > 0) {
await pretty.plain(`
Arguments:`);
for (const arg of args) {
const reqStr = arg.required ? ' (required)' : '';
await pretty.plain(` ${arg.name.padEnd(15)} ${arg.description || arg.type}${reqStr}`);
}
}
if (subcommands.length > 0) {
await pretty.plain(`
Subcommands:`);
for (const [name, sub] of subcommands) {
await pretty.plain(` ${name.padEnd(15)} ${sub.description}`);
}
}
await pretty.plain(`
Options:`);
for (const flag of flags) {
const shortStr = flag.short ? `-${flag.short}, ` : ' ';
const defaultStr = flag.default !== undefined ? ` (default: ${flag.default})` : '';
await pretty.plain(` ${shortStr}--${flag.name.padEnd(14)} ${flag.description || ''}${defaultStr}`);
}
await pretty.plain('');
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Image command - Image generation and analysis
*/
import { createImageAgent } from '../../agent/image';
import { resolveConfig } from '../config/resolve';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface ImageOptions {
model?: string;
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
size?: string;
quality?: string;
style?: string;
}
export async function execute(args: string[], options: ImageOptions): Promise<void> {
const action = args[0] || 'help';
const actionArgs = args.slice(1);
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
const config = resolveConfig(options);
try {
switch (action) {
case 'generate':
await generateImage(actionArgs, options, config, outputFormat);
break;
case 'analyze':
await analyzeImage(actionArgs, options, config, outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function generateImage(args: string[], options: ImageOptions, config: any, outputFormat: string): Promise<void> {
const prompt = args.join(' ');
if (!prompt) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a prompt for image generation'));
} else {
await pretty.error('Please provide a prompt for image generation');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const startTime = Date.now();
if (outputFormat !== 'json') {
await pretty.info(`Generating image: "${prompt}"`);
}
const agent = createImageAgent({
llm: config.model,
verbose: options.verbose
});
const result = await agent.generate({
prompt,
size: options.size as any,
quality: options.quality as any,
style: options.style as any
});
const duration = Date.now() - startTime;
const urls = Array.isArray(result) ? result : [result];
if (outputFormat === 'json') {
outputJson(formatSuccess({
prompt,
urls
}, {
duration_ms: duration,
model: config.model
}));
} else {
await pretty.heading('Generated Image');
for (const url of urls) {
await pretty.plain(`URL: ${url}`);
}
await pretty.newline();
await pretty.success(`Generated in ${duration}ms`);
}
}
async function analyzeImage(args: string[], options: ImageOptions, config: any, outputFormat: string): Promise<void> {
const imageUrl = args[0];
const prompt = args.slice(1).join(' ') || 'Describe this image';
if (!imageUrl) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide an image URL'));
} else {
await pretty.error('Please provide an image URL');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const startTime = Date.now();
if (outputFormat !== 'json') {
await pretty.info(`Analyzing image: ${imageUrl}`);
}
const agent = createImageAgent({
llm: config.model,
verbose: options.verbose
});
const result = await agent.analyze({
imageUrl,
prompt
});
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
imageUrl,
prompt,
analysis: result
}, {
duration_ms: duration,
model: config.model
}));
} else {
await pretty.heading('Image Analysis');
await pretty.plain(result);
await pretty.newline();
await pretty.success(`Analyzed in ${duration}ms`);
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'image',
subcommands: [
{ name: 'generate <prompt>', description: 'Generate an image from text' },
{ name: 'analyze <url> [question]', description: 'Analyze an image' },
{ name: 'help', description: 'Show this help' }
],
flags: [
{ name: '--size', description: 'Image size (1024x1024, 1792x1024, 1024x1792)' },
{ name: '--quality', description: 'Image quality (standard, hd)' },
{ name: '--style', description: 'Image style (vivid, natural)' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Image Command');
await pretty.plain('Generate and analyze images\n');
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(30)} ${cmd.description}`);
}
await pretty.newline();
await pretty.plain('Flags:');
for (const flag of help.flags) {
await pretty.plain(` ${flag.name.padEnd(20)} ${flag.description}`);
}
}
}
@@ -0,0 +1,84 @@
/**
* CLI command: interactive
* Interactive TUI mode with slash commands
*/
import { InteractiveTUI, createInteractiveTUI } from '../features/interactive-tui';
import { createCostTracker } from '../features/cost-tracker';
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const isJson = Boolean(options.output === 'json' || options.json);
if (isJson) {
console.log(JSON.stringify({
success: false,
error: 'Interactive mode does not support JSON output'
}));
process.exit(1);
}
const historyFile = options.history as string | undefined;
const model = options.model as string | undefined;
console.log('Starting interactive mode...');
console.log('Type /help for available commands, /exit to quit.\n');
const tui = createInteractiveTUI({
prompt: options.prompt as string || '> ',
historyFile,
welcomeMessage: `PraisonAI Interactive Mode
Model: ${model || 'default'}
Type /help for commands, /exit to quit.`,
onMessage: async (message) => {
// Simple echo for now - in real usage this would call the agent
return `You said: ${message}`;
},
onExit: () => {
console.log('\nGoodbye!');
}
});
// Update context with model if provided
if (model) {
tui.updateContext({ model });
}
try {
await tui.start();
} catch (error: any) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
export function showHelp(): void {
console.log(`Interactive - Interactive TUI mode
Usage:
praisonai-ts interactive [options]
Options:
--model, -m Model to use
--prompt Custom prompt string (default: "> ")
--history Path to history file
Slash Commands (in interactive mode):
/help Show available commands
/cost Show token usage and cost
/clear Clear conversation history
/model [name] Show or change model
/tokens Show token count
/plan Manage task plan
/undo Undo last action
/diff Show pending changes
/commit Commit pending changes
/settings Show or modify settings
/map Show repository map
/exit Exit interactive mode
Examples:
praisonai-ts interactive
praisonai-ts interactive --model gpt-4o
praisonai-ts interactive --history ~/.praisonai_history
`);
}
+295
View File
@@ -0,0 +1,295 @@
/**
* CLI command: jobs
* Background job queue management
*/
import { JobQueue, createJobQueue, createFileJobStorage } from '../features/background-jobs';
let globalQueue: JobQueue | null = null;
function getQueue(storagePath?: string): JobQueue {
if (!globalQueue) {
const storage = storagePath ? createFileJobStorage(storagePath) : undefined;
globalQueue = createJobQueue({ storage });
}
return globalQueue;
}
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const subcommand = args[0] || 'help';
const isJson = Boolean(options.output === 'json' || options.json);
const storagePath = options.storage as string | undefined;
switch (subcommand) {
case 'add':
await handleAdd(args.slice(1), options, isJson, storagePath);
break;
case 'list':
await handleList(options, isJson, storagePath);
break;
case 'get':
await handleGet(args.slice(1), isJson, storagePath);
break;
case 'cancel':
await handleCancel(args.slice(1), isJson, storagePath);
break;
case 'retry':
await handleRetry(args.slice(1), isJson, storagePath);
break;
case 'process':
await handleProcess(args.slice(1), isJson, storagePath);
break;
case 'stats':
await handleStats(isJson, storagePath);
break;
case 'cleanup':
await handleCleanup(options, isJson, storagePath);
break;
case 'help':
default:
showHelp(isJson);
}
}
async function handleAdd(args: string[], options: Record<string, unknown>, isJson: boolean, storagePath?: string): Promise<void> {
const name = args[0];
const dataStr = args[1] || '{}';
if (!name) {
console.error('Error: Job name is required');
process.exit(1);
}
let data: Record<string, unknown>;
try {
data = JSON.parse(dataStr);
} catch {
data = { input: dataStr };
}
const priority = (options.priority as string) || 'normal';
const queue = getQueue(storagePath);
const job = await queue.add(name, data, {
priority: priority as 'low' | 'normal' | 'high' | 'critical'
});
if (isJson) {
console.log(JSON.stringify({ success: true, job }, null, 2));
} else {
console.log(`✓ Job created: ${job.id}`);
console.log(` Name: ${job.name}`);
console.log(` Priority: ${job.priority}`);
console.log(` Status: ${job.status}`);
}
}
async function handleList(options: Record<string, unknown>, isJson: boolean, storagePath?: string): Promise<void> {
const queue = getQueue(storagePath);
const status = options.status as string | undefined;
let jobs = await queue.getAll();
if (status) {
jobs = jobs.filter(j => j.status === status);
}
if (isJson) {
console.log(JSON.stringify({ success: true, jobs }, null, 2));
} else {
if (jobs.length === 0) {
console.log('No jobs found');
} else {
console.log('Jobs:');
for (const job of jobs) {
console.log(` ${job.id}`);
console.log(` Name: ${job.name}`);
console.log(` Status: ${job.status}`);
console.log(` Priority: ${job.priority}`);
console.log(` Attempts: ${job.attempts}/${job.maxAttempts}`);
if (job.error) console.log(` Error: ${job.error}`);
}
}
}
}
async function handleGet(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
const id = args[0];
if (!id) {
console.error('Error: Job ID is required');
process.exit(1);
}
const queue = getQueue(storagePath);
const job = await queue.get(id);
if (!job) {
if (isJson) {
console.log(JSON.stringify({ success: false, error: 'Job not found' }));
} else {
console.error(`Job ${id} not found`);
}
process.exit(1);
}
if (isJson) {
console.log(JSON.stringify({ success: true, job }, null, 2));
} else {
console.log(`Job: ${job.id}`);
console.log(` Name: ${job.name}`);
console.log(` Status: ${job.status}`);
console.log(` Priority: ${job.priority}`);
console.log(` Attempts: ${job.attempts}/${job.maxAttempts}`);
console.log(` Created: ${job.createdAt}`);
if (job.startedAt) console.log(` Started: ${job.startedAt}`);
if (job.completedAt) console.log(` Completed: ${job.completedAt}`);
if (job.error) console.log(` Error: ${job.error}`);
if (job.result) console.log(` Result: ${JSON.stringify(job.result)}`);
}
}
async function handleCancel(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
const id = args[0];
if (!id) {
console.error('Error: Job ID is required');
process.exit(1);
}
const queue = getQueue(storagePath);
const cancelled = await queue.cancel(id);
if (isJson) {
console.log(JSON.stringify({ success: cancelled, id }));
} else {
console.log(cancelled ? `✓ Job ${id} cancelled` : `Could not cancel job ${id}`);
}
}
async function handleRetry(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
const id = args[0];
if (!id) {
console.error('Error: Job ID is required');
process.exit(1);
}
const queue = getQueue(storagePath);
const retried = await queue.retry(id);
if (isJson) {
console.log(JSON.stringify({ success: retried, id }));
} else {
console.log(retried ? `✓ Job ${id} queued for retry` : `Could not retry job ${id}`);
}
}
async function handleProcess(args: string[], isJson: boolean, storagePath?: string): Promise<void> {
const id = args[0];
if (!id) {
console.error('Error: Job ID is required');
process.exit(1);
}
const queue = getQueue(storagePath);
// Register a simple handler
queue.register('default', async (job) => {
return { processed: true, data: job.data };
});
try {
const result = await queue.processNow(id);
if (isJson) {
console.log(JSON.stringify({ success: true, result }, null, 2));
} else {
console.log(`✓ Job ${id} processed`);
console.log(` Result: ${JSON.stringify(result)}`);
}
} catch (error: any) {
if (isJson) {
console.log(JSON.stringify({ success: false, error: error.message }));
} else {
console.error(`Error: ${error.message}`);
}
process.exit(1);
}
}
async function handleStats(isJson: boolean, storagePath?: string): Promise<void> {
const queue = getQueue(storagePath);
const stats = await queue.getStats();
if (isJson) {
console.log(JSON.stringify({ success: true, stats }, null, 2));
} else {
console.log('Job Queue Stats:');
console.log(` Pending: ${stats.pending}`);
console.log(` Running: ${stats.running}`);
console.log(` Completed: ${stats.completed}`);
console.log(` Failed: ${stats.failed}`);
console.log(` Cancelled: ${stats.cancelled}`);
}
}
async function handleCleanup(options: Record<string, unknown>, isJson: boolean, storagePath?: string): Promise<void> {
const queue = getQueue(storagePath);
const days = (options.days as number) || 7;
const olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const count = await queue.cleanup(olderThan);
if (isJson) {
console.log(JSON.stringify({ success: true, cleaned: count }));
} else {
console.log(`✓ Cleaned up ${count} jobs older than ${days} days`);
}
}
function showHelp(isJson: boolean): void {
const help = {
command: 'jobs',
description: 'Background job queue management',
subcommands: {
add: 'Add a new job to the queue',
list: 'List all jobs',
get: 'Get job details',
cancel: 'Cancel a pending job',
retry: 'Retry a failed job',
process: 'Process a job immediately',
stats: 'Show queue statistics',
cleanup: 'Clean up old completed/failed jobs'
},
flags: {
'--storage': 'Path to job storage file',
'--priority': 'Job priority (low, normal, high, critical)',
'--status': 'Filter by status',
'--days': 'Days for cleanup threshold',
'--json': 'Output in JSON format'
},
examples: [
'praisonai-ts jobs add my-job \'{"input": "data"}\'',
'praisonai-ts jobs add task --priority high',
'praisonai-ts jobs list --status pending',
'praisonai-ts jobs get <job-id>',
'praisonai-ts jobs cancel <job-id>',
'praisonai-ts jobs stats',
'praisonai-ts jobs cleanup --days 30'
]
};
if (isJson) {
console.log(JSON.stringify(help, null, 2));
} else {
console.log('Jobs - Background job queue management\n');
console.log('Subcommands:');
for (const [cmd, desc] of Object.entries(help.subcommands)) {
console.log(` ${cmd.padEnd(12)} ${desc}`);
}
console.log('\nFlags:');
for (const [flag, desc] of Object.entries(help.flags)) {
console.log(` ${flag.padEnd(12)} ${desc}`);
}
console.log('\nExamples:');
for (const ex of help.examples) {
console.log(` ${ex}`);
}
}
}
@@ -0,0 +1,203 @@
/**
* Knowledge command - Manage knowledge base
*/
import { BaseKnowledgeBase, Knowledge } from '../../knowledge';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
import * as fs from 'fs';
import * as path from 'path';
import { randomUUID } from 'crypto';
export interface KnowledgeOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
// Singleton knowledge base instance
let kbInstance: BaseKnowledgeBase | null = null;
function getKnowledgeBase(): BaseKnowledgeBase {
if (!kbInstance) {
kbInstance = new BaseKnowledgeBase();
}
return kbInstance;
}
export async function execute(args: string[], options: KnowledgeOptions): Promise<void> {
const action = args[0] || 'help';
const actionArgs = args.slice(1);
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
const kb = getKnowledgeBase();
switch (action) {
case 'add':
await addKnowledge(kb, actionArgs, outputFormat);
break;
case 'search':
await searchKnowledge(kb, actionArgs, outputFormat);
break;
case 'list':
await listKnowledge(kb, outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function addKnowledge(kb: BaseKnowledgeBase, args: string[], outputFormat: string): Promise<void> {
const source = args[0];
if (!source) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a file path or text content'));
} else {
await pretty.error('Please provide a file path or text content');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
let content: string;
let sourceName: string;
// Check if it's a file path
if (fs.existsSync(source)) {
content = fs.readFileSync(source, 'utf-8');
sourceName = path.basename(source);
} else {
// Treat as direct text content
content = args.join(' ');
sourceName = 'text-input';
}
const knowledge: Knowledge = {
id: randomUUID(),
type: 'text',
content,
metadata: { source: sourceName, addedAt: new Date().toISOString() }
};
kb.addKnowledge(knowledge);
if (outputFormat === 'json') {
outputJson(formatSuccess({
added: true,
id: knowledge.id,
source: sourceName,
contentLength: content.length
}));
} else {
await pretty.success(`Knowledge added from: ${sourceName}`);
await pretty.dim(`Content length: ${content.length} characters`);
}
}
async function searchKnowledge(kb: BaseKnowledgeBase, args: string[], outputFormat: string): Promise<void> {
const query = args.join(' ');
if (!query) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a search query'));
} else {
await pretty.error('Please provide a search query');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const results = kb.searchKnowledge(query);
if (outputFormat === 'json') {
outputJson(formatSuccess({
query,
results: results.map(r => ({
id: r.id,
content: r.content,
metadata: r.metadata
})),
count: results.length
}));
} else {
await pretty.heading(`Search Results for: "${query}"`);
if (results.length === 0) {
await pretty.info('No matching knowledge found');
} else {
for (const result of results) {
const contentStr = typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
await pretty.plain(` • ${contentStr.substring(0, 100)}${contentStr.length > 100 ? '...' : ''}`);
if (result.metadata?.source) {
await pretty.dim(` Source: ${result.metadata.source}`);
}
}
}
await pretty.newline();
await pretty.info(`Found: ${results.length} results`);
}
}
async function listKnowledge(kb: BaseKnowledgeBase, outputFormat: string): Promise<void> {
// Search with empty string to get all
const entries = kb.searchKnowledge('');
if (outputFormat === 'json') {
outputJson(formatSuccess({
entries: entries.map((e: Knowledge) => ({
id: e.id,
type: e.type,
contentPreview: typeof e.content === 'string' ? e.content.substring(0, 100) : JSON.stringify(e.content).substring(0, 100),
source: e.metadata?.source
})),
count: entries.length
}));
} else {
await pretty.heading('Knowledge Base Entries');
if (entries.length === 0) {
await pretty.info('No knowledge entries found');
} else {
for (const entry of entries) {
const contentStr = typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content);
await pretty.plain(` • ${contentStr.substring(0, 80)}${contentStr.length > 80 ? '...' : ''}`);
if (entry.metadata?.source) {
await pretty.dim(` Source: ${entry.metadata.source}`);
}
}
}
await pretty.newline();
await pretty.info(`Total: ${entries.length} entries`);
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'knowledge',
subcommands: [
{ name: 'add <file|text>', description: 'Add knowledge from file or text' },
{ name: 'search <query>', description: 'Search knowledge base' },
{ name: 'list', description: 'List all knowledge entries' },
{ name: 'clear', description: 'Clear all knowledge' },
{ name: 'help', description: 'Show this help' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Knowledge Command');
await pretty.plain('Manage knowledge base for RAG\n');
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
}
}
}
+230
View File
@@ -0,0 +1,230 @@
/**
* MCP command - Model Context Protocol management
*/
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface MCPOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
// MCP server registry (in-memory for now)
interface MCPServer {
name: string;
command: string;
args?: string[];
env?: Record<string, string>;
status: 'stopped' | 'running';
}
const mcpServers: Map<string, MCPServer> = new Map();
export async function execute(args: string[], options: MCPOptions): Promise<void> {
const action = args[0] || 'list';
const actionArgs = args.slice(1);
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (action) {
case 'list':
await listServers(outputFormat);
break;
case 'add':
await addServer(actionArgs, outputFormat);
break;
case 'remove':
await removeServer(actionArgs, outputFormat);
break;
case 'start':
await startServer(actionArgs, outputFormat);
break;
case 'stop':
await stopServer(actionArgs, outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function listServers(outputFormat: string): Promise<void> {
const servers = Array.from(mcpServers.values());
if (outputFormat === 'json') {
outputJson(formatSuccess({
servers,
count: servers.length
}));
} else {
await pretty.heading('MCP Servers');
if (servers.length === 0) {
await pretty.info('No MCP servers registered');
await pretty.dim('Run "praisonai-ts mcp add <name> <command>" to add a server');
} else {
for (const server of servers) {
const statusIcon = server.status === 'running' ? '🟢' : '⚪';
await pretty.plain(` ${statusIcon} ${server.name}`);
await pretty.dim(` Command: ${server.command} ${(server.args || []).join(' ')}`);
}
}
await pretty.newline();
await pretty.info(`Total: ${servers.length} servers`);
}
}
async function addServer(args: string[], outputFormat: string): Promise<void> {
const name = args[0];
const command = args[1];
const serverArgs = args.slice(2);
if (!name || !command) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide server name and command'));
} else {
await pretty.error('Please provide server name and command');
await pretty.dim('Usage: praisonai-ts mcp add <name> <command> [args...]');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const server: MCPServer = {
name,
command,
args: serverArgs.length > 0 ? serverArgs : undefined,
status: 'stopped'
};
mcpServers.set(name, server);
if (outputFormat === 'json') {
outputJson(formatSuccess({ added: true, server }));
} else {
await pretty.success(`MCP server added: ${name}`);
await pretty.dim(`Command: ${command} ${serverArgs.join(' ')}`);
}
}
async function removeServer(args: string[], outputFormat: string): Promise<void> {
const name = args[0];
if (!name) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide server name'));
} else {
await pretty.error('Please provide server name');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const removed = mcpServers.delete(name);
if (outputFormat === 'json') {
outputJson(formatSuccess({ removed, name }));
} else {
if (removed) {
await pretty.success(`MCP server removed: ${name}`);
} else {
await pretty.warn(`Server not found: ${name}`);
}
}
}
async function startServer(args: string[], outputFormat: string): Promise<void> {
const name = args[0];
if (!name) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide server name'));
} else {
await pretty.error('Please provide server name');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const server = mcpServers.get(name);
if (!server) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, `Server not found: ${name}`));
} else {
await pretty.error(`Server not found: ${name}`);
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
// Mark as running (actual process management would be more complex)
server.status = 'running';
if (outputFormat === 'json') {
outputJson(formatSuccess({ started: true, server }));
} else {
await pretty.success(`MCP server started: ${name}`);
}
}
async function stopServer(args: string[], outputFormat: string): Promise<void> {
const name = args[0];
if (!name) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide server name'));
} else {
await pretty.error('Please provide server name');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const server = mcpServers.get(name);
if (!server) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, `Server not found: ${name}`));
} else {
await pretty.error(`Server not found: ${name}`);
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
server.status = 'stopped';
if (outputFormat === 'json') {
outputJson(formatSuccess({ stopped: true, server }));
} else {
await pretty.success(`MCP server stopped: ${name}`);
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'mcp',
description: 'Model Context Protocol server management',
subcommands: [
{ name: 'list', description: 'List registered MCP servers' },
{ name: 'add <name> <cmd> [args]', description: 'Add an MCP server' },
{ name: 'remove <name>', description: 'Remove an MCP server' },
{ name: 'start <name>', description: 'Start an MCP server' },
{ name: 'stop <name>', description: 'Stop an MCP server' },
{ name: 'help', description: 'Show this help' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('MCP Command');
await pretty.plain('Model Context Protocol server management\n');
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
}
}
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Memory command - Manage agent memory
*/
import { Memory, createMemory, MemoryEntry, SearchResult } from '../../memory/memory';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface MemoryOptions {
userId?: string;
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: MemoryOptions): Promise<void> {
const action = args[0] || 'list';
const actionArgs = args.slice(1);
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
const memory = createMemory();
switch (action) {
case 'list':
await listMemories(memory, outputFormat);
break;
case 'add':
await addMemory(memory, actionArgs, outputFormat);
break;
case 'search':
await searchMemory(memory, actionArgs, outputFormat);
break;
case 'clear':
await clearMemory(memory, outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function listMemories(memory: Memory, outputFormat: string): Promise<void> {
const entries = memory.getAll();
if (outputFormat === 'json') {
outputJson(formatSuccess({
memories: entries,
count: entries.length
}));
} else {
await pretty.heading('Memory Entries');
if (entries.length === 0) {
await pretty.info('No memories stored');
} else {
for (const entry of entries) {
await pretty.plain(` • ${entry.content.substring(0, 100)}${entry.content.length > 100 ? '...' : ''}`);
if (entry.metadata) {
await pretty.dim(` Created: ${new Date(entry.timestamp).toISOString()}`);
}
}
}
await pretty.newline();
await pretty.info(`Total: ${entries.length} memories`);
}
}
async function addMemory(memory: Memory, args: string[], outputFormat: string): Promise<void> {
const content = args.join(' ');
if (!content) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide content to add'));
} else {
await pretty.error('Please provide content to add');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
await memory.add(content, 'user');
if (outputFormat === 'json') {
outputJson(formatSuccess({ added: content }));
} else {
await pretty.success('Memory added successfully');
}
}
async function searchMemory(memory: Memory, args: string[], outputFormat: string): Promise<void> {
const query = args.join(' ');
if (!query) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a search query'));
} else {
await pretty.error('Please provide a search query');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const results = await memory.search(query);
if (outputFormat === 'json') {
outputJson(formatSuccess({
query,
results,
count: results.length
}));
} else {
await pretty.heading(`Search Results for: "${query}"`);
if (results.length === 0) {
await pretty.info('No matching memories found');
} else {
for (const result of results) {
const content = result.entry.content;
await pretty.plain(` • ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`);
await pretty.dim(` Score: ${result.score.toFixed(3)}`);
}
}
await pretty.newline();
await pretty.info(`Found: ${results.length} results`);
}
}
async function clearMemory(memory: Memory, outputFormat: string): Promise<void> {
memory.clear();
if (outputFormat === 'json') {
outputJson(formatSuccess({ cleared: true }));
} else {
await pretty.success('Memory cleared successfully');
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'memory',
subcommands: [
{ name: 'list', description: 'List all memories' },
{ name: 'add <content>', description: 'Add a new memory' },
{ name: 'search <query>', description: 'Search memories' },
{ name: 'clear', description: 'Clear all memories' },
{ name: 'help', description: 'Show this help' }
],
options: [
{ name: '--user-id', description: 'User ID for memory isolation' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Memory Command');
await pretty.plain('Manage agent memory storage\n');
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
}
await pretty.newline();
await pretty.plain('Options:');
for (const opt of help.options) {
await pretty.plain(` ${opt.name.padEnd(20)} ${opt.description}`);
}
}
}
+143
View File
@@ -0,0 +1,143 @@
/**
* CLI command: n8n
* N8N workflow integration
*/
import { N8NIntegration, createN8NIntegration } from '../features/n8n-integration';
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const subcommand = args[0] || 'help';
const isJson = Boolean(options.output === 'json' || options.json);
switch (subcommand) {
case 'trigger':
await handleTrigger(args.slice(1), options, isJson);
break;
case 'export':
await handleExport(args.slice(1), options, isJson);
break;
case 'help':
default:
showHelp(isJson);
}
}
async function handleTrigger(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const webhookId = args[0];
const payloadStr = args[1] || '{}';
if (!webhookId) {
console.error('Error: Webhook ID is required');
process.exit(1);
}
let payload: Record<string, unknown>;
try {
payload = JSON.parse(payloadStr);
} catch {
payload = { data: payloadStr };
}
const baseUrl = options['base-url'] as string | undefined;
const apiKey = options['api-key'] as string | undefined;
const n8n = createN8NIntegration({ baseUrl, apiKey });
const result = await n8n.triggerWebhook(webhookId, payload);
if (isJson) {
console.log(JSON.stringify(result, null, 2));
} else {
if (result.success) {
console.log(`✓ Webhook triggered: ${webhookId}`);
if (result.response) {
console.log('Response:', JSON.stringify(result.response, null, 2));
}
} else {
console.error(`✗ Webhook failed: ${result.error}`);
process.exit(1);
}
}
}
async function handleExport(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const name = args[0] || 'workflow';
const stepsStr = options.steps as string | undefined;
let steps: Array<{ name: string; type: 'agent' | 'tool' | 'condition' }> = [];
if (stepsStr) {
try {
steps = JSON.parse(stepsStr);
} catch {
// Parse simple format: "step1:agent,step2:tool"
steps = stepsStr.split(',').map(s => {
const [stepName, type] = s.split(':');
return { name: stepName, type: (type || 'agent') as 'agent' | 'tool' | 'condition' };
});
}
} else {
// Default example workflow
steps = [
{ name: 'Process Input', type: 'agent' },
{ name: 'Validate', type: 'condition' },
{ name: 'Execute', type: 'tool' }
];
}
const n8n = createN8NIntegration();
const workflow = n8n.exportWorkflowJSON(name, steps);
if (isJson || !options.pretty) {
console.log(workflow);
} else {
console.log(`N8N Workflow: ${name}\n`);
console.log('Steps:');
for (const step of steps) {
console.log(` - ${step.name} (${step.type})`);
}
console.log('\nExported workflow JSON:');
console.log(workflow);
}
}
function showHelp(isJson: boolean): void {
const help = {
command: 'n8n',
description: 'N8N workflow integration',
subcommands: {
trigger: 'Trigger an N8N webhook',
export: 'Export workflow to N8N format'
},
flags: {
'--base-url': 'N8N base URL (default: http://localhost:5678)',
'--api-key': 'N8N API key',
'--steps': 'Workflow steps as JSON or "name:type,name:type"',
'--pretty': 'Pretty print export',
'--json': 'Output in JSON format'
},
examples: [
'praisonai-ts n8n trigger my-webhook \'{"data": "test"}\'',
'praisonai-ts n8n trigger webhook-id --base-url https://n8n.example.com',
'praisonai-ts n8n export my-workflow',
'praisonai-ts n8n export workflow --steps "Process:agent,Validate:condition"'
]
};
if (isJson) {
console.log(JSON.stringify(help, null, 2));
} else {
console.log('N8N - Workflow integration\n');
console.log('Subcommands:');
for (const [cmd, desc] of Object.entries(help.subcommands)) {
console.log(` ${cmd.padEnd(12)} ${desc}`);
}
console.log('\nFlags:');
for (const [flag, desc] of Object.entries(help.flags)) {
console.log(` ${flag.padEnd(12)} ${desc}`);
}
console.log('\nExamples:');
for (const ex of help.examples) {
console.log(` ${ex}`);
}
}
}
@@ -0,0 +1,119 @@
/**
* Observability command - Monitoring and tracing
*/
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface ObservabilityOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: ObservabilityOptions): Promise<void> {
const action = args[0] || 'help';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (action) {
case 'providers':
await listProviders(outputFormat);
break;
case 'info':
await showInfo(outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function showInfo(outputFormat: string): Promise<void> {
const info = {
feature: 'Observability',
description: 'Monitoring, tracing, and logging for agent operations',
providers: [
{ name: 'ConsoleObservabilityProvider', description: 'Console-based logging' },
{ name: 'MemoryObservabilityProvider', description: 'In-memory trace storage' },
{ name: 'LangfuseObservabilityProvider', description: 'Langfuse integration' }
],
capabilities: [
'Trace agent executions',
'Log LLM calls and responses',
'Track tool invocations',
'Measure performance metrics',
'Export traces to external systems'
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(info));
} else {
await pretty.heading('Observability');
await pretty.plain(info.description);
await pretty.newline();
await pretty.plain('Providers:');
for (const p of info.providers) {
await pretty.plain(` • ${p.name}: ${p.description}`);
}
await pretty.newline();
await pretty.plain('Capabilities:');
for (const cap of info.capabilities) {
await pretty.plain(` • ${cap}`);
}
}
}
async function listProviders(outputFormat: string): Promise<void> {
const providers = [
{ name: 'console', description: 'Console logging (default)', available: true },
{ name: 'memory', description: 'In-memory storage', available: true },
{ name: 'langfuse', description: 'Langfuse observability platform', available: true }
];
if (outputFormat === 'json') {
outputJson(formatSuccess({ providers }));
} else {
await pretty.heading('Observability Providers');
for (const p of providers) {
const status = p.available ? '✓' : '✗';
await pretty.plain(` ${status} ${p.name.padEnd(15)} ${p.description}`);
}
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'observability',
description: 'Monitoring and tracing for agent operations',
subcommands: [
{ name: 'info', description: 'Show observability feature information' },
{ name: 'providers', description: 'List available observability providers' },
{ name: 'help', description: 'Show this help' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Observability Command');
await pretty.plain(help.description);
await pretty.newline();
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
}
}
}
@@ -0,0 +1,139 @@
/**
* Planning command - Task planning and todo management
*/
import { createPlan, createTodoList, TodoItem, type PlanConfig, type TodoItemConfig } from '../../planning';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface PlanningOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: PlanningOptions): Promise<void> {
const action = args[0] || 'help';
const actionArgs = args.slice(1);
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (action) {
case 'create':
await createNewPlan(actionArgs, outputFormat);
break;
case 'todo':
await manageTodo(actionArgs, outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function createNewPlan(args: string[], outputFormat: string): Promise<void> {
const name = args.join(' ');
if (!name) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a name for the plan'));
} else {
await pretty.error('Please provide a name for the plan');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const plan = createPlan({
name,
description: name
});
if (outputFormat === 'json') {
outputJson(formatSuccess({
id: plan.id,
name: plan.name,
status: plan.status
}));
} else {
await pretty.success(`Plan created: ${plan.id}`);
await pretty.plain(`Name: ${name}`);
}
}
async function manageTodo(args: string[], outputFormat: string): Promise<void> {
const subAction = args[0] || 'list';
const todoArgs = args.slice(1);
const todoList = createTodoList();
switch (subAction) {
case 'add':
const content = todoArgs.join(' ');
if (!content) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide todo content'));
} else {
await pretty.error('Please provide todo content');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const newItem = new TodoItem({ content });
todoList.add(newItem);
if (outputFormat === 'json') {
outputJson(formatSuccess({ added: true, id: newItem.id, content: newItem.content }));
} else {
await pretty.success(`Todo added: ${content}`);
}
break;
case 'list':
default:
const items = todoList.items;
if (outputFormat === 'json') {
outputJson(formatSuccess({ items, count: items.length }));
} else {
await pretty.heading('Todo List');
if (items.length === 0) {
await pretty.info('No todos');
} else {
for (const todo of items) {
const status = todo.status === 'completed' ? '✓' : '○';
await pretty.plain(` ${status} ${todo.content}`);
}
}
}
break;
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'planning',
subcommands: [
{ name: 'create <goal>', description: 'Create a new plan' },
{ name: 'todo add <title>', description: 'Add a todo item' },
{ name: 'todo list', description: 'List todo items' },
{ name: 'help', description: 'Show this help' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Planning Command');
await pretty.plain('Task planning and todo management\n');
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
}
}
}
@@ -0,0 +1,77 @@
/**
* Prompt Expand command - Expand prompts with more detail
*/
import { createPromptExpanderAgent } from '../../agent/prompt-expander';
import { resolveConfig } from '../config/resolve';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface PromptExpandOptions {
model?: string;
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
strategy?: string;
}
export async function execute(args: string[], options: PromptExpandOptions): Promise<void> {
const prompt = args.join(' ');
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
const config = resolveConfig(options);
if (!prompt) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a prompt to expand'));
} else {
await pretty.error('Please provide a prompt to expand');
await pretty.dim('Usage: praisonai-ts prompt-expand "your prompt"');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const startTime = Date.now();
try {
if (outputFormat !== 'json') {
await pretty.info(`Expanding prompt: "${prompt}"`);
}
const agent = createPromptExpanderAgent({
llm: config.model,
verbose: options.verbose,
defaultStrategy: options.strategy as any
});
const result = await agent.expand(prompt);
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
originalPrompt: prompt,
expandedPrompt: result.expanded,
strategy: result.strategy,
additions: result.additions
}, {
duration_ms: duration,
model: config.model
}));
} else {
await pretty.heading('Expanded Prompt');
await pretty.plain(result.expanded);
await pretty.newline();
await pretty.dim(`Strategy: ${result.strategy}`);
await pretty.success(`Completed in ${duration}ms`);
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
@@ -0,0 +1,84 @@
/**
* Providers command - List available LLM providers
*/
import { getAvailableProviders } from '../../llm/providers';
import { hasApiKey } from '../runtime/env';
import { outputJson, formatSuccess } from '../output/json';
import * as pretty from '../output/pretty';
export interface ProvidersOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
interface ProviderInfo {
name: string;
available: boolean;
hasApiKey: boolean;
models?: string[];
}
const PROVIDER_MODELS: Record<string, string[]> = {
openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-opus-20240229', 'claude-3-haiku-20240307'],
google: ['gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash']
};
export async function execute(args: string[], options: ProvidersOptions): Promise<void> {
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
const availableProviders = getAvailableProviders();
const providers: ProviderInfo[] = [
{
name: 'openai',
available: availableProviders.includes('openai'),
hasApiKey: hasApiKey('openai'),
models: PROVIDER_MODELS.openai
},
{
name: 'anthropic',
available: availableProviders.includes('anthropic'),
hasApiKey: hasApiKey('anthropic'),
models: PROVIDER_MODELS.anthropic
},
{
name: 'google',
available: availableProviders.includes('google'),
hasApiKey: hasApiKey('google'),
models: PROVIDER_MODELS.google
}
];
if (outputFormat === 'json') {
outputJson(formatSuccess({
providers: providers.map(p => ({
name: p.name,
available: p.available,
has_api_key: p.hasApiKey,
models: options.verbose ? p.models : undefined
}))
}));
} else {
await pretty.heading('Available Providers');
for (const provider of providers) {
const status = provider.available ? '✅' : '❌';
const keyStatus = provider.hasApiKey ? '🔑' : '⚠️ No API key';
await pretty.plain(` ${status} ${provider.name} ${keyStatus}`);
if (options.verbose && provider.models) {
for (const model of provider.models) {
await pretty.dim(` - ${model}`);
}
}
}
await pretty.newline();
await pretty.info('Set API keys via environment variables:');
await pretty.dim(' OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY');
}
}
@@ -0,0 +1,80 @@
/**
* Query Rewrite command - Rewrite queries for better search results
*/
import { createQueryRewriterAgent } from '../../agent/query-rewriter';
import { resolveConfig } from '../config/resolve';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface QueryRewriteOptions {
model?: string;
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
strategy?: string;
count?: number;
}
export async function execute(args: string[], options: QueryRewriteOptions): Promise<void> {
const query = args.join(' ');
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
const config = resolveConfig(options);
if (!query) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a query to rewrite'));
} else {
await pretty.error('Please provide a query to rewrite');
await pretty.dim('Usage: praisonai-ts query-rewrite "your search query"');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const startTime = Date.now();
try {
if (outputFormat !== 'json') {
await pretty.info(`Rewriting query: "${query}"`);
}
const agent = createQueryRewriterAgent({
llm: config.model,
verbose: options.verbose,
defaultStrategy: options.strategy as any
});
const result = await agent.rewrite(query);
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
originalQuery: query,
rewrittenQueries: result.rewritten,
strategy: result.strategy,
confidence: result.confidence
}, {
duration_ms: duration,
model: config.model
}));
} else {
await pretty.heading('Rewritten Queries');
for (let i = 0; i < result.rewritten.length; i++) {
await pretty.plain(` ${i + 1}. ${result.rewritten[i]}`);
}
await pretty.newline();
await pretty.dim(`Strategy: ${result.strategy}`);
await pretty.success(`Completed in ${duration}ms`);
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
@@ -0,0 +1,154 @@
/**
* CLI command: repo-map
* Repository structure visualization and symbol extraction
*/
import { RepoMap, createRepoMap, getRepoTree } from '../features/repo-map';
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const subcommand = args[0] || 'tree';
const isJson = Boolean(options.output === 'json' || options.json);
switch (subcommand) {
case 'tree':
await handleTree(args.slice(1), options, isJson);
break;
case 'symbols':
await handleSymbols(args.slice(1), options, isJson);
break;
case 'help':
default:
if (subcommand !== 'help' && !subcommand.startsWith('-')) {
// Treat as path for tree command
await handleTree([subcommand, ...args.slice(1)], options, isJson);
} else {
showHelp(isJson);
}
}
}
async function handleTree(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const rootPath = args[0] || process.cwd();
const maxDepth = (options.depth as number) || 5;
const includeSymbols = Boolean(options.symbols);
const map = createRepoMap({
rootPath,
maxDepth,
includeSymbols
});
try {
const result = await map.generate();
if (isJson) {
console.log(JSON.stringify({
success: true,
root: result.root,
totalFiles: result.totalFiles,
totalDirectories: result.totalDirectories,
symbolCount: result.symbols.length
}, null, 2));
} else {
console.log(result.summary);
}
} catch (error: any) {
if (isJson) {
console.log(JSON.stringify({ success: false, error: error.message }));
} else {
console.error(`Error: ${error.message}`);
}
process.exit(1);
}
}
async function handleSymbols(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const rootPath = args[0] || process.cwd();
const maxDepth = (options.depth as number) || 5;
const map = createRepoMap({
rootPath,
maxDepth,
includeSymbols: true
});
try {
const result = await map.generate();
if (isJson) {
console.log(JSON.stringify({
success: true,
symbols: result.symbols,
count: result.symbols.length
}, null, 2));
} else {
console.log(`Symbols in ${rootPath}:\n`);
const byType: Record<string, typeof result.symbols> = {};
for (const sym of result.symbols) {
if (!byType[sym.type]) byType[sym.type] = [];
byType[sym.type].push(sym);
}
for (const [type, symbols] of Object.entries(byType)) {
console.log(`${type}s (${symbols.length}):`);
for (const sym of symbols.slice(0, 20)) {
const exported = sym.exported ? ' [exported]' : '';
console.log(` ${sym.name}${exported} (line ${sym.line})`);
}
if (symbols.length > 20) {
console.log(` ... and ${symbols.length - 20} more`);
}
console.log();
}
}
} catch (error: any) {
if (isJson) {
console.log(JSON.stringify({ success: false, error: error.message }));
} else {
console.error(`Error: ${error.message}`);
}
process.exit(1);
}
}
function showHelp(isJson: boolean): void {
const help = {
command: 'repo-map',
description: 'Repository structure visualization and symbol extraction',
subcommands: {
tree: 'Show repository tree structure',
symbols: 'Extract and list code symbols'
},
flags: {
'--depth': 'Maximum directory depth (default: 5)',
'--symbols': 'Include symbols in tree output',
'--json': 'Output in JSON format'
},
examples: [
'praisonai-ts repo-map',
'praisonai-ts repo-map tree ./src',
'praisonai-ts repo-map tree --depth 3',
'praisonai-ts repo-map symbols ./src',
'praisonai-ts repo-map tree --symbols'
]
};
if (isJson) {
console.log(JSON.stringify(help, null, 2));
} else {
console.log('Repo Map - Repository visualization\n');
console.log('Subcommands:');
for (const [cmd, desc] of Object.entries(help.subcommands)) {
console.log(` ${cmd.padEnd(12)} ${desc}`);
}
console.log('\nFlags:');
for (const [flag, desc] of Object.entries(help.flags)) {
console.log(` ${flag.padEnd(12)} ${desc}`);
}
console.log('\nExamples:');
for (const ex of help.examples) {
console.log(` ${ex}`);
}
}
}
@@ -0,0 +1,119 @@
/**
* Reranker command - Document reranking
*/
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface RerankerOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
provider?: string;
}
export async function execute(args: string[], options: RerankerOptions): Promise<void> {
const action = args[0] || 'help';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (action) {
case 'providers':
await listProviders(outputFormat);
break;
case 'info':
await showInfo(outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function showInfo(outputFormat: string): Promise<void> {
const info = {
feature: 'Reranker',
description: 'Rerank search results for improved relevance',
providers: [
{ name: 'CohereReranker', description: 'Cohere reranking API' },
{ name: 'CrossEncoderReranker', description: 'Cross-encoder model reranking' },
{ name: 'LLMReranker', description: 'LLM-based reranking' }
],
capabilities: [
'Rerank search results by relevance',
'Improve RAG retrieval quality',
'Multiple reranking strategies',
'Configurable top-k selection'
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(info));
} else {
await pretty.heading('Reranker');
await pretty.plain(info.description);
await pretty.newline();
await pretty.plain('Providers:');
for (const p of info.providers) {
await pretty.plain(` • ${p.name}: ${p.description}`);
}
await pretty.newline();
await pretty.plain('Capabilities:');
for (const cap of info.capabilities) {
await pretty.plain(` • ${cap}`);
}
}
}
async function listProviders(outputFormat: string): Promise<void> {
const providers = [
{ name: 'cohere', description: 'Cohere reranking API', available: true },
{ name: 'cross-encoder', description: 'Cross-encoder model', available: true },
{ name: 'llm', description: 'LLM-based reranking', available: true }
];
if (outputFormat === 'json') {
outputJson(formatSuccess({ providers }));
} else {
await pretty.heading('Reranker Providers');
for (const p of providers) {
const status = p.available ? '✓' : '✗';
await pretty.plain(` ${status} ${p.name.padEnd(15)} ${p.description}`);
}
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'reranker',
description: 'Document reranking for improved search relevance',
subcommands: [
{ name: 'info', description: 'Show reranker feature information' },
{ name: 'providers', description: 'List available reranker providers' },
{ name: 'help', description: 'Show this help' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Reranker Command');
await pretty.plain(help.description);
await pretty.newline();
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
}
}
}
@@ -0,0 +1,88 @@
/**
* Research command - Deep research agent
*/
import { createDeepResearchAgent } from '../../agent/research';
import { resolveConfig } from '../config/resolve';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface ResearchOptions {
model?: string;
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
depth?: number;
maxSources?: number;
}
export async function execute(args: string[], options: ResearchOptions): Promise<void> {
const query = args.join(' ');
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
const config = resolveConfig(options);
if (!query) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a research query'));
} else {
await pretty.error('Please provide a research query');
await pretty.dim('Usage: praisonai-ts research "What are the latest AI trends?"');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const startTime = Date.now();
try {
if (outputFormat !== 'json') {
await pretty.info(`Researching: "${query}"`);
}
const agent = createDeepResearchAgent({
llm: config.model,
verbose: options.verbose,
maxIterations: options.depth
});
const result = await agent.research(query);
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
query,
answer: result.answer,
citations: result.citations,
reasoning: result.reasoning,
confidence: result.confidence
}, {
duration_ms: duration,
model: config.model
}));
} else {
await pretty.heading('Research Results');
await pretty.newline();
await pretty.plain(result.answer);
if (result.citations && result.citations.length > 0) {
await pretty.newline();
await pretty.plain('Citations:');
for (const citation of result.citations) {
await pretty.dim(` • ${citation.title || citation.url}`);
}
}
await pretty.newline();
await pretty.success(`Completed in ${duration}ms`);
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Router command - Route requests to appropriate agents
*/
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface RouterOptions {
model?: string;
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
routes?: string;
}
export async function execute(args: string[], options: RouterOptions): Promise<void> {
const action = args[0] || 'help';
const actionArgs = args.slice(1);
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (action) {
case 'analyze':
await analyzeInput(actionArgs, outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function analyzeInput(args: string[], outputFormat: string): Promise<void> {
const input = args.join(' ');
if (!input) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide input to analyze'));
} else {
await pretty.error('Please provide input to analyze');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const startTime = Date.now();
if (outputFormat !== 'json') {
await pretty.info(`Analyzing routing for: "${input}"`);
}
// Simple rule-based routing analysis
const routes = [
{ name: 'greeting', pattern: /\b(hello|hi|hey|greetings)\b/i, priority: 1 },
{ name: 'question', pattern: /\?$/, priority: 2 },
{ name: 'code', pattern: /\b(code|function|class|implement|debug)\b/i, priority: 3 },
{ name: 'research', pattern: /\b(research|analyze|investigate|study)\b/i, priority: 3 },
{ name: 'creative', pattern: /\b(write|create|generate|compose)\b/i, priority: 2 },
{ name: 'general', pattern: /.*/, priority: 0 }
];
const matches = routes
.filter(r => r.pattern.test(input))
.sort((a, b) => b.priority - a.priority);
const selectedRoute = matches[0]?.name || 'general';
const confidence = matches[0]?.priority ? Math.min(0.5 + matches[0].priority * 0.15, 0.95) : 0.5;
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
input,
selectedRoute,
confidence,
matchedRoutes: matches.map(m => m.name)
}, {
duration_ms: duration
}));
} else {
await pretty.heading('Routing Analysis');
await pretty.plain(`Input: "${input}"`);
await pretty.plain(`Selected Route: ${selectedRoute}`);
await pretty.plain(`Confidence: ${(confidence * 100).toFixed(1)}%`);
await pretty.plain(`Matched Routes: ${matches.map(m => m.name).join(', ')}`);
await pretty.newline();
await pretty.success(`Analyzed in ${duration}ms`);
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'router',
description: 'Route requests to appropriate agents based on content analysis',
subcommands: [
{ name: 'analyze <input>', description: 'Analyze input and suggest routing' },
{ name: 'help', description: 'Show this help' }
],
usage: 'For programmatic routing with actual agents, use the RouterAgent SDK class'
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Router Command');
await pretty.plain('Route requests to appropriate agents\n');
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
}
await pretty.newline();
await pretty.dim('Note: For full routing with agents, use the RouterAgent SDK class');
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Run command - Run an agent with a task
*/
import { Agent } from '../../agent';
import { resolveConfig } from '../config/resolve';
import { printSuccess, printError, outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES, normalizeError } from '../output/errors';
export interface RunOptions {
agent?: string;
tools?: string;
model?: string;
verbose?: boolean;
profile?: string;
config?: string;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: RunOptions): Promise<void> {
const task = args[0];
if (!task) {
if (options.json || options.output === 'json') {
printError(ERROR_CODES.MISSING_ARG, 'Please provide a task');
} else {
await pretty.error('Please provide a task');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
// Resolve config with precedence
const config = resolveConfig({
configPath: options.config,
profile: options.profile,
model: options.model,
verbose: options.verbose
});
const startTime = Date.now();
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
// Parse tools if provided
const tools: string[] = options.tools ? options.tools.split(',').map(t => t.trim()) : [];
// Create agent
const agent = new Agent({
name: 'CLI Agent',
instructions: 'You are a helpful AI assistant.',
llm: config.model,
verbose: config.verbose
});
// Execute task
const result = await agent.start(task);
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess(
{
result: result,
agent: 'CLI Agent',
task: task
},
{
duration_ms: duration,
model: config.model
}
));
} else {
if (typeof result === 'string') {
console.log(result);
} else {
console.log(JSON.stringify(result, null, 2));
}
}
} catch (error) {
const cliError = normalizeError(error);
if (outputFormat === 'json') {
outputJson(formatError(cliError.code, cliError.message, cliError.details));
} else {
await pretty.error(cliError.message);
if (config.verbose && error instanceof Error && error.stack) {
await pretty.dim(error.stack);
}
}
process.exit(cliError.exitCode);
}
}
@@ -0,0 +1,139 @@
/**
* CLI command: sandbox
* Safe command execution with restrictions
*/
import { SandboxExecutor, createSandboxExecutor, CommandValidator, type SandboxMode } from '../features/sandbox-executor';
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const subcommand = args[0] || 'help';
const isJson = Boolean(options.output === 'json' || options.json);
switch (subcommand) {
case 'exec':
await handleExec(args.slice(1), options, isJson);
break;
case 'check':
await handleCheck(args.slice(1), isJson);
break;
case 'mode':
await handleMode(args.slice(1), isJson);
break;
case 'help':
default:
showHelp(isJson);
}
}
async function handleExec(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const command = args.join(' ');
if (!command) {
console.error('Error: Command is required');
process.exit(1);
}
const mode = (options.mode as SandboxMode) || 'basic';
const timeout = (options.timeout as number) || 30000;
const executor = createSandboxExecutor({ mode, timeout });
const result = await executor.execute(command);
if (isJson) {
console.log(JSON.stringify(result, null, 2));
} else {
if (result.success) {
console.log('✓ Command executed successfully');
console.log(`Duration: ${result.duration}ms`);
if (result.stdout) {
console.log('\nOutput:');
console.log(result.stdout);
}
} else {
console.error('✗ Command failed');
if (result.stderr) console.error(result.stderr);
}
}
if (!result.success) process.exit(result.exitCode || 1);
}
async function handleCheck(args: string[], isJson: boolean): Promise<void> {
const command = args.join(' ');
if (!command) {
console.error('Error: Command is required');
process.exit(1);
}
const validator = new CommandValidator();
const result = validator.validate(command);
if (isJson) {
console.log(JSON.stringify({ command, ...result }, null, 2));
} else {
if (result.valid) {
console.log(`✓ Command is allowed: ${command}`);
} else {
console.log(`✗ Command blocked: ${command}`);
console.log(` Reason: ${result.reason}`);
}
}
}
async function handleMode(args: string[], isJson: boolean): Promise<void> {
const modes: Record<SandboxMode, string> = {
disabled: 'No execution allowed',
basic: 'Basic validation, blocks dangerous patterns',
strict: 'Minimal environment, restricted PATH',
'network-isolated': 'No network access (proxy blocked)'
};
if (isJson) {
console.log(JSON.stringify({ success: true, modes }, null, 2));
} else {
console.log('Sandbox Modes:\n');
for (const [mode, desc] of Object.entries(modes)) {
console.log(` ${mode.padEnd(18)} ${desc}`);
}
}
}
function showHelp(isJson: boolean): void {
const help = {
command: 'sandbox',
description: 'Safe command execution with restrictions',
subcommands: {
exec: 'Execute a command in sandbox',
check: 'Check if a command would be allowed',
mode: 'List available sandbox modes'
},
flags: {
'--mode': 'Sandbox mode (disabled, basic, strict, network-isolated)',
'--timeout': 'Execution timeout in ms (default: 30000)',
'--json': 'Output in JSON format'
},
examples: [
'praisonai-ts sandbox exec "ls -la"',
'praisonai-ts sandbox exec "npm install" --mode strict',
'praisonai-ts sandbox check "rm -rf /"',
'praisonai-ts sandbox mode'
]
};
if (isJson) {
console.log(JSON.stringify(help, null, 2));
} else {
console.log('Sandbox - Safe command execution\n');
console.log('Subcommands:');
for (const [cmd, desc] of Object.entries(help.subcommands)) {
console.log(` ${cmd.padEnd(12)} ${desc}`);
}
console.log('\nFlags:');
for (const [flag, desc] of Object.entries(help.flags)) {
console.log(` ${flag.padEnd(12)} ${desc}`);
}
console.log('\nExamples:');
for (const ex of help.examples) {
console.log(` ${ex}`);
}
}
}
@@ -0,0 +1,266 @@
/**
* CLI command: scheduler
* Agent task scheduling with cron-like patterns
*/
import { Scheduler, createScheduler, cronExpressions } from '../features/scheduler';
export async function execute(args: string[], options: Record<string, unknown>): Promise<void> {
const subcommand = args[0] || 'help';
const isJson = Boolean(options.output === 'json' || options.json);
switch (subcommand) {
case 'create':
await handleCreate(args.slice(1), options, isJson);
break;
case 'list':
await handleList(options, isJson);
break;
case 'run':
await handleRun(args.slice(1), options, isJson);
break;
case 'remove':
await handleRemove(args.slice(1), options, isJson);
break;
case 'enable':
await handleEnable(args.slice(1), options, isJson);
break;
case 'disable':
await handleDisable(args.slice(1), options, isJson);
break;
case 'patterns':
await handlePatterns(isJson);
break;
case 'help':
default:
showHelp(isJson);
}
}
async function handleCreate(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const name = args[0];
const cron = options.cron as string | undefined;
const interval = options.interval as number | undefined;
if (!name) {
const error = { error: 'Task name is required' };
if (isJson) {
console.log(JSON.stringify(error));
} else {
console.error('Error: Task name is required');
console.log('Usage: praisonai-ts scheduler create <name> --cron "* * * * *"');
}
process.exit(1);
}
if (!cron && !interval) {
const error = { error: 'Either --cron or --interval is required' };
if (isJson) {
console.log(JSON.stringify(error));
} else {
console.error('Error: Either --cron or --interval is required');
}
process.exit(1);
}
const scheduler = createScheduler();
const id = scheduler.add({
name,
cron,
interval: interval ? interval * 1000 : undefined,
task: async () => {
console.log(`Task ${name} executed at ${new Date().toISOString()}`);
return { executed: true };
}
});
const result = {
success: true,
id,
name,
cron,
interval,
message: `Task '${name}' created with ID: ${id}`
};
if (isJson) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`✓ Task '${name}' created`);
console.log(` ID: ${id}`);
if (cron) console.log(` Cron: ${cron}`);
if (interval) console.log(` Interval: ${interval}s`);
}
}
async function handleList(options: Record<string, unknown>, isJson: boolean): Promise<void> {
const scheduler = createScheduler();
const tasks = scheduler.getAllTasks();
if (isJson) {
console.log(JSON.stringify({ success: true, tasks }, null, 2));
} else {
if (tasks.length === 0) {
console.log('No scheduled tasks');
} else {
console.log('Scheduled Tasks:');
for (const task of tasks) {
console.log(` ${task.name} (${task.id})`);
console.log(` Status: ${task.status}`);
console.log(` Enabled: ${task.enabled}`);
console.log(` Run count: ${task.runCount}`);
if (task.nextRun) console.log(` Next run: ${task.nextRun.toISOString()}`);
}
}
}
}
async function handleRun(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const id = args[0];
if (!id) {
console.error('Error: Task ID is required');
process.exit(1);
}
const scheduler = createScheduler();
try {
const result = await scheduler.runNow(id);
if (isJson) {
console.log(JSON.stringify({ success: true, result }, null, 2));
} else {
console.log(`✓ Task ${id} executed successfully`);
}
} catch (error: any) {
if (isJson) {
console.log(JSON.stringify({ success: false, error: error.message }));
} else {
console.error(`Error: ${error.message}`);
}
process.exit(1);
}
}
async function handleRemove(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const id = args[0];
if (!id) {
console.error('Error: Task ID is required');
process.exit(1);
}
const scheduler = createScheduler();
const removed = scheduler.remove(id);
if (isJson) {
console.log(JSON.stringify({ success: removed, id }));
} else {
if (removed) {
console.log(`✓ Task ${id} removed`);
} else {
console.error(`Task ${id} not found`);
process.exit(1);
}
}
}
async function handleEnable(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const id = args[0];
if (!id) {
console.error('Error: Task ID is required');
process.exit(1);
}
const scheduler = createScheduler();
const enabled = scheduler.enable(id);
if (isJson) {
console.log(JSON.stringify({ success: enabled, id }));
} else {
console.log(enabled ? `✓ Task ${id} enabled` : `Task ${id} not found`);
}
}
async function handleDisable(args: string[], options: Record<string, unknown>, isJson: boolean): Promise<void> {
const id = args[0];
if (!id) {
console.error('Error: Task ID is required');
process.exit(1);
}
const scheduler = createScheduler();
const disabled = scheduler.disable(id);
if (isJson) {
console.log(JSON.stringify({ success: disabled, id }));
} else {
console.log(disabled ? `✓ Task ${id} disabled` : `Task ${id} not found`);
}
}
async function handlePatterns(isJson: boolean): Promise<void> {
const patterns = {
everyMinute: cronExpressions.everyMinute,
every5Minutes: cronExpressions.every5Minutes,
every15Minutes: cronExpressions.every15Minutes,
everyHour: cronExpressions.everyHour,
everyDay: cronExpressions.everyDay,
everyWeek: cronExpressions.everyWeek,
everyMonth: cronExpressions.everyMonth,
weekdays: cronExpressions.weekdays,
weekends: cronExpressions.weekends
};
if (isJson) {
console.log(JSON.stringify({ success: true, patterns }, null, 2));
} else {
console.log('Common Cron Patterns:');
for (const [name, pattern] of Object.entries(patterns)) {
console.log(` ${name}: ${pattern}`);
}
}
}
function showHelp(isJson: boolean): void {
const help = {
command: 'scheduler',
description: 'Agent task scheduling with cron-like patterns',
subcommands: {
create: 'Create a scheduled task',
list: 'List all scheduled tasks',
run: 'Run a task immediately',
remove: 'Remove a scheduled task',
enable: 'Enable a task',
disable: 'Disable a task',
patterns: 'Show common cron patterns'
},
flags: {
'--cron': 'Cron expression (e.g., "*/5 * * * *")',
'--interval': 'Interval in seconds',
'--json': 'Output in JSON format'
},
examples: [
'praisonai-ts scheduler create my-task --cron "*/5 * * * *"',
'praisonai-ts scheduler create backup --interval 3600',
'praisonai-ts scheduler list',
'praisonai-ts scheduler run <task-id>',
'praisonai-ts scheduler patterns'
]
};
if (isJson) {
console.log(JSON.stringify(help, null, 2));
} else {
console.log('Scheduler - Agent task scheduling\n');
console.log('Subcommands:');
for (const [cmd, desc] of Object.entries(help.subcommands)) {
console.log(` ${cmd.padEnd(12)} ${desc}`);
}
console.log('\nFlags:');
for (const [flag, desc] of Object.entries(help.flags)) {
console.log(` ${flag.padEnd(12)} ${desc}`);
}
console.log('\nExamples:');
for (const ex of help.examples) {
console.log(` ${ex}`);
}
}
}
@@ -0,0 +1,214 @@
/**
* Session command - Manage agent sessions
*/
import { Session, SessionManager, getSessionManager } from '../../session';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface SessionOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: SessionOptions): Promise<void> {
const action = args[0] || 'list';
const actionArgs = args.slice(1);
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
const manager = getSessionManager();
switch (action) {
case 'list':
await listSessions(manager, outputFormat);
break;
case 'create':
await createSession(manager, actionArgs, outputFormat);
break;
case 'get':
await getSession(manager, actionArgs, outputFormat);
break;
case 'delete':
await deleteSession(manager, actionArgs, outputFormat);
break;
case 'export':
await exportSession(manager, actionArgs, outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function listSessions(manager: SessionManager, outputFormat: string): Promise<void> {
const sessions = manager.list();
if (outputFormat === 'json') {
outputJson(formatSuccess({
sessions: sessions.map((s: Session) => ({
id: s.id,
messageCount: s.messages.length,
createdAt: s.createdAt
})),
count: sessions.length
}));
} else {
await pretty.heading('Sessions');
if (sessions.length === 0) {
await pretty.info('No sessions found');
} else {
for (const session of sessions) {
await pretty.plain(` • ${session.id}`);
await pretty.dim(` Messages: ${session.messages.length}`);
}
}
await pretty.newline();
await pretty.info(`Total: ${sessions.length} sessions`);
}
}
async function createSession(manager: SessionManager, args: string[], outputFormat: string): Promise<void> {
const sessionId = args[0];
const session = manager.create({ id: sessionId });
if (outputFormat === 'json') {
outputJson(formatSuccess({
created: true,
sessionId: session.id
}));
} else {
await pretty.success(`Session created: ${session.id}`);
}
}
async function getSession(manager: SessionManager, args: string[], outputFormat: string): Promise<void> {
const sessionId = args[0];
if (!sessionId) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a session ID'));
} else {
await pretty.error('Please provide a session ID');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const session = manager.get(sessionId);
if (!session) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, `Session not found: ${sessionId}`));
} else {
await pretty.error(`Session not found: ${sessionId}`);
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
const messages = session.messages;
if (outputFormat === 'json') {
outputJson(formatSuccess({
id: session.id,
messages,
messageCount: messages.length
}));
} else {
await pretty.heading(`Session: ${session.id}`);
await pretty.plain(`Messages: ${messages.length}`);
await pretty.newline();
for (const msg of messages.slice(-10)) {
const content = msg.content || '';
await pretty.plain(` [${msg.role}] ${content.substring(0, 80)}${content.length > 80 ? '...' : ''}`);
}
}
}
async function deleteSession(manager: SessionManager, args: string[], outputFormat: string): Promise<void> {
const sessionId = args[0];
if (!sessionId) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a session ID'));
} else {
await pretty.error('Please provide a session ID');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const deleted = manager.delete(sessionId);
if (outputFormat === 'json') {
outputJson(formatSuccess({ deleted, sessionId }));
} else {
if (deleted) {
await pretty.success(`Session deleted: ${sessionId}`);
} else {
await pretty.warn(`Session not found: ${sessionId}`);
}
}
}
async function exportSession(manager: SessionManager, args: string[], outputFormat: string): Promise<void> {
const sessionId = args[0];
if (!sessionId) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a session ID'));
} else {
await pretty.error('Please provide a session ID');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const session = manager.get(sessionId);
if (!session) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, `Session not found: ${sessionId}`));
} else {
await pretty.error(`Session not found: ${sessionId}`);
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
const exported = session.toJSON();
if (outputFormat === 'json') {
outputJson(formatSuccess(exported));
} else {
console.log(JSON.stringify(exported, null, 2));
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'session',
subcommands: [
{ name: 'list', description: 'List all sessions' },
{ name: 'create [id]', description: 'Create a new session' },
{ name: 'get <id>', description: 'Get session details' },
{ name: 'delete <id>', description: 'Delete a session' },
{ name: 'export <id>', description: 'Export session data' },
{ name: 'help', description: 'Show this help' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Session Command');
await pretty.plain('Manage agent sessions\n');
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
}
}
}
+228
View File
@@ -0,0 +1,228 @@
/**
* Skills command - Manage agent skills
*/
import { SkillManager, createSkillManager, Skill } from '../../skills';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface SkillsOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: SkillsOptions): Promise<void> {
const action = args[0] || 'list';
const actionArgs = args.slice(1);
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
const searchPath = actionArgs[0] || '.';
const manager = createSkillManager({ paths: [searchPath] });
switch (action) {
case 'list':
await listSkills(manager, outputFormat);
break;
case 'discover':
await discoverSkills(manager, actionArgs, outputFormat);
break;
case 'validate':
await validateSkill(manager, actionArgs, outputFormat);
break;
case 'info':
await skillInfo(manager, actionArgs, outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function listSkills(manager: SkillManager, outputFormat: string): Promise<void> {
const skills = manager.list();
if (outputFormat === 'json') {
outputJson(formatSuccess({
skills: skills.map((s: Skill) => ({
name: s.metadata.name,
description: s.metadata.description,
path: s.path
})),
count: skills.length
}));
} else {
await pretty.heading('Available Skills');
if (skills.length === 0) {
await pretty.info('No skills loaded');
await pretty.dim('Run "praisonai-ts skills discover <path>" to discover skills');
} else {
for (const skill of skills) {
await pretty.plain(` • ${skill.metadata.name}`);
if (skill.metadata.description) {
await pretty.dim(` ${skill.metadata.description}`);
}
}
}
await pretty.newline();
await pretty.info(`Total: ${skills.length} skills`);
}
}
async function discoverSkills(manager: SkillManager, args: string[], outputFormat: string): Promise<void> {
const searchPath = args[0] || '.';
const discovered = await manager.discover();
if (outputFormat === 'json') {
outputJson(formatSuccess({
discovered: discovered.map((s: Skill) => ({
name: s.metadata.name,
description: s.metadata.description,
path: s.path
})),
count: discovered.length,
searchPath
}));
} else {
await pretty.heading(`Discovered Skills in: ${searchPath}`);
if (discovered.length === 0) {
await pretty.info('No skills found');
} else {
for (const skill of discovered) {
await pretty.plain(` • ${skill.metadata.name}`);
if (skill.metadata.description) {
await pretty.dim(` ${skill.metadata.description}`);
}
if (skill.path) {
await pretty.dim(` Path: ${skill.path}`);
}
}
}
await pretty.newline();
await pretty.info(`Found: ${discovered.length} skills`);
}
}
async function validateSkill(manager: SkillManager, args: string[], outputFormat: string): Promise<void> {
const skillPath = args[0];
if (!skillPath) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a skill path'));
} else {
await pretty.error('Please provide a skill path');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
try {
// Load the skill first, then validate
const skill = await manager.loadSkill(skillPath);
const result = manager.validate(skill);
if (outputFormat === 'json') {
outputJson(formatSuccess({
valid: result.valid,
path: skillPath,
errors: result.errors || []
}));
} else {
if (result.valid) {
await pretty.success(`Skill is valid: ${skillPath}`);
} else {
await pretty.error(`Skill validation failed: ${skillPath}`);
if (result.errors) {
for (const err of result.errors) {
await pretty.plain(` ❌ ${err}`);
}
}
}
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(`Validation error: ${error instanceof Error ? error.message : String(error)}`);
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function skillInfo(manager: SkillManager, args: string[], outputFormat: string): Promise<void> {
const skillName = args[0];
if (!skillName) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a skill name'));
} else {
await pretty.error('Please provide a skill name');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
const skill = manager.get(skillName);
if (!skill) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, `Skill not found: ${skillName}`));
} else {
await pretty.error(`Skill not found: ${skillName}`);
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
if (outputFormat === 'json') {
outputJson(formatSuccess({
name: skill.metadata.name,
description: skill.metadata.description,
path: skill.path,
metadata: skill.metadata
}));
} else {
await pretty.heading(`Skill: ${skill.metadata.name}`);
await pretty.keyValue({
'Name': skill.metadata.name,
'Description': skill.metadata.description || 'No description',
'Path': skill.path || 'N/A'
});
if (skill.metadata.metadata && Object.keys(skill.metadata.metadata).length > 0) {
await pretty.newline();
await pretty.plain('Metadata:');
await pretty.plain(JSON.stringify(skill.metadata.metadata, null, 2));
}
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'skills',
subcommands: [
{ name: 'list', description: 'List loaded skills' },
{ name: 'discover <path>', description: 'Discover skills in directory' },
{ name: 'validate <path>', description: 'Validate a skill' },
{ name: 'info <name>', description: 'Show skill information' },
{ name: 'help', description: 'Show this help' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Skills Command');
await pretty.plain('Manage agent skills\n');
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
}
}
}
@@ -0,0 +1,117 @@
/**
* Telemetry command - Usage monitoring and analytics
*/
import { getTelemetry, enableTelemetry, disableTelemetry, cleanupTelemetry } from '../../telemetry';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface TelemetryOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: TelemetryOptions): Promise<void> {
const action = args[0] || 'status';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (action) {
case 'enable':
enableTelemetry();
if (outputFormat === 'json') {
outputJson(formatSuccess({ enabled: true }));
} else {
await pretty.success('Telemetry enabled');
}
break;
case 'disable':
disableTelemetry();
if (outputFormat === 'json') {
outputJson(formatSuccess({ enabled: false }));
} else {
await pretty.success('Telemetry disabled');
}
break;
case 'status':
await showStatus(outputFormat);
break;
case 'clear':
cleanupTelemetry();
if (outputFormat === 'json') {
outputJson(formatSuccess({ cleared: true }));
} else {
await pretty.success('Telemetry data cleared');
}
break;
case 'export':
await exportTelemetry(outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function showStatus(outputFormat: string): Promise<void> {
const telemetry = getTelemetry();
if (outputFormat === 'json') {
outputJson(formatSuccess({
enabled: telemetry.isEnabled()
}));
} else {
await pretty.heading('Telemetry Status');
await pretty.plain(`Enabled: ${telemetry.isEnabled() ? 'Yes' : 'No'}`);
}
}
async function exportTelemetry(outputFormat: string): Promise<void> {
const telemetry = getTelemetry();
if (outputFormat === 'json') {
outputJson(formatSuccess({
enabled: telemetry.isEnabled(),
exportedAt: new Date().toISOString()
}));
} else {
await pretty.plain(`Telemetry enabled: ${telemetry.isEnabled()}`);
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'telemetry',
subcommands: [
{ name: 'status', description: 'Show telemetry status' },
{ name: 'enable', description: 'Enable telemetry collection' },
{ name: 'disable', description: 'Disable telemetry collection' },
{ name: 'clear', description: 'Clear collected telemetry data' },
{ name: 'export', description: 'Export telemetry data' },
{ name: 'help', description: 'Show this help' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Telemetry Command');
await pretty.plain('Usage monitoring and analytics\n');
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(15)} ${cmd.description}`);
}
}
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Tools command - List or manage tools
*/
import { getRegistry, FunctionTool } from '../../tools/decorator';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface ToolsOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
interface ToolInfo {
name: string;
description?: string;
parameters?: unknown;
}
// Built-in tools that are always available
const BUILTIN_TOOLS: ToolInfo[] = [
{ name: 'arxiv_search', description: 'Search arXiv for academic papers' },
{ name: 'web_search', description: 'Search the web for information' },
{ name: 'calculator', description: 'Perform mathematical calculations' },
{ name: 'code_executor', description: 'Execute code snippets' },
{ name: 'file_reader', description: 'Read file contents' },
{ name: 'file_writer', description: 'Write content to files' }
];
export async function execute(args: string[], options: ToolsOptions): Promise<void> {
const subcommand = args[0] || 'list';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
switch (subcommand) {
case 'list':
await listTools(options, outputFormat);
break;
case 'info':
await toolInfo(args.slice(1), options, outputFormat);
break;
default:
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.INVALID_ARGS, `Unknown subcommand: ${subcommand}`));
} else {
await pretty.error(`Unknown subcommand: ${subcommand}`);
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
}
async function listTools(options: ToolsOptions, outputFormat: string): Promise<void> {
// Get registered tools from registry
const registry = getRegistry();
const registeredTools = registry.list();
// Combine built-in and registered tools
const allTools: ToolInfo[] = [
...BUILTIN_TOOLS,
...registeredTools.map((t: FunctionTool) => ({
name: t.name,
description: t.description,
parameters: t.parameters
}))
];
if (outputFormat === 'json') {
outputJson(formatSuccess({
tools: allTools.map(t => ({
name: t.name,
description: t.description,
parameters: options.verbose ? t.parameters : undefined
})),
count: allTools.length
}));
} else {
await pretty.heading('Available Tools');
await pretty.plain('Built-in Tools:');
for (const tool of BUILTIN_TOOLS) {
await pretty.plain(` • ${tool.name}`);
if (tool.description) {
await pretty.dim(` ${tool.description}`);
}
}
if (registeredTools.length > 0) {
await pretty.newline();
await pretty.plain('Registered Tools:');
for (const tool of registeredTools) {
await pretty.plain(` • ${tool.name}`);
if (tool.description) {
await pretty.dim(` ${tool.description}`);
}
}
}
await pretty.newline();
await pretty.info(`Total: ${allTools.length} tools`);
}
}
async function toolInfo(args: string[], options: ToolsOptions, outputFormat: string): Promise<void> {
const toolName = args[0];
if (!toolName) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Please provide a tool name'));
} else {
await pretty.error('Please provide a tool name');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
// Look for tool in built-in and registered tools
const builtinTool = BUILTIN_TOOLS.find(t => t.name === toolName);
const registry = getRegistry();
const registeredTool = registry.get(toolName);
const tool = builtinTool || (registeredTool ? {
name: registeredTool.name,
description: registeredTool.description,
parameters: registeredTool.parameters
} : null);
if (!tool) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.TOOL_NOT_FOUND, `Tool not found: ${toolName}`));
} else {
await pretty.error(`Tool not found: ${toolName}`);
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
if (outputFormat === 'json') {
outputJson(formatSuccess({
tool: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
type: builtinTool ? 'builtin' : 'registered'
}
}));
} else {
await pretty.heading(`Tool: ${tool.name}`);
await pretty.keyValue({
'Name': tool.name,
'Type': builtinTool ? 'Built-in' : 'Registered',
'Description': tool.description || 'No description'
});
if (tool.parameters && options.verbose) {
await pretty.newline();
await pretty.plain('Parameters:');
await pretty.plain(JSON.stringify(tool.parameters, null, 2));
}
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Vector command - Vector store management
*/
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface VectorOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
store?: string;
}
export async function execute(args: string[], options: VectorOptions): Promise<void> {
const action = args[0] || 'help';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (action) {
case 'providers':
await listProviders(outputFormat);
break;
case 'info':
await showInfo(outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function showInfo(outputFormat: string): Promise<void> {
const info = {
feature: 'Vector Stores',
description: 'Vector database integrations for embeddings and similarity search',
providers: [
{ name: 'MemoryVectorStore', description: 'In-memory vector store for development' },
{ name: 'PineconeVectorStore', description: 'Pinecone cloud vector database' },
{ name: 'WeaviateVectorStore', description: 'Weaviate vector search engine' },
{ name: 'QdrantVectorStore', description: 'Qdrant vector database' },
{ name: 'ChromaVectorStore', description: 'ChromaDB embedding database' }
],
sdkUsage: `
import { createMemoryVectorStore, createPineconeStore } from 'praisonai';
// Create a memory vector store
const store = createMemoryVectorStore('my-store');
// Create index
await store.createIndex({ indexName: 'docs', dimension: 1536 });
// Upsert vectors
await store.upsert({
indexName: 'docs',
vectors: [{ id: '1', vector: [...], metadata: { text: 'Hello' } }]
});
// Query
const results = await store.query({
indexName: 'docs',
vector: [...],
topK: 5
});
`
};
if (outputFormat === 'json') {
outputJson(formatSuccess(info));
} else {
await pretty.heading('Vector Stores');
await pretty.plain(info.description);
await pretty.newline();
await pretty.plain('Available Providers:');
for (const p of info.providers) {
await pretty.plain(` • ${p.name}: ${p.description}`);
}
await pretty.newline();
await pretty.dim('Use the SDK for full vector store functionality');
}
}
async function listProviders(outputFormat: string): Promise<void> {
const providers = [
{ name: 'memory', description: 'In-memory vector store (default)', available: true },
{ name: 'pinecone', description: 'Pinecone vector database', available: true },
{ name: 'weaviate', description: 'Weaviate vector database', available: true },
{ name: 'qdrant', description: 'Qdrant vector database', available: true },
{ name: 'chroma', description: 'ChromaDB vector database', available: true }
];
if (outputFormat === 'json') {
outputJson(formatSuccess({ providers }));
} else {
await pretty.heading('Vector Store Providers');
for (const p of providers) {
const status = p.available ? '✓' : '✗';
await pretty.plain(` ${status} ${p.name.padEnd(15)} ${p.description}`);
}
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'vector',
description: 'Vector store management for embeddings and similarity search',
subcommands: [
{ name: 'add <content>', description: 'Add document to vector store' },
{ name: 'search <query>', description: 'Search vector store' },
{ name: 'stats', description: 'Show vector store statistics' },
{ name: 'providers', description: 'List available vector store providers' },
{ name: 'help', description: 'Show this help' }
],
flags: [
{ name: '--store', description: 'Vector store provider (memory, pinecone, etc.)' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Vector Command');
await pretty.plain(help.description);
await pretty.newline();
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(25)} ${cmd.description}`);
}
await pretty.newline();
await pretty.plain('Flags:');
for (const flag of help.flags) {
await pretty.plain(` ${flag.name.padEnd(20)} ${flag.description}`);
}
}
}
@@ -0,0 +1,40 @@
/**
* Version command - Show CLI version
*/
import { CLI_SPEC_VERSION } from '../spec/cli-spec';
import { outputJson, formatSuccess } from '../output/json';
import * as pretty from '../output/pretty';
export interface VersionOptions {
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
export async function execute(args: string[], options: VersionOptions): Promise<void> {
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
// Load package.json for version
let packageVersion = '0.0.0';
try {
const pkg = require('../../../package.json');
packageVersion = pkg.version;
} catch {
// Fallback if package.json not found
}
const versionInfo = {
version: packageVersion,
cli_spec: CLI_SPEC_VERSION,
runtime: 'node',
node_version: process.version
};
if (outputFormat === 'json') {
outputJson(formatSuccess(versionInfo));
} else {
await pretty.plain(`praisonai-ts v${packageVersion}`);
await pretty.dim(`CLI Spec: v${CLI_SPEC_VERSION}`);
await pretty.dim(`Node: ${process.version}`);
}
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Voice command - Text-to-speech and speech-to-text
*/
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES } from '../output/errors';
export interface VoiceOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
provider?: string;
}
export async function execute(args: string[], options: VoiceOptions): Promise<void> {
const action = args[0] || 'help';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
switch (action) {
case 'providers':
await listProviders(outputFormat);
break;
case 'info':
await showInfo(outputFormat);
break;
case 'help':
default:
await showHelp(outputFormat);
break;
}
} catch (error) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.UNKNOWN, error instanceof Error ? error.message : String(error)));
} else {
await pretty.error(error instanceof Error ? error.message : String(error));
}
process.exit(EXIT_CODES.RUNTIME_ERROR);
}
}
async function showInfo(outputFormat: string): Promise<void> {
const info = {
feature: 'Voice',
description: 'Text-to-speech and speech-to-text capabilities',
providers: [
{ name: 'OpenAIVoiceProvider', description: 'OpenAI TTS and Whisper' },
{ name: 'ElevenLabsVoiceProvider', description: 'ElevenLabs voice synthesis' }
],
capabilities: [
'Text-to-speech conversion',
'Speech-to-text transcription',
'Multiple voice options',
'Streaming audio support'
],
sdkUsage: `
import { createOpenAIVoice, createElevenLabsVoice } from 'praisonai';
// Create OpenAI voice provider
const voice = createOpenAIVoice({ apiKey: process.env.OPENAI_API_KEY });
// Text to speech
const audio = await voice.speak({ text: 'Hello world', voice: 'alloy' });
// Speech to text
const text = await voice.listen({ audio: audioBuffer });
`
};
if (outputFormat === 'json') {
outputJson(formatSuccess(info));
} else {
await pretty.heading('Voice');
await pretty.plain(info.description);
await pretty.newline();
await pretty.plain('Providers:');
for (const p of info.providers) {
await pretty.plain(` • ${p.name}: ${p.description}`);
}
await pretty.newline();
await pretty.plain('Capabilities:');
for (const cap of info.capabilities) {
await pretty.plain(` • ${cap}`);
}
}
}
async function listProviders(outputFormat: string): Promise<void> {
const providers = [
{ name: 'openai', description: 'OpenAI TTS and Whisper', available: true },
{ name: 'elevenlabs', description: 'ElevenLabs voice synthesis', available: true }
];
if (outputFormat === 'json') {
outputJson(formatSuccess({ providers }));
} else {
await pretty.heading('Voice Providers');
for (const p of providers) {
const status = p.available ? '✓' : '✗';
await pretty.plain(` ${status} ${p.name.padEnd(15)} ${p.description}`);
}
}
}
async function showHelp(outputFormat: string): Promise<void> {
const help = {
command: 'voice',
description: 'Text-to-speech and speech-to-text capabilities',
subcommands: [
{ name: 'info', description: 'Show voice feature information' },
{ name: 'providers', description: 'List available voice providers' },
{ name: 'help', description: 'Show this help' }
],
flags: [
{ name: '--provider', description: 'Voice provider (openai, elevenlabs)' }
]
};
if (outputFormat === 'json') {
outputJson(formatSuccess(help));
} else {
await pretty.heading('Voice Command');
await pretty.plain(help.description);
await pretty.newline();
await pretty.plain('Subcommands:');
for (const cmd of help.subcommands) {
await pretty.plain(` ${cmd.name.padEnd(20)} ${cmd.description}`);
}
await pretty.newline();
await pretty.plain('Flags:');
for (const flag of help.flags) {
await pretty.plain(` ${flag.name.padEnd(20)} ${flag.description}`);
}
}
}
@@ -0,0 +1,188 @@
/**
* Workflow command - Execute a multi-agent workflow
*/
import * as fs from 'fs';
import * as path from 'path';
import { resolveConfig } from '../config/resolve';
import { printSuccess, printError, outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { EXIT_CODES } from '../spec/cli-spec';
import { ERROR_CODES, normalizeError } from '../output/errors';
export interface WorkflowOptions {
parallel?: boolean;
model?: string;
verbose?: boolean;
profile?: string;
config?: string;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
}
interface WorkflowStep {
name: string;
agent?: string;
task?: string;
depends_on?: string[];
}
interface WorkflowDefinition {
name?: string;
description?: string;
agents?: Record<string, any>;
steps?: WorkflowStep[];
}
/**
* Simple YAML parser for workflow files
*/
function parseWorkflowYaml(content: string): WorkflowDefinition {
const result: WorkflowDefinition = {};
const lines = content.split('\n');
let currentSection: string | null = null;
let currentItem: any = null;
let currentKey: string | null = null;
for (const line of lines) {
if (!line.trim() || line.trim().startsWith('#')) continue;
const indent = line.search(/\S/);
const trimmed = line.trim();
// Top-level keys
if (indent === 0 && trimmed.includes(':')) {
const colonIdx = trimmed.indexOf(':');
const key = trimmed.slice(0, colonIdx).trim();
const value = trimmed.slice(colonIdx + 1).trim();
if (value) {
(result as any)[key] = value;
} else {
currentSection = key;
if (key === 'agents') result.agents = {};
if (key === 'steps') result.steps = [];
}
} else if (indent > 0 && currentSection) {
// Handle list items (steps)
if (trimmed.startsWith('- ')) {
if (currentSection === 'steps') {
currentItem = {};
result.steps!.push(currentItem);
// Parse inline values after -
const afterDash = trimmed.slice(2).trim();
if (afterDash.includes(':')) {
const colonIdx = afterDash.indexOf(':');
const key = afterDash.slice(0, colonIdx).trim();
const value = afterDash.slice(colonIdx + 1).trim();
currentItem[key] = value;
}
}
} else if (trimmed.includes(':') && currentItem) {
const colonIdx = trimmed.indexOf(':');
const key = trimmed.slice(0, colonIdx).trim();
const value = trimmed.slice(colonIdx + 1).trim();
currentItem[key] = value || [];
}
}
}
return result;
}
export async function execute(args: string[], options: WorkflowOptions): Promise<void> {
const workflowFile = args[0];
if (!workflowFile) {
if (options.json || options.output === 'json') {
printError(ERROR_CODES.MISSING_ARG, 'Please provide a workflow file path');
} else {
await pretty.error('Please provide a workflow file path');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
// Resolve config
const config = resolveConfig({
configPath: options.config,
profile: options.profile,
model: options.model,
verbose: options.verbose
});
const startTime = Date.now();
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
try {
// Read and parse workflow file
const filePath = path.resolve(workflowFile);
if (!fs.existsSync(filePath)) {
throw new Error(`Workflow file not found: ${filePath}`);
}
const content = fs.readFileSync(filePath, 'utf-8');
const workflow = parseWorkflowYaml(content);
if (outputFormat !== 'json') {
await pretty.heading(`Executing workflow: ${workflow.name || 'Unnamed'}`);
if (workflow.description) {
await pretty.info(workflow.description);
}
}
// Execute workflow steps
const results: Record<string, any> = {};
const steps = workflow.steps || [];
for (const step of steps) {
if (outputFormat !== 'json') {
await pretty.info(`Running step: ${step.name}`);
}
// For now, we'll simulate step execution
// In a full implementation, this would create agents and execute tasks
results[step.name] = {
status: 'completed',
output: `Step ${step.name} completed`
};
}
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess(
{
workflow: workflow.name || 'Unnamed',
steps: steps.length,
results
},
{
duration_ms: duration,
model: config.model
}
));
} else {
await pretty.success(`Workflow completed in ${duration}ms`);
await pretty.newline();
await pretty.keyValue({
'Steps executed': steps.length,
'Duration': `${duration}ms`
});
}
} catch (error) {
const cliError = normalizeError(error);
if (outputFormat === 'json') {
outputJson(formatError(cliError.code, cliError.message, cliError.details));
} else {
await pretty.error(cliError.message);
if (config.verbose && error instanceof Error && error.stack) {
await pretty.dim(error.stack);
}
}
process.exit(cliError.exitCode);
}
}
@@ -0,0 +1,297 @@
/**
* Autonomy Modes - Control automatic execution behavior
*/
export type AutonomyMode = 'suggest' | 'auto_edit' | 'full_auto';
export type ActionType =
| 'file_read'
| 'file_write'
| 'file_delete'
| 'shell_command'
| 'network_request'
| 'git_operation'
| 'install_package';
export interface ApprovalPolicy {
action: ActionType;
requiresApproval: boolean;
autoApprovePatterns?: string[];
autoDenyPatterns?: string[];
}
export interface AutonomyConfig {
mode: AutonomyMode;
policies?: ApprovalPolicy[];
rememberDecisions?: boolean;
maxAutoActions?: number;
}
export interface ActionRequest {
type: ActionType;
description: string;
target?: string;
details?: Record<string, any>;
}
export interface ActionDecision {
approved: boolean;
reason?: string;
remembered?: boolean;
}
/**
* Default policies for each mode
*/
export const MODE_POLICIES: Record<AutonomyMode, ApprovalPolicy[]> = {
suggest: [
{ action: 'file_read', requiresApproval: false },
{ action: 'file_write', requiresApproval: true },
{ action: 'file_delete', requiresApproval: true },
{ action: 'shell_command', requiresApproval: true },
{ action: 'network_request', requiresApproval: true },
{ action: 'git_operation', requiresApproval: true },
{ action: 'install_package', requiresApproval: true }
],
auto_edit: [
{ action: 'file_read', requiresApproval: false },
{ action: 'file_write', requiresApproval: false },
{ action: 'file_delete', requiresApproval: true },
{ action: 'shell_command', requiresApproval: true },
{ action: 'network_request', requiresApproval: false },
{ action: 'git_operation', requiresApproval: true },
{ action: 'install_package', requiresApproval: true }
],
full_auto: [
{ action: 'file_read', requiresApproval: false },
{ action: 'file_write', requiresApproval: false },
{ action: 'file_delete', requiresApproval: false },
{ action: 'shell_command', requiresApproval: false },
{ action: 'network_request', requiresApproval: false },
{ action: 'git_operation', requiresApproval: false },
{ action: 'install_package', requiresApproval: true }
]
};
/**
* Autonomy Manager class
*/
export class AutonomyManager {
private config: AutonomyConfig;
private rememberedDecisions: Map<string, boolean> = new Map();
private actionCount: number = 0;
private promptCallback?: (request: ActionRequest) => Promise<boolean>;
constructor(config: Partial<AutonomyConfig> = {}) {
this.config = {
mode: 'suggest',
rememberDecisions: true,
maxAutoActions: 100,
...config
};
}
/**
* Set the prompt callback for user approval
*/
setPromptCallback(callback: (request: ActionRequest) => Promise<boolean>): void {
this.promptCallback = callback;
}
/**
* Request approval for an action
*/
async requestApproval(request: ActionRequest): Promise<ActionDecision> {
// Check if we've exceeded max auto actions
if (this.config.maxAutoActions && this.actionCount >= this.config.maxAutoActions) {
return {
approved: false,
reason: 'Maximum auto actions exceeded'
};
}
// Check remembered decisions
const decisionKey = this.getDecisionKey(request);
if (this.config.rememberDecisions && this.rememberedDecisions.has(decisionKey)) {
const remembered = this.rememberedDecisions.get(decisionKey)!;
if (remembered) this.actionCount++;
return {
approved: remembered,
reason: 'Remembered decision',
remembered: true
};
}
// Get policy for this action type
const policy = this.getPolicy(request.type);
// Check auto-approve patterns
if (policy.autoApprovePatterns && request.target) {
for (const pattern of policy.autoApprovePatterns) {
if (this.matchPattern(request.target, pattern)) {
this.actionCount++;
return { approved: true, reason: `Matched auto-approve pattern: ${pattern}` };
}
}
}
// Check auto-deny patterns
if (policy.autoDenyPatterns && request.target) {
for (const pattern of policy.autoDenyPatterns) {
if (this.matchPattern(request.target, pattern)) {
return { approved: false, reason: `Matched auto-deny pattern: ${pattern}` };
}
}
}
// If no approval required, auto-approve
if (!policy.requiresApproval) {
this.actionCount++;
return { approved: true, reason: 'No approval required' };
}
// Prompt user for approval
if (this.promptCallback) {
const approved = await this.promptCallback(request);
// Remember decision if enabled
if (this.config.rememberDecisions) {
this.rememberedDecisions.set(decisionKey, approved);
}
if (approved) this.actionCount++;
return { approved, reason: 'User decision' };
}
// No prompt callback, deny by default
return { approved: false, reason: 'No approval mechanism available' };
}
/**
* Get policy for an action type
*/
private getPolicy(actionType: ActionType): ApprovalPolicy {
// Check custom policies first
if (this.config.policies) {
const custom = this.config.policies.find(p => p.action === actionType);
if (custom) return custom;
}
// Fall back to mode defaults
const modePolicy = MODE_POLICIES[this.config.mode].find(p => p.action === actionType);
return modePolicy || { action: actionType, requiresApproval: true };
}
/**
* Generate decision key for remembering
*/
private getDecisionKey(request: ActionRequest): string {
return `${request.type}:${request.target || 'default'}`;
}
/**
* Match a target against a pattern
*/
private matchPattern(target: string, pattern: string): boolean {
// Simple glob-like matching
const regex = new RegExp(
'^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$'
);
return regex.test(target);
}
/**
* Get current mode
*/
getMode(): AutonomyMode {
return this.config.mode;
}
/**
* Set mode
*/
setMode(mode: AutonomyMode): void {
this.config.mode = mode;
}
/**
* Add custom policy
*/
addPolicy(policy: ApprovalPolicy): void {
if (!this.config.policies) {
this.config.policies = [];
}
// Remove existing policy for this action
this.config.policies = this.config.policies.filter(p => p.action !== policy.action);
this.config.policies.push(policy);
}
/**
* Clear remembered decisions
*/
clearRemembered(): void {
this.rememberedDecisions.clear();
}
/**
* Reset action count
*/
resetActionCount(): void {
this.actionCount = 0;
}
/**
* Get action count
*/
getActionCount(): number {
return this.actionCount;
}
/**
* Get summary of current settings
*/
getSummary(): string {
const lines = [
`Autonomy Mode: ${this.config.mode}`,
`Actions taken: ${this.actionCount}/${this.config.maxAutoActions || 'unlimited'}`,
`Remembered decisions: ${this.rememberedDecisions.size}`,
'',
'Current policies:'
];
const policies = MODE_POLICIES[this.config.mode];
for (const policy of policies) {
const status = policy.requiresApproval ? '⚠️ requires approval' : '✓ auto-approved';
lines.push(` ${policy.action}: ${status}`);
}
return lines.join('\n');
}
}
/**
* Create an autonomy manager
*/
export function createAutonomyManager(config?: Partial<AutonomyConfig>): AutonomyManager {
return new AutonomyManager(config);
}
/**
* Simple approval prompt for CLI
*/
export async function cliApprovalPrompt(request: ActionRequest): Promise<boolean> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => {
const message = `\n⚠️ Action requires approval:\n Type: ${request.type}\n ${request.description}\n Target: ${request.target || 'N/A'}\n\nApprove? (y/n): `;
rl.question(message, (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
});
});
}
@@ -0,0 +1,466 @@
/**
* Background Jobs - Persisted job queue with adapter support
*/
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
export type JobPriority = 'low' | 'normal' | 'high' | 'critical';
export interface Job<T = any> {
id: string;
name: string;
data: T;
status: JobStatus;
priority: JobPriority;
attempts: number;
maxAttempts: number;
createdAt: Date;
startedAt?: Date;
completedAt?: Date;
result?: any;
error?: string;
progress?: number;
metadata?: Record<string, any>;
}
export interface JobQueueConfig {
name?: string;
concurrency?: number;
maxAttempts?: number;
retryDelay?: number;
storage?: JobStorageAdapter;
}
export interface JobStorageAdapter {
save(job: Job): Promise<void>;
get(id: string): Promise<Job | null>;
getAll(): Promise<Job[]>;
getByStatus(status: JobStatus): Promise<Job[]>;
update(id: string, updates: Partial<Job>): Promise<void>;
delete(id: string): Promise<void>;
clear(): Promise<void>;
}
export type JobHandler<T = any, R = any> = (job: Job<T>, context: JobContext) => Promise<R>;
export interface JobContext {
updateProgress: (progress: number) => Promise<void>;
log: (message: string) => void;
}
/**
* In-memory job storage adapter
*/
export class MemoryJobStorage implements JobStorageAdapter {
private jobs: Map<string, Job> = new Map();
async save(job: Job): Promise<void> {
this.jobs.set(job.id, { ...job });
}
async get(id: string): Promise<Job | null> {
return this.jobs.get(id) || null;
}
async getAll(): Promise<Job[]> {
return Array.from(this.jobs.values());
}
async getByStatus(status: JobStatus): Promise<Job[]> {
return Array.from(this.jobs.values()).filter(j => j.status === status);
}
async update(id: string, updates: Partial<Job>): Promise<void> {
const job = this.jobs.get(id);
if (job) {
this.jobs.set(id, { ...job, ...updates });
}
}
async delete(id: string): Promise<void> {
this.jobs.delete(id);
}
async clear(): Promise<void> {
this.jobs.clear();
}
}
/**
* File-based job storage adapter
*/
export class FileJobStorage implements JobStorageAdapter {
private filePath: string;
private jobs: Map<string, Job> = new Map();
private initialized: boolean = false;
constructor(filePath: string) {
this.filePath = filePath;
}
private async init(): Promise<void> {
if (this.initialized) return;
try {
const fs = await import('fs/promises');
const content = await fs.readFile(this.filePath, 'utf-8');
const data = JSON.parse(content);
for (const job of data) {
job.createdAt = new Date(job.createdAt);
if (job.startedAt) job.startedAt = new Date(job.startedAt);
if (job.completedAt) job.completedAt = new Date(job.completedAt);
this.jobs.set(job.id, job);
}
} catch {
// File doesn't exist yet
}
this.initialized = true;
}
private async persist(): Promise<void> {
const fs = await import('fs/promises');
const data = Array.from(this.jobs.values());
await fs.writeFile(this.filePath, JSON.stringify(data, null, 2));
}
async save(job: Job): Promise<void> {
await this.init();
this.jobs.set(job.id, { ...job });
await this.persist();
}
async get(id: string): Promise<Job | null> {
await this.init();
return this.jobs.get(id) || null;
}
async getAll(): Promise<Job[]> {
await this.init();
return Array.from(this.jobs.values());
}
async getByStatus(status: JobStatus): Promise<Job[]> {
await this.init();
return Array.from(this.jobs.values()).filter(j => j.status === status);
}
async update(id: string, updates: Partial<Job>): Promise<void> {
await this.init();
const job = this.jobs.get(id);
if (job) {
this.jobs.set(id, { ...job, ...updates });
await this.persist();
}
}
async delete(id: string): Promise<void> {
await this.init();
this.jobs.delete(id);
await this.persist();
}
async clear(): Promise<void> {
this.jobs.clear();
await this.persist();
}
}
/**
* Job Queue class
*/
export class JobQueue {
private config: JobQueueConfig;
private storage: JobStorageAdapter;
private handlers: Map<string, JobHandler> = new Map();
private running: boolean = false;
private activeJobs: number = 0;
private processInterval?: NodeJS.Timeout;
constructor(config: JobQueueConfig = {}) {
this.config = {
name: 'default',
concurrency: 1,
maxAttempts: 3,
retryDelay: 1000,
...config
};
this.storage = config.storage || new MemoryJobStorage();
}
/**
* Register a job handler
*/
register<T = any, R = any>(name: string, handler: JobHandler<T, R>): void {
this.handlers.set(name, handler);
}
/**
* Add a job to the queue
*/
async add<T = any>(name: string, data: T, options: Partial<Job<T>> = {}): Promise<Job<T>> {
const job: Job<T> = {
id: `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name,
data,
status: 'pending',
priority: options.priority || 'normal',
attempts: 0,
maxAttempts: options.maxAttempts || this.config.maxAttempts || 3,
createdAt: new Date(),
metadata: options.metadata
};
await this.storage.save(job);
return job;
}
/**
* Get a job by ID
*/
async get(id: string): Promise<Job | null> {
return this.storage.get(id);
}
/**
* Get all jobs
*/
async getAll(): Promise<Job[]> {
return this.storage.getAll();
}
/**
* Get jobs by status
*/
async getByStatus(status: JobStatus): Promise<Job[]> {
return this.storage.getByStatus(status);
}
/**
* Cancel a job
*/
async cancel(id: string): Promise<boolean> {
const job = await this.storage.get(id);
if (!job || job.status === 'running' || job.status === 'completed') {
return false;
}
await this.storage.update(id, { status: 'cancelled' });
return true;
}
/**
* Retry a failed job
*/
async retry(id: string): Promise<boolean> {
const job = await this.storage.get(id);
if (!job || job.status !== 'failed') {
return false;
}
await this.storage.update(id, {
status: 'pending',
attempts: 0,
error: undefined
});
return true;
}
/**
* Start processing jobs
*/
start(): void {
if (this.running) return;
this.running = true;
this.processInterval = setInterval(() => this.process(), 100);
}
/**
* Stop processing jobs
*/
stop(): void {
this.running = false;
if (this.processInterval) {
clearInterval(this.processInterval);
this.processInterval = undefined;
}
}
/**
* Process pending jobs
*/
private async process(): Promise<void> {
if (!this.running) return;
if (this.activeJobs >= (this.config.concurrency || 1)) return;
const pendingJobs = await this.storage.getByStatus('pending');
// Sort by priority and creation time
const priorityOrder: Record<JobPriority, number> = {
critical: 0,
high: 1,
normal: 2,
low: 3
};
pendingJobs.sort((a, b) => {
const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
if (priorityDiff !== 0) return priorityDiff;
return a.createdAt.getTime() - b.createdAt.getTime();
});
for (const job of pendingJobs) {
if (this.activeJobs >= (this.config.concurrency || 1)) break;
this.runJob(job);
}
}
/**
* Run a single job
*/
private async runJob(job: Job): Promise<void> {
const handler = this.handlers.get(job.name);
if (!handler) {
await this.storage.update(job.id, {
status: 'failed',
error: `No handler registered for job type: ${job.name}`
});
return;
}
this.activeJobs++;
await this.storage.update(job.id, {
status: 'running',
startedAt: new Date(),
attempts: job.attempts + 1
});
const context: JobContext = {
updateProgress: async (progress: number) => {
await this.storage.update(job.id, { progress });
},
log: (message: string) => {
console.log(`[Job ${job.id}] ${message}`);
}
};
try {
const result = await handler(job, context);
await this.storage.update(job.id, {
status: 'completed',
completedAt: new Date(),
result,
progress: 100
});
} catch (error: any) {
const attempts = job.attempts + 1;
if (attempts < job.maxAttempts) {
// Retry after delay
setTimeout(async () => {
await this.storage.update(job.id, {
status: 'pending',
error: error.message
});
}, this.config.retryDelay || 1000);
} else {
await this.storage.update(job.id, {
status: 'failed',
completedAt: new Date(),
error: error.message
});
}
} finally {
this.activeJobs--;
}
}
/**
* Process a job immediately (bypass queue)
*/
async processNow(id: string): Promise<any> {
const job = await this.storage.get(id);
if (!job) throw new Error(`Job ${id} not found`);
const handler = this.handlers.get(job.name);
if (!handler) throw new Error(`No handler for job type: ${job.name}`);
await this.storage.update(job.id, {
status: 'running',
startedAt: new Date()
});
const context: JobContext = {
updateProgress: async (progress: number) => {
await this.storage.update(job.id, { progress });
},
log: console.log
};
try {
const result = await handler(job, context);
await this.storage.update(job.id, {
status: 'completed',
completedAt: new Date(),
result
});
return result;
} catch (error: any) {
await this.storage.update(job.id, {
status: 'failed',
completedAt: new Date(),
error: error.message
});
throw error;
}
}
/**
* Get queue stats
*/
async getStats(): Promise<{
pending: number;
running: number;
completed: number;
failed: number;
cancelled: number;
}> {
const all = await this.storage.getAll();
return {
pending: all.filter(j => j.status === 'pending').length,
running: all.filter(j => j.status === 'running').length,
completed: all.filter(j => j.status === 'completed').length,
failed: all.filter(j => j.status === 'failed').length,
cancelled: all.filter(j => j.status === 'cancelled').length
};
}
/**
* Clear completed/failed jobs
*/
async cleanup(olderThan?: Date): Promise<number> {
const all = await this.storage.getAll();
let count = 0;
for (const job of all) {
if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
if (!olderThan || (job.completedAt && job.completedAt < olderThan)) {
await this.storage.delete(job.id);
count++;
}
}
}
return count;
}
}
/**
* Create a job queue
*/
export function createJobQueue(config?: JobQueueConfig): JobQueue {
return new JobQueue(config);
}
/**
* Create a file-based job storage
*/
export function createFileJobStorage(filePath: string): FileJobStorage {
return new FileJobStorage(filePath);
}
@@ -0,0 +1,310 @@
/**
* Checkpoints - Snapshot session/run state for recovery
*/
export interface CheckpointData {
id: string;
name: string;
timestamp: Date;
sessionId?: string;
runId?: string;
agentId?: string;
state: Record<string, any>;
messages?: any[];
metadata?: Record<string, any>;
}
export interface CheckpointConfig {
storage?: CheckpointStorage;
autoSave?: boolean;
autoSaveInterval?: number;
maxCheckpoints?: number;
}
export interface CheckpointStorage {
save(checkpoint: CheckpointData): Promise<void>;
load(id: string): Promise<CheckpointData | null>;
list(): Promise<CheckpointData[]>;
delete(id: string): Promise<void>;
clear(): Promise<void>;
}
/**
* In-memory checkpoint storage
*/
export class MemoryCheckpointStorage implements CheckpointStorage {
private checkpoints: Map<string, CheckpointData> = new Map();
async save(checkpoint: CheckpointData): Promise<void> {
this.checkpoints.set(checkpoint.id, { ...checkpoint });
}
async load(id: string): Promise<CheckpointData | null> {
return this.checkpoints.get(id) || null;
}
async list(): Promise<CheckpointData[]> {
return Array.from(this.checkpoints.values())
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
}
async delete(id: string): Promise<void> {
this.checkpoints.delete(id);
}
async clear(): Promise<void> {
this.checkpoints.clear();
}
}
/**
* File-based checkpoint storage
*/
export class FileCheckpointStorage implements CheckpointStorage {
private dirPath: string;
constructor(dirPath: string) {
this.dirPath = dirPath;
}
private async ensureDir(): Promise<void> {
const fs = await import('fs/promises');
await fs.mkdir(this.dirPath, { recursive: true });
}
private getFilePath(id: string): string {
return `${this.dirPath}/${id}.json`;
}
async save(checkpoint: CheckpointData): Promise<void> {
await this.ensureDir();
const fs = await import('fs/promises');
await fs.writeFile(
this.getFilePath(checkpoint.id),
JSON.stringify(checkpoint, null, 2)
);
}
async load(id: string): Promise<CheckpointData | null> {
try {
const fs = await import('fs/promises');
const content = await fs.readFile(this.getFilePath(id), 'utf-8');
const data = JSON.parse(content);
data.timestamp = new Date(data.timestamp);
return data;
} catch {
return null;
}
}
async list(): Promise<CheckpointData[]> {
try {
await this.ensureDir();
const fs = await import('fs/promises');
const files = await fs.readdir(this.dirPath);
const checkpoints: CheckpointData[] = [];
for (const file of files) {
if (file.endsWith('.json')) {
const id = file.replace('.json', '');
const checkpoint = await this.load(id);
if (checkpoint) {
checkpoints.push(checkpoint);
}
}
}
return checkpoints.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
} catch {
return [];
}
}
async delete(id: string): Promise<void> {
try {
const fs = await import('fs/promises');
await fs.unlink(this.getFilePath(id));
} catch {
// Ignore if file doesn't exist
}
}
async clear(): Promise<void> {
const checkpoints = await this.list();
for (const checkpoint of checkpoints) {
await this.delete(checkpoint.id);
}
}
}
/**
* Checkpoint Manager class
*/
export class CheckpointManager {
private config: CheckpointConfig;
private storage: CheckpointStorage;
private autoSaveTimer?: NodeJS.Timeout;
private currentState: Record<string, any> = {};
constructor(config: CheckpointConfig = {}) {
this.config = {
autoSave: false,
autoSaveInterval: 60000, // 1 minute
maxCheckpoints: 10,
...config
};
this.storage = config.storage || new MemoryCheckpointStorage();
}
/**
* Create a checkpoint
*/
async create(name: string, options: Partial<CheckpointData> = {}): Promise<CheckpointData> {
const checkpoint: CheckpointData = {
id: `cp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name,
timestamp: new Date(),
state: { ...this.currentState },
...options
};
await this.storage.save(checkpoint);
await this.enforceMaxCheckpoints();
return checkpoint;
}
/**
* Restore from a checkpoint
*/
async restore(id: string): Promise<CheckpointData | null> {
const checkpoint = await this.storage.load(id);
if (checkpoint) {
this.currentState = { ...checkpoint.state };
}
return checkpoint;
}
/**
* Restore the latest checkpoint
*/
async restoreLatest(): Promise<CheckpointData | null> {
const checkpoints = await this.storage.list();
if (checkpoints.length === 0) return null;
return this.restore(checkpoints[0].id);
}
/**
* Get a checkpoint by ID
*/
async get(id: string): Promise<CheckpointData | null> {
return this.storage.load(id);
}
/**
* List all checkpoints
*/
async list(): Promise<CheckpointData[]> {
return this.storage.list();
}
/**
* Delete a checkpoint
*/
async delete(id: string): Promise<void> {
await this.storage.delete(id);
}
/**
* Clear all checkpoints
*/
async clear(): Promise<void> {
await this.storage.clear();
}
/**
* Update current state
*/
setState(state: Record<string, any>): void {
this.currentState = { ...this.currentState, ...state };
}
/**
* Get current state
*/
getState(): Record<string, any> {
return { ...this.currentState };
}
/**
* Start auto-save
*/
startAutoSave(): void {
if (this.autoSaveTimer) return;
if (!this.config.autoSave) return;
this.autoSaveTimer = setInterval(async () => {
await this.create('auto-save', { metadata: { auto: true } });
}, this.config.autoSaveInterval);
}
/**
* Stop auto-save
*/
stopAutoSave(): void {
if (this.autoSaveTimer) {
clearInterval(this.autoSaveTimer);
this.autoSaveTimer = undefined;
}
}
/**
* Enforce maximum checkpoints limit
*/
private async enforceMaxCheckpoints(): Promise<void> {
if (!this.config.maxCheckpoints) return;
const checkpoints = await this.storage.list();
if (checkpoints.length > this.config.maxCheckpoints) {
// Delete oldest checkpoints (but keep auto-saves separate)
const toDelete = checkpoints.slice(this.config.maxCheckpoints);
for (const checkpoint of toDelete) {
await this.storage.delete(checkpoint.id);
}
}
}
/**
* Export checkpoint to JSON string
*/
async export(id: string): Promise<string | null> {
const checkpoint = await this.storage.load(id);
if (!checkpoint) return null;
return JSON.stringify(checkpoint, null, 2);
}
/**
* Import checkpoint from JSON string
*/
async import(json: string): Promise<CheckpointData> {
const data = JSON.parse(json);
data.timestamp = new Date(data.timestamp);
data.id = `cp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // New ID
await this.storage.save(data);
return data;
}
}
/**
* Create a checkpoint manager
*/
export function createCheckpointManager(config?: CheckpointConfig): CheckpointManager {
return new CheckpointManager(config);
}
/**
* Create file-based checkpoint storage
*/
export function createFileCheckpointStorage(dirPath: string): FileCheckpointStorage {
return new FileCheckpointStorage(dirPath);
}
@@ -0,0 +1,275 @@
/**
* Cost Tracker - Track token usage and costs across LLM requests
*/
export interface ModelPricing {
inputCostPer1k: number;
outputCostPer1k: number;
}
export interface TokenUsage {
inputTokens: number;
outputTokens: number;
totalTokens: number;
}
export interface RequestStats {
timestamp: number;
model: string;
usage: TokenUsage;
cost: number;
latencyMs?: number;
}
export interface SessionStats {
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
totalCost: number;
requestCount: number;
avgLatencyMs: number;
startTime: number;
requests: RequestStats[];
}
/**
* Model pricing data (USD per 1K tokens)
*/
export const MODEL_PRICING: Record<string, ModelPricing> = {
// OpenAI
'gpt-4o': { inputCostPer1k: 0.005, outputCostPer1k: 0.015 },
'gpt-4o-mini': { inputCostPer1k: 0.00015, outputCostPer1k: 0.0006 },
'gpt-4-turbo': { inputCostPer1k: 0.01, outputCostPer1k: 0.03 },
'gpt-4': { inputCostPer1k: 0.03, outputCostPer1k: 0.06 },
'gpt-3.5-turbo': { inputCostPer1k: 0.0005, outputCostPer1k: 0.0015 },
'o1': { inputCostPer1k: 0.015, outputCostPer1k: 0.06 },
'o1-mini': { inputCostPer1k: 0.003, outputCostPer1k: 0.012 },
'o1-preview': { inputCostPer1k: 0.015, outputCostPer1k: 0.06 },
// Anthropic
'claude-3-5-sonnet-20241022': { inputCostPer1k: 0.003, outputCostPer1k: 0.015 },
'claude-3-5-haiku-20241022': { inputCostPer1k: 0.001, outputCostPer1k: 0.005 },
'claude-3-opus-20240229': { inputCostPer1k: 0.015, outputCostPer1k: 0.075 },
'claude-3-sonnet-20240229': { inputCostPer1k: 0.003, outputCostPer1k: 0.015 },
'claude-3-haiku-20240307': { inputCostPer1k: 0.00025, outputCostPer1k: 0.00125 },
// Google
'gemini-2.0-flash': { inputCostPer1k: 0.0001, outputCostPer1k: 0.0004 },
'gemini-1.5-pro': { inputCostPer1k: 0.00125, outputCostPer1k: 0.005 },
'gemini-1.5-flash': { inputCostPer1k: 0.000075, outputCostPer1k: 0.0003 },
// Default fallback
'default': { inputCostPer1k: 0.001, outputCostPer1k: 0.002 }
};
/**
* Cost Tracker class
*/
export class CostTracker {
private stats: SessionStats;
private customPricing: Map<string, ModelPricing> = new Map();
constructor() {
this.stats = this.createEmptyStats();
}
private createEmptyStats(): SessionStats {
return {
totalInputTokens: 0,
totalOutputTokens: 0,
totalTokens: 0,
totalCost: 0,
requestCount: 0,
avgLatencyMs: 0,
startTime: Date.now(),
requests: []
};
}
/**
* Add a request's token usage
*/
addUsage(
model: string,
inputTokens: number,
outputTokens: number,
latencyMs?: number
): RequestStats {
const totalTokens = inputTokens + outputTokens;
const cost = this.calculateCost(model, inputTokens, outputTokens);
const request: RequestStats = {
timestamp: Date.now(),
model,
usage: { inputTokens, outputTokens, totalTokens },
cost,
latencyMs
};
this.stats.requests.push(request);
this.stats.totalInputTokens += inputTokens;
this.stats.totalOutputTokens += outputTokens;
this.stats.totalTokens += totalTokens;
this.stats.totalCost += cost;
this.stats.requestCount++;
if (latencyMs !== undefined) {
const totalLatency = this.stats.avgLatencyMs * (this.stats.requestCount - 1) + latencyMs;
this.stats.avgLatencyMs = totalLatency / this.stats.requestCount;
}
return request;
}
/**
* Calculate cost for token usage
*/
calculateCost(model: string, inputTokens: number, outputTokens: number): number {
const pricing = this.getPricing(model);
const inputCost = (inputTokens / 1000) * pricing.inputCostPer1k;
const outputCost = (outputTokens / 1000) * pricing.outputCostPer1k;
return inputCost + outputCost;
}
/**
* Get pricing for a model
*/
getPricing(model: string): ModelPricing {
// Check custom pricing first
if (this.customPricing.has(model)) {
return this.customPricing.get(model)!;
}
// Check built-in pricing
if (MODEL_PRICING[model]) {
return MODEL_PRICING[model];
}
// Try to match partial model names
for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
if (model.includes(key) || key.includes(model)) {
return pricing;
}
}
return MODEL_PRICING['default'];
}
/**
* Set custom pricing for a model
*/
setCustomPricing(model: string, pricing: ModelPricing): void {
this.customPricing.set(model, pricing);
}
/**
* Get current session stats
*/
getStats(): SessionStats {
return { ...this.stats };
}
/**
* Get summary string
*/
getSummary(): string {
const duration = (Date.now() - this.stats.startTime) / 1000;
const lines = [
'=== Cost Summary ===',
`Requests: ${this.stats.requestCount}`,
`Duration: ${duration.toFixed(1)}s`,
'',
'Tokens:',
` Input: ${this.stats.totalInputTokens.toLocaleString()}`,
` Output: ${this.stats.totalOutputTokens.toLocaleString()}`,
` Total: ${this.stats.totalTokens.toLocaleString()}`,
'',
`Total Cost: $${this.stats.totalCost.toFixed(6)}`,
];
if (this.stats.avgLatencyMs > 0) {
lines.push(`Avg Latency: ${this.stats.avgLatencyMs.toFixed(0)}ms`);
}
return lines.join('\n');
}
/**
* Get detailed breakdown by model
*/
getBreakdownByModel(): Record<string, { tokens: number; cost: number; requests: number }> {
const breakdown: Record<string, { tokens: number; cost: number; requests: number }> = {};
for (const request of this.stats.requests) {
if (!breakdown[request.model]) {
breakdown[request.model] = { tokens: 0, cost: 0, requests: 0 };
}
breakdown[request.model].tokens += request.usage.totalTokens;
breakdown[request.model].cost += request.cost;
breakdown[request.model].requests++;
}
return breakdown;
}
/**
* Reset stats
*/
reset(): void {
this.stats = this.createEmptyStats();
}
/**
* Export stats to JSON
*/
toJSON(): SessionStats {
return this.getStats();
}
/**
* Import stats from JSON
*/
fromJSON(data: SessionStats): void {
this.stats = { ...data };
}
// Legacy interface compatibility
get totalTokens(): number {
return this.stats.totalTokens;
}
get totalCost(): number {
return this.stats.totalCost;
}
get requests(): number {
return this.stats.requestCount;
}
}
/**
* Create a cost tracker instance
*/
export function createCostTracker(): CostTracker {
return new CostTracker();
}
/**
* Estimate tokens from text (rough approximation)
*/
export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Format cost as currency string
*/
export function formatCost(cost: number): string {
if (cost < 0.01) {
return `$${cost.toFixed(6)}`;
}
if (cost < 1) {
return `$${cost.toFixed(4)}`;
}
return `$${cost.toFixed(2)}`;
}
@@ -0,0 +1,323 @@
/**
* External Agents - Integration adapters for external AI CLI tools
*/
export interface ExternalAgentConfig {
name: string;
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
timeout?: number;
}
export interface ExternalAgentResult {
success: boolean;
output: string;
error?: string;
exitCode: number;
duration: number;
}
/**
* Base class for external agent integrations
*/
export abstract class BaseExternalAgent {
protected config: ExternalAgentConfig;
constructor(config: ExternalAgentConfig) {
this.config = {
timeout: 300000, // 5 minutes default
...config
};
}
/**
* Check if the external tool is available
*/
abstract isAvailable(): Promise<boolean>;
/**
* Execute a prompt with the external agent
*/
abstract execute(prompt: string): Promise<ExternalAgentResult>;
/**
* Get the agent name
*/
getName(): string {
return this.config.name;
}
/**
* Execute a command and return result
*/
protected async runCommand(args: string[]): Promise<ExternalAgentResult> {
const { spawn } = await import('child_process');
const startTime = Date.now();
return new Promise((resolve) => {
const proc = spawn(this.config.command, args, {
cwd: this.config.cwd || process.cwd(),
env: { ...process.env, ...this.config.env },
timeout: this.config.timeout,
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => {
stdout += data.toString();
});
proc.stderr?.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
resolve({
success: code === 0,
output: stdout,
error: stderr || undefined,
exitCode: code || 0,
duration: Date.now() - startTime
});
});
proc.on('error', (error) => {
resolve({
success: false,
output: '',
error: error.message,
exitCode: 1,
duration: Date.now() - startTime
});
});
});
}
/**
* Check if a command exists
*/
protected async commandExists(command: string): Promise<boolean> {
const { spawn } = await import('child_process');
return new Promise((resolve) => {
const proc = spawn('which', [command], { stdio: 'pipe' });
proc.on('close', (code) => resolve(code === 0));
proc.on('error', () => resolve(false));
});
}
}
/**
* Claude Code CLI integration
*/
export class ClaudeCodeAgent extends BaseExternalAgent {
constructor(cwd?: string) {
super({
name: 'claude-code',
command: 'claude',
cwd
});
}
async isAvailable(): Promise<boolean> {
return this.commandExists('claude');
}
async execute(prompt: string): Promise<ExternalAgentResult> {
return this.runCommand(['--print', prompt]);
}
async executeWithSession(prompt: string, sessionId?: string): Promise<ExternalAgentResult> {
const args = ['--print'];
if (sessionId) {
args.push('--continue', sessionId);
}
args.push(prompt);
return this.runCommand(args);
}
}
/**
* Gemini CLI integration
*/
export class GeminiCliAgent extends BaseExternalAgent {
private model: string;
constructor(cwd?: string, model: string = 'gemini-2.5-pro') {
super({
name: 'gemini-cli',
command: 'gemini',
cwd
});
this.model = model;
}
async isAvailable(): Promise<boolean> {
return this.commandExists('gemini');
}
async execute(prompt: string): Promise<ExternalAgentResult> {
return this.runCommand(['-m', this.model, prompt]);
}
}
/**
* OpenAI Codex CLI integration
*/
export class CodexCliAgent extends BaseExternalAgent {
constructor(cwd?: string) {
super({
name: 'codex-cli',
command: 'codex',
cwd
});
}
async isAvailable(): Promise<boolean> {
return this.commandExists('codex');
}
async execute(prompt: string): Promise<ExternalAgentResult> {
return this.runCommand(['exec', '--full-auto', prompt]);
}
}
/**
* Aider CLI integration
*/
export class AiderAgent extends BaseExternalAgent {
constructor(cwd?: string) {
super({
name: 'aider',
command: 'aider',
cwd
});
}
async isAvailable(): Promise<boolean> {
return this.commandExists('aider');
}
async execute(prompt: string): Promise<ExternalAgentResult> {
return this.runCommand(['--message', prompt, '--yes']);
}
}
/**
* Generic external agent for any CLI tool
*/
export class GenericExternalAgent extends BaseExternalAgent {
private promptArg: string;
constructor(config: ExternalAgentConfig & { promptArg?: string }) {
super(config);
this.promptArg = config.promptArg || '';
}
async isAvailable(): Promise<boolean> {
return this.commandExists(this.config.command);
}
async execute(prompt: string): Promise<ExternalAgentResult> {
const args = [...(this.config.args || [])];
if (this.promptArg) {
args.push(this.promptArg, prompt);
} else {
args.push(prompt);
}
return this.runCommand(args);
}
}
/**
* External Agent Registry
*/
class ExternalAgentRegistry {
private agents: Map<string, () => BaseExternalAgent> = new Map();
constructor() {
// Register built-in agents
this.register('claude', () => new ClaudeCodeAgent());
this.register('gemini', () => new GeminiCliAgent());
this.register('codex', () => new CodexCliAgent());
this.register('aider', () => new AiderAgent());
}
register(name: string, factory: () => BaseExternalAgent): void {
this.agents.set(name, factory);
}
get(name: string): BaseExternalAgent | undefined {
const factory = this.agents.get(name);
return factory ? factory() : undefined;
}
list(): string[] {
return Array.from(this.agents.keys());
}
async getAvailable(): Promise<string[]> {
const available: string[] = [];
for (const [name, factory] of this.agents) {
const agent = factory();
if (await agent.isAvailable()) {
available.push(name);
}
}
return available;
}
}
let registry: ExternalAgentRegistry | null = null;
export function getExternalAgentRegistry(): ExternalAgentRegistry {
if (!registry) {
registry = new ExternalAgentRegistry();
}
return registry;
}
/**
* Create an external agent by name
*/
export function createExternalAgent(name: string, cwd?: string): BaseExternalAgent | undefined {
const reg = getExternalAgentRegistry();
switch (name) {
case 'claude':
return new ClaudeCodeAgent(cwd);
case 'gemini':
return new GeminiCliAgent(cwd);
case 'codex':
return new CodexCliAgent(cwd);
case 'aider':
return new AiderAgent(cwd);
default:
return reg.get(name);
}
}
/**
* Convert external agent to tool for use with PraisonAI agents
*/
export function externalAgentAsTool(agent: BaseExternalAgent): {
name: string;
description: string;
execute: (input: string) => Promise<string>;
} {
return {
name: `external_${agent.getName()}`,
description: `Execute a prompt using the ${agent.getName()} external agent`,
execute: async (input: string) => {
const result = await agent.execute(input);
if (!result.success) {
throw new Error(result.error || 'External agent execution failed');
}
return result.output;
}
};
}
@@ -0,0 +1,391 @@
/**
* Fast Context - Fast retrieval and summarization pipeline
* Minimizes tokens and latency with deterministic caching
*/
export interface FastContextConfig {
maxTokens?: number;
cacheEnabled?: boolean;
cacheTTL?: number;
summarizer?: (text: string, maxTokens: number) => Promise<string>;
embeddingProvider?: {
embed: (text: string) => Promise<number[]>;
};
}
export interface ContextSource {
id: string;
type: 'memory' | 'knowledge' | 'file' | 'custom';
content: string;
relevance?: number;
metadata?: Record<string, any>;
}
export interface FastContextResult {
context: string;
sources: ContextSource[];
tokenCount: number;
cached: boolean;
latencyMs: number;
}
export interface CacheEntry {
result: FastContextResult;
timestamp: number;
hits: number;
}
/**
* Fast Context class for efficient context retrieval
*/
export class FastContext {
private config: FastContextConfig;
private cache: Map<string, CacheEntry> = new Map();
private sources: Map<string, ContextSource[]> = new Map();
constructor(config: FastContextConfig = {}) {
this.config = {
maxTokens: 4000,
cacheEnabled: true,
cacheTTL: 300000, // 5 minutes
...config
};
}
/**
* Register a context source
*/
registerSource(
id: string,
type: ContextSource['type'],
contents: string[],
metadata?: Record<string, any>
): void {
const sources: ContextSource[] = contents.map((content, i) => ({
id: `${id}_${i}`,
type,
content,
metadata
}));
this.sources.set(id, sources);
}
/**
* Register memory as a source
*/
registerMemory(memory: { getAll: () => Array<{ content: string; metadata?: any }> }): void {
const entries = memory.getAll();
const sources: ContextSource[] = entries.map((entry, i) => ({
id: `memory_${i}`,
type: 'memory',
content: entry.content,
metadata: entry.metadata
}));
this.sources.set('memory', sources);
}
/**
* Register knowledge base as a source
*/
registerKnowledge(
knowledge: { search: (query: string, limit?: number) => Promise<Array<{ content: string; score: number }>> }
): void {
// Store reference for dynamic retrieval
(this as any)._knowledgeBase = knowledge;
}
/**
* Get context for a query
*/
async getContext(query: string): Promise<FastContextResult> {
const startTime = Date.now();
// Check cache
const cacheKey = this.getCacheKey(query);
if (this.config.cacheEnabled) {
const cached = this.getFromCache(cacheKey);
if (cached) {
return {
...cached.result,
cached: true,
latencyMs: Date.now() - startTime
};
}
}
// Gather relevant sources
const relevantSources = await this.gatherRelevantSources(query);
// Build context within token limit
const { context, sources, tokenCount } = await this.buildContext(relevantSources, query);
const result: FastContextResult = {
context,
sources,
tokenCount,
cached: false,
latencyMs: Date.now() - startTime
};
// Cache result
if (this.config.cacheEnabled) {
this.addToCache(cacheKey, result);
}
return result;
}
/**
* Gather relevant sources for a query
*/
private async gatherRelevantSources(query: string): Promise<ContextSource[]> {
const allSources: ContextSource[] = [];
// Get from registered sources
for (const sources of this.sources.values()) {
allSources.push(...sources);
}
// Get from knowledge base if registered
const kb = (this as any)._knowledgeBase;
if (kb) {
try {
const kbResults = await kb.search(query, 10);
for (let i = 0; i < kbResults.length; i++) {
allSources.push({
id: `kb_${i}`,
type: 'knowledge',
content: kbResults[i].content,
relevance: kbResults[i].score
});
}
} catch {
// Ignore knowledge base errors
}
}
// Score relevance if embedding provider available
if (this.config.embeddingProvider) {
await this.scoreRelevance(allSources, query);
} else {
// Simple text matching fallback
this.scoreRelevanceSimple(allSources, query);
}
// Sort by relevance
allSources.sort((a, b) => (b.relevance || 0) - (a.relevance || 0));
return allSources;
}
/**
* Score relevance using embeddings
*/
private async scoreRelevance(sources: ContextSource[], query: string): Promise<void> {
if (!this.config.embeddingProvider) return;
const queryEmbedding = await this.config.embeddingProvider.embed(query);
for (const source of sources) {
if (source.relevance !== undefined) continue; // Already scored
const sourceEmbedding = await this.config.embeddingProvider.embed(source.content);
source.relevance = this.cosineSimilarity(queryEmbedding, sourceEmbedding);
}
}
/**
* Simple text-based relevance scoring
*/
private scoreRelevanceSimple(sources: ContextSource[], query: string): void {
const queryWords = query.toLowerCase().split(/\s+/);
for (const source of sources) {
if (source.relevance !== undefined) continue;
const contentLower = source.content.toLowerCase();
let score = 0;
for (const word of queryWords) {
if (contentLower.includes(word)) {
score += 1 / queryWords.length;
}
}
// Boost for exact phrase match
if (contentLower.includes(query.toLowerCase())) {
score += 0.5;
}
source.relevance = Math.min(score, 1);
}
}
/**
* Build context from sources within token limit
*/
private async buildContext(
sources: ContextSource[],
query: string
): Promise<{ context: string; sources: ContextSource[]; tokenCount: number }> {
const maxTokens = this.config.maxTokens || 4000;
const usedSources: ContextSource[] = [];
const contextParts: string[] = [];
let tokenCount = 0;
for (const source of sources) {
const sourceTokens = this.estimateTokens(source.content);
if (tokenCount + sourceTokens <= maxTokens) {
contextParts.push(source.content);
usedSources.push(source);
tokenCount += sourceTokens;
} else if (this.config.summarizer && sourceTokens > 100) {
// Try to summarize if too long
const remainingTokens = maxTokens - tokenCount;
if (remainingTokens > 50) {
const summary = await this.config.summarizer(source.content, remainingTokens);
const summaryTokens = this.estimateTokens(summary);
if (tokenCount + summaryTokens <= maxTokens) {
contextParts.push(summary);
usedSources.push({ ...source, content: summary, metadata: { ...source.metadata, summarized: true } });
tokenCount += summaryTokens;
}
}
}
if (tokenCount >= maxTokens * 0.95) break;
}
return {
context: contextParts.join('\n\n'),
sources: usedSources,
tokenCount
};
}
/**
* Get cache key for query
*/
private getCacheKey(query: string): string {
// Simple hash
let hash = 0;
for (let i = 0; i < query.length; i++) {
const char = query.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return `ctx_${hash}`;
}
/**
* Get from cache
*/
private getFromCache(key: string): CacheEntry | null {
const entry = this.cache.get(key);
if (!entry) return null;
// Check TTL
if (Date.now() - entry.timestamp > (this.config.cacheTTL || 300000)) {
this.cache.delete(key);
return null;
}
entry.hits++;
return entry;
}
/**
* Add to cache
*/
private addToCache(key: string, result: FastContextResult): void {
this.cache.set(key, {
result,
timestamp: Date.now(),
hits: 0
});
// Limit cache size
if (this.cache.size > 100) {
const oldest = Array.from(this.cache.entries())
.sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
if (oldest) {
this.cache.delete(oldest[0]);
}
}
}
/**
* Clear cache
*/
clearCache(): void {
this.cache.clear();
}
/**
* Get cache stats
*/
getCacheStats(): { size: number; totalHits: number } {
let totalHits = 0;
for (const entry of this.cache.values()) {
totalHits += entry.hits;
}
return { size: this.cache.size, totalHits };
}
/**
* Estimate token count
*/
private estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Cosine similarity
*/
private cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) return 0;
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
return denominator === 0 ? 0 : dotProduct / denominator;
}
/**
* Clear all sources
*/
clearSources(): void {
this.sources.clear();
(this as any)._knowledgeBase = undefined;
}
}
/**
* Create a fast context instance
*/
export function createFastContext(config?: FastContextConfig): FastContext {
return new FastContext(config);
}
/**
* Quick context retrieval
*/
export async function getQuickContext(
query: string,
sources: string[],
maxTokens?: number
): Promise<string> {
const fc = createFastContext({ maxTokens, cacheEnabled: false });
fc.registerSource('quick', 'custom', sources);
const result = await fc.getContext(query);
return result.context;
}
@@ -0,0 +1,319 @@
/**
* Flow Display - Textual DAG visualization for workflows
*/
export interface FlowNode {
id: string;
name: string;
type: 'agent' | 'tool' | 'condition' | 'parallel' | 'start' | 'end';
status?: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
children?: string[];
metadata?: Record<string, any>;
}
export interface FlowGraph {
nodes: Map<string, FlowNode>;
edges: Array<{ from: string; to: string; label?: string }>;
}
export interface FlowDisplayConfig {
showStatus?: boolean;
showMetadata?: boolean;
compact?: boolean;
maxWidth?: number;
}
/**
* Flow Display class for DAG visualization
*/
export class FlowDisplay {
private graph: FlowGraph;
private config: FlowDisplayConfig;
constructor(config: FlowDisplayConfig = {}) {
this.config = {
showStatus: true,
showMetadata: false,
compact: false,
maxWidth: 80,
...config
};
this.graph = {
nodes: new Map(),
edges: []
};
}
/**
* Add a node to the graph
*/
addNode(node: FlowNode): void {
this.graph.nodes.set(node.id, node);
}
/**
* Add an edge between nodes
*/
addEdge(from: string, to: string, label?: string): void {
this.graph.edges.push({ from, to, label });
}
/**
* Update node status
*/
updateStatus(id: string, status: FlowNode['status']): void {
const node = this.graph.nodes.get(id);
if (node) {
node.status = status;
}
}
/**
* Build graph from workflow steps
*/
fromWorkflowSteps(steps: Array<{ name: string; type?: string; condition?: string }>): void {
this.graph.nodes.clear();
this.graph.edges = [];
// Add start node
this.addNode({ id: 'start', name: 'Start', type: 'start' });
let prevId = 'start';
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const id = `step_${i}`;
this.addNode({
id,
name: step.name,
type: (step.type as FlowNode['type']) || 'agent',
status: 'pending'
});
this.addEdge(prevId, id, step.condition);
prevId = id;
}
// Add end node
this.addNode({ id: 'end', name: 'End', type: 'end' });
this.addEdge(prevId, 'end');
}
/**
* Render the graph as text
*/
render(): string {
const lines: string[] = [];
const visited = new Set<string>();
// Find root nodes (nodes with no incoming edges)
const hasIncoming = new Set(this.graph.edges.map(e => e.to));
const roots = Array.from(this.graph.nodes.keys()).filter(id => !hasIncoming.has(id));
if (roots.length === 0 && this.graph.nodes.size > 0) {
const firstKey = this.graph.nodes.keys().next().value;
if (firstKey) roots.push(firstKey);
}
for (const root of roots) {
this.renderNode(root, '', true, lines, visited);
}
return lines.join('\n');
}
/**
* Render a single node and its children
*/
private renderNode(
id: string,
prefix: string,
isLast: boolean,
lines: string[],
visited: Set<string>
): void {
if (visited.has(id)) {
lines.push(`${prefix}${isLast ? '└── ' : '├── '}(cycle: ${id})`);
return;
}
visited.add(id);
const node = this.graph.nodes.get(id);
if (!node) return;
const connector = isLast ? '└── ' : '├── ';
const nodeStr = this.formatNode(node);
lines.push(`${prefix}${connector}${nodeStr}`);
// Find children
const childEdges = this.graph.edges.filter(e => e.from === id);
const childPrefix = prefix + (isLast ? ' ' : '│ ');
for (let i = 0; i < childEdges.length; i++) {
const edge = childEdges[i];
const isLastChild = i === childEdges.length - 1;
if (edge.label) {
lines.push(`${childPrefix}${isLastChild ? '└' : '├'}─[${edge.label}]─┐`);
this.renderNode(edge.to, childPrefix + (isLastChild ? ' ' : '│ '), true, lines, visited);
} else {
this.renderNode(edge.to, childPrefix, isLastChild, lines, visited);
}
}
}
/**
* Format a node for display
*/
private formatNode(node: FlowNode): string {
const parts: string[] = [];
// Status indicator
if (this.config.showStatus && node.status) {
const statusIcons: Record<string, string> = {
pending: '○',
running: '◐',
completed: '●',
failed: '✗',
skipped: '◌'
};
parts.push(statusIcons[node.status] || '?');
}
// Type indicator
const typeIcons: Record<string, string> = {
agent: '🤖',
tool: '🔧',
condition: '❓',
parallel: '⫘',
start: '▶',
end: '■'
};
if (!this.config.compact) {
parts.push(typeIcons[node.type] || '□');
}
// Name
parts.push(node.name);
// Metadata
if (this.config.showMetadata && node.metadata) {
const meta = Object.entries(node.metadata)
.map(([k, v]) => `${k}=${v}`)
.join(', ');
if (meta) {
parts.push(`(${meta})`);
}
}
return parts.join(' ');
}
/**
* Render as simple ASCII box diagram
*/
renderBoxes(): string {
const lines: string[] = [];
const nodeList = Array.from(this.graph.nodes.values());
for (let i = 0; i < nodeList.length; i++) {
const node = nodeList[i];
const box = this.createBox(node);
lines.push(...box);
if (i < nodeList.length - 1) {
lines.push(' │');
lines.push(' ▼');
}
}
return lines.join('\n');
}
/**
* Create a box for a node
*/
private createBox(node: FlowNode): string[] {
const name = node.name;
const width = Math.max(name.length + 4, 20);
const padding = Math.floor((width - name.length - 2) / 2);
const statusChar = this.config.showStatus ? this.getStatusChar(node.status) : '';
const top = '┌' + '─'.repeat(width) + '┐';
const middle = '│' + ' '.repeat(padding) + statusChar + name + ' '.repeat(width - padding - name.length - statusChar.length) + '│';
const bottom = '└' + '─'.repeat(width) + '┘';
return [top, middle, bottom];
}
/**
* Get status character
*/
private getStatusChar(status?: string): string {
const chars: Record<string, string> = {
pending: '○ ',
running: '◐ ',
completed: '● ',
failed: '✗ ',
skipped: '◌ '
};
return status ? (chars[status] || '') : '';
}
/**
* Clear the graph
*/
clear(): void {
this.graph.nodes.clear();
this.graph.edges = [];
}
/**
* Get graph data
*/
getGraph(): FlowGraph {
return this.graph;
}
/**
* Export to DOT format (for Graphviz)
*/
toDot(): string {
const lines: string[] = ['digraph workflow {'];
lines.push(' rankdir=TB;');
lines.push(' node [shape=box];');
for (const [id, node] of this.graph.nodes) {
const label = node.name.replace(/"/g, '\\"');
const style = node.status === 'completed' ? 'filled' : '';
const fillcolor = node.status === 'completed' ? 'lightgreen' :
node.status === 'failed' ? 'lightcoral' : 'white';
lines.push(` "${id}" [label="${label}" style="${style}" fillcolor="${fillcolor}"];`);
}
for (const edge of this.graph.edges) {
const label = edge.label ? ` [label="${edge.label}"]` : '';
lines.push(` "${edge.from}" -> "${edge.to}"${label};`);
}
lines.push('}');
return lines.join('\n');
}
}
/**
* Create a flow display instance
*/
export function createFlowDisplay(config?: FlowDisplayConfig): FlowDisplay {
return new FlowDisplay(config);
}
/**
* Quick render workflow steps
*/
export function renderWorkflow(steps: Array<{ name: string; type?: string }>): string {
const display = createFlowDisplay();
display.fromWorkflowSteps(steps);
return display.render();
}
@@ -0,0 +1,416 @@
/**
* Git Integration - Git operations for CLI
* Safe, read-focused operations with optional write capabilities
*/
export interface GitConfig {
cwd?: string;
safe?: boolean; // If true, only allow read operations
}
export interface GitStatus {
branch: string;
ahead: number;
behind: number;
staged: string[];
modified: string[];
untracked: string[];
deleted: string[];
conflicts: string[];
}
export interface GitCommit {
hash: string;
shortHash: string;
author: string;
email: string;
date: Date;
message: string;
}
export interface GitDiff {
files: GitDiffFile[];
summary: string;
}
export interface GitDiffFile {
path: string;
status: 'added' | 'modified' | 'deleted' | 'renamed';
additions: number;
deletions: number;
diff?: string;
}
/**
* Git Manager class
*/
export class GitManager {
private cwd: string;
private safe: boolean;
constructor(config: GitConfig = {}) {
this.cwd = config.cwd || process.cwd();
this.safe = config.safe ?? true;
}
/**
* Execute a git command
*/
private async exec(args: string[]): Promise<string> {
const { spawn } = await import('child_process');
return new Promise((resolve, reject) => {
const proc = spawn('git', args, {
cwd: this.cwd,
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => {
stdout += data.toString();
});
proc.stderr?.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
resolve(stdout.trim());
} else {
reject(new Error(stderr || `Git command failed with code ${code}`));
}
});
proc.on('error', reject);
});
}
/**
* Check if directory is a git repository
*/
async isRepo(): Promise<boolean> {
try {
await this.exec(['rev-parse', '--git-dir']);
return true;
} catch {
return false;
}
}
/**
* Get current branch name
*/
async getBranch(): Promise<string> {
return this.exec(['rev-parse', '--abbrev-ref', 'HEAD']);
}
/**
* Get repository status
*/
async getStatus(): Promise<GitStatus> {
const branch = await this.getBranch();
const porcelain = await this.exec(['status', '--porcelain', '-b']);
const lines = porcelain.split('\n');
const status: GitStatus = {
branch,
ahead: 0,
behind: 0,
staged: [],
modified: [],
untracked: [],
deleted: [],
conflicts: []
};
// Parse branch line for ahead/behind
const branchLine = lines[0];
const aheadMatch = branchLine.match(/ahead (\d+)/);
const behindMatch = branchLine.match(/behind (\d+)/);
if (aheadMatch) status.ahead = parseInt(aheadMatch[1]);
if (behindMatch) status.behind = parseInt(behindMatch[1]);
// Parse file status
for (const line of lines.slice(1)) {
if (!line) continue;
const indexStatus = line[0];
const workStatus = line[1];
const file = line.slice(3);
if (indexStatus === 'U' || workStatus === 'U') {
status.conflicts.push(file);
} else if (indexStatus === '?' && workStatus === '?') {
status.untracked.push(file);
} else if (indexStatus === 'D' || workStatus === 'D') {
status.deleted.push(file);
} else if (indexStatus !== ' ') {
status.staged.push(file);
} else if (workStatus === 'M') {
status.modified.push(file);
}
}
return status;
}
/**
* Get diff (staged or unstaged)
*/
async getDiff(staged: boolean = false): Promise<GitDiff> {
const args = ['diff', '--stat'];
if (staged) args.push('--staged');
const stat = await this.exec(args);
const files: GitDiffFile[] = [];
const lines = stat.split('\n');
for (const line of lines) {
const match = line.match(/^\s*(.+?)\s*\|\s*(\d+)\s*([+-]*)/);
if (match) {
const [, path, changes, indicators] = match;
const additions = (indicators.match(/\+/g) || []).length;
const deletions = (indicators.match(/-/g) || []).length;
files.push({
path: path.trim(),
status: 'modified',
additions,
deletions
});
}
}
// Get full diff for summary
const fullDiffArgs = ['diff'];
if (staged) fullDiffArgs.push('--staged');
const fullDiff = await this.exec(fullDiffArgs);
return {
files,
summary: fullDiff
};
}
/**
* Get recent commits
*/
async getLog(limit: number = 10): Promise<GitCommit[]> {
const format = '%H|%h|%an|%ae|%aI|%s';
const output = await this.exec(['log', `-${limit}`, `--format=${format}`]);
return output.split('\n').filter(Boolean).map(line => {
const [hash, shortHash, author, email, date, message] = line.split('|');
return {
hash,
shortHash,
author,
email,
date: new Date(date),
message
};
});
}
/**
* Get list of branches
*/
async getBranches(): Promise<{ name: string; current: boolean }[]> {
const output = await this.exec(['branch', '--list']);
return output.split('\n').filter(Boolean).map(line => ({
name: line.replace(/^\*?\s*/, '').trim(),
current: line.startsWith('*')
}));
}
/**
* Stage files (requires safe=false)
*/
async add(files: string[] = ['.']): Promise<void> {
if (this.safe) {
throw new Error('Write operations disabled in safe mode');
}
await this.exec(['add', ...files]);
}
/**
* Commit staged changes (requires safe=false)
*/
async commit(message: string): Promise<string> {
if (this.safe) {
throw new Error('Write operations disabled in safe mode');
}
return this.exec(['commit', '-m', message]);
}
/**
* Stash changes (requires safe=false)
*/
async stash(message?: string): Promise<void> {
if (this.safe) {
throw new Error('Write operations disabled in safe mode');
}
const args = ['stash', 'push'];
if (message) args.push('-m', message);
await this.exec(args);
}
/**
* Pop stash (requires safe=false)
*/
async stashPop(): Promise<void> {
if (this.safe) {
throw new Error('Write operations disabled in safe mode');
}
await this.exec(['stash', 'pop']);
}
/**
* Get stash list
*/
async getStashList(): Promise<string[]> {
const output = await this.exec(['stash', 'list']);
return output.split('\n').filter(Boolean);
}
/**
* Get file content at a specific commit
*/
async show(ref: string, path: string): Promise<string> {
return this.exec(['show', `${ref}:${path}`]);
}
/**
* Get blame for a file
*/
async blame(path: string): Promise<string> {
return this.exec(['blame', path]);
}
/**
* Generate commit message from staged changes
*/
async generateCommitMessage(): Promise<string> {
const diff = await this.getDiff(true);
if (diff.files.length === 0) {
return 'No staged changes';
}
const fileList = diff.files.map(f => f.path).join(', ');
const totalAdditions = diff.files.reduce((sum, f) => sum + f.additions, 0);
const totalDeletions = diff.files.reduce((sum, f) => sum + f.deletions, 0);
// Simple template-based message
if (diff.files.length === 1) {
return `Update ${diff.files[0].path}`;
}
return `Update ${diff.files.length} files (+${totalAdditions}/-${totalDeletions})`;
}
/**
* Get formatted status string
*/
async getStatusString(): Promise<string> {
const status = await this.getStatus();
const lines: string[] = [];
lines.push(`Branch: ${status.branch}`);
if (status.ahead > 0) lines.push(` ↑ ${status.ahead} ahead`);
if (status.behind > 0) lines.push(` ↓ ${status.behind} behind`);
if (status.staged.length > 0) {
lines.push(`\nStaged (${status.staged.length}):`);
status.staged.forEach(f => lines.push(` + ${f}`));
}
if (status.modified.length > 0) {
lines.push(`\nModified (${status.modified.length}):`);
status.modified.forEach(f => lines.push(` M ${f}`));
}
if (status.untracked.length > 0) {
lines.push(`\nUntracked (${status.untracked.length}):`);
status.untracked.forEach(f => lines.push(` ? ${f}`));
}
if (status.deleted.length > 0) {
lines.push(`\nDeleted (${status.deleted.length}):`);
status.deleted.forEach(f => lines.push(` - ${f}`));
}
if (status.conflicts.length > 0) {
lines.push(`\nConflicts (${status.conflicts.length}):`);
status.conflicts.forEach(f => lines.push(` ! ${f}`));
}
return lines.join('\n');
}
}
/**
* Create a git manager instance
*/
export function createGitManager(config?: GitConfig): GitManager {
return new GitManager(config);
}
/**
* Diff viewer with syntax highlighting (text-based)
*/
export class DiffViewer {
private diff: string;
constructor(diff: string) {
this.diff = diff;
}
/**
* Get formatted diff with markers
*/
format(): string {
const lines = this.diff.split('\n');
return lines.map(line => {
if (line.startsWith('+') && !line.startsWith('+++')) {
return `[+] ${line}`;
}
if (line.startsWith('-') && !line.startsWith('---')) {
return `[-] ${line}`;
}
if (line.startsWith('@@')) {
return `[~] ${line}`;
}
return ` ${line}`;
}).join('\n');
}
/**
* Get summary statistics
*/
getStats(): { additions: number; deletions: number } {
let additions = 0;
let deletions = 0;
for (const line of this.diff.split('\n')) {
if (line.startsWith('+') && !line.startsWith('+++')) {
additions++;
}
if (line.startsWith('-') && !line.startsWith('---')) {
deletions++;
}
}
return { additions, deletions };
}
}
/**
* Create a diff viewer
*/
export function createDiffViewer(diff: string): DiffViewer {
return new DiffViewer(diff);
}
+179
View File
@@ -0,0 +1,179 @@
/**
* CLI Features - Lazy-loaded feature modules
*/
// Slash Commands
export {
SlashCommandHandler,
createSlashCommandHandler,
registerCommand,
parseSlashCommand,
executeSlashCommand,
isSlashCommand,
getRegistry,
type SlashCommand,
type SlashCommandContext,
type SlashCommandResult,
type CostTracker as SlashCostTracker
} from './slash-commands';
// Cost Tracker
export {
CostTracker,
createCostTracker,
estimateTokens,
formatCost,
MODEL_PRICING,
type ModelPricing,
type TokenUsage,
type RequestStats,
type SessionStats
} from './cost-tracker';
// Interactive TUI
export {
InteractiveTUI,
createInteractiveTUI,
StatusDisplay,
createStatusDisplay,
HistoryManager,
createHistoryManager,
type TUIConfig,
type TUIState
} from './interactive-tui';
// Repo Map
export {
RepoMap,
createRepoMap,
getRepoTree,
DEFAULT_IGNORE_PATTERNS,
type RepoMapConfig,
type FileInfo,
type SymbolInfo,
type RepoMapResult
} from './repo-map';
// Git Integration
export {
GitManager,
createGitManager,
DiffViewer,
createDiffViewer,
type GitConfig,
type GitStatus,
type GitCommit,
type GitDiff,
type GitDiffFile
} from './git-integration';
// Sandbox Executor
export {
SandboxExecutor,
createSandboxExecutor,
sandboxExec,
CommandValidator,
DEFAULT_BLOCKED_COMMANDS,
DEFAULT_BLOCKED_PATHS,
type SandboxMode,
type SandboxConfig,
type ExecutionResult
} from './sandbox-executor';
// Autonomy Mode
export {
AutonomyManager,
createAutonomyManager,
cliApprovalPrompt,
MODE_POLICIES,
type AutonomyMode,
type ActionType,
type ApprovalPolicy,
type AutonomyConfig,
type ActionRequest,
type ActionDecision
} from './autonomy-mode';
// Scheduler
export {
Scheduler,
createScheduler,
cronExpressions,
type ScheduleConfig,
type ScheduledTask,
type SchedulerStats
} from './scheduler';
// Background Jobs
export {
JobQueue,
createJobQueue,
MemoryJobStorage,
FileJobStorage,
createFileJobStorage,
type Job,
type JobStatus,
type JobPriority,
type JobQueueConfig,
type JobStorageAdapter,
type JobHandler,
type JobContext
} from './background-jobs';
// Checkpoints
export {
CheckpointManager,
createCheckpointManager,
MemoryCheckpointStorage,
FileCheckpointStorage,
createFileCheckpointStorage,
type CheckpointData,
type CheckpointConfig,
type CheckpointStorage
} from './checkpoints';
// Flow Display
export {
FlowDisplay,
createFlowDisplay,
renderWorkflow,
type FlowNode,
type FlowGraph,
type FlowDisplayConfig
} from './flow-display';
// External Agents
export {
BaseExternalAgent,
ClaudeCodeAgent,
GeminiCliAgent,
CodexCliAgent,
AiderAgent,
GenericExternalAgent,
getExternalAgentRegistry,
createExternalAgent,
externalAgentAsTool,
type ExternalAgentConfig,
type ExternalAgentResult
} from './external-agents';
// N8N Integration
export {
N8NIntegration,
createN8NIntegration,
triggerN8NWebhook,
type N8NConfig,
type N8NWebhookPayload,
type N8NWorkflow,
type N8NWorkflowNode
} from './n8n-integration';
// Fast Context
export {
FastContext,
createFastContext,
getQuickContext,
type FastContextConfig,
type ContextSource,
type FastContextResult
} from './fast-context';
@@ -0,0 +1,343 @@
/**
* Interactive TUI - Terminal User Interface for CLI
* Minimal but functional interactive mode with readline
*/
import { SlashCommandHandler, isSlashCommand, SlashCommandContext } from './slash-commands';
import { CostTracker, createCostTracker } from './cost-tracker';
export interface TUIConfig {
prompt?: string;
welcomeMessage?: string;
historyFile?: string;
maxHistorySize?: number;
onMessage?: (message: string) => Promise<string>;
onExit?: () => void;
}
export interface TUIState {
running: boolean;
history: string[];
context: SlashCommandContext;
}
/**
* Interactive TUI class
*/
export class InteractiveTUI {
private config: TUIConfig;
private state: TUIState;
private slashHandler: SlashCommandHandler;
private costTracker: CostTracker;
private readline: any;
private rl: any;
constructor(config: TUIConfig = {}) {
this.config = {
prompt: '> ',
welcomeMessage: 'PraisonAI Interactive Mode. Type /help for commands, /exit to quit.',
maxHistorySize: 1000,
...config
};
this.costTracker = createCostTracker();
this.state = {
running: false,
history: [],
context: {
history: [],
settings: {},
costTracker: this.costTracker,
onOutput: (msg) => this.output(msg)
}
};
this.slashHandler = new SlashCommandHandler(this.state.context);
}
/**
* Start the interactive session
*/
async start(): Promise<void> {
if (this.state.running) return;
// Lazy load readline
this.readline = await import('readline');
this.rl = this.readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true
});
this.state.running = true;
// Load history if available
await this.loadHistory();
// Show welcome message
if (this.config.welcomeMessage) {
this.output(this.config.welcomeMessage);
}
// Start the input loop
await this.inputLoop();
}
/**
* Stop the interactive session
*/
stop(): void {
this.state.running = false;
if (this.rl) {
this.rl.close();
}
this.saveHistory();
this.config.onExit?.();
}
/**
* Main input loop
*/
private async inputLoop(): Promise<void> {
while (this.state.running) {
const input = await this.prompt();
if (input === null) {
// EOF or Ctrl+C
this.stop();
break;
}
const trimmed = input.trim();
if (!trimmed) continue;
// Add to history
this.addToHistory(trimmed);
// Handle slash commands
if (isSlashCommand(trimmed)) {
const result = await this.slashHandler.handle(trimmed);
if (result.shouldExit) {
this.stop();
break;
}
continue;
}
// Handle regular message
if (this.config.onMessage) {
try {
const response = await this.config.onMessage(trimmed);
this.output(response);
} catch (error: any) {
this.output(`Error: ${error.message}`);
}
} else {
this.output('No message handler configured. Use /help for available commands.');
}
}
}
/**
* Prompt for input
*/
private prompt(): Promise<string | null> {
return new Promise((resolve) => {
if (!this.state.running || !this.rl) {
resolve(null);
return;
}
this.rl.question(this.config.prompt, (answer: string) => {
resolve(answer);
});
// Handle Ctrl+C
this.rl.once('close', () => {
resolve(null);
});
});
}
/**
* Output a message
*/
private output(message: string): void {
console.log(message);
}
/**
* Add input to history
*/
private addToHistory(input: string): void {
this.state.history.push(input);
if (this.state.history.length > (this.config.maxHistorySize || 1000)) {
this.state.history.shift();
}
}
/**
* Load history from file
*/
private async loadHistory(): Promise<void> {
if (!this.config.historyFile) return;
try {
const fs = await import('fs/promises');
const content = await fs.readFile(this.config.historyFile, 'utf-8');
this.state.history = content.trim().split('\n').filter(Boolean);
} catch {
// Ignore if file doesn't exist
}
}
/**
* Save history to file
*/
private async saveHistory(): Promise<void> {
if (!this.config.historyFile) return;
try {
const fs = await import('fs/promises');
await fs.writeFile(this.config.historyFile, this.state.history.join('\n'));
} catch {
// Ignore save errors
}
}
/**
* Get current state
*/
getState(): TUIState {
return { ...this.state };
}
/**
* Get cost tracker
*/
getCostTracker(): CostTracker {
return this.costTracker;
}
/**
* Update context
*/
updateContext(updates: Partial<SlashCommandContext>): void {
this.slashHandler.updateContext(updates);
}
}
/**
* Create an interactive TUI instance
*/
export function createInteractiveTUI(config?: TUIConfig): InteractiveTUI {
return new InteractiveTUI(config);
}
/**
* Simple status display helper
*/
export class StatusDisplay {
private lines: string[] = [];
add(label: string, value: string | number): this {
this.lines.push(`${label}: ${value}`);
return this;
}
addSeparator(): this {
this.lines.push('─'.repeat(40));
return this;
}
addHeader(text: string): this {
this.lines.push(`\n=== ${text} ===`);
return this;
}
toString(): string {
return this.lines.join('\n');
}
print(): void {
console.log(this.toString());
}
clear(): void {
this.lines = [];
}
}
/**
* Create a status display
*/
export function createStatusDisplay(): StatusDisplay {
return new StatusDisplay();
}
/**
* History manager for persistent command history
*/
export class HistoryManager {
private history: string[] = [];
private maxSize: number;
private filePath?: string;
constructor(maxSize: number = 1000, filePath?: string) {
this.maxSize = maxSize;
this.filePath = filePath;
}
add(entry: string): void {
// Don't add duplicates of the last entry
if (this.history[this.history.length - 1] === entry) return;
this.history.push(entry);
if (this.history.length > this.maxSize) {
this.history.shift();
}
}
getAll(): string[] {
return [...this.history];
}
search(query: string): string[] {
const lower = query.toLowerCase();
return this.history.filter(h => h.toLowerCase().includes(lower));
}
async load(): Promise<void> {
if (!this.filePath) return;
try {
const fs = await import('fs/promises');
const content = await fs.readFile(this.filePath, 'utf-8');
this.history = content.trim().split('\n').filter(Boolean);
} catch {
// File doesn't exist yet
}
}
async save(): Promise<void> {
if (!this.filePath) return;
try {
const fs = await import('fs/promises');
await fs.writeFile(this.filePath, this.history.join('\n'));
} catch {
// Ignore save errors
}
}
clear(): void {
this.history = [];
}
}
/**
* Create a history manager
*/
export function createHistoryManager(maxSize?: number, filePath?: string): HistoryManager {
return new HistoryManager(maxSize, filePath);
}
@@ -0,0 +1,351 @@
/**
* N8N Integration - Webhook triggers and workflow export for n8n
*/
export interface N8NConfig {
baseUrl?: string;
apiKey?: string;
webhookPath?: string;
}
export interface N8NWebhookPayload {
event: string;
data: Record<string, any>;
timestamp: string;
source: string;
}
export interface N8NWorkflowNode {
id: string;
name: string;
type: string;
position: [number, number];
parameters: Record<string, any>;
credentials?: Record<string, any>;
}
export interface N8NWorkflowConnection {
node: string;
type: string;
index: number;
}
export interface N8NWorkflow {
name: string;
nodes: N8NWorkflowNode[];
connections: Record<string, { main: N8NWorkflowConnection[][] }>;
settings?: Record<string, any>;
}
/**
* N8N Integration class
*/
export class N8NIntegration {
private config: N8NConfig;
constructor(config: N8NConfig = {}) {
this.config = {
baseUrl: config.baseUrl || process.env.N8N_BASE_URL || 'http://localhost:5678',
apiKey: config.apiKey || process.env.N8N_API_KEY,
webhookPath: config.webhookPath || '/webhook',
...config
};
}
/**
* Trigger a webhook
*/
async triggerWebhook(
webhookId: string,
payload: Record<string, any>
): Promise<{ success: boolean; response?: any; error?: string }> {
const url = `${this.config.baseUrl}${this.config.webhookPath}/${webhookId}`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.config.apiKey ? { 'X-N8N-API-KEY': this.config.apiKey } : {})
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.text();
return { success: false, error: `HTTP ${response.status}: ${error}` };
}
const data = await response.json().catch(() => ({}));
return { success: true, response: data };
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* Create a webhook payload for agent events
*/
createAgentEventPayload(
event: string,
agentId: string,
data: Record<string, any>
): N8NWebhookPayload {
return {
event,
data: {
agentId,
...data
},
timestamp: new Date().toISOString(),
source: 'praisonai'
};
}
/**
* Export PraisonAI workflow to n8n format
*/
exportWorkflow(
name: string,
steps: Array<{
name: string;
type: 'agent' | 'tool' | 'condition';
config?: Record<string, any>;
}>
): N8NWorkflow {
const nodes: N8NWorkflowNode[] = [];
const connections: Record<string, { main: N8NWorkflowConnection[][] }> = {};
// Add trigger node
nodes.push({
id: 'trigger',
name: 'Webhook Trigger',
type: 'n8n-nodes-base.webhook',
position: [250, 300],
parameters: {
httpMethod: 'POST',
path: name.toLowerCase().replace(/\s+/g, '-'),
responseMode: 'onReceived'
}
});
let prevNodeId = 'trigger';
let yPosition = 300;
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const nodeId = `node_${i}`;
yPosition += 150;
// Map step type to n8n node type
const nodeType = this.mapStepTypeToN8N(step.type);
nodes.push({
id: nodeId,
name: step.name,
type: nodeType,
position: [250, yPosition],
parameters: this.mapStepParameters(step)
});
// Add connection from previous node
if (!connections[prevNodeId]) {
connections[prevNodeId] = { main: [[]] };
}
connections[prevNodeId].main[0].push({
node: nodeId,
type: 'main',
index: 0
});
prevNodeId = nodeId;
}
// Add response node
const responseNodeId = 'response';
nodes.push({
id: responseNodeId,
name: 'Response',
type: 'n8n-nodes-base.respondToWebhook',
position: [250, yPosition + 150],
parameters: {
respondWith: 'json',
responseBody: '={{ $json }}'
}
});
connections[prevNodeId] = {
main: [[{ node: responseNodeId, type: 'main', index: 0 }]]
};
return {
name,
nodes,
connections,
settings: {
executionOrder: 'v1'
}
};
}
/**
* Map PraisonAI step type to n8n node type
*/
private mapStepTypeToN8N(type: string): string {
const mapping: Record<string, string> = {
agent: 'n8n-nodes-base.httpRequest',
tool: 'n8n-nodes-base.function',
condition: 'n8n-nodes-base.if'
};
return mapping[type] || 'n8n-nodes-base.noOp';
}
/**
* Map step parameters to n8n format
*/
private mapStepParameters(step: { type: string; config?: Record<string, any> }): Record<string, any> {
switch (step.type) {
case 'agent':
return {
method: 'POST',
url: '={{ $env.PRAISONAI_API_URL }}/agent/execute',
sendBody: true,
bodyParameters: {
parameters: [
{ name: 'input', value: '={{ $json.input }}' }
]
},
...step.config
};
case 'tool':
return {
functionCode: `
// Tool execution
const input = $input.all();
// Add your tool logic here
return input;
`.trim(),
...step.config
};
case 'condition':
return {
conditions: {
boolean: [
{
value1: '={{ $json.success }}',
value2: true
}
]
},
...step.config
};
default:
return step.config || {};
}
}
/**
* Generate n8n workflow JSON string
*/
exportWorkflowJSON(
name: string,
steps: Array<{ name: string; type: 'agent' | 'tool' | 'condition'; config?: Record<string, any> }>
): string {
const workflow = this.exportWorkflow(name, steps);
return JSON.stringify(workflow, null, 2);
}
/**
* Import n8n workflow and convert to PraisonAI format
*/
importWorkflow(n8nWorkflow: N8NWorkflow): Array<{
name: string;
type: string;
config: Record<string, any>;
}> {
const steps: Array<{ name: string; type: string; config: Record<string, any> }> = [];
// Skip trigger and response nodes
const workflowNodes = n8nWorkflow.nodes.filter(
n => !n.type.includes('webhook') && !n.type.includes('respondToWebhook')
);
for (const node of workflowNodes) {
steps.push({
name: node.name,
type: this.mapN8NTypeToStep(node.type),
config: node.parameters
});
}
return steps;
}
/**
* Map n8n node type to PraisonAI step type
*/
private mapN8NTypeToStep(n8nType: string): string {
if (n8nType.includes('httpRequest')) return 'agent';
if (n8nType.includes('function') || n8nType.includes('code')) return 'tool';
if (n8nType.includes('if') || n8nType.includes('switch')) return 'condition';
return 'tool';
}
/**
* Create a webhook listener (for local development)
*/
async createWebhookListener(
port: number,
handler: (payload: N8NWebhookPayload) => Promise<any>
): Promise<{ close: () => void }> {
const http = await import('http');
const server = http.createServer(async (req, res) => {
if (req.method !== 'POST') {
res.writeHead(405);
res.end('Method not allowed');
return;
}
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const payload = JSON.parse(body) as N8NWebhookPayload;
const result = await handler(payload);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (error: any) {
res.writeHead(500);
res.end(JSON.stringify({ error: error.message }));
}
});
});
server.listen(port);
console.log(`N8N webhook listener started on port ${port}`);
return {
close: () => server.close()
};
}
}
/**
* Create an n8n integration instance
*/
export function createN8NIntegration(config?: N8NConfig): N8NIntegration {
return new N8NIntegration(config);
}
/**
* Quick function to trigger an n8n webhook
*/
export async function triggerN8NWebhook(
webhookId: string,
payload: Record<string, any>,
config?: N8NConfig
): Promise<{ success: boolean; response?: any; error?: string }> {
const integration = createN8NIntegration(config);
return integration.triggerWebhook(webhookId, payload);
}
@@ -0,0 +1,383 @@
/**
* Repo Map - Repository structure visualization and symbol extraction
*/
export interface RepoMapConfig {
rootPath: string;
maxDepth?: number;
ignorePatterns?: string[];
includeSymbols?: boolean;
maxFiles?: number;
}
export interface FileInfo {
path: string;
relativePath: string;
size: number;
isDirectory: boolean;
children?: FileInfo[];
symbols?: SymbolInfo[];
}
export interface SymbolInfo {
name: string;
type: 'function' | 'class' | 'method' | 'variable' | 'interface' | 'type' | 'export';
line: number;
exported?: boolean;
}
export interface RepoMapResult {
root: string;
tree: FileInfo;
totalFiles: number;
totalDirectories: number;
symbols: SymbolInfo[];
summary: string;
}
/**
* Default ignore patterns
*/
export const DEFAULT_IGNORE_PATTERNS = [
'node_modules',
'.git',
'dist',
'build',
'.next',
'.nuxt',
'coverage',
'.cache',
'__pycache__',
'.pytest_cache',
'venv',
'.venv',
'*.pyc',
'*.pyo',
'.DS_Store',
'Thumbs.db',
'*.log',
'*.lock'
];
/**
* RepoMap class for repository analysis
*/
export class RepoMap {
private config: RepoMapConfig;
private fs: any;
private path: any;
constructor(config: RepoMapConfig) {
this.config = {
maxDepth: 10,
ignorePatterns: DEFAULT_IGNORE_PATTERNS,
includeSymbols: true,
maxFiles: 1000,
...config
};
}
/**
* Generate repository map
*/
async generate(): Promise<RepoMapResult> {
this.fs = await import('fs/promises');
this.path = await import('path');
const tree = await this.scanDirectory(this.config.rootPath, 0);
const { files, directories } = this.countItems(tree);
const symbols = this.config.includeSymbols ? this.collectSymbols(tree) : [];
return {
root: this.config.rootPath,
tree,
totalFiles: files,
totalDirectories: directories,
symbols,
summary: this.generateSummary(tree, files, directories, symbols.length)
};
}
/**
* Scan a directory recursively
*/
private async scanDirectory(dirPath: string, depth: number): Promise<FileInfo> {
const relativePath = this.path.relative(this.config.rootPath, dirPath);
const stats = await this.fs.stat(dirPath);
const info: FileInfo = {
path: dirPath,
relativePath: relativePath || '.',
size: stats.size,
isDirectory: stats.isDirectory()
};
if (!stats.isDirectory() || depth >= (this.config.maxDepth || 10)) {
return info;
}
const entries = await this.fs.readdir(dirPath, { withFileTypes: true });
info.children = [];
for (const entry of entries) {
if (this.shouldIgnore(entry.name)) continue;
const entryPath = this.path.join(dirPath, entry.name);
if (entry.isDirectory()) {
const child = await this.scanDirectory(entryPath, depth + 1);
info.children.push(child);
} else {
const fileStats = await this.fs.stat(entryPath).catch(() => null);
if (!fileStats) continue;
const fileInfo: FileInfo = {
path: entryPath,
relativePath: this.path.relative(this.config.rootPath, entryPath),
size: fileStats.size,
isDirectory: false
};
if (this.config.includeSymbols && this.isCodeFile(entry.name)) {
fileInfo.symbols = await this.extractSymbols(entryPath);
}
info.children.push(fileInfo);
}
}
// Sort: directories first, then files alphabetically
info.children.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1;
}
return a.relativePath.localeCompare(b.relativePath);
});
return info;
}
/**
* Check if path should be ignored
*/
private shouldIgnore(name: string): boolean {
for (const pattern of this.config.ignorePatterns || []) {
if (pattern.startsWith('*')) {
if (name.endsWith(pattern.slice(1))) return true;
} else if (name === pattern) {
return true;
}
}
return false;
}
/**
* Check if file is a code file
*/
private isCodeFile(name: string): boolean {
const codeExtensions = ['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java', '.rb', '.php'];
return codeExtensions.some(ext => name.endsWith(ext));
}
/**
* Extract symbols from a file
*/
private async extractSymbols(filePath: string): Promise<SymbolInfo[]> {
try {
const content = await this.fs.readFile(filePath, 'utf-8');
const ext = this.path.extname(filePath);
switch (ext) {
case '.ts':
case '.tsx':
case '.js':
case '.jsx':
return this.extractJSSymbols(content);
case '.py':
return this.extractPythonSymbols(content);
default:
return [];
}
} catch {
return [];
}
}
/**
* Extract symbols from JavaScript/TypeScript
*/
private extractJSSymbols(content: string): SymbolInfo[] {
const symbols: SymbolInfo[] = [];
const lines = content.split('\n');
const patterns = [
{ regex: /^export\s+(?:async\s+)?function\s+(\w+)/m, type: 'function' as const, exported: true },
{ regex: /^export\s+class\s+(\w+)/m, type: 'class' as const, exported: true },
{ regex: /^export\s+interface\s+(\w+)/m, type: 'interface' as const, exported: true },
{ regex: /^export\s+type\s+(\w+)/m, type: 'type' as const, exported: true },
{ regex: /^export\s+const\s+(\w+)/m, type: 'variable' as const, exported: true },
{ regex: /^(?:async\s+)?function\s+(\w+)/m, type: 'function' as const },
{ regex: /^class\s+(\w+)/m, type: 'class' as const },
{ regex: /^interface\s+(\w+)/m, type: 'interface' as const },
{ regex: /^type\s+(\w+)/m, type: 'type' as const },
{ regex: /^const\s+(\w+)\s*=/m, type: 'variable' as const },
];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
for (const { regex, type, exported } of patterns) {
const match = line.match(regex);
if (match) {
symbols.push({
name: match[1],
type,
line: i + 1,
exported
});
break;
}
}
}
return symbols;
}
/**
* Extract symbols from Python
*/
private extractPythonSymbols(content: string): SymbolInfo[] {
const symbols: SymbolInfo[] = [];
const lines = content.split('\n');
const patterns = [
{ regex: /^def\s+(\w+)\s*\(/m, type: 'function' as const },
{ regex: /^async\s+def\s+(\w+)\s*\(/m, type: 'function' as const },
{ regex: /^class\s+(\w+)/m, type: 'class' as const },
];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip indented definitions (methods)
if (line.startsWith(' ') || line.startsWith('\t')) continue;
for (const { regex, type } of patterns) {
const match = line.match(regex);
if (match) {
symbols.push({
name: match[1],
type,
line: i + 1
});
break;
}
}
}
return symbols;
}
/**
* Count files and directories
*/
private countItems(tree: FileInfo): { files: number; directories: number } {
let files = 0;
let directories = 0;
const count = (node: FileInfo) => {
if (node.isDirectory) {
directories++;
for (const child of node.children || []) {
count(child);
}
} else {
files++;
}
};
count(tree);
return { files, directories };
}
/**
* Collect all symbols from tree
*/
private collectSymbols(tree: FileInfo): SymbolInfo[] {
const symbols: SymbolInfo[] = [];
const collect = (node: FileInfo) => {
if (node.symbols) {
symbols.push(...node.symbols);
}
for (const child of node.children || []) {
collect(child);
}
};
collect(tree);
return symbols;
}
/**
* Generate text summary
*/
private generateSummary(tree: FileInfo, files: number, directories: number, symbolCount: number): string {
const lines: string[] = [];
lines.push(`Repository: ${this.config.rootPath}`);
lines.push(`Files: ${files}, Directories: ${directories}, Symbols: ${symbolCount}`);
lines.push('');
lines.push(this.renderTree(tree, ''));
return lines.join('\n');
}
/**
* Render tree as text
*/
private renderTree(node: FileInfo, prefix: string): string {
const lines: string[] = [];
const name = this.path.basename(node.path);
if (node.isDirectory) {
lines.push(`${prefix}${name}/`);
const children = node.children || [];
for (let i = 0; i < children.length; i++) {
const isLast = i === children.length - 1;
const childPrefix = prefix + (isLast ? '└── ' : '├── ');
const nextPrefix = prefix + (isLast ? ' ' : '│ ');
lines.push(this.renderTree(children[i], childPrefix).replace(childPrefix, ''));
// Add children lines with proper prefix
const childLines = this.renderTree(children[i], nextPrefix).split('\n').slice(1);
lines.push(...childLines);
}
} else {
const symbolCount = node.symbols?.length || 0;
const symbolSuffix = symbolCount > 0 ? ` (${symbolCount} symbols)` : '';
lines.push(`${prefix}${name}${symbolSuffix}`);
}
return lines.join('\n');
}
/**
* Get tree as simple text format
*/
async getTreeText(): Promise<string> {
const result = await this.generate();
return result.summary;
}
}
/**
* Create a repo map instance
*/
export function createRepoMap(config: RepoMapConfig): RepoMap {
return new RepoMap(config);
}
/**
* Quick function to get repo tree
*/
export async function getRepoTree(rootPath: string, maxDepth?: number): Promise<string> {
const map = createRepoMap({ rootPath, maxDepth, includeSymbols: false });
return map.getTreeText();
}
@@ -0,0 +1,334 @@
/**
* Sandbox Executor - Safe command execution with restrictions
*/
export type SandboxMode = 'disabled' | 'basic' | 'strict' | 'network-isolated';
export interface SandboxConfig {
mode: SandboxMode;
allowedCommands?: string[];
blockedCommands?: string[];
blockedPaths?: string[];
timeout?: number;
maxOutputSize?: number;
cwd?: string;
env?: Record<string, string>;
}
export interface ExecutionResult {
success: boolean;
stdout: string;
stderr: string;
exitCode: number;
duration: number;
truncated?: boolean;
}
/**
* Default blocked commands (dangerous operations)
*/
export const DEFAULT_BLOCKED_COMMANDS = [
'rm -rf /',
'rm -rf /*',
'rm -rf ~',
'rm -rf ~/*',
'mkfs',
'dd if=/dev/zero',
'dd if=/dev/random',
':(){ :|:& };:',
'chmod -R 777 /',
'chown -R',
'sudo rm',
'sudo dd',
'sudo mkfs',
'shutdown',
'reboot',
'halt',
'poweroff',
'init 0',
'init 6'
];
/**
* Default blocked paths
*/
export const DEFAULT_BLOCKED_PATHS = [
'/etc/passwd',
'/etc/shadow',
'/etc/sudoers',
'/root',
'~/.ssh',
'~/.gnupg',
'~/.aws',
'~/.config/gcloud',
'/var/log',
'/boot',
'/sys',
'/proc'
];
/**
* Command validator
*/
export class CommandValidator {
private blockedCommands: string[];
private blockedPaths: string[];
private allowedCommands?: string[];
constructor(config: Partial<SandboxConfig> = {}) {
this.blockedCommands = config.blockedCommands || DEFAULT_BLOCKED_COMMANDS;
this.blockedPaths = config.blockedPaths || DEFAULT_BLOCKED_PATHS;
this.allowedCommands = config.allowedCommands;
}
/**
* Validate a command
*/
validate(command: string): { valid: boolean; reason?: string } {
const normalized = command.toLowerCase().trim();
// Check allowlist first if specified
if (this.allowedCommands) {
const baseCmd = normalized.split(/\s+/)[0];
if (!this.allowedCommands.includes(baseCmd)) {
return { valid: false, reason: `Command '${baseCmd}' not in allowlist` };
}
}
// Check blocked commands
for (const blocked of this.blockedCommands) {
if (normalized.includes(blocked.toLowerCase())) {
return { valid: false, reason: `Blocked command pattern: ${blocked}` };
}
}
// Check blocked paths
for (const blockedPath of this.blockedPaths) {
const expandedPath = blockedPath.replace('~', process.env.HOME || '');
if (normalized.includes(expandedPath.toLowerCase())) {
return { valid: false, reason: `Blocked path: ${blockedPath}` };
}
}
// Check for shell injection patterns
const dangerousPatterns = [
/;\s*rm\s/i,
/\|\s*rm\s/i,
/`.*`/,
/\$\(.*\)/,
/>\s*\/dev\/sd/i,
/>\s*\/dev\/null.*2>&1.*&/i
];
for (const pattern of dangerousPatterns) {
if (pattern.test(command)) {
return { valid: false, reason: 'Potentially dangerous pattern detected' };
}
}
return { valid: true };
}
}
/**
* Sandbox Executor class
*/
export class SandboxExecutor {
private config: SandboxConfig;
private validator: CommandValidator;
constructor(config: Partial<SandboxConfig> = {}) {
this.config = {
mode: 'basic',
timeout: 30000,
maxOutputSize: 1024 * 1024, // 1MB
cwd: process.cwd(),
...config
};
this.validator = new CommandValidator(this.config);
}
/**
* Execute a command in the sandbox
*/
async execute(command: string): Promise<ExecutionResult> {
const startTime = Date.now();
// Disabled mode - no execution
if (this.config.mode === 'disabled') {
return {
success: false,
stdout: '',
stderr: 'Sandbox execution is disabled',
exitCode: 1,
duration: 0
};
}
// Validate command
const validation = this.validator.validate(command);
if (!validation.valid) {
return {
success: false,
stdout: '',
stderr: `Command rejected: ${validation.reason}`,
exitCode: 1,
duration: 0
};
}
try {
const result = await this.spawn(command);
return {
...result,
duration: Date.now() - startTime
};
} catch (error: any) {
return {
success: false,
stdout: '',
stderr: error.message,
exitCode: 1,
duration: Date.now() - startTime
};
}
}
/**
* Spawn the command
*/
private async spawn(command: string): Promise<Omit<ExecutionResult, 'duration'>> {
const { spawn } = await import('child_process');
return new Promise((resolve) => {
const env = this.buildEnv();
const proc = spawn('sh', ['-c', command], {
cwd: this.config.cwd,
env,
timeout: this.config.timeout,
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
let truncated = false;
const maxSize = this.config.maxOutputSize || 1024 * 1024;
proc.stdout?.on('data', (data) => {
if (stdout.length < maxSize) {
stdout += data.toString();
if (stdout.length > maxSize) {
stdout = stdout.slice(0, maxSize);
truncated = true;
}
}
});
proc.stderr?.on('data', (data) => {
if (stderr.length < maxSize) {
stderr += data.toString();
if (stderr.length > maxSize) {
stderr = stderr.slice(0, maxSize);
truncated = true;
}
}
});
proc.on('close', (code) => {
resolve({
success: code === 0,
stdout,
stderr,
exitCode: code || 0,
truncated
});
});
proc.on('error', (error) => {
resolve({
success: false,
stdout,
stderr: error.message,
exitCode: 1
});
});
});
}
/**
* Build environment variables based on sandbox mode
*/
private buildEnv(): Record<string, string> {
const baseEnv = { ...process.env, ...this.config.env };
switch (this.config.mode) {
case 'strict':
// Minimal environment
return {
PATH: '/usr/local/bin:/usr/bin:/bin',
HOME: process.env.HOME || '',
USER: process.env.USER || '',
SHELL: '/bin/sh',
TERM: 'dumb',
...this.config.env
};
case 'network-isolated':
// No network access (requires additional OS-level setup)
return {
...baseEnv,
http_proxy: 'http://localhost:0',
https_proxy: 'http://localhost:0',
HTTP_PROXY: 'http://localhost:0',
HTTPS_PROXY: 'http://localhost:0',
no_proxy: '',
NO_PROXY: ''
};
case 'basic':
default:
return baseEnv as Record<string, string>;
}
}
/**
* Check if a command would be allowed
*/
wouldAllow(command: string): { allowed: boolean; reason?: string } {
if (this.config.mode === 'disabled') {
return { allowed: false, reason: 'Sandbox execution is disabled' };
}
const validation = this.validator.validate(command);
return { allowed: validation.valid, reason: validation.reason };
}
/**
* Get current sandbox mode
*/
getMode(): SandboxMode {
return this.config.mode;
}
/**
* Update sandbox mode
*/
setMode(mode: SandboxMode): void {
this.config.mode = mode;
}
}
/**
* Create a sandbox executor
*/
export function createSandboxExecutor(config?: Partial<SandboxConfig>): SandboxExecutor {
return new SandboxExecutor(config);
}
/**
* Quick execute with default sandbox
*/
export async function sandboxExec(command: string, config?: Partial<SandboxConfig>): Promise<ExecutionResult> {
const executor = createSandboxExecutor(config);
return executor.execute(command);
}
@@ -0,0 +1,360 @@
/**
* Agent Scheduler - Cron-like scheduling for agent tasks
*/
export interface ScheduleConfig {
id?: string;
name: string;
cron?: string;
interval?: number; // milliseconds
task: () => Promise<any>;
enabled?: boolean;
maxRuns?: number;
onComplete?: (result: any) => void;
onError?: (error: Error) => void;
}
export interface ScheduledTask {
id: string;
name: string;
cron?: string;
interval?: number;
enabled: boolean;
lastRun?: Date;
nextRun?: Date;
runCount: number;
maxRuns?: number;
status: 'idle' | 'running' | 'completed' | 'error';
lastError?: string;
}
export interface SchedulerStats {
totalTasks: number;
activeTasks: number;
totalRuns: number;
errors: number;
}
/**
* Parse cron expression (simplified: minute hour day month weekday)
*/
function parseCron(cron: string): { minute: number[]; hour: number[]; day: number[]; month: number[]; weekday: number[] } {
const parts = cron.split(/\s+/);
if (parts.length !== 5) {
throw new Error('Invalid cron expression. Expected: minute hour day month weekday');
}
const parseField = (field: string, min: number, max: number): number[] => {
if (field === '*') {
return Array.from({ length: max - min + 1 }, (_, i) => min + i);
}
if (field.includes('/')) {
const [, step] = field.split('/');
const stepNum = parseInt(step);
return Array.from({ length: Math.ceil((max - min + 1) / stepNum) }, (_, i) => min + i * stepNum);
}
if (field.includes(',')) {
return field.split(',').map(n => parseInt(n));
}
if (field.includes('-')) {
const [start, end] = field.split('-').map(n => parseInt(n));
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
return [parseInt(field)];
};
return {
minute: parseField(parts[0], 0, 59),
hour: parseField(parts[1], 0, 23),
day: parseField(parts[2], 1, 31),
month: parseField(parts[3], 1, 12),
weekday: parseField(parts[4], 0, 6)
};
}
/**
* Get next run time from cron expression
*/
function getNextCronRun(cron: string, from: Date = new Date()): Date {
const parsed = parseCron(cron);
const next = new Date(from);
next.setSeconds(0);
next.setMilliseconds(0);
next.setMinutes(next.getMinutes() + 1);
for (let i = 0; i < 366 * 24 * 60; i++) { // Max 1 year search
const minute = next.getMinutes();
const hour = next.getHours();
const day = next.getDate();
const month = next.getMonth() + 1;
const weekday = next.getDay();
if (
parsed.minute.includes(minute) &&
parsed.hour.includes(hour) &&
parsed.day.includes(day) &&
parsed.month.includes(month) &&
parsed.weekday.includes(weekday)
) {
return next;
}
next.setMinutes(next.getMinutes() + 1);
}
throw new Error('Could not find next run time');
}
/**
* Scheduler class
*/
export class Scheduler {
private tasks: Map<string, { config: ScheduleConfig; task: ScheduledTask; timer?: NodeJS.Timeout }> = new Map();
private running: boolean = false;
private stats: SchedulerStats = {
totalTasks: 0,
activeTasks: 0,
totalRuns: 0,
errors: 0
};
/**
* Add a scheduled task
*/
add(config: ScheduleConfig): string {
const id = config.id || `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const task: ScheduledTask = {
id,
name: config.name,
cron: config.cron,
interval: config.interval,
enabled: config.enabled ?? true,
runCount: 0,
maxRuns: config.maxRuns,
status: 'idle'
};
if (config.cron) {
task.nextRun = getNextCronRun(config.cron);
} else if (config.interval) {
task.nextRun = new Date(Date.now() + config.interval);
}
this.tasks.set(id, { config, task });
this.stats.totalTasks++;
if (this.running && task.enabled) {
this.scheduleTask(id);
}
return id;
}
/**
* Remove a task
*/
remove(id: string): boolean {
const entry = this.tasks.get(id);
if (!entry) return false;
if (entry.timer) {
clearTimeout(entry.timer);
}
this.tasks.delete(id);
this.stats.totalTasks--;
return true;
}
/**
* Enable a task
*/
enable(id: string): boolean {
const entry = this.tasks.get(id);
if (!entry) return false;
entry.task.enabled = true;
if (this.running) {
this.scheduleTask(id);
}
return true;
}
/**
* Disable a task
*/
disable(id: string): boolean {
const entry = this.tasks.get(id);
if (!entry) return false;
entry.task.enabled = false;
if (entry.timer) {
clearTimeout(entry.timer);
entry.timer = undefined;
}
return true;
}
/**
* Start the scheduler
*/
start(): void {
if (this.running) return;
this.running = true;
for (const [id, entry] of this.tasks) {
if (entry.task.enabled) {
this.scheduleTask(id);
}
}
}
/**
* Stop the scheduler
*/
stop(): void {
this.running = false;
for (const entry of this.tasks.values()) {
if (entry.timer) {
clearTimeout(entry.timer);
entry.timer = undefined;
}
}
}
/**
* Schedule a task's next run
*/
private scheduleTask(id: string): void {
const entry = this.tasks.get(id);
if (!entry || !entry.task.enabled || !this.running) return;
// Check if max runs reached
if (entry.task.maxRuns && entry.task.runCount >= entry.task.maxRuns) {
entry.task.status = 'completed';
return;
}
let delay: number;
if (entry.config.cron) {
const nextRun = getNextCronRun(entry.config.cron);
entry.task.nextRun = nextRun;
delay = nextRun.getTime() - Date.now();
} else if (entry.config.interval) {
entry.task.nextRun = new Date(Date.now() + entry.config.interval);
delay = entry.config.interval;
} else {
return;
}
entry.timer = setTimeout(() => this.runTask(id), delay);
this.stats.activeTasks = Array.from(this.tasks.values()).filter(e => e.timer).length;
}
/**
* Run a task
*/
private async runTask(id: string): Promise<void> {
const entry = this.tasks.get(id);
if (!entry) return;
entry.task.status = 'running';
entry.task.lastRun = new Date();
try {
const result = await entry.config.task();
entry.task.runCount++;
entry.task.status = 'idle';
entry.task.lastError = undefined;
this.stats.totalRuns++;
entry.config.onComplete?.(result);
} catch (error: any) {
entry.task.status = 'error';
entry.task.lastError = error.message;
this.stats.errors++;
entry.config.onError?.(error);
}
// Schedule next run
this.scheduleTask(id);
}
/**
* Run a task immediately
*/
async runNow(id: string): Promise<any> {
const entry = this.tasks.get(id);
if (!entry) throw new Error(`Task ${id} not found`);
entry.task.status = 'running';
entry.task.lastRun = new Date();
try {
const result = await entry.config.task();
entry.task.runCount++;
entry.task.status = 'idle';
this.stats.totalRuns++;
return result;
} catch (error: any) {
entry.task.status = 'error';
entry.task.lastError = error.message;
this.stats.errors++;
throw error;
}
}
/**
* Get task info
*/
getTask(id: string): ScheduledTask | undefined {
return this.tasks.get(id)?.task;
}
/**
* Get all tasks
*/
getAllTasks(): ScheduledTask[] {
return Array.from(this.tasks.values()).map(e => e.task);
}
/**
* Get scheduler stats
*/
getStats(): SchedulerStats {
return { ...this.stats };
}
/**
* Check if scheduler is running
*/
isRunning(): boolean {
return this.running;
}
}
/**
* Create a scheduler instance
*/
export function createScheduler(): Scheduler {
return new Scheduler();
}
/**
* Helper to create common cron expressions
*/
export const cronExpressions = {
everyMinute: '* * * * *',
every5Minutes: '*/5 * * * *',
every15Minutes: '*/15 * * * *',
everyHour: '0 * * * *',
everyDay: '0 0 * * *',
everyWeek: '0 0 * * 0',
everyMonth: '0 0 1 * *',
weekdays: '0 9 * * 1-5',
weekends: '0 10 * * 0,6'
};
@@ -0,0 +1,372 @@
/**
* Slash Commands - Interactive command system for CLI
* Supports /help, /cost, /clear, /model, /tokens, /plan, /undo, /diff, /commit, /exit, /settings, /map
*/
export interface SlashCommand {
name: string;
aliases?: string[];
description: string;
usage?: string;
execute: (args: string[], context: SlashCommandContext) => Promise<SlashCommandResult>;
}
export interface SlashCommandContext {
sessionId?: string;
model?: string;
tokenCount?: number;
costTracker?: CostTracker;
history?: string[];
settings?: Record<string, any>;
onOutput?: (message: string) => void;
}
export interface SlashCommandResult {
success: boolean;
message?: string;
data?: any;
shouldExit?: boolean;
}
export interface CostTracker {
totalTokens: number;
totalCost: number;
requests: number;
addUsage(model: string, inputTokens: number, outputTokens: number, latencyMs?: number): any;
reset(): void;
getSummary(): string;
}
/**
* Built-in slash commands
*/
const BUILTIN_COMMANDS: SlashCommand[] = [
{
name: 'help',
aliases: ['h', '?'],
description: 'Show available commands',
execute: async (_args, context) => {
const commands = getRegistry().getAll();
const lines = ['Available commands:', ''];
for (const cmd of commands) {
const aliases = cmd.aliases ? ` (${cmd.aliases.map(a => '/' + a).join(', ')})` : '';
lines.push(` /${cmd.name}${aliases} - ${cmd.description}`);
if (cmd.usage) {
lines.push(` Usage: ${cmd.usage}`);
}
}
context.onOutput?.(lines.join('\n'));
return { success: true, message: lines.join('\n') };
}
},
{
name: 'cost',
aliases: ['c'],
description: 'Show token usage and cost',
execute: async (_args, context) => {
if (!context.costTracker) {
return { success: false, message: 'Cost tracking not enabled' };
}
const summary = context.costTracker.getSummary();
context.onOutput?.(summary);
return { success: true, message: summary };
}
},
{
name: 'clear',
description: 'Clear conversation history',
execute: async (_args, context) => {
if (context.history) {
context.history.length = 0;
}
context.onOutput?.('Conversation cleared');
return { success: true, message: 'Conversation cleared' };
}
},
{
name: 'model',
aliases: ['m'],
description: 'Show or change the current model',
usage: '/model [model-name]',
execute: async (args, context) => {
if (args.length === 0) {
const msg = `Current model: ${context.model || 'default'}`;
context.onOutput?.(msg);
return { success: true, message: msg, data: { model: context.model } };
}
const newModel = args[0];
context.model = newModel;
const msg = `Model changed to: ${newModel}`;
context.onOutput?.(msg);
return { success: true, message: msg, data: { model: newModel } };
}
},
{
name: 'tokens',
aliases: ['t'],
description: 'Show token count for current session',
execute: async (_args, context) => {
const count = context.tokenCount || 0;
const msg = `Token count: ${count}`;
context.onOutput?.(msg);
return { success: true, message: msg, data: { tokens: count } };
}
},
{
name: 'plan',
aliases: ['p'],
description: 'Show or manage task plan',
usage: '/plan [add|remove|clear] [task]',
execute: async (args, context) => {
const plans = context.settings?.plans || [];
if (args.length === 0) {
if (plans.length === 0) {
context.onOutput?.('No plans set');
return { success: true, message: 'No plans set' };
}
const msg = ['Current plan:', ...plans.map((p: string, i: number) => ` ${i + 1}. ${p}`)].join('\n');
context.onOutput?.(msg);
return { success: true, message: msg, data: { plans } };
}
const action = args[0];
const task = args.slice(1).join(' ');
if (action === 'add' && task) {
plans.push(task);
context.settings = { ...context.settings, plans };
context.onOutput?.(`Added: ${task}`);
return { success: true, message: `Added: ${task}` };
}
if (action === 'remove' && task) {
const index = parseInt(task) - 1;
if (index >= 0 && index < plans.length) {
const removed = plans.splice(index, 1)[0];
context.settings = { ...context.settings, plans };
context.onOutput?.(`Removed: ${removed}`);
return { success: true, message: `Removed: ${removed}` };
}
}
if (action === 'clear') {
context.settings = { ...context.settings, plans: [] };
context.onOutput?.('Plans cleared');
return { success: true, message: 'Plans cleared' };
}
return { success: false, message: 'Invalid plan command' };
}
},
{
name: 'undo',
aliases: ['u'],
description: 'Undo last action',
execute: async (_args, context) => {
if (context.history && context.history.length > 0) {
const removed = context.history.pop();
context.onOutput?.(`Undone: ${removed?.substring(0, 50)}...`);
return { success: true, message: 'Last action undone' };
}
return { success: false, message: 'Nothing to undo' };
}
},
{
name: 'diff',
aliases: ['d'],
description: 'Show pending changes',
execute: async (_args, context) => {
const diff = context.settings?.pendingDiff || 'No pending changes';
context.onOutput?.(diff);
return { success: true, message: diff };
}
},
{
name: 'commit',
description: 'Commit pending changes',
execute: async (args, context) => {
const message = args.join(' ') || 'Auto-commit';
context.onOutput?.(`Committed with message: ${message}`);
context.settings = { ...context.settings, pendingDiff: null };
return { success: true, message: `Committed: ${message}` };
}
},
{
name: 'exit',
aliases: ['quit', 'q'],
description: 'Exit the session',
execute: async (_args, context) => {
context.onOutput?.('Goodbye!');
return { success: true, message: 'Exiting', shouldExit: true };
}
},
{
name: 'settings',
aliases: ['s'],
description: 'Show or modify settings',
usage: '/settings [key] [value]',
execute: async (args, context) => {
if (args.length === 0) {
const settings = JSON.stringify(context.settings || {}, null, 2);
context.onOutput?.(settings);
return { success: true, message: settings, data: context.settings };
}
if (args.length === 1) {
const value = context.settings?.[args[0]];
const msg = `${args[0]}: ${JSON.stringify(value)}`;
context.onOutput?.(msg);
return { success: true, message: msg, data: { [args[0]]: value } };
}
const [key, ...valueParts] = args;
let value: any = valueParts.join(' ');
try {
value = JSON.parse(value);
} catch {
// Keep as string
}
context.settings = { ...context.settings, [key]: value };
context.onOutput?.(`Set ${key} = ${JSON.stringify(value)}`);
return { success: true, message: `Set ${key}` };
}
},
{
name: 'map',
description: 'Show repository map',
execute: async (_args, context) => {
const map = context.settings?.repoMap || 'Repository map not available. Run /map refresh to generate.';
context.onOutput?.(map);
return { success: true, message: map };
}
}
];
/**
* Slash Command Registry
*/
class SlashCommandRegistry {
private commands: Map<string, SlashCommand> = new Map();
private aliases: Map<string, string> = new Map();
constructor() {
// Register built-in commands
for (const cmd of BUILTIN_COMMANDS) {
this.register(cmd);
}
}
register(command: SlashCommand): void {
this.commands.set(command.name, command);
if (command.aliases) {
for (const alias of command.aliases) {
this.aliases.set(alias, command.name);
}
}
}
get(name: string): SlashCommand | undefined {
const resolved = this.aliases.get(name) || name;
return this.commands.get(resolved);
}
getAll(): SlashCommand[] {
return Array.from(this.commands.values());
}
has(name: string): boolean {
return this.commands.has(name) || this.aliases.has(name);
}
}
let registry: SlashCommandRegistry | null = null;
export function getRegistry(): SlashCommandRegistry {
if (!registry) {
registry = new SlashCommandRegistry();
}
return registry;
}
export function registerCommand(command: SlashCommand): void {
getRegistry().register(command);
}
/**
* Parse slash command from input
*/
export function parseSlashCommand(input: string): { command: string; args: string[] } | null {
const trimmed = input.trim();
if (!trimmed.startsWith('/')) {
return null;
}
const parts = trimmed.slice(1).split(/\s+/);
const command = parts[0].toLowerCase();
const args = parts.slice(1);
return { command, args };
}
/**
* Execute a slash command
*/
export async function executeSlashCommand(
input: string,
context: SlashCommandContext
): Promise<SlashCommandResult> {
const parsed = parseSlashCommand(input);
if (!parsed) {
return { success: false, message: 'Not a slash command' };
}
const command = getRegistry().get(parsed.command);
if (!command) {
return { success: false, message: `Unknown command: /${parsed.command}` };
}
return command.execute(parsed.args, context);
}
/**
* Check if input is a slash command
*/
export function isSlashCommand(input: string): boolean {
return input.trim().startsWith('/');
}
/**
* Slash Command Handler for CLI integration
*/
export class SlashCommandHandler {
private context: SlashCommandContext;
constructor(initialContext: Partial<SlashCommandContext> = {}) {
this.context = {
history: [],
settings: {},
...initialContext
};
}
async handle(input: string): Promise<SlashCommandResult> {
return executeSlashCommand(input, this.context);
}
isCommand(input: string): boolean {
return isSlashCommand(input);
}
getContext(): SlashCommandContext {
return this.context;
}
updateContext(updates: Partial<SlashCommandContext>): void {
this.context = { ...this.context, ...updates };
}
registerCommand(command: SlashCommand): void {
registerCommand(command);
}
}
export function createSlashCommandHandler(context?: Partial<SlashCommandContext>): SlashCommandHandler {
return new SlashCommandHandler(context);
}
+147 -136
View File
@@ -1,163 +1,174 @@
#!/usr/bin/env node
/**
* PraisonAI TypeScript CLI
* Implements CLI Spec v1.0.0
*
* Binary: praisonai-ts
* Package: praisonai (npm)
*/
import { createProvider, isProviderAvailable, getAvailableProviders } from '../llm/providers';
import { Session } from '../session';
import { tool, ToolRegistry } from '../tools/decorator';
import { validateCommand, COMMANDS, GLOBAL_FLAGS, EXIT_CODES, CLI_SPEC_VERSION } from './spec/cli-spec';
import { exitInvalidArgs, exitError } from './runtime/exit';
import { outputJson, formatError } from './output/json';
import { ERROR_CODES, normalizeError } from './output/errors';
interface CLIOptions {
model?: string;
stream?: boolean;
verbose?: boolean;
sessionId?: string;
export interface ParsedArgs {
command: string;
subcommand?: string;
args: string[];
options: Record<string, unknown>;
}
async function chat(prompt: string, options: CLIOptions = {}): Promise<string> {
const model = options.model || process.env.PRAISONAI_MODEL || 'openai/gpt-4o-mini';
const provider = createProvider(model);
const session = new Session({ id: options.sessionId });
session.addMessage({ role: 'user', content: prompt });
/**
* Parse command line arguments according to CLI spec
*/
function parseArgs(argv: string[]): ParsedArgs {
const result: ParsedArgs = {
command: 'help',
args: [],
options: {}
};
if (options.stream) {
let fullText = '';
const stream = await provider.streamText({
messages: [{ role: 'user', content: prompt }],
onToken: (token) => {
process.stdout.write(token);
fullText += token;
}
});
for await (const chunk of stream) {
// Stream is consumed by onToken
}
console.log(); // New line after streaming
return fullText;
} else {
const result = await provider.generateText({
messages: [{ role: 'user', content: prompt }]
});
console.log(result.text);
return result.text;
}
}
async function listProviders(): Promise<void> {
console.log('Available Providers:');
const available = getAvailableProviders();
const providers = ['openai', 'anthropic', 'google'];
for (const p of providers) {
const status = available.includes(p) ? '✅' : '❌';
console.log(` ${status} ${p}`);
}
}
async function version(): Promise<void> {
const pkg = require('../../package.json');
console.log(`praisonai v${pkg.version}`);
}
async function help(): Promise<void> {
console.log(`
PraisonAI TypeScript CLI
Usage:
praisonai-ts chat <prompt> Chat with an AI agent
praisonai-ts providers List available providers
praisonai-ts version Show version
praisonai-ts help Show this help
Options:
--model, -m <model> Model to use (e.g., openai/gpt-4o-mini)
--stream, -s Enable streaming output
--verbose, -v Verbose output
--session <id> Session ID for conversation continuity
Environment Variables:
OPENAI_API_KEY OpenAI API key
ANTHROPIC_API_KEY Anthropic API key
GOOGLE_API_KEY Google API key
PRAISONAI_MODEL Default model
Examples:
praisonai-ts chat "Hello, how are you?"
praisonai-ts chat "Write a poem" --stream
praisonai-ts chat "Explain AI" -m anthropic/claude-sonnet-4-20250514
praisonai-ts providers
`);
}
function parseArgs(args: string[]): { command: string; prompt: string; options: CLIOptions } {
const options: CLIOptions = {};
let command = 'help';
let prompt = '';
let i = 0;
while (i < args.length) {
const arg = args[i];
if (arg === '--model' || arg === '-m') {
options.model = args[++i];
} else if (arg === '--stream' || arg === '-s') {
options.stream = true;
} else if (arg === '--verbose' || arg === '-v') {
options.verbose = true;
} else if (arg === '--session') {
options.sessionId = args[++i];
} else if (!arg.startsWith('-')) {
if (!command || command === 'help') {
command = arg;
let foundCommand = false;
while (i < argv.length) {
const arg = argv[i];
if (arg.startsWith('--')) {
// Long flag
const flagName = arg.slice(2);
const nextArg = argv[i + 1];
// Check if it's a boolean flag or has a value
if (!nextArg || nextArg.startsWith('-')) {
result.options[flagName] = true;
} else {
prompt = arg;
result.options[flagName] = nextArg;
i++;
}
} else if (arg.startsWith('-') && arg.length === 2) {
// Short flag
const shortFlag = arg.slice(1);
const nextArg = argv[i + 1];
// Find the corresponding long flag name from global flags and command-specific flags
const globalFlag = GLOBAL_FLAGS.find(f => f.short === shortFlag);
// Also check command-specific flags (e.g., -m for model in chat command)
const commandShortFlags: Record<string, string> = {
'm': 'model',
's': 'stream',
'a': 'agent',
't': 'tools'
};
const flagName = globalFlag?.name || commandShortFlags[shortFlag] || shortFlag;
if (!nextArg || nextArg.startsWith('-')) {
result.options[flagName] = true;
} else {
result.options[flagName] = nextArg;
i++;
}
} else if (!foundCommand) {
// First non-flag argument is the command
result.command = arg;
foundCommand = true;
} else if (!result.subcommand && COMMANDS[result.command]?.subcommands) {
// Check if this is a subcommand
const cmdSpec = COMMANDS[result.command];
if (cmdSpec.subcommands && arg in cmdSpec.subcommands) {
result.subcommand = arg;
} else {
result.args.push(arg);
}
} else {
// Positional argument
result.args.push(arg);
}
i++;
}
return { command, prompt, options };
// Handle --json shorthand
if (result.options.json) {
result.options.output = 'json';
}
return result;
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const { command, prompt, options } = parseArgs(args);
/**
* Load and execute a command (lazy loading)
*/
async function executeCommand(parsed: ParsedArgs): Promise<void> {
const { command, subcommand, args, options } = parsed;
// Validate command exists
if (!validateCommand(command)) {
const isJson = options.output === 'json' || options.json;
if (isJson) {
outputJson(formatError(ERROR_CODES.INVALID_ARGS, `Unknown command: ${command}`));
} else {
console.error(`Error: Unknown command '${command}'`);
console.error('Run "praisonai-ts help" for available commands');
}
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
}
// Lazy load the command module
try {
switch (command) {
case 'chat':
if (!prompt) {
console.error('Error: Please provide a prompt');
process.exit(1);
}
await chat(prompt, options);
break;
case 'providers':
await listProviders();
break;
case 'version':
await version();
break;
case 'help':
default:
await help();
break;
const commandModule = await import(`./commands/${command}`);
// Pass subcommand as first arg if present
const commandArgs = subcommand ? [subcommand, ...args] : args;
await commandModule.execute(commandArgs, options);
} catch (error: unknown) {
const cliError = normalizeError(error);
const isJson = options.output === 'json' || options.json;
if (isJson) {
outputJson(formatError(cliError.code, cliError.message, cliError.details));
} else {
console.error(`Error: ${cliError.message}`);
if (options.verbose && error instanceof Error && error.stack) {
console.error(error.stack);
}
}
} catch (error: any) {
console.error('Error:', error.message);
if (options.verbose) {
console.error(error.stack);
}
process.exit(1);
process.exit(cliError.exitCode);
}
}
/**
* Main CLI entry point
*/
async function main(): Promise<void> {
const argv = process.argv.slice(2);
// Fast path for version
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
const helpModule = await import('./commands/help');
await helpModule.execute([], {});
return;
}
if (argv[0] === '--version') {
const versionModule = await import('./commands/version');
await versionModule.execute([], {});
return;
}
const parsed = parseArgs(argv);
await executeCommand(parsed);
}
// Export for programmatic use
export { chat, listProviders, version, help, parseArgs };
export { parseArgs, executeCommand, CLI_SPEC_VERSION };
// Run CLI if executed directly
if (require.main === module) {
main();
main().catch((error) => {
console.error('Fatal error:', error.message);
process.exit(EXIT_CODES.RUNTIME_ERROR);
});
}
+345
View File
@@ -111,6 +111,351 @@ export const COMMANDS: Record<string, Command> = {
}
}
},
memory: {
description: 'Manage agent memory',
subcommands: {
list: { description: 'List all memories' },
add: { description: 'Add a new memory', args: [{ name: 'content', type: 'string', required: true, position: 0 }] },
search: { description: 'Search memories', args: [{ name: 'query', type: 'string', required: true, position: 0 }] },
clear: { description: 'Clear all memories' }
}
},
session: {
description: 'Manage agent sessions',
subcommands: {
list: { description: 'List all sessions' },
create: { description: 'Create a new session', args: [{ name: 'id', type: 'string', required: false, position: 0 }] },
get: { description: 'Get session details', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
delete: { description: 'Delete a session', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
export: { description: 'Export session data', args: [{ name: 'id', type: 'string', required: true, position: 0 }] }
}
},
knowledge: {
description: 'Manage knowledge base',
subcommands: {
add: { description: 'Add knowledge from file or text', args: [{ name: 'source', type: 'string', required: true, position: 0 }] },
search: { description: 'Search knowledge base', args: [{ name: 'query', type: 'string', required: true, position: 0 }] },
list: { description: 'List all knowledge entries' }
}
},
skills: {
description: 'Manage agent skills',
subcommands: {
list: { description: 'List loaded skills' },
discover: { description: 'Discover skills in directory', args: [{ name: 'path', type: 'string', required: false, position: 0 }] },
validate: { description: 'Validate a skill', args: [{ name: 'path', type: 'string', required: true, position: 0 }] },
info: { description: 'Show skill information', args: [{ name: 'name', type: 'string', required: true, position: 0 }] }
}
},
mcp: {
description: 'Model Context Protocol management',
subcommands: {
list: { description: 'List registered MCP servers' },
add: { description: 'Add an MCP server', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
remove: { description: 'Remove an MCP server', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
start: { description: 'Start an MCP server', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
stop: { description: 'Stop an MCP server', args: [{ name: 'name', type: 'string', required: true, position: 0 }] }
}
},
auto: {
description: 'Auto-generate agents from topic',
args: [{ name: 'topic', type: 'string', required: true, position: 0 }],
flags: [
{ name: 'pattern', type: 'string', description: 'Agent pattern (sequential, parallel, routing)' },
{ name: 'agents', type: 'integer', description: 'Number of agents to generate' }
]
},
image: {
description: 'Image generation and analysis',
subcommands: {
generate: { description: 'Generate image from text', args: [{ name: 'prompt', type: 'string', required: true, position: 0 }] },
analyze: { description: 'Analyze an image', args: [{ name: 'url', type: 'string', required: true, position: 0 }] }
},
flags: [
{ name: 'size', type: 'string', description: 'Image size' },
{ name: 'quality', type: 'string', description: 'Image quality' },
{ name: 'style', type: 'string', description: 'Image style' }
]
},
research: {
description: 'Deep research on a topic',
args: [{ name: 'query', type: 'string', required: true, position: 0 }],
flags: [
{ name: 'depth', type: 'integer', description: 'Research depth (iterations)' },
{ name: 'max-sources', type: 'integer', description: 'Maximum sources to use' }
]
},
guardrail: {
description: 'Content validation and safety',
subcommands: {
check: { description: 'Check content against guardrails', args: [{ name: 'content', type: 'string', required: true, position: 0 }] }
},
flags: [
{ name: 'criteria', type: 'string', description: 'Custom validation criteria' }
]
},
telemetry: {
description: 'Usage monitoring and analytics',
subcommands: {
status: { description: 'Show telemetry status' },
enable: { description: 'Enable telemetry' },
disable: { description: 'Disable telemetry' },
clear: { description: 'Clear telemetry data' },
export: { description: 'Export telemetry data' }
}
},
planning: {
description: 'Task planning and todo management',
subcommands: {
create: { description: 'Create a new plan', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
todo: { description: 'Manage todo items' }
}
},
'query-rewrite': {
description: 'Rewrite queries for better search results',
args: [{ name: 'query', type: 'string', required: true, position: 0 }],
flags: [
{ name: 'strategy', type: 'string', description: 'Rewrite strategy (expand, simplify, decompose, rephrase, auto)' }
]
},
'prompt-expand': {
description: 'Expand prompts with more detail',
args: [{ name: 'prompt', type: 'string', required: true, position: 0 }],
flags: [
{ name: 'strategy', type: 'string', description: 'Expand strategy (detail, context, examples, constraints, auto)' }
]
},
router: {
description: 'Route requests to appropriate agents',
subcommands: {
analyze: { description: 'Analyze input and suggest routing', args: [{ name: 'input', type: 'string', required: true, position: 0 }] }
}
},
context: {
description: 'Manage conversation context',
subcommands: {
chat: { description: 'Chat with context management', args: [{ name: 'message', type: 'string', required: true, position: 0 }] },
summarize: { description: 'Summarize context', args: [{ name: 'text', type: 'string', required: true, position: 0 }] }
},
flags: [
{ name: 'max-messages', type: 'integer', description: 'Maximum messages in context' }
]
},
handoff: {
description: 'Agent handoff management',
subcommands: {
info: { description: 'Show handoff feature information' }
}
},
vector: {
description: 'Vector store management',
subcommands: {
info: { description: 'Show vector store information' },
providers: { description: 'List available vector store providers' }
}
},
observability: {
description: 'Monitoring and tracing',
subcommands: {
info: { description: 'Show observability information' },
providers: { description: 'List available observability providers' }
}
},
voice: {
description: 'Text-to-speech and speech-to-text',
subcommands: {
info: { description: 'Show voice feature information' },
providers: { description: 'List available voice providers' }
}
},
reranker: {
description: 'Document reranking',
subcommands: {
info: { description: 'Show reranker information' },
providers: { description: 'List available reranker providers' }
}
},
'graph-rag': {
description: 'Graph-based retrieval augmented generation',
subcommands: {
info: { description: 'Show Graph RAG information' }
}
},
cache: {
description: 'Caching management',
subcommands: {
info: { description: 'Show cache information' },
providers: { description: 'List available cache providers' }
}
},
db: {
description: 'Database adapter management',
subcommands: {
info: { description: 'Show database adapter information' },
adapters: { description: 'List available database adapters' }
}
},
scheduler: {
description: 'Agent task scheduling',
subcommands: {
create: { description: 'Create a scheduled task', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
list: { description: 'List all scheduled tasks' },
run: { description: 'Run a task immediately', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
remove: { description: 'Remove a scheduled task', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
enable: { description: 'Enable a task', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
disable: { description: 'Disable a task', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
patterns: { description: 'Show common cron patterns' }
},
flags: [
{ name: 'cron', type: 'string', description: 'Cron expression' },
{ name: 'interval', type: 'integer', description: 'Interval in seconds' }
]
},
jobs: {
description: 'Background job queue management',
subcommands: {
add: { description: 'Add a new job', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
list: { description: 'List all jobs' },
get: { description: 'Get job details', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
cancel: { description: 'Cancel a pending job', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
retry: { description: 'Retry a failed job', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
process: { description: 'Process a job immediately', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
stats: { description: 'Show queue statistics' },
cleanup: { description: 'Clean up old jobs' }
},
flags: [
{ name: 'storage', type: 'string', description: 'Path to job storage file' },
{ name: 'priority', type: 'string', description: 'Job priority (low, normal, high, critical)' },
{ name: 'status', type: 'string', description: 'Filter by status' }
]
},
checkpoints: {
description: 'Session state checkpointing',
subcommands: {
create: { description: 'Create a checkpoint', args: [{ name: 'name', type: 'string', required: false, position: 0 }] },
list: { description: 'List all checkpoints' },
get: { description: 'Get checkpoint details', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
restore: { description: 'Restore from checkpoint', args: [{ name: 'id', type: 'string', required: false, position: 0 }] },
delete: { description: 'Delete a checkpoint', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
export: { description: 'Export checkpoint as JSON', args: [{ name: 'id', type: 'string', required: true, position: 0 }] },
import: { description: 'Import checkpoint from JSON', args: [{ name: 'json', type: 'string', required: true, position: 0 }] }
},
flags: [
{ name: 'storage', type: 'string', description: 'Path to checkpoint storage' },
{ name: 'state', type: 'string', description: 'JSON state to save' }
]
},
'fast-context': {
description: 'Fast context retrieval',
subcommands: {
query: { description: 'Query for relevant context', args: [{ name: 'query', type: 'string', required: true, position: 0 }] },
stats: { description: 'Show cache statistics' },
clear: { description: 'Clear cache and sources' }
},
flags: [
{ name: 'max-tokens', type: 'integer', description: 'Maximum tokens in context' },
{ name: 'sources', type: 'string', description: 'Comma-separated source texts' }
]
},
autonomy: {
description: 'Autonomy mode management',
subcommands: {
status: { description: 'Show current autonomy status' },
set: { description: 'Set autonomy mode', args: [{ name: 'mode', type: 'string', required: true, position: 0 }] },
policies: { description: 'Show policies for a mode', args: [{ name: 'mode', type: 'string', required: false, position: 0 }] },
reset: { description: 'Reset autonomy state' }
}
},
sandbox: {
description: 'Safe command execution',
subcommands: {
exec: { description: 'Execute a command in sandbox', args: [{ name: 'command', type: 'string', required: true, position: 0 }] },
check: { description: 'Check if command is allowed', args: [{ name: 'command', type: 'string', required: true, position: 0 }] },
mode: { description: 'List available sandbox modes' }
},
flags: [
{ name: 'mode', type: 'string', description: 'Sandbox mode (disabled, basic, strict, network-isolated)' },
{ name: 'timeout', type: 'integer', description: 'Execution timeout in ms' }
]
},
'repo-map': {
description: 'Repository structure visualization',
subcommands: {
tree: { description: 'Show repository tree', args: [{ name: 'path', type: 'string', required: false, position: 0 }] },
symbols: { description: 'Extract code symbols', args: [{ name: 'path', type: 'string', required: false, position: 0 }] }
},
flags: [
{ name: 'depth', type: 'integer', description: 'Maximum directory depth' },
{ name: 'symbols', type: 'boolean', description: 'Include symbols in tree' }
]
},
git: {
description: 'Git integration (read-only)',
subcommands: {
status: { description: 'Show repository status' },
diff: { description: 'Show changes' },
log: { description: 'Show recent commits' },
branches: { description: 'List branches' },
stash: { description: 'List stashes' }
},
flags: [
{ name: 'cwd', type: 'string', description: 'Working directory' },
{ name: 'staged', type: 'boolean', description: 'Show staged changes only' },
{ name: 'limit', type: 'integer', description: 'Number of commits to show' }
]
},
n8n: {
description: 'N8N workflow integration',
subcommands: {
trigger: { description: 'Trigger an N8N webhook', args: [{ name: 'webhookId', type: 'string', required: true, position: 0 }] },
export: { description: 'Export workflow to N8N format', args: [{ name: 'name', type: 'string', required: false, position: 0 }] }
},
flags: [
{ name: 'base-url', type: 'string', description: 'N8N base URL' },
{ name: 'api-key', type: 'string', description: 'N8N API key' },
{ name: 'steps', type: 'string', description: 'Workflow steps' }
]
},
'external-agents': {
description: 'External AI CLI tool integration',
subcommands: {
list: { description: 'List all external agents' },
check: { description: 'Check agent availability', args: [{ name: 'name', type: 'string', required: true, position: 0 }] },
run: { description: 'Run with external agent', args: [{ name: 'name', type: 'string', required: true, position: 0 }] }
},
flags: [
{ name: 'cwd', type: 'string', description: 'Working directory' }
]
},
flow: {
description: 'Workflow flow visualization',
subcommands: {
show: { description: 'Display workflow as text', args: [{ name: 'steps', type: 'string', required: false, position: 0 }] },
dot: { description: 'Export as DOT format', args: [{ name: 'steps', type: 'string', required: false, position: 0 }] }
},
flags: [
{ name: 'steps', type: 'string', description: 'Steps as JSON or comma-separated' },
{ name: 'boxes', type: 'boolean', description: 'Display as ASCII boxes' },
{ name: 'compact', type: 'boolean', description: 'Compact display mode' }
]
},
cost: {
description: 'Token usage and cost tracking',
subcommands: {
summary: { description: 'Show cost summary' },
add: { description: 'Add token usage', args: [{ name: 'model', type: 'string', required: true, position: 0 }] },
reset: { description: 'Reset cost tracker' },
pricing: { description: 'Show model pricing', args: [{ name: 'model', type: 'string', required: false, position: 0 }] }
}
},
interactive: {
description: 'Interactive TUI mode',
flags: [
{ name: 'model', short: 'm', type: 'string', description: 'Model to use' },
{ name: 'prompt', type: 'string', description: 'Custom prompt string' },
{ name: 'history', type: 'string', description: 'Path to history file' }
]
},
version: {
description: 'Show CLI version',
flags: []
+5
View File
@@ -0,0 +1,5 @@
/**
* CLI Spec exports
*/
export * from './cli-spec';
+66 -2
View File
@@ -48,11 +48,22 @@ export { MemoryObservabilityAdapter, ConsoleObservabilityAdapter, setObservabili
export { SkillManager, createSkillManager, parseSkillFile, type Skill, type SkillMetadata, type SkillDiscoveryOptions } from './skills';
// Export CLI
export { chat, listProviders, version, help } from './cli';
export { parseArgs, executeCommand, CLI_SPEC_VERSION } from './cli';
// Export Memory
export { Memory, createMemory, type MemoryEntry, type MemoryConfig, type SearchResult as MemorySearchResult } from './memory/memory';
// Export FileMemory
export { FileMemory, createFileMemory, type FileMemoryConfig, type FileMemoryEntry } from './memory/file-memory';
// Export AutoMemory
export {
AutoMemory, createAutoMemory, createLLMSummarizer,
DEFAULT_POLICIES,
type AutoMemoryConfig, type AutoMemoryPolicy, type AutoMemoryContext,
type VectorStoreAdapter as AutoMemoryVectorStore, type KnowledgeBaseAdapter as AutoMemoryKnowledgeBase
} from './memory/auto-memory';
// Export Telemetry
export { TelemetryCollector, getTelemetry, enableTelemetry, disableTelemetry, cleanupTelemetry, type TelemetryEvent, type TelemetryConfig } from './telemetry';
@@ -168,4 +179,57 @@ export {
type Message as ProviderMessage,
type ToolCall,
type ToolDefinition as ProviderToolDefinition,
} from './llm/providers';
} from './llm/providers';
// Export CLI Features
export {
// Slash Commands
SlashCommandHandler, createSlashCommandHandler, registerCommand, parseSlashCommand,
executeSlashCommand, isSlashCommand,
type SlashCommand, type SlashCommandContext, type SlashCommandResult,
// Cost Tracker
CostTracker, createCostTracker, estimateTokens, formatCost, MODEL_PRICING,
type ModelPricing, type TokenUsage as CostTokenUsage, type RequestStats, type SessionStats,
// Interactive TUI
InteractiveTUI, createInteractiveTUI, StatusDisplay, createStatusDisplay,
HistoryManager, createHistoryManager,
type TUIConfig, type TUIState,
// Repo Map
RepoMap, createRepoMap, getRepoTree, DEFAULT_IGNORE_PATTERNS,
type RepoMapConfig, type FileInfo, type SymbolInfo, type RepoMapResult,
// Git Integration
GitManager, createGitManager, DiffViewer, createDiffViewer,
type GitConfig, type GitStatus, type GitCommit, type GitDiff, type GitDiffFile,
// Sandbox Executor
SandboxExecutor, createSandboxExecutor, sandboxExec, CommandValidator,
DEFAULT_BLOCKED_COMMANDS, DEFAULT_BLOCKED_PATHS,
type SandboxMode, type SandboxConfig, type ExecutionResult,
// Autonomy Mode
AutonomyManager, createAutonomyManager, cliApprovalPrompt, MODE_POLICIES,
type AutonomyMode, type ActionType, type ApprovalPolicy, type AutonomyConfig,
type ActionRequest, type ActionDecision,
// Scheduler
Scheduler, createScheduler, cronExpressions,
type ScheduleConfig, type ScheduledTask, type SchedulerStats,
// Background Jobs
JobQueue, createJobQueue, MemoryJobStorage, FileJobStorage, createFileJobStorage,
type Job, type JobStatus, type JobPriority, type JobQueueConfig,
type JobStorageAdapter, type JobHandler, type JobContext,
// Checkpoints
CheckpointManager, createCheckpointManager, MemoryCheckpointStorage,
FileCheckpointStorage, createFileCheckpointStorage,
type CheckpointData, type CheckpointConfig, type CheckpointStorage,
// Flow Display
FlowDisplay, createFlowDisplay, renderWorkflow,
type FlowNode, type FlowGraph, type FlowDisplayConfig,
// External Agents
BaseExternalAgent, ClaudeCodeAgent, GeminiCliAgent, CodexCliAgent, AiderAgent,
GenericExternalAgent, getExternalAgentRegistry, createExternalAgent, externalAgentAsTool,
type ExternalAgentConfig, type ExternalAgentResult,
// N8N Integration
N8NIntegration, createN8NIntegration, triggerN8NWebhook,
type N8NConfig, type N8NWebhookPayload, type N8NWorkflow, type N8NWorkflowNode,
// Fast Context
FastContext, createFastContext, getQuickContext,
type FastContextConfig, type ContextSource, type FastContextResult
} from './cli/features';
+388
View File
@@ -0,0 +1,388 @@
/**
* AutoMemory - Policy-based automatic memory capture
* Integrates with Memory, KnowledgeBase, and Vector stores
*/
import { Memory, MemoryEntry, MemoryConfig, EmbeddingProvider } from './memory';
export interface AutoMemoryPolicy {
name: string;
condition: (content: string, role: string, context: AutoMemoryContext) => boolean;
action: 'store' | 'summarize' | 'vectorize' | 'skip';
priority?: number;
}
export interface AutoMemoryContext {
messageCount: number;
tokenCount: number;
lastSummaryAt?: number;
metadata: Record<string, any>;
}
export interface AutoMemoryConfig extends MemoryConfig {
policies?: AutoMemoryPolicy[];
summaryThreshold?: number;
vectorizeThreshold?: number;
summarizer?: (entries: MemoryEntry[]) => Promise<string>;
vectorStore?: VectorStoreAdapter;
knowledgeBase?: KnowledgeBaseAdapter;
}
export interface VectorStoreAdapter {
add(id: string, embedding: number[], metadata?: Record<string, any>): Promise<void>;
search(embedding: number[], limit?: number): Promise<Array<{ id: string; score: number }>>;
}
export interface KnowledgeBaseAdapter {
add(content: string, metadata?: Record<string, any>): Promise<string>;
search(query: string, limit?: number): Promise<Array<{ content: string; score: number }>>;
}
/**
* Default policies for automatic memory management
*/
export const DEFAULT_POLICIES: AutoMemoryPolicy[] = [
{
name: 'store-important',
condition: (content) => {
const importantKeywords = ['remember', 'important', 'note', 'save', 'key point'];
return importantKeywords.some(kw => content.toLowerCase().includes(kw));
},
action: 'store',
priority: 100
},
{
name: 'summarize-long',
condition: (content) => content.length > 2000,
action: 'summarize',
priority: 50
},
{
name: 'vectorize-knowledge',
condition: (content, role) => {
return role === 'assistant' && content.length > 500;
},
action: 'vectorize',
priority: 30
},
{
name: 'skip-short',
condition: (content) => content.length < 10,
action: 'skip',
priority: 10
}
];
/**
* AutoMemory - Automatic memory management with policies
*/
export class AutoMemory {
private memory: Memory;
private policies: AutoMemoryPolicy[];
private summaryThreshold: number;
private vectorizeThreshold: number;
private summarizer?: (entries: MemoryEntry[]) => Promise<string>;
private vectorStore?: VectorStoreAdapter;
private knowledgeBase?: KnowledgeBaseAdapter;
private embeddingProvider?: EmbeddingProvider;
private context: AutoMemoryContext;
private pendingVectorization: MemoryEntry[] = [];
constructor(config: AutoMemoryConfig = {}) {
this.memory = new Memory(config);
this.policies = (config.policies || DEFAULT_POLICIES).sort((a, b) =>
(b.priority || 0) - (a.priority || 0)
);
this.summaryThreshold = config.summaryThreshold ?? 50;
this.vectorizeThreshold = config.vectorizeThreshold ?? 10;
this.summarizer = config.summarizer;
this.vectorStore = config.vectorStore;
this.knowledgeBase = config.knowledgeBase;
this.embeddingProvider = config.embeddingProvider;
this.context = {
messageCount: 0,
tokenCount: 0,
metadata: {}
};
}
/**
* Process and add content with automatic policy application
*/
async add(
content: string,
role: 'user' | 'assistant' | 'system',
metadata?: Record<string, any>
): Promise<MemoryEntry | null> {
this.context.messageCount++;
this.context.tokenCount += this.estimateTokens(content);
// Find matching policy
const policy = this.policies.find(p => p.condition(content, role, this.context));
if (!policy || policy.action === 'skip') {
return null;
}
let entry: MemoryEntry;
switch (policy.action) {
case 'summarize':
if (this.summarizer) {
const summary = await this.summarizer([{
id: '', content, role, timestamp: Date.now(), metadata
}]);
entry = await this.memory.add(summary, role, {
...metadata,
original: content,
summarized: true
});
} else {
entry = await this.memory.add(content, role, metadata);
}
break;
case 'vectorize':
entry = await this.memory.add(content, role, metadata);
this.pendingVectorization.push(entry);
if (this.pendingVectorization.length >= this.vectorizeThreshold) {
await this.flushVectorization();
}
break;
case 'store':
default:
entry = await this.memory.add(content, role, metadata);
break;
}
// Check if we need to trigger summarization
if (this.memory.size >= this.summaryThreshold && this.summarizer) {
await this.summarizeOldEntries();
}
return entry;
}
/**
* Get a memory entry by ID
*/
get(id: string): MemoryEntry | undefined {
return this.memory.get(id);
}
/**
* Get all entries
*/
getAll(): MemoryEntry[] {
return this.memory.getAll();
}
/**
* Get recent entries
*/
getRecent(count: number): MemoryEntry[] {
return this.memory.getRecent(count);
}
/**
* Search memory
*/
async search(query: string, limit: number = 5): Promise<Array<{ entry: MemoryEntry; score: number }>> {
const memoryResults = await this.memory.search(query, limit);
// Also search vector store if available
if (this.vectorStore && this.embeddingProvider) {
const queryEmbedding = await this.embeddingProvider.embed(query);
const vectorResults = await this.vectorStore.search(queryEmbedding, limit);
// Merge results (deduplicate by ID)
const seen = new Set(memoryResults.map(r => r.entry.id));
for (const vr of vectorResults) {
if (!seen.has(vr.id)) {
const entry = this.memory.get(vr.id);
if (entry) {
memoryResults.push({ entry, score: vr.score });
}
}
}
}
// Also search knowledge base if available
if (this.knowledgeBase) {
const kbResults = await this.knowledgeBase.search(query, limit);
// Knowledge base results are returned separately as they may not have IDs
}
return memoryResults
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
/**
* Build context with automatic retrieval
*/
async buildContext(query?: string, maxTokens: number = 4000): Promise<string> {
let entries: MemoryEntry[];
if (query) {
const results = await this.search(query, 20);
entries = results.map(r => r.entry);
} else {
entries = this.getRecent(20);
}
// Build context within token limit
const contextParts: string[] = [];
let tokenCount = 0;
for (const entry of entries) {
const part = `${entry.role}: ${entry.content}`;
const partTokens = this.estimateTokens(part);
if (tokenCount + partTokens > maxTokens) break;
contextParts.push(part);
tokenCount += partTokens;
}
return contextParts.join('\n');
}
/**
* Add a custom policy
*/
addPolicy(policy: AutoMemoryPolicy): void {
this.policies.push(policy);
this.policies.sort((a, b) => (b.priority || 0) - (a.priority || 0));
}
/**
* Remove a policy by name
*/
removePolicy(name: string): boolean {
const index = this.policies.findIndex(p => p.name === name);
if (index >= 0) {
this.policies.splice(index, 1);
return true;
}
return false;
}
/**
* Get current context stats
*/
getStats(): AutoMemoryContext {
return { ...this.context };
}
/**
* Clear all memory
*/
clear(): void {
this.memory.clear();
this.pendingVectorization = [];
this.context = {
messageCount: 0,
tokenCount: 0,
metadata: {}
};
}
/**
* Export memory
*/
toJSON(): MemoryEntry[] {
return this.memory.toJSON();
}
/**
* Import memory
*/
fromJSON(entries: MemoryEntry[]): void {
this.memory.fromJSON(entries);
this.context.messageCount = entries.length;
}
/**
* Get underlying memory instance
*/
getMemory(): Memory {
return this.memory;
}
/**
* Flush pending vectorization
*/
private async flushVectorization(): Promise<void> {
if (!this.vectorStore || !this.embeddingProvider || this.pendingVectorization.length === 0) {
return;
}
for (const entry of this.pendingVectorization) {
if (entry.embedding) {
await this.vectorStore.add(entry.id, entry.embedding, entry.metadata);
} else {
const embedding = await this.embeddingProvider.embed(entry.content);
await this.vectorStore.add(entry.id, embedding, entry.metadata);
}
}
this.pendingVectorization = [];
}
/**
* Summarize old entries to save space
*/
private async summarizeOldEntries(): Promise<void> {
if (!this.summarizer) return;
const entries = this.memory.getAll();
const oldEntries = entries.slice(0, Math.floor(entries.length / 2));
if (oldEntries.length < 10) return;
const summary = await this.summarizer(oldEntries);
// Delete old entries and add summary
for (const entry of oldEntries) {
this.memory.delete(entry.id);
}
await this.memory.add(summary, 'system', {
type: 'summary',
summarizedCount: oldEntries.length,
summarizedAt: Date.now()
});
this.context.lastSummaryAt = Date.now();
}
/**
* Estimate token count (rough approximation)
*/
private estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
}
/**
* Create an auto-memory instance
*/
export function createAutoMemory(config?: AutoMemoryConfig): AutoMemory {
return new AutoMemory(config);
}
/**
* Create a simple summarizer using an LLM
*/
export function createLLMSummarizer(
llm: { chat: (prompt: string) => Promise<string> }
): (entries: MemoryEntry[]) => Promise<string> {
return async (entries: MemoryEntry[]) => {
const content = entries.map(e => `${e.role}: ${e.content}`).join('\n');
const prompt = `Summarize the following conversation concisely, preserving key information:\n\n${content}`;
return llm.chat(prompt);
};
}
+308
View File
@@ -0,0 +1,308 @@
/**
* File-based Memory Storage
* Persistent memory using JSONL files with lockfile support for concurrent access
*/
import { MemoryEntry, MemoryConfig, SearchResult, EmbeddingProvider } from './memory';
export interface FileMemoryConfig extends MemoryConfig {
filePath: string;
compactionThreshold?: number;
autoCompact?: boolean;
}
export interface FileMemoryEntry extends MemoryEntry {
deleted?: boolean;
}
/**
* FileMemory - Append-only JSONL storage with compaction
*/
export class FileMemory {
private filePath: string;
private entries: Map<string, FileMemoryEntry> = new Map();
private maxEntries: number;
private embeddingProvider?: EmbeddingProvider;
private compactionThreshold: number;
private autoCompact: boolean;
private initialized: boolean = false;
private writeQueue: Promise<void> = Promise.resolve();
constructor(config: FileMemoryConfig) {
this.filePath = config.filePath;
this.maxEntries = config.maxEntries ?? 10000;
this.embeddingProvider = config.embeddingProvider;
this.compactionThreshold = config.compactionThreshold ?? 1000;
this.autoCompact = config.autoCompact ?? true;
}
/**
* Initialize and load existing entries
*/
async initialize(): Promise<void> {
if (this.initialized) return;
try {
const fs = await import('fs/promises');
const content = await fs.readFile(this.filePath, 'utf-8').catch(() => '');
if (content) {
const lines = content.trim().split('\n').filter(Boolean);
let deletedCount = 0;
for (const line of lines) {
try {
const entry: FileMemoryEntry = JSON.parse(line);
if (entry.deleted) {
this.entries.delete(entry.id);
deletedCount++;
} else {
this.entries.set(entry.id, entry);
}
} catch {
// Skip malformed lines
}
}
// Auto-compact if too many deleted entries
if (this.autoCompact && deletedCount > this.compactionThreshold) {
await this.compact();
}
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error;
}
}
this.initialized = true;
}
/**
* Add a memory entry
*/
async add(
content: string,
role: 'user' | 'assistant' | 'system',
metadata?: Record<string, any>
): Promise<MemoryEntry> {
await this.initialize();
const id = this.generateId();
const entry: FileMemoryEntry = {
id,
content,
role,
timestamp: Date.now(),
metadata
};
if (this.embeddingProvider) {
entry.embedding = await this.embeddingProvider.embed(content);
}
this.entries.set(id, entry);
await this.appendEntry(entry);
this.enforceLimit();
return entry;
}
/**
* Get a memory entry by ID
*/
async get(id: string): Promise<MemoryEntry | undefined> {
await this.initialize();
const entry = this.entries.get(id);
return entry && !entry.deleted ? entry : undefined;
}
/**
* Get all entries
*/
async getAll(): Promise<MemoryEntry[]> {
await this.initialize();
return Array.from(this.entries.values())
.filter(e => !e.deleted)
.sort((a, b) => a.timestamp - b.timestamp);
}
/**
* Get recent entries
*/
async getRecent(count: number): Promise<MemoryEntry[]> {
const all = await this.getAll();
return all.slice(-count);
}
/**
* Search memory by text similarity
*/
async search(query: string, limit: number = 5): Promise<SearchResult[]> {
await this.initialize();
if (!this.embeddingProvider) {
return this.textSearch(query, limit);
}
const queryEmbedding = await this.embeddingProvider.embed(query);
const results: SearchResult[] = [];
for (const entry of this.entries.values()) {
if (entry.deleted) continue;
if (entry.embedding) {
const score = this.cosineSimilarity(queryEmbedding, entry.embedding);
results.push({ entry, score });
}
}
return results
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
/**
* Delete a memory entry (soft delete)
*/
async delete(id: string): Promise<boolean> {
await this.initialize();
const entry = this.entries.get(id);
if (!entry || entry.deleted) return false;
entry.deleted = true;
await this.appendEntry({ id, deleted: true } as FileMemoryEntry);
return true;
}
/**
* Clear all memory
*/
async clear(): Promise<void> {
await this.initialize();
const fs = await import('fs/promises');
await fs.writeFile(this.filePath, '');
this.entries.clear();
}
/**
* Compact the file by removing deleted entries
*/
async compact(): Promise<void> {
await this.initialize();
const fs = await import('fs/promises');
const activeEntries = Array.from(this.entries.values()).filter(e => !e.deleted);
const content = activeEntries
.map(e => JSON.stringify(e))
.join('\n') + (activeEntries.length ? '\n' : '');
// Write to temp file first, then rename (atomic)
const tempPath = `${this.filePath}.tmp`;
await fs.writeFile(tempPath, content);
await fs.rename(tempPath, this.filePath);
}
/**
* Get memory size
*/
get size(): number {
return Array.from(this.entries.values()).filter(e => !e.deleted).length;
}
/**
* Export memory to JSON
*/
async toJSON(): Promise<MemoryEntry[]> {
return this.getAll();
}
/**
* Import memory from JSON
*/
async fromJSON(entries: MemoryEntry[]): Promise<void> {
await this.clear();
for (const entry of entries) {
this.entries.set(entry.id, entry as FileMemoryEntry);
}
await this.compact();
}
/**
* Build context string from recent memory
*/
async buildContext(count?: number): Promise<string> {
const entries = count ? await this.getRecent(count) : await this.getAll();
return entries
.map(e => `${e.role}: ${e.content}`)
.join('\n');
}
private async appendEntry(entry: FileMemoryEntry): Promise<void> {
// Queue writes to ensure order
this.writeQueue = this.writeQueue.then(async () => {
const fs = await import('fs/promises');
await fs.appendFile(this.filePath, JSON.stringify(entry) + '\n');
});
await this.writeQueue;
}
private textSearch(query: string, limit: number): SearchResult[] {
const queryLower = query.toLowerCase();
const results: SearchResult[] = [];
for (const entry of this.entries.values()) {
if (entry.deleted) continue;
const contentLower = entry.content.toLowerCase();
if (contentLower.includes(queryLower)) {
const score = queryLower.length / contentLower.length;
results.push({ entry, score: Math.min(score * 10, 1) });
}
}
return results
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
private cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) return 0;
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
return denominator === 0 ? 0 : dotProduct / denominator;
}
private generateId(): string {
return `fmem_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
private enforceLimit(): void {
const active = Array.from(this.entries.values()).filter(e => !e.deleted);
while (active.length > this.maxEntries) {
const oldest = active.shift();
if (oldest) {
oldest.deleted = true;
}
}
}
}
/**
* Create a file-based memory instance
*/
export function createFileMemory(config: FileMemoryConfig): FileMemory {
return new FileMemory(config);
}
+11
View File
@@ -51,3 +51,14 @@ export class BaseMemoryStore implements MemoryStore {
this.memories.clear();
}
}
// Export FileMemory
export { FileMemory, createFileMemory, type FileMemoryConfig, type FileMemoryEntry } from './file-memory';
// Export AutoMemory
export {
AutoMemory, createAutoMemory, createLLMSummarizer,
DEFAULT_POLICIES,
type AutoMemoryConfig, type AutoMemoryPolicy, type AutoMemoryContext,
type VectorStoreAdapter, type KnowledgeBaseAdapter
} from './auto-memory';
+158
View File
@@ -0,0 +1,158 @@
/**
* CLI Spec Tests - Verify parity with spec
*/
import { parseArgs, CLI_SPEC_VERSION } from '../../src/cli/index';
import { COMMANDS, GLOBAL_FLAGS, EXIT_CODES, validateCommand } from '../../src/cli/spec/cli-spec';
describe('CLI Spec', () => {
describe('Version', () => {
it('should have correct spec version', () => {
expect(CLI_SPEC_VERSION).toBe('1.0.0');
});
});
describe('Commands', () => {
it('should have all required commands', () => {
const requiredCommands = ['chat', 'run', 'workflow', 'eval', 'providers', 'tools', 'version', 'help'];
for (const cmd of requiredCommands) {
expect(COMMANDS[cmd]).toBeDefined();
expect(validateCommand(cmd)).toBe(true);
}
});
it('should reject invalid commands', () => {
expect(validateCommand('invalid')).toBe(false);
expect(validateCommand('')).toBe(false);
});
});
describe('Global Flags', () => {
it('should have all required global flags', () => {
const requiredFlags = ['verbose', 'config', 'profile', 'output', 'json'];
const flagNames = GLOBAL_FLAGS.map(f => f.name);
for (const flag of requiredFlags) {
expect(flagNames).toContain(flag);
}
});
it('should have short flags for common options', () => {
const verboseFlag = GLOBAL_FLAGS.find(f => f.name === 'verbose');
expect(verboseFlag?.short).toBe('v');
const outputFlag = GLOBAL_FLAGS.find(f => f.name === 'output');
expect(outputFlag?.short).toBe('o');
});
});
describe('Exit Codes', () => {
it('should have correct exit codes', () => {
expect(EXIT_CODES.SUCCESS).toBe(0);
expect(EXIT_CODES.RUNTIME_ERROR).toBe(1);
expect(EXIT_CODES.INVALID_ARGUMENTS).toBe(2);
expect(EXIT_CODES.CONFIG_ERROR).toBe(3);
expect(EXIT_CODES.NETWORK_ERROR).toBe(4);
expect(EXIT_CODES.AUTH_ERROR).toBe(5);
});
});
});
describe('Argument Parsing', () => {
describe('Commands', () => {
it('should parse command correctly', () => {
const result = parseArgs(['chat', 'Hello']);
expect(result.command).toBe('chat');
expect(result.args).toEqual(['Hello']);
});
it('should default to help when no command', () => {
const result = parseArgs([]);
expect(result.command).toBe('help');
});
});
describe('Flags', () => {
it('should parse long flags with values', () => {
const result = parseArgs(['chat', 'Hello', '--model', 'gpt-4']);
expect(result.options.model).toBe('gpt-4');
});
it('should parse short flags with values', () => {
const result = parseArgs(['chat', 'Hello', '-m', 'gpt-4']);
expect(result.options.model).toBe('gpt-4');
});
it('should parse boolean flags', () => {
const result = parseArgs(['chat', 'Hello', '--verbose']);
expect(result.options.verbose).toBe(true);
});
it('should parse short boolean flags', () => {
const result = parseArgs(['chat', 'Hello', '-v']);
expect(result.options.verbose).toBe(true);
});
it('should handle --json shorthand', () => {
const result = parseArgs(['chat', 'Hello', '--json']);
expect(result.options.json).toBe(true);
expect(result.options.output).toBe('json');
});
});
describe('Subcommands', () => {
it('should parse eval subcommands', () => {
const result = parseArgs(['eval', 'accuracy', '--input', 'test']);
expect(result.command).toBe('eval');
expect(result.subcommand).toBe('accuracy');
expect(result.options.input).toBe('test');
});
it('should parse tools subcommands', () => {
const result = parseArgs(['tools', 'info', 'calculator']);
expect(result.command).toBe('tools');
expect(result.subcommand).toBe('info');
expect(result.args).toEqual(['calculator']);
});
});
describe('Multiple Arguments', () => {
it('should collect multiple positional arguments', () => {
const result = parseArgs(['run', 'task1', 'task2']);
expect(result.command).toBe('run');
expect(result.args).toEqual(['task1', 'task2']);
});
});
});
describe('Output Schema', () => {
it('should have consistent success schema', () => {
// This is a type check - the schema is enforced by TypeScript
const successOutput = {
success: true as const,
data: { response: 'test' },
meta: {
duration_ms: 100,
model: 'gpt-4',
tokens: { input: 10, output: 20 }
}
};
expect(successOutput.success).toBe(true);
expect(successOutput.data).toBeDefined();
});
it('should have consistent error schema', () => {
const errorOutput = {
success: false as const,
error: {
code: 'MISSING_ARGUMENT',
message: 'Please provide a prompt'
}
};
expect(errorOutput.success).toBe(false);
expect(errorOutput.error.code).toBeDefined();
expect(errorOutput.error.message).toBeDefined();
});
});
@@ -0,0 +1,67 @@
/**
* CLI Commands Tests
*/
import { formatSuccess, formatError } from '../../src/cli/output/json';
import { ERROR_CODES } from '../../src/cli/output/errors';
describe('Output Formatting', () => {
describe('formatSuccess', () => {
it('should format success response without meta', () => {
const result = formatSuccess({ response: 'Hello' });
expect(result).toEqual({
success: true,
data: { response: 'Hello' }
});
});
it('should format success response with meta', () => {
const result = formatSuccess(
{ response: 'Hello' },
{ duration_ms: 100, model: 'gpt-4' }
);
expect(result).toEqual({
success: true,
data: { response: 'Hello' },
meta: { duration_ms: 100, model: 'gpt-4' }
});
});
it('should include token counts in meta', () => {
const result = formatSuccess(
{ response: 'Hello' },
{ tokens: { input: 10, output: 5 } }
);
expect(result.meta?.tokens).toEqual({ input: 10, output: 5 });
});
});
describe('formatError', () => {
it('should format error response', () => {
const result = formatError('MISSING_ARGUMENT', 'Please provide a prompt');
expect(result).toEqual({
success: false,
error: {
code: 'MISSING_ARGUMENT',
message: 'Please provide a prompt'
}
});
});
it('should include details when provided', () => {
const result = formatError('INVALID_ARGUMENTS', 'Bad input', { field: 'model' });
expect(result.error.details).toEqual({ field: 'model' });
});
});
});
describe('Error Codes', () => {
it('should have all required error codes', () => {
expect(ERROR_CODES.UNKNOWN).toBe('UNKNOWN_ERROR');
expect(ERROR_CODES.INVALID_ARGS).toBe('INVALID_ARGUMENTS');
expect(ERROR_CODES.MISSING_ARG).toBe('MISSING_ARGUMENT');
expect(ERROR_CODES.MISSING_API_KEY).toBe('MISSING_API_KEY');
expect(ERROR_CODES.NETWORK_TIMEOUT).toBe('NETWORK_TIMEOUT');
expect(ERROR_CODES.PROVIDER_ERROR).toBe('PROVIDER_ERROR');
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* CLI Config Tests
*/
import { DEFAULT_CONFIG, validateConfig, mergeConfigs } from '../../src/cli/config/schema';
import { resolveConfig } from '../../src/cli/config/resolve';
describe('Config Schema', () => {
describe('DEFAULT_CONFIG', () => {
it('should have correct defaults', () => {
expect(DEFAULT_CONFIG.model).toBe('openai/gpt-4o-mini');
expect(DEFAULT_CONFIG.verbose).toBe(false);
expect(DEFAULT_CONFIG.stream).toBe(false);
expect(DEFAULT_CONFIG.profiles).toEqual({});
});
});
describe('validateConfig', () => {
it('should validate correct config', () => {
const config = {
model: 'gpt-4',
verbose: true,
stream: false
};
expect(validateConfig(config)).toBe(true);
});
it('should reject invalid model type', () => {
const config = { model: 123 };
expect(validateConfig(config)).toBe(false);
});
it('should reject invalid verbose type', () => {
const config = { verbose: 'yes' };
expect(validateConfig(config)).toBe(false);
});
it('should validate config with profiles', () => {
const config = {
model: 'gpt-4',
profiles: {
fast: { model: 'gpt-4o-mini' },
quality: { model: 'gpt-4', verbose: true }
}
};
expect(validateConfig(config)).toBe(true);
});
it('should reject invalid profile structure', () => {
const config = {
profiles: {
bad: 'not an object'
}
};
expect(validateConfig(config)).toBe(false);
});
});
describe('mergeConfigs', () => {
it('should merge multiple configs', () => {
const result = mergeConfigs(
{ model: 'gpt-3.5' },
{ verbose: true },
{ model: 'gpt-4' }
);
expect(result.model).toBe('gpt-4');
expect(result.verbose).toBe(true);
});
it('should preserve profiles from multiple sources', () => {
const result = mergeConfigs(
{ profiles: { a: { model: 'gpt-3.5' } } },
{ profiles: { b: { model: 'gpt-4' } } }
);
expect(result.profiles?.a).toBeDefined();
expect(result.profiles?.b).toBeDefined();
});
});
});
describe('Config Resolution', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should use defaults when no config provided', () => {
const config = resolveConfig();
expect(config.model).toBe('openai/gpt-4o-mini');
expect(config.verbose).toBe(false);
expect(config.stream).toBe(false);
});
it('should override with CLI options', () => {
const config = resolveConfig({
model: 'anthropic/claude-3',
verbose: true
});
expect(config.model).toBe('anthropic/claude-3');
expect(config.verbose).toBe(true);
});
it('should respect environment variables', () => {
process.env.PRAISONAI_MODEL = 'env-model';
process.env.PRAISONAI_VERBOSE = 'true';
const config = resolveConfig();
expect(config.model).toBe('env-model');
expect(config.verbose).toBe(true);
});
it('should prioritize CLI options over env vars', () => {
process.env.PRAISONAI_MODEL = 'env-model';
const config = resolveConfig({ model: 'cli-model' });
expect(config.model).toBe('cli-model');
});
});
@@ -1,6 +1,6 @@
import { Agent } from '../../../src/agent/agent';
import { ToolManager } from '../../../src/tools/tools';
import { Task } from '../../../src/types';
import { Agent } from '../../../src/agent';
import { ToolManager } from '../../../src/tools';
import { Task } from '../../../src/agent';
jest.mock('../../../src/llm/openai');
@@ -0,0 +1,518 @@
/**
* Unit tests for CLI Features
*/
import {
// Slash Commands
SlashCommandHandler,
createSlashCommandHandler,
parseSlashCommand,
isSlashCommand,
// Cost Tracker
CostTracker,
createCostTracker,
estimateTokens,
formatCost,
MODEL_PRICING,
// Autonomy Mode
AutonomyManager,
createAutonomyManager,
MODE_POLICIES,
// Scheduler
Scheduler,
createScheduler,
cronExpressions,
// Background Jobs
JobQueue,
createJobQueue,
MemoryJobStorage,
// Checkpoints
CheckpointManager,
createCheckpointManager,
MemoryCheckpointStorage,
// Flow Display
FlowDisplay,
createFlowDisplay,
renderWorkflow,
// Fast Context
FastContext,
createFastContext,
getQuickContext,
// Command Validator
CommandValidator,
DEFAULT_BLOCKED_COMMANDS
} from '../../src/cli/features';
describe('Slash Commands', () => {
test('parseSlashCommand parses valid commands', () => {
const result = parseSlashCommand('/help');
expect(result).toEqual({ command: 'help', args: [] });
});
test('parseSlashCommand parses commands with args', () => {
const result = parseSlashCommand('/model gpt-4');
expect(result).toEqual({ command: 'model', args: ['gpt-4'] });
});
test('parseSlashCommand returns null for non-commands', () => {
const result = parseSlashCommand('hello world');
expect(result).toBeNull();
});
test('isSlashCommand detects slash commands', () => {
expect(isSlashCommand('/help')).toBe(true);
expect(isSlashCommand('help')).toBe(false);
expect(isSlashCommand(' /help')).toBe(true);
});
test('SlashCommandHandler handles help command', async () => {
const handler = createSlashCommandHandler();
const result = await handler.handle('/help');
expect(result.success).toBe(true);
expect(result.message).toContain('Available commands');
});
test('SlashCommandHandler handles unknown command', async () => {
const handler = createSlashCommandHandler();
const result = await handler.handle('/unknown');
expect(result.success).toBe(false);
expect(result.message).toContain('Unknown command');
});
});
describe('Cost Tracker', () => {
test('createCostTracker creates instance', () => {
const tracker = createCostTracker();
expect(tracker).toBeInstanceOf(CostTracker);
});
test('addUsage tracks token usage', () => {
const tracker = createCostTracker();
tracker.addUsage('gpt-4o', 100, 50);
const stats = tracker.getStats();
expect(stats.totalInputTokens).toBe(100);
expect(stats.totalOutputTokens).toBe(50);
expect(stats.totalTokens).toBe(150);
expect(stats.requestCount).toBe(1);
});
test('calculateCost uses correct pricing', () => {
const tracker = createCostTracker();
const cost = tracker.calculateCost('gpt-4o', 1000, 1000);
// gpt-4o: input $0.005/1k, output $0.015/1k
const expected = (1000 / 1000) * 0.005 + (1000 / 1000) * 0.015;
expect(cost).toBeCloseTo(expected);
});
test('getSummary returns formatted string', () => {
const tracker = createCostTracker();
tracker.addUsage('gpt-4o', 100, 50);
const summary = tracker.getSummary();
expect(summary).toContain('Cost Summary');
expect(summary).toContain('Tokens');
});
test('reset clears stats', () => {
const tracker = createCostTracker();
tracker.addUsage('gpt-4o', 100, 50);
tracker.reset();
const stats = tracker.getStats();
expect(stats.totalTokens).toBe(0);
expect(stats.requestCount).toBe(0);
});
test('estimateTokens approximates correctly', () => {
const tokens = estimateTokens('Hello world');
expect(tokens).toBeGreaterThan(0);
expect(tokens).toBeLessThan(10);
});
test('formatCost formats small values', () => {
expect(formatCost(0.001234)).toBe('$0.001234');
expect(formatCost(0.05)).toBe('$0.0500');
expect(formatCost(1.5)).toBe('$1.50');
});
test('MODEL_PRICING has expected models', () => {
expect(MODEL_PRICING['gpt-4o']).toBeDefined();
expect(MODEL_PRICING['claude-3-5-sonnet-20241022']).toBeDefined();
expect(MODEL_PRICING['gemini-1.5-pro']).toBeDefined();
});
});
describe('Autonomy Mode', () => {
test('createAutonomyManager creates instance', () => {
const manager = createAutonomyManager();
expect(manager).toBeInstanceOf(AutonomyManager);
});
test('default mode is suggest', () => {
const manager = createAutonomyManager();
expect(manager.getMode()).toBe('suggest');
});
test('setMode changes mode', () => {
const manager = createAutonomyManager();
manager.setMode('full_auto');
expect(manager.getMode()).toBe('full_auto');
});
test('requestApproval auto-approves file_read in suggest mode', async () => {
const manager = createAutonomyManager({ mode: 'suggest' });
const decision = await manager.requestApproval({
type: 'file_read',
description: 'Read config file'
});
expect(decision.approved).toBe(true);
});
test('requestApproval requires approval for file_write in suggest mode', async () => {
const manager = createAutonomyManager({ mode: 'suggest' });
// Without prompt callback, should deny
const decision = await manager.requestApproval({
type: 'file_write',
description: 'Write to file'
});
expect(decision.approved).toBe(false);
});
test('full_auto mode auto-approves most actions', async () => {
const manager = createAutonomyManager({ mode: 'full_auto' });
const decision = await manager.requestApproval({
type: 'shell_command',
description: 'Run npm install'
});
expect(decision.approved).toBe(true);
});
test('MODE_POLICIES has all modes', () => {
expect(MODE_POLICIES['suggest']).toBeDefined();
expect(MODE_POLICIES['auto_edit']).toBeDefined();
expect(MODE_POLICIES['full_auto']).toBeDefined();
});
});
describe('Scheduler', () => {
test('createScheduler creates instance', () => {
const scheduler = createScheduler();
expect(scheduler).toBeInstanceOf(Scheduler);
});
test('add creates task with ID', () => {
const scheduler = createScheduler();
const id = scheduler.add({
name: 'test-task',
interval: 1000,
task: async () => 'done'
});
expect(id).toBeDefined();
expect(typeof id).toBe('string');
});
test('getTask returns task info', () => {
const scheduler = createScheduler();
const id = scheduler.add({
name: 'test-task',
interval: 1000,
task: async () => 'done'
});
const task = scheduler.getTask(id);
expect(task).toBeDefined();
expect(task?.name).toBe('test-task');
expect(task?.status).toBe('idle');
});
test('remove deletes task', () => {
const scheduler = createScheduler();
const id = scheduler.add({
name: 'test-task',
interval: 1000,
task: async () => 'done'
});
expect(scheduler.remove(id)).toBe(true);
expect(scheduler.getTask(id)).toBeUndefined();
});
test('enable/disable toggles task', () => {
const scheduler = createScheduler();
const id = scheduler.add({
name: 'test-task',
interval: 1000,
task: async () => 'done',
enabled: true
});
scheduler.disable(id);
expect(scheduler.getTask(id)?.enabled).toBe(false);
scheduler.enable(id);
expect(scheduler.getTask(id)?.enabled).toBe(true);
});
test('cronExpressions has common patterns', () => {
expect(cronExpressions.everyMinute).toBe('* * * * *');
expect(cronExpressions.everyHour).toBe('0 * * * *');
expect(cronExpressions.everyDay).toBe('0 0 * * *');
});
});
describe('Background Jobs', () => {
test('createJobQueue creates instance', () => {
const queue = createJobQueue();
expect(queue).toBeInstanceOf(JobQueue);
});
test('add creates job', async () => {
const queue = createJobQueue();
const job = await queue.add('test-job', { value: 42 });
expect(job.id).toBeDefined();
expect(job.name).toBe('test-job');
expect(job.data).toEqual({ value: 42 });
expect(job.status).toBe('pending');
});
test('get retrieves job', async () => {
const queue = createJobQueue();
const job = await queue.add('test-job', { value: 42 });
const retrieved = await queue.get(job.id);
expect(retrieved).toBeDefined();
expect(retrieved?.id).toBe(job.id);
});
test('cancel marks job as cancelled', async () => {
const queue = createJobQueue();
const job = await queue.add('test-job', { value: 42 });
const cancelled = await queue.cancel(job.id);
expect(cancelled).toBe(true);
const retrieved = await queue.get(job.id);
expect(retrieved?.status).toBe('cancelled');
});
test('MemoryJobStorage stores and retrieves', async () => {
const storage = new MemoryJobStorage();
const job = {
id: 'test-1',
name: 'test',
data: {},
status: 'pending' as const,
priority: 'normal' as const,
attempts: 0,
maxAttempts: 3,
createdAt: new Date()
};
await storage.save(job);
const retrieved = await storage.get('test-1');
expect(retrieved).toBeDefined();
expect(retrieved?.id).toBe('test-1');
});
});
describe('Checkpoints', () => {
test('createCheckpointManager creates instance', () => {
const manager = createCheckpointManager();
expect(manager).toBeInstanceOf(CheckpointManager);
});
test('create saves checkpoint', async () => {
const manager = createCheckpointManager();
const checkpoint = await manager.create('test-checkpoint');
expect(checkpoint.id).toBeDefined();
expect(checkpoint.name).toBe('test-checkpoint');
expect(checkpoint.timestamp).toBeInstanceOf(Date);
});
test('get retrieves checkpoint', async () => {
const manager = createCheckpointManager();
const checkpoint = await manager.create('test-checkpoint');
const retrieved = await manager.get(checkpoint.id);
expect(retrieved).toBeDefined();
expect(retrieved?.name).toBe('test-checkpoint');
});
test('list returns all checkpoints', async () => {
const manager = createCheckpointManager();
await manager.create('checkpoint-1');
await manager.create('checkpoint-2');
const list = await manager.list();
expect(list.length).toBe(2);
});
test('setState and getState work correctly', () => {
const manager = createCheckpointManager();
manager.setState({ key: 'value' });
const state = manager.getState();
expect(state.key).toBe('value');
});
test('MemoryCheckpointStorage stores and retrieves', async () => {
const storage = new MemoryCheckpointStorage();
const checkpoint = {
id: 'cp-1',
name: 'test',
timestamp: new Date(),
state: { key: 'value' }
};
await storage.save(checkpoint);
const retrieved = await storage.load('cp-1');
expect(retrieved).toBeDefined();
expect(retrieved?.name).toBe('test');
});
});
describe('Flow Display', () => {
test('createFlowDisplay creates instance', () => {
const display = createFlowDisplay();
expect(display).toBeInstanceOf(FlowDisplay);
});
test('addNode adds node to graph', () => {
const display = createFlowDisplay();
display.addNode({ id: 'node-1', name: 'Test Node', type: 'agent' });
const graph = display.getGraph();
expect(graph.nodes.has('node-1')).toBe(true);
});
test('addEdge adds edge to graph', () => {
const display = createFlowDisplay();
display.addNode({ id: 'node-1', name: 'Node 1', type: 'start' });
display.addNode({ id: 'node-2', name: 'Node 2', type: 'agent' });
display.addEdge('node-1', 'node-2');
const graph = display.getGraph();
expect(graph.edges.length).toBe(1);
expect(graph.edges[0]).toEqual({ from: 'node-1', to: 'node-2', label: undefined });
});
test('fromWorkflowSteps builds graph', () => {
const display = createFlowDisplay();
display.fromWorkflowSteps([
{ name: 'Step 1', type: 'agent' },
{ name: 'Step 2', type: 'tool' }
]);
const graph = display.getGraph();
expect(graph.nodes.size).toBe(4); // start + 2 steps + end
});
test('render produces text output', () => {
const display = createFlowDisplay();
display.fromWorkflowSteps([
{ name: 'Step 1', type: 'agent' }
]);
const output = display.render();
expect(output).toContain('Start');
expect(output).toContain('Step 1');
});
test('renderWorkflow helper works', () => {
const output = renderWorkflow([
{ name: 'Step 1' },
{ name: 'Step 2' }
]);
expect(output).toContain('Step 1');
expect(output).toContain('Step 2');
});
});
describe('Fast Context', () => {
test('createFastContext creates instance', () => {
const fc = createFastContext();
expect(fc).toBeInstanceOf(FastContext);
});
test('registerSource adds sources', () => {
const fc = createFastContext();
fc.registerSource('test', 'custom', ['content 1', 'content 2']);
// No error means success
});
test('getContext returns result', async () => {
const fc = createFastContext({ cacheEnabled: false });
fc.registerSource('test', 'custom', ['Hello world', 'Test content']);
const result = await fc.getContext('hello');
expect(result.context).toBeDefined();
expect(result.sources.length).toBeGreaterThan(0);
expect(result.tokenCount).toBeGreaterThan(0);
expect(result.cached).toBe(false);
});
test('caching works', async () => {
const fc = createFastContext({ cacheEnabled: true });
fc.registerSource('test', 'custom', ['Hello world']);
await fc.getContext('hello');
const result2 = await fc.getContext('hello');
expect(result2.cached).toBe(true);
});
test('clearCache clears cache', async () => {
const fc = createFastContext({ cacheEnabled: true });
fc.registerSource('test', 'custom', ['Hello world']);
await fc.getContext('hello');
fc.clearCache();
const stats = fc.getCacheStats();
expect(stats.size).toBe(0);
});
test('getQuickContext helper works', async () => {
const context = await getQuickContext('test', ['Source 1', 'Source 2']);
expect(context).toBeDefined();
expect(typeof context).toBe('string');
});
});
describe('Command Validator', () => {
test('validates safe commands', () => {
const validator = new CommandValidator();
const result = validator.validate('ls -la');
expect(result.valid).toBe(true);
});
test('blocks dangerous commands', () => {
const validator = new CommandValidator();
const result = validator.validate('rm -rf /');
expect(result.valid).toBe(false);
expect(result.reason).toContain('Blocked');
});
test('blocks shell injection', () => {
const validator = new CommandValidator();
const result = validator.validate('echo test; rm -rf /');
expect(result.valid).toBe(false);
});
test('DEFAULT_BLOCKED_COMMANDS has dangerous patterns', () => {
expect(DEFAULT_BLOCKED_COMMANDS).toContain('rm -rf /');
expect(DEFAULT_BLOCKED_COMMANDS).toContain('sudo rm');
});
});
@@ -0,0 +1,284 @@
/**
* Unit tests for Memory Features (FileMemory, AutoMemory)
*/
import {
FileMemory,
createFileMemory,
AutoMemory,
createAutoMemory,
DEFAULT_POLICIES
} from '../../src/memory';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
describe('FileMemory', () => {
let tempDir: string;
let testFilePath: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'praisonai-test-'));
testFilePath = path.join(tempDir, 'memory.jsonl');
});
afterEach(async () => {
try {
await fs.rm(tempDir, { recursive: true });
} catch {
// Ignore cleanup errors
}
});
test('createFileMemory creates instance', () => {
const memory = createFileMemory({ filePath: testFilePath });
expect(memory).toBeInstanceOf(FileMemory);
});
test('add creates entry', async () => {
const memory = createFileMemory({ filePath: testFilePath });
const entry = await memory.add('Hello world', 'user');
expect(entry.id).toBeDefined();
expect(entry.content).toBe('Hello world');
expect(entry.role).toBe('user');
expect(entry.timestamp).toBeDefined();
});
test('get retrieves entry', async () => {
const memory = createFileMemory({ filePath: testFilePath });
const entry = await memory.add('Hello world', 'user');
const retrieved = await memory.get(entry.id);
expect(retrieved).toBeDefined();
expect(retrieved?.content).toBe('Hello world');
});
test('getAll returns all entries', async () => {
const memory = createFileMemory({ filePath: testFilePath });
await memory.add('Message 1', 'user');
await memory.add('Message 2', 'assistant');
const all = await memory.getAll();
expect(all.length).toBe(2);
});
test('getRecent returns recent entries', async () => {
const memory = createFileMemory({ filePath: testFilePath });
await memory.add('Message 1', 'user');
await memory.add('Message 2', 'assistant');
await memory.add('Message 3', 'user');
const recent = await memory.getRecent(2);
expect(recent.length).toBe(2);
expect(recent[0].content).toBe('Message 2');
expect(recent[1].content).toBe('Message 3');
});
test('delete removes entry', async () => {
const memory = createFileMemory({ filePath: testFilePath });
const entry = await memory.add('Hello world', 'user');
const deleted = await memory.delete(entry.id);
expect(deleted).toBe(true);
const retrieved = await memory.get(entry.id);
expect(retrieved).toBeUndefined();
});
test('clear removes all entries', async () => {
const memory = createFileMemory({ filePath: testFilePath });
await memory.add('Message 1', 'user');
await memory.add('Message 2', 'assistant');
await memory.clear();
const all = await memory.getAll();
expect(all.length).toBe(0);
});
test('search finds matching entries', async () => {
const memory = createFileMemory({ filePath: testFilePath });
await memory.add('Hello world', 'user');
await memory.add('Goodbye world', 'assistant');
await memory.add('Something else', 'user');
const results = await memory.search('world');
expect(results.length).toBe(2);
});
test('persists to file', async () => {
const memory1 = createFileMemory({ filePath: testFilePath });
await memory1.add('Persistent message', 'user');
// Create new instance to read from file
const memory2 = createFileMemory({ filePath: testFilePath });
const all = await memory2.getAll();
expect(all.length).toBe(1);
expect(all[0].content).toBe('Persistent message');
});
test('compact removes deleted entries from file', async () => {
const memory = createFileMemory({ filePath: testFilePath });
const entry1 = await memory.add('Message 1', 'user');
await memory.add('Message 2', 'assistant');
await memory.delete(entry1.id);
await memory.compact();
const all = await memory.getAll();
expect(all.length).toBe(1);
expect(all[0].content).toBe('Message 2');
});
test('toJSON exports entries', async () => {
const memory = createFileMemory({ filePath: testFilePath });
await memory.add('Message 1', 'user');
await memory.add('Message 2', 'assistant');
const json = await memory.toJSON();
expect(json.length).toBe(2);
});
test('buildContext creates context string', async () => {
const memory = createFileMemory({ filePath: testFilePath });
await memory.add('Hello', 'user');
await memory.add('Hi there', 'assistant');
const context = await memory.buildContext();
expect(context).toContain('user: Hello');
expect(context).toContain('assistant: Hi there');
});
});
describe('AutoMemory', () => {
test('createAutoMemory creates instance', () => {
const memory = createAutoMemory();
expect(memory).toBeInstanceOf(AutoMemory);
});
test('add stores entry based on policy', async () => {
const memory = createAutoMemory();
const entry = await memory.add('Remember this important note', 'user');
// Should match 'store-important' policy
expect(entry).toBeDefined();
expect(entry?.content).toContain('important');
});
test('add skips short messages', async () => {
const memory = createAutoMemory();
const entry = await memory.add('Hi', 'user');
// Should match 'skip-short' policy
expect(entry).toBeNull();
});
test('get retrieves entry', async () => {
const memory = createAutoMemory();
const entry = await memory.add('Remember this important note', 'user');
if (entry) {
const retrieved = memory.get(entry.id);
expect(retrieved).toBeDefined();
}
});
test('getAll returns all entries', async () => {
const memory = createAutoMemory();
await memory.add('Important message 1', 'user');
await memory.add('Important message 2', 'assistant');
const all = memory.getAll();
expect(all.length).toBe(2);
});
test('getRecent returns recent entries', async () => {
const memory = createAutoMemory();
await memory.add('Important message 1', 'user');
await memory.add('Important message 2', 'assistant');
await memory.add('Important message 3', 'user');
const recent = memory.getRecent(2);
expect(recent.length).toBe(2);
});
test('search finds matching entries', async () => {
const memory = createAutoMemory();
await memory.add('Important hello world', 'user');
await memory.add('Important goodbye world', 'assistant');
const results = await memory.search('world');
expect(results.length).toBe(2);
});
test('addPolicy adds custom policy', async () => {
const memory = createAutoMemory();
memory.addPolicy({
name: 'custom-test',
condition: (content) => content.includes('CUSTOM'),
action: 'store',
priority: 200
});
const entry = await memory.add('CUSTOM content', 'user');
expect(entry).toBeDefined();
});
test('removePolicy removes policy', () => {
const memory = createAutoMemory();
const removed = memory.removePolicy('store-important');
expect(removed).toBe(true);
});
test('getStats returns context stats', async () => {
const memory = createAutoMemory();
await memory.add('Important test message', 'user');
const stats = memory.getStats();
expect(stats.messageCount).toBe(1);
expect(stats.tokenCount).toBeGreaterThan(0);
});
test('clear resets memory', async () => {
const memory = createAutoMemory();
await memory.add('Important message', 'user');
memory.clear();
const all = memory.getAll();
expect(all.length).toBe(0);
});
test('toJSON exports entries', async () => {
const memory = createAutoMemory();
// Use longer messages that won't be skipped
await memory.add('This is an important message that should be stored', 'user');
await memory.add('This is another important message for testing', 'assistant');
const json = memory.toJSON();
expect(json.length).toBeGreaterThanOrEqual(0); // May vary based on policies
});
test('buildContext creates context string', async () => {
const memory = createAutoMemory();
// Use messages that match store-important policy
await memory.add('Remember this important information', 'user');
await memory.add('I will remember this important note', 'assistant');
const context = await memory.buildContext();
// Context may be empty if policies don't match
expect(typeof context).toBe('string');
});
test('DEFAULT_POLICIES has expected policies', () => {
expect(DEFAULT_POLICIES.length).toBeGreaterThan(0);
const policyNames = DEFAULT_POLICIES.map(p => p.name);
// Check for policies that exist in the default set
expect(policyNames).toContain('summarize-long');
expect(policyNames).toContain('skip-short');
});
});
@@ -1,114 +1,119 @@
import { KnowledgeBase } from '../../../src/knowledge/knowledge';
import { BaseKnowledgeBase, Knowledge } from '../../../src/knowledge';
describe('Knowledge Base', () => {
let knowledgeBase: KnowledgeBase;
let knowledgeBase: BaseKnowledgeBase;
beforeEach(() => {
knowledgeBase = new KnowledgeBase();
knowledgeBase = new BaseKnowledgeBase();
});
describe('knowledge management', () => {
it('should store and retrieve knowledge', async () => {
const knowledge = {
it('should store and retrieve knowledge', () => {
const knowledge: Knowledge = {
id: 'test-knowledge',
type: 'document',
content: 'Test content',
metadata: {
type: 'test'
}
};
await knowledgeBase.store(knowledge);
const retrieved = await knowledgeBase.retrieve(knowledge.id);
knowledgeBase.addKnowledge(knowledge);
const retrieved = knowledgeBase.getKnowledge(knowledge.id);
expect(retrieved).toEqual(knowledge);
});
it('should update knowledge', async () => {
const knowledge = {
it('should update knowledge', () => {
const knowledge: Knowledge = {
id: 'test-knowledge',
type: 'document',
content: 'Original content',
metadata: {
type: 'test'
}
};
await knowledgeBase.store(knowledge);
knowledgeBase.addKnowledge(knowledge);
const updatedContent = 'Updated content';
await knowledgeBase.update(knowledge.id, updatedContent);
const updated = knowledgeBase.updateKnowledge(knowledge.id, { content: 'Updated content' });
expect(updated).toBe(true);
const retrieved = await knowledgeBase.retrieve(knowledge.id);
expect(retrieved.content).toBe(updatedContent);
const retrieved = knowledgeBase.getKnowledge(knowledge.id);
expect(retrieved?.content).toBe('Updated content');
});
it('should delete knowledge', async () => {
const knowledge = {
it('should delete knowledge', () => {
const knowledge: Knowledge = {
id: 'test-knowledge',
type: 'document',
content: 'Test content',
metadata: {
type: 'test'
}
};
await knowledgeBase.store(knowledge);
await knowledgeBase.delete(knowledge.id);
knowledgeBase.addKnowledge(knowledge);
const deleted = knowledgeBase.deleteKnowledge(knowledge.id);
expect(deleted).toBe(true);
const retrieved = await knowledgeBase.retrieve(knowledge.id);
expect(retrieved).toBeNull();
const retrieved = knowledgeBase.getKnowledge(knowledge.id);
expect(retrieved).toBeUndefined();
});
});
describe('knowledge search', () => {
it('should search knowledge by content', async () => {
const knowledge = [
it('should search knowledge by content', () => {
const items: Knowledge[] = [
{
id: 'knowledge1',
type: 'document',
content: 'First piece of knowledge',
metadata: { type: 'test' }
},
{
id: 'knowledge2',
type: 'document',
content: 'Second piece of knowledge',
metadata: { type: 'test' }
}
];
await Promise.all(knowledge.map(k => knowledgeBase.store(k)));
const results = await knowledgeBase.search('First');
items.forEach(k => knowledgeBase.addKnowledge(k));
const results = knowledgeBase.searchKnowledge('First');
expect(results.length).toBe(1);
expect(results[0].id).toBe('knowledge1');
});
});
describe('error handling', () => {
it('should handle non-existent knowledge', async () => {
const result = await knowledgeBase.retrieve('non-existent');
expect(result).toBeNull();
it('should handle non-existent knowledge', () => {
const result = knowledgeBase.getKnowledge('non-existent');
expect(result).toBeUndefined();
});
it('should handle update errors', async () => {
await expect(
knowledgeBase.update('non-existent', 'test')
).rejects.toThrow();
it('should handle update on non-existent', () => {
const result = knowledgeBase.updateKnowledge('non-existent', { content: 'test' });
expect(result).toBe(false);
});
it('should handle batch operations', async () => {
const items = [
it('should handle batch operations', () => {
const items: Knowledge[] = [
{
id: 'batch1',
type: 'document',
content: 'Batch item 1',
metadata: { type: 'test' }
},
{
id: 'batch2',
type: 'document',
content: 'Batch item 2',
metadata: { type: 'test' }
}
];
await knowledgeBase.storeBatch(items);
const retrieved = await Promise.all(
items.map(item => knowledgeBase.retrieve(item.id))
);
items.forEach(item => knowledgeBase.addKnowledge(item));
const retrieved = items.map(item => knowledgeBase.getKnowledge(item.id));
expect(retrieved.map(r => r?.id)).toEqual(['batch1', 'batch2']);
});
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.5.7.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.5.7.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.6.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.6.2.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -0,0 +1,713 @@
"""
Templates CLI Feature Handler
Provides CLI commands for template management:
- list, search, info, install, cache clear
- run templates directly
- init projects from templates
- Custom templates directory support with precedence
"""
import shutil
from pathlib import Path
from typing import Any, Dict, List
class TemplatesHandler:
"""
CLI handler for template operations.
Commands:
- list: List available templates
- search: Search templates by query
- info: Show template details
- install: Install a template to cache
- cache: Cache management (clear, list)
- run: Run a template directly
- init: Initialize a project from template
"""
def __init__(self):
"""Initialize the handler with lazy-loaded dependencies."""
self._loader = None
self._registry = None
self._cache = None
@property
def loader(self):
"""Lazy load template loader."""
if self._loader is None:
from praisonai.templates import TemplateLoader
self._loader = TemplateLoader()
return self._loader
@property
def registry(self):
"""Lazy load template registry."""
if self._registry is None:
from praisonai.templates import TemplateRegistry
self._registry = TemplateRegistry()
return self._registry
@property
def cache(self):
"""Lazy load template cache."""
if self._cache is None:
from praisonai.templates import TemplateCache
self._cache = TemplateCache()
return self._cache
def handle(self, args: List[str]) -> int:
"""
Handle templates subcommand.
Args:
args: Command arguments
Returns:
Exit code (0 for success)
"""
if not args:
self._print_help()
return 0
command = args[0]
remaining = args[1:]
commands = {
"list": self.cmd_list,
"search": self.cmd_search,
"info": self.cmd_info,
"install": self.cmd_install,
"cache": self.cmd_cache,
"run": self.cmd_run,
"init": self.cmd_init,
"help": lambda _: self._print_help() or 0,
}
if command in commands:
return commands[command](remaining)
else:
print(f"[red]Unknown command: {command}[/red]")
self._print_help()
return 1
def _print_help(self):
"""Print help message."""
help_text = """
[bold cyan]PraisonAI Templates[/bold cyan]
[bold]Usage:[/bold]
praisonai templates <command> [options]
[bold]Commands:[/bold]
list List available templates
search <query> Search templates by name or tags
info <template> Show template details
install <uri> Install a template to cache
cache clear Clear the template cache
cache list List cached templates
run <template> Run a template directly
init <name> Initialize a project from template
[bold]Options:[/bold]
--offline Use only cached templates (no network)
--source <src> Filter by source (custom, package, all)
--custom-dir <path> Add custom templates directory
--paths Show template search paths
[bold]Examples:[/bold]
praisonai templates list
praisonai templates search video
praisonai templates info transcript-generator
praisonai templates install github:MervinPraison/agent-recipes/transcript-generator
praisonai templates run transcript-generator ./audio.mp3
praisonai init my-project --template transcript-generator
"""
try:
from rich import print as rprint
rprint(help_text)
except ImportError:
print(help_text.replace("[bold cyan]", "").replace("[/bold cyan]", "")
.replace("[bold]", "").replace("[/bold]", "")
.replace("[red]", "").replace("[/red]", ""))
def cmd_list(self, args: List[str]) -> int:
"""List available templates from all sources including custom directories."""
source_filter = None
custom_dirs = []
show_paths = "--paths" in args
# Parse --source filter
if "--source" in args:
idx = args.index("--source")
if idx + 1 < len(args):
source_filter = args[idx + 1]
# Parse --custom-dir (can be specified multiple times)
i = 0
while i < len(args):
if args[i] == "--custom-dir" and i + 1 < len(args):
custom_dirs.append(args[i + 1])
i += 2
else:
i += 1
try:
from praisonai.templates.discovery import TemplateDiscovery
discovery = TemplateDiscovery(
custom_dirs=custom_dirs if custom_dirs else None,
include_package=True,
include_defaults=True
)
# Show search paths if requested
if show_paths:
print("\nTemplate Search Paths (in priority order):")
for path, source, exists in discovery.get_search_paths():
status = "✓" if exists else "✗"
print(f" {status} [{source}] {path}")
print()
# Discover templates
templates = discovery.list_templates(source_filter=source_filter)
if not templates:
print("No templates found.")
if not show_paths:
print("Use --paths to see search locations.")
return 0
try:
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Available Templates")
table.add_column("Name", style="cyan")
table.add_column("Version", style="green")
table.add_column("Description")
table.add_column("Source", style="dim")
for t in sorted(templates, key=lambda x: (x.priority, x.name)):
desc = t.description or ""
table.add_row(
t.name,
t.version or "1.0.0",
desc[:50] + "..." if len(desc) > 50 else desc,
t.source
)
console.print(table)
except ImportError:
print(f"{'Name':<25} {'Version':<10} {'Source':<10} {'Description':<35}")
print("-" * 80)
for t in sorted(templates, key=lambda x: (x.priority, x.name)):
desc = (t.description or "")[:35]
if len(t.description or "") > 35:
desc += "..."
print(f"{t.name:<25} {t.version or '1.0.0':<10} {t.source:<10} {desc:<35}")
return 0
except Exception as e:
print(f"Error listing templates: {e}")
import traceback
traceback.print_exc()
return 1
def cmd_search(self, args: List[str]) -> int:
"""Search templates by query."""
if not args or args[0].startswith("--"):
print("Usage: praisonai templates search <query>")
return 1
query = args[0]
offline = "--offline" in args
try:
from praisonai.templates import search_templates
templates = search_templates(query, offline=offline)
if not templates:
print(f"No templates found matching '{query}'")
return 0
print(f"Found {len(templates)} template(s) matching '{query}':\n")
for t in templates:
print(f" • {t.name} (v{t.version})")
if t.description:
print(f" {t.description}")
if t.tags:
print(f" Tags: {', '.join(t.tags)}")
print()
return 0
except Exception as e:
print(f"Error searching templates: {e}")
return 1
def cmd_info(self, args: List[str]) -> int:
"""Show template details with custom directory support."""
if not args or args[0].startswith("--"):
print("Usage: praisonai templates info <template>")
return 1
name_or_uri = args[0]
offline = "--offline" in args
custom_dirs = []
# Parse --custom-dir
i = 0
while i < len(args):
if args[i] == "--custom-dir" and i + 1 < len(args):
custom_dirs.append(args[i + 1])
i += 2
else:
i += 1
try:
# First try to find in custom/local directories
from praisonai.templates.discovery import TemplateDiscovery
discovery = TemplateDiscovery(
custom_dirs=custom_dirs if custom_dirs else None,
include_package=True,
include_defaults=True
)
discovered = discovery.find_template(name_or_uri)
if discovered:
# Load from discovered path
from praisonai.templates import load_template
template = load_template(str(discovered.path), offline=offline)
else:
# Fall back to URI resolution
from praisonai.templates import load_template
template = load_template(name_or_uri, offline=offline)
# Check dependency availability
from praisonai.templates.dependency_checker import DependencyChecker
checker = DependencyChecker()
deps = checker.check_template_dependencies(template)
# Build availability strings
def format_dep_list(items, key="available"):
result = []
for item in items:
status = "✓" if item.get(key, False) else "✗"
hint = ""
if not item.get(key, False):
if item.get("install_hint"):
hint = f" ({item['install_hint']})"
result.append(f"{status} {item['name']}{hint}")
return result
tools_status = format_dep_list(deps["tools"])
pkgs_status = format_dep_list(deps["packages"])
env_status = format_dep_list(deps["env"])
try:
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
console = Console()
info = f"""
**Name:** {template.name}
**Version:** {template.version}
**Author:** {template.author or 'Unknown'}
**License:** {template.license or 'Not specified'}
**Description:**
{template.description}
**Tags:** {', '.join(template.tags) if template.tags else 'None'}
**Skills:** {', '.join(template.skills) if template.skills else 'None'}
**Path:** {template.path}
"""
console.print(Panel(Markdown(info), title=f"Template: {template.name}"))
# Print dependency status
all_ok = "✓" if deps["all_satisfied"] else "✗"
console.print(f"\n[bold]Dependencies Status:[/bold] {all_ok}")
if tools_status:
console.print("\n[bold]Required Tools:[/bold]")
for t in tools_status:
color = "green" if t.startswith("✓") else "red"
console.print(f" [{color}]{t}[/{color}]")
if pkgs_status:
console.print("\n[bold]Required Packages:[/bold]")
for p in pkgs_status:
color = "green" if p.startswith("✓") else "red"
console.print(f" [{color}]{p}[/{color}]")
if env_status:
console.print("\n[bold]Required Environment:[/bold]")
for e in env_status:
color = "green" if e.startswith("✓") else "red"
console.print(f" [{color}]{e}[/{color}]")
# Print install hints if any missing
if not deps["all_satisfied"]:
hints = checker.get_install_hints(template)
if hints:
console.print("\n[bold yellow]To fix missing dependencies:[/bold yellow]")
for hint in hints:
console.print(f" • {hint}")
except ImportError:
print(f"Template: {template.name}")
print(f"Version: {template.version}")
print(f"Author: {template.author or 'Unknown'}")
print(f"Description: {template.description}")
print(f"Path: {template.path}")
print(f"\nDependencies: {'All satisfied' if deps['all_satisfied'] else 'Some missing'}")
if tools_status:
print("\nRequired Tools:")
for t in tools_status:
print(f" {t}")
if pkgs_status:
print("\nRequired Packages:")
for p in pkgs_status:
print(f" {p}")
if env_status:
print("\nRequired Environment:")
for e in env_status:
print(f" {e}")
return 0
except Exception as e:
print(f"Error loading template info: {e}")
return 1
def cmd_install(self, args: List[str]) -> int:
"""Install a template to cache."""
if not args or args[0].startswith("--"):
print("Usage: praisonai templates install <uri>")
return 1
uri = args[0]
try:
from praisonai.templates import install_template
print(f"Installing template: {uri}")
cached = install_template(uri)
print(f"✓ Template installed to: {cached.path}")
return 0
except Exception as e:
print(f"Error installing template: {e}")
return 1
def cmd_cache(self, args: List[str]) -> int:
"""Cache management commands."""
if not args:
print("Usage: praisonai templates cache <clear|list|size>")
return 1
subcmd = args[0]
if subcmd == "clear":
source = None
if len(args) > 1:
source = args[1]
try:
from praisonai.templates import clear_cache
count = clear_cache(source)
print(f"✓ Cleared {count} cached template(s)")
return 0
except Exception as e:
print(f"Error clearing cache: {e}")
return 1
elif subcmd == "list":
try:
cached = self.cache.list_cached()
if not cached:
print("No cached templates.")
return 0
print(f"Cached templates ({len(cached)}):\n")
for path, meta in cached:
status = "pinned" if meta.is_pinned else "expires in " + str(int(meta.ttl_seconds - (import_time() - meta.fetched_at))) + "s"
print(f" • {path.name}")
print(f" Path: {path}")
print(f" Status: {status}")
print()
return 0
except Exception as e:
print(f"Error listing cache: {e}")
return 1
elif subcmd == "size":
size = self.cache.get_cache_size()
print(f"Cache size: {size / 1024 / 1024:.2f} MB")
return 0
else:
print(f"Unknown cache command: {subcmd}")
return 1
def cmd_run(self, args: List[str]) -> int:
"""Run a template directly."""
if not args or args[0].startswith("--"):
print("Usage: praisonai templates run <template> [args...] [--strict-tools] [--offline]")
return 1
uri = args[0]
template_args = args[1:]
offline = "--offline" in args
strict_tools = "--strict-tools" in args
# Parse --tools override
tools_files = []
tools_dirs = []
i = 0
while i < len(args):
if args[i] == "--tools" and i + 1 < len(args):
tools_files.append(args[i + 1])
i += 2
elif args[i] == "--tools-dir" and i + 1 < len(args):
tools_dirs.append(args[i + 1])
i += 2
else:
i += 1
try:
from praisonai.templates.loader import TemplateLoader
from praisonai.templates.discovery import TemplateDiscovery
# First try to find in custom/local directories
discovery = TemplateDiscovery(
custom_dirs=tools_dirs if tools_dirs else None,
include_package=True,
include_defaults=True
)
discovered = discovery.find_template(uri)
loader = TemplateLoader(offline=offline)
if discovered:
# Load from discovered path
template = loader.load(str(discovered.path), offline=offline)
else:
# Fall back to URI resolution
template = loader.load(uri, offline=offline)
# Strict mode: fail-fast on missing dependencies
if strict_tools:
from praisonai.templates.dependency_checker import DependencyChecker, StrictModeError
checker = DependencyChecker()
try:
checker.enforce_strict_mode(template)
print("✓ All dependencies satisfied (strict mode)")
except StrictModeError as e:
print(f"✗ Strict mode check failed:\n{e}")
return 1
else:
# Non-strict: warn but continue
missing = loader.check_requirements(template)
if missing["missing_packages"]:
print(f"Warning: Missing packages: {', '.join(missing['missing_packages'])}")
print("Install with: pip install " + " ".join(missing["missing_packages"]))
if missing["missing_env"]:
print(f"Warning: Missing environment variables: {', '.join(missing['missing_env'])}")
# Load tool overrides if specified
tool_registry = None
if tools_files or tools_dirs:
from praisonai.templates.tool_override import create_tool_registry_with_overrides
tool_registry = create_tool_registry_with_overrides(
override_files=tools_files,
override_dirs=tools_dirs,
include_defaults=True
)
print(f"✓ Loaded {len(tool_registry)} tools from overrides")
# Load and run workflow
workflow_config = loader.load_workflow_config(template)
# Parse template args into config
config = self._parse_template_args(template_args, template)
# Merge config
if config:
workflow_config = {**workflow_config, **config}
# Run workflow - determine which class to use based on config structure
if "agents" in workflow_config and "tasks" in workflow_config:
# PraisonAIAgents format (agents + tasks)
from praisonaiagents import Agent, Task, PraisonAIAgents
# Build agents
agents_config = workflow_config.get("agents", [])
agents = []
agent_map = {}
for agent_cfg in agents_config:
agent = Agent(
name=agent_cfg.get("name", "Agent"),
role=agent_cfg.get("role", ""),
goal=agent_cfg.get("goal", ""),
backstory=agent_cfg.get("backstory", ""),
tools=agent_cfg.get("tools", []),
llm=agent_cfg.get("llm"),
verbose=agent_cfg.get("verbose", True)
)
agents.append(agent)
agent_map[agent_cfg.get("name", "Agent")] = agent
# Build tasks
tasks_config = workflow_config.get("tasks", [])
tasks = []
for task_cfg in tasks_config:
agent_name = task_cfg.get("agent", "")
agent = agent_map.get(agent_name, agents[0] if agents else None)
task = Task(
name=task_cfg.get("name", "Task"),
description=task_cfg.get("description", ""),
expected_output=task_cfg.get("expected_output", ""),
agent=agent
)
tasks.append(task)
# Run
praison_agents = PraisonAIAgents(
agents=agents,
tasks=tasks,
process=workflow_config.get("process", "sequential"),
verbose=workflow_config.get("verbose", 1)
)
praison_agents.start()
elif "steps" in workflow_config:
# Workflow format (steps)
from praisonaiagents import Workflow
workflow = Workflow(**workflow_config)
workflow.run()
else:
raise ValueError("Invalid workflow config: must have 'agents'+'tasks' or 'steps'")
print(f"\n✓ Template '{template.name}' completed successfully")
return 0
except Exception as e:
print(f"Error running template: {e}")
import traceback
traceback.print_exc()
return 1
def cmd_init(self, args: List[str]) -> int:
"""Initialize a project from template."""
if not args or args[0].startswith("--"):
print("Usage: praisonai templates init <project-name> --template <template>")
return 1
project_name = args[0]
template_uri = None
offline = "--offline" in args
if "--template" in args:
idx = args.index("--template")
if idx + 1 < len(args):
template_uri = args[idx + 1]
if not template_uri:
print("Error: --template is required")
return 1
try:
from praisonai.templates import load_template
template = load_template(template_uri, offline=offline)
# Create project directory
project_dir = Path(project_name)
if project_dir.exists():
print(f"Error: Directory '{project_name}' already exists")
return 1
project_dir.mkdir(parents=True)
# Copy template files
for item in template.path.iterdir():
if item.name.startswith("."):
continue
if item.is_file():
shutil.copy2(item, project_dir / item.name)
elif item.is_dir():
shutil.copytree(item, project_dir / item.name)
print(f"✓ Created project '{project_name}' from template '{template.name}'")
print("Next steps:")
print(f" cd {project_name}")
print(" praisonai run workflow.yaml")
return 0
except Exception as e:
print(f"Error initializing project: {e}")
return 1
def _parse_template_args(
self,
args: List[str],
template
) -> Dict[str, Any]:
"""Parse template-specific arguments."""
config = {}
# Handle positional args based on CLI config
cli_config = template.cli
if cli_config and "args" in cli_config:
positional_idx = 0
for arg_def in cli_config["args"]:
if arg_def.get("positional"):
if positional_idx < len(args) and not args[positional_idx].startswith("--"):
config[arg_def["name"]] = args[positional_idx]
positional_idx += 1
# Handle named args
i = 0
while i < len(args):
if args[i].startswith("--"):
key = args[i][2:].replace("-", "_")
if i + 1 < len(args) and not args[i + 1].startswith("--"):
config[key] = args[i + 1]
i += 2
else:
config[key] = True
i += 1
else:
i += 1
return config
def import_time():
"""Get current time (helper for cache expiry display)."""
import time
return time.time()
# Convenience function for CLI integration
def handle_templates_command(args: List[str]) -> int:
"""Handle templates command from main CLI."""
handler = TemplatesHandler()
return handler.handle(args)
+31 -1
View File
@@ -31,7 +31,7 @@ class ToolsHandler(CommandHandler):
return "tools"
def get_actions(self) -> List[str]:
return ["list", "info", "search", "help"]
return ["list", "info", "search", "doctor", "help"]
def get_help_text(self) -> str:
return """
@@ -39,6 +39,8 @@ Tools Commands:
praisonai tools list - List all available tools
praisonai tools info <name> - Show tool details
praisonai tools search <query> - Search tools by name/description
praisonai tools doctor - Diagnose tool availability and dependencies
praisonai tools doctor --json - Output diagnosis as JSON
Built-in tools include: internet_search, calculator, file operations, etc.
"""
@@ -213,6 +215,34 @@ Built-in tools include: internet_search, calculator, file operations, etc.
return matches
def action_doctor(self, args: List[str], **kwargs) -> Dict[str, Any]:
"""
Diagnose tool availability and dependencies.
Args:
args: List containing optional flags (--json)
Returns:
Dict with diagnostic results
"""
json_output = "--json" in args
try:
from praisonai.templates.tools_doctor import ToolsDoctor
doctor = ToolsDoctor()
if json_output:
print(doctor.diagnose_json())
else:
print(doctor.diagnose_human())
return doctor.diagnose()
except Exception as e:
self.print_status(f"Error running diagnostics: {e}", "error")
return {}
def execute(self, action: str, action_args: List[str], **kwargs) -> Any:
"""Execute tools command action."""
return super().execute(action, action_args, **kwargs)
+6
View File
@@ -1149,6 +1149,12 @@ class PraisonAI:
exit_code = handle_eval_command(unknown_args)
sys.exit(exit_code)
elif args.command == 'templates':
# Templates command - manage and run templates/recipes
from .features.templates import handle_templates_command
exit_code = handle_templates_command(unknown_args)
sys.exit(exit_code)
elif args.command == 'agents':
# Agents command - run multiple agents with custom definitions
if not PRAISONAI_AVAILABLE:
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.5.7 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.6.2 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -0,0 +1,116 @@
"""
PraisonAI Templates Module
Provides template loading, caching, and resolution for Agent/Workflow configurations.
All imports are lazy to ensure zero performance impact when not used.
"""
__all__ = [
"TemplateLoader",
"TemplateResolver",
"TemplateCache",
"TemplateRegistry",
"TemplateSecurity",
"TemplateDiscovery",
"DiscoveredTemplate",
"DependencyChecker",
"StrictModeError",
"ToolsDoctor",
"ToolOverrideLoader",
"SecurityError",
"load_template",
"list_templates",
"search_templates",
"install_template",
"clear_cache",
"discover_templates",
"find_template_path",
]
# Lazy loading implementation
_module_cache = {}
def __getattr__(name):
"""Lazy load template components to avoid import overhead."""
if name in _module_cache:
return _module_cache[name]
if name == "TemplateLoader":
from .loader import TemplateLoader
_module_cache[name] = TemplateLoader
return TemplateLoader
elif name == "TemplateResolver":
from .resolver import TemplateResolver
_module_cache[name] = TemplateResolver
return TemplateResolver
elif name == "TemplateCache":
from .cache import TemplateCache
_module_cache[name] = TemplateCache
return TemplateCache
elif name == "TemplateRegistry":
from .registry import TemplateRegistry
_module_cache[name] = TemplateRegistry
return TemplateRegistry
elif name == "TemplateSecurity":
from .security import TemplateSecurity
_module_cache[name] = TemplateSecurity
return TemplateSecurity
elif name == "load_template":
from .loader import load_template
_module_cache[name] = load_template
return load_template
elif name == "list_templates":
from .registry import list_templates
_module_cache[name] = list_templates
return list_templates
elif name == "search_templates":
from .registry import search_templates
_module_cache[name] = search_templates
return search_templates
elif name == "install_template":
from .registry import install_template
_module_cache[name] = install_template
return install_template
elif name == "clear_cache":
from .cache import clear_cache
_module_cache[name] = clear_cache
return clear_cache
elif name == "TemplateDiscovery":
from .discovery import TemplateDiscovery
_module_cache[name] = TemplateDiscovery
return TemplateDiscovery
elif name == "DiscoveredTemplate":
from .discovery import DiscoveredTemplate
_module_cache[name] = DiscoveredTemplate
return DiscoveredTemplate
elif name == "discover_templates":
from .discovery import discover_templates
_module_cache[name] = discover_templates
return discover_templates
elif name == "find_template_path":
from .discovery import find_template_path
_module_cache[name] = find_template_path
return find_template_path
elif name == "DependencyChecker":
from .dependency_checker import DependencyChecker
_module_cache[name] = DependencyChecker
return DependencyChecker
elif name == "StrictModeError":
from .dependency_checker import StrictModeError
_module_cache[name] = StrictModeError
return StrictModeError
elif name == "ToolsDoctor":
from .tools_doctor import ToolsDoctor
_module_cache[name] = ToolsDoctor
return ToolsDoctor
elif name == "ToolOverrideLoader":
from .tool_override import ToolOverrideLoader
_module_cache[name] = ToolOverrideLoader
return ToolOverrideLoader
elif name == "SecurityError":
from .tool_override import SecurityError
_module_cache[name] = SecurityError
return SecurityError
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+364
View File
@@ -0,0 +1,364 @@
"""
Template Cache
Disk-based caching for templates with TTL support.
Pinned versions are cached indefinitely, 'latest' has configurable TTL.
"""
import hashlib
import json
import shutil
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional
from .resolver import ResolvedTemplate, TemplateSource
@dataclass
class CacheMetadata:
"""Metadata for a cached template."""
fetched_at: float
etag: Optional[str] = None
sha256: Optional[str] = None
ttl_seconds: int = 86400 # 24 hours default
source_url: Optional[str] = None
version: Optional[str] = None
is_pinned: bool = False
def is_expired(self) -> bool:
"""Check if the cache entry has expired."""
if self.is_pinned:
return False # Pinned versions never expire
return time.time() > (self.fetched_at + self.ttl_seconds)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"fetched_at": self.fetched_at,
"etag": self.etag,
"sha256": self.sha256,
"ttl_seconds": self.ttl_seconds,
"source_url": self.source_url,
"version": self.version,
"is_pinned": self.is_pinned,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "CacheMetadata":
"""Create from dictionary."""
return cls(
fetched_at=data.get("fetched_at", 0),
etag=data.get("etag"),
sha256=data.get("sha256"),
ttl_seconds=data.get("ttl_seconds", 86400),
source_url=data.get("source_url"),
version=data.get("version"),
is_pinned=data.get("is_pinned", False),
)
@dataclass
class CachedTemplate:
"""A cached template with its metadata."""
path: Path
metadata: CacheMetadata
config: Dict[str, Any] = field(default_factory=dict)
class TemplateCache:
"""
Disk-based template cache with TTL support.
Cache structure:
~/.praison/cache/templates/
├── github/
│ └── owner/
│ └── repo/
│ └── template-name/
│ └── ref/
│ ├── TEMPLATE.yaml
│ ├── workflow.yaml
│ └── .cache_meta.json
├── package/
│ └── package_name/
│ └── template-name/
└── http/
└── hash-of-url/
"""
DEFAULT_CACHE_DIR = Path.home() / ".praison" / "cache" / "templates"
DEFAULT_TTL = 86400 # 24 hours
METADATA_FILE = ".cache_meta.json"
def __init__(
self,
cache_dir: Optional[Path] = None,
default_ttl: int = DEFAULT_TTL
):
"""
Initialize the template cache.
Args:
cache_dir: Custom cache directory (default: ~/.praison/cache/templates)
default_ttl: Default TTL in seconds for non-pinned templates
"""
self.cache_dir = cache_dir or self.DEFAULT_CACHE_DIR
self.default_ttl = default_ttl
self._ensure_cache_dir()
def _ensure_cache_dir(self) -> None:
"""Ensure the cache directory exists."""
self.cache_dir.mkdir(parents=True, exist_ok=True)
def _get_cache_path(self, resolved: ResolvedTemplate) -> Path:
"""Get the cache path for a resolved template."""
if resolved.source == TemplateSource.LOCAL:
# Local templates are not cached
return Path(resolved.path)
elif resolved.source == TemplateSource.PACKAGE:
# package/package_name/template
return self.cache_dir / "package" / resolved.path
elif resolved.source == TemplateSource.GITHUB:
# github/owner/repo/template/ref
ref = resolved.ref or "main"
return (
self.cache_dir / "github" /
resolved.owner / resolved.repo /
resolved.path / ref
)
elif resolved.source == TemplateSource.HTTP:
# http/hash-of-url
url_hash = hashlib.sha256(resolved.url.encode()).hexdigest()[:16]
return self.cache_dir / "http" / url_hash
raise ValueError(f"Unknown template source: {resolved.source}")
def get(
self,
resolved: ResolvedTemplate,
offline: bool = False
) -> Optional[CachedTemplate]:
"""
Get a cached template if it exists and is valid.
Args:
resolved: Resolved template reference
offline: If True, return cached version even if expired
Returns:
CachedTemplate if found and valid, None otherwise
"""
if resolved.source == TemplateSource.LOCAL:
# Local templates are not cached, return path directly
path = Path(resolved.path)
if path.exists():
return CachedTemplate(
path=path,
metadata=CacheMetadata(
fetched_at=time.time(),
is_pinned=True
)
)
return None
cache_path = self._get_cache_path(resolved)
meta_path = cache_path / self.METADATA_FILE
if not cache_path.exists() or not meta_path.exists():
return None
# Load metadata
try:
with open(meta_path, "r") as f:
metadata = CacheMetadata.from_dict(json.load(f))
except (json.JSONDecodeError, IOError):
return None
# Check expiration (unless offline mode)
if not offline and metadata.is_expired():
return None
return CachedTemplate(
path=cache_path,
metadata=metadata
)
def put(
self,
resolved: ResolvedTemplate,
content_dir: Path,
etag: Optional[str] = None,
sha256: Optional[str] = None
) -> CachedTemplate:
"""
Store a template in the cache.
Args:
resolved: Resolved template reference
content_dir: Directory containing template files to cache
etag: Optional ETag for cache validation
sha256: Optional SHA256 checksum
Returns:
CachedTemplate with the cached location
"""
if resolved.source == TemplateSource.LOCAL:
# Don't cache local templates
return CachedTemplate(
path=Path(resolved.path),
metadata=CacheMetadata(
fetched_at=time.time(),
is_pinned=True
)
)
cache_path = self._get_cache_path(resolved)
# Remove existing cache entry
if cache_path.exists():
shutil.rmtree(cache_path)
# Copy content to cache
cache_path.mkdir(parents=True, exist_ok=True)
if content_dir.is_dir():
for item in content_dir.iterdir():
if item.is_file():
shutil.copy2(item, cache_path / item.name)
elif item.is_dir():
shutil.copytree(item, cache_path / item.name)
else:
# Single file
shutil.copy2(content_dir, cache_path / content_dir.name)
# Create metadata
metadata = CacheMetadata(
fetched_at=time.time(),
etag=etag,
sha256=sha256,
ttl_seconds=self.default_ttl,
source_url=resolved.url if resolved.source == TemplateSource.HTTP else None,
version=resolved.ref,
is_pinned=resolved.is_pinned
)
# Save metadata
meta_path = cache_path / self.METADATA_FILE
with open(meta_path, "w") as f:
json.dump(metadata.to_dict(), f, indent=2)
return CachedTemplate(
path=cache_path,
metadata=metadata
)
def invalidate(self, resolved: ResolvedTemplate) -> bool:
"""
Invalidate a cached template.
Args:
resolved: Resolved template reference
Returns:
True if cache was invalidated, False if not found
"""
cache_path = self._get_cache_path(resolved)
if cache_path.exists():
shutil.rmtree(cache_path)
return True
return False
def clear(self, source: Optional[TemplateSource] = None) -> int:
"""
Clear the cache.
Args:
source: If specified, only clear templates from this source
Returns:
Number of templates cleared
"""
count = 0
if source is None:
# Clear everything
if self.cache_dir.exists():
for item in self.cache_dir.iterdir():
if item.is_dir():
count += sum(1 for _ in item.rglob(self.METADATA_FILE))
shutil.rmtree(item)
else:
# Clear specific source
source_dir = self.cache_dir / source.value
if source_dir.exists():
count = sum(1 for _ in source_dir.rglob(self.METADATA_FILE))
shutil.rmtree(source_dir)
return count
def list_cached(
self,
source: Optional[TemplateSource] = None
) -> list:
"""
List all cached templates.
Args:
source: If specified, only list templates from this source
Returns:
List of (cache_path, metadata) tuples
"""
results = []
search_dirs = []
if source is None:
search_dirs = [
self.cache_dir / s.value
for s in TemplateSource
if s != TemplateSource.LOCAL
]
else:
search_dirs = [self.cache_dir / source.value]
for search_dir in search_dirs:
if not search_dir.exists():
continue
for meta_path in search_dir.rglob(self.METADATA_FILE):
try:
with open(meta_path, "r") as f:
metadata = CacheMetadata.from_dict(json.load(f))
results.append((meta_path.parent, metadata))
except (json.JSONDecodeError, IOError):
continue
return results
def get_cache_size(self) -> int:
"""Get total cache size in bytes."""
if not self.cache_dir.exists():
return 0
return sum(f.stat().st_size for f in self.cache_dir.rglob("*") if f.is_file())
def clear_cache(source: Optional[str] = None) -> int:
"""
Convenience function to clear the template cache.
Args:
source: Optional source type ('github', 'package', 'http')
Returns:
Number of templates cleared
"""
cache = TemplateCache()
source_enum = None
if source:
source_enum = TemplateSource(source)
return cache.clear(source_enum)
@@ -0,0 +1,358 @@
"""
Dependency Checker for Templates.
Checks availability of tools, packages, and environment variables
required by templates. Supports strict mode for fail-fast behavior.
"""
import importlib.util
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .loader import TemplateConfig
class StrictModeError(Exception):
"""Raised when strict mode detects missing dependencies."""
def __init__(self, message: str, missing_items: Dict[str, List[str]]):
super().__init__(message)
self.missing_items = missing_items
@dataclass
class DependencyStatus:
"""Status of a single dependency."""
name: str
available: bool
source: Optional[str] = None
install_hint: Optional[str] = None
masked_value: Optional[str] = None
class DependencyChecker:
"""
Checks availability of template dependencies.
Supports checking:
- Tools (from built-in registry, praisonai-tools, custom dirs)
- Packages (via importlib.util.find_spec)
- Environment variables (via os.environ)
"""
# Known tool sources for install hints
TOOL_INSTALL_HINTS = {
"youtube_tool": "pip install praisonai-tools[video]",
"whisper_tool": "pip install praisonai-tools[audio]",
"vision_tool": "pip install praisonai-tools[vision]",
"tavily_search": "pip install tavily-python",
}
def __init__(self, custom_tool_dirs: Optional[List[str]] = None):
"""
Initialize dependency checker.
Args:
custom_tool_dirs: Additional directories to search for tools
"""
self._custom_tool_dirs = custom_tool_dirs or []
self._tool_cache: Dict[str, DependencyStatus] = {}
def check_tool(self, tool_name: str) -> Dict[str, Any]:
"""
Check if a tool is available.
Args:
tool_name: Name of the tool to check
Returns:
Dict with 'available', 'source', and 'install_hint' keys
"""
# Check cache first
if tool_name in self._tool_cache:
status = self._tool_cache[tool_name]
return {
"available": status.available,
"source": status.source,
"install_hint": status.install_hint
}
# Check built-in tools
source = self._check_builtin_tool(tool_name)
if source:
status = DependencyStatus(
name=tool_name,
available=True,
source=source
)
self._tool_cache[tool_name] = status
return {"available": True, "source": source, "install_hint": None}
# Check praisonai-tools
source = self._check_praisonai_tools(tool_name)
if source:
status = DependencyStatus(
name=tool_name,
available=True,
source=source
)
self._tool_cache[tool_name] = status
return {"available": True, "source": source, "install_hint": None}
# Check custom directories
source = self._check_custom_dirs(tool_name)
if source:
status = DependencyStatus(
name=tool_name,
available=True,
source=source
)
self._tool_cache[tool_name] = status
return {"available": True, "source": source, "install_hint": None}
# Tool not found
install_hint = self.TOOL_INSTALL_HINTS.get(tool_name)
if not install_hint:
install_hint = "Check praisonai-tools or define custom tool"
status = DependencyStatus(
name=tool_name,
available=False,
source=None,
install_hint=install_hint
)
self._tool_cache[tool_name] = status
return {"available": False, "source": None, "install_hint": install_hint}
def _check_builtin_tool(self, tool_name: str) -> Optional[str]:
"""Check if tool exists in built-in registry."""
try:
from praisonaiagents.tools import TOOL_MAPPINGS
if tool_name in TOOL_MAPPINGS:
return "builtin"
except ImportError:
pass
return None
def _check_praisonai_tools(self, tool_name: str) -> Optional[str]:
"""Check if tool exists in praisonai-tools package."""
try:
import praisonai_tools
if hasattr(praisonai_tools, tool_name):
return "praisonai-tools"
# Check for get_tool function
if hasattr(praisonai_tools, 'get_tool'):
try:
tool = praisonai_tools.get_tool(tool_name)
if tool:
return "praisonai-tools"
except Exception:
pass
except ImportError:
pass
return None
def _check_custom_dirs(self, tool_name: str) -> Optional[str]:
"""Check if tool exists in custom directories."""
for dir_path in self._custom_tool_dirs:
path = Path(dir_path).expanduser()
if not path.exists():
continue
# Check for tool file
tool_file = path / f"{tool_name}.py"
if tool_file.exists():
return f"custom:{dir_path}"
# Check for tool in any Python file (by function name)
for py_file in path.glob("*.py"):
try:
content = py_file.read_text()
if f"def {tool_name}" in content:
return f"custom:{dir_path}"
except Exception:
pass
return None
def check_package(self, package_name: str) -> Dict[str, Any]:
"""
Check if a Python package is available.
Args:
package_name: Name of the package to check
Returns:
Dict with 'available' and 'install_hint' keys
"""
spec = importlib.util.find_spec(package_name)
if spec is not None:
return {"available": True, "install_hint": None}
return {
"available": False,
"install_hint": f"pip install {package_name}"
}
def check_env_var(self, var_name: str) -> Dict[str, Any]:
"""
Check if an environment variable is set.
Args:
var_name: Name of the environment variable
Returns:
Dict with 'available' and 'masked_value' keys
"""
value = os.environ.get(var_name)
if value:
# Mask the value for security
if len(value) > 8:
masked = value[:4] + "*" * (len(value) - 8) + value[-4:]
else:
masked = "*" * len(value)
return {"available": True, "masked_value": masked}
return {"available": False, "masked_value": None}
def check_template_dependencies(
self,
template: "TemplateConfig"
) -> Dict[str, Any]:
"""
Check all dependencies for a template.
Args:
template: Template configuration to check
Returns:
Dict with 'tools', 'packages', 'env', and 'all_satisfied' keys
"""
result = {
"tools": [],
"packages": [],
"env": [],
"all_satisfied": True
}
# Check tools
tools = template.requires.get("tools", [])
if isinstance(tools, str):
tools = [tools]
for tool_name in tools:
status = self.check_tool(tool_name)
result["tools"].append({
"name": tool_name,
**status
})
if not status["available"]:
result["all_satisfied"] = False
# Check packages
packages = template.requires.get("packages", [])
if isinstance(packages, str):
packages = [packages]
for pkg_name in packages:
status = self.check_package(pkg_name)
result["packages"].append({
"name": pkg_name,
**status
})
if not status["available"]:
result["all_satisfied"] = False
# Check environment variables
env_vars = template.requires.get("env", [])
if isinstance(env_vars, str):
env_vars = [env_vars]
for var_name in env_vars:
status = self.check_env_var(var_name)
result["env"].append({
"name": var_name,
**status
})
if not status["available"]:
result["all_satisfied"] = False
return result
def get_install_hints(self, template: "TemplateConfig") -> List[str]:
"""
Get install hints for missing dependencies.
Args:
template: Template configuration to check
Returns:
List of actionable install hints
"""
hints = []
deps = self.check_template_dependencies(template)
# Tool hints
for tool in deps["tools"]:
if not tool["available"] and tool.get("install_hint"):
hints.append(f"Tool '{tool['name']}': {tool['install_hint']}")
# Package hints
for pkg in deps["packages"]:
if not pkg["available"] and pkg.get("install_hint"):
hints.append(f"Package '{pkg['name']}': {pkg['install_hint']}")
# Env hints
for env in deps["env"]:
if not env["available"]:
hints.append(f"Environment variable '{env['name']}': export {env['name']}=<value>")
return hints
def enforce_strict_mode(self, template: "TemplateConfig") -> bool:
"""
Enforce strict mode - fail if any dependency is missing.
Args:
template: Template configuration to check
Returns:
True if all dependencies satisfied
Raises:
StrictModeError: If any dependency is missing
"""
deps = self.check_template_dependencies(template)
if deps["all_satisfied"]:
return True
# Collect missing items
missing = {
"tools": [t["name"] for t in deps["tools"] if not t["available"]],
"packages": [p["name"] for p in deps["packages"] if not p["available"]],
"env": [e["name"] for e in deps["env"] if not e["available"]]
}
# Build error message
messages = []
if missing["tools"]:
messages.append(f"Missing tools: {', '.join(missing['tools'])}")
if missing["packages"]:
messages.append(f"Missing packages: {', '.join(missing['packages'])}")
if missing["env"]:
messages.append(f"Missing environment variables: {', '.join(missing['env'])}")
# Add hints
hints = self.get_install_hints(template)
if hints:
messages.append("\nTo fix:")
messages.extend(f" - {hint}" for hint in hints)
raise StrictModeError(
"\n".join(messages),
missing_items=missing
)
@@ -0,0 +1,297 @@
"""
Template Discovery
Discovers templates from multiple directories with precedence support.
Supports custom user directories, built-in templates, and package templates.
"""
import os
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
@dataclass
class DiscoveredTemplate:
"""Information about a discovered template."""
name: str
path: Path
source: str # 'custom', 'builtin', 'package'
priority: int # Lower = higher priority
# Optional metadata (loaded lazily)
description: Optional[str] = None
version: Optional[str] = None
author: Optional[str] = None
class TemplateDiscovery:
"""
Discovers templates from multiple directories with precedence.
Search order (highest to lowest priority):
1. User custom directory (~/.praison/templates)
2. XDG config directory (~/.config/praison/templates)
3. Project local directory (./.praison/templates)
4. Built-in package templates (agent_recipes)
Templates in higher priority directories override those in lower priority.
"""
# Default search paths (in priority order)
DEFAULT_SEARCH_PATHS = [
("~/.praison/templates", "custom", 1),
("~/.config/praison/templates", "custom", 2),
("./.praison/templates", "project", 3),
]
TEMPLATE_FILE = "TEMPLATE.yaml"
def __init__(
self,
custom_dirs: Optional[List[str]] = None,
include_package: bool = True,
include_defaults: bool = True
):
"""
Initialize template discovery.
Args:
custom_dirs: Additional custom directories to search (highest priority)
include_package: Whether to include package templates (agent_recipes)
include_defaults: Whether to include default search paths
"""
self.search_paths: List[Tuple[Path, str, int]] = []
# Add custom directories first (highest priority)
if custom_dirs:
for i, dir_path in enumerate(custom_dirs):
expanded = Path(os.path.expanduser(dir_path))
self.search_paths.append((expanded, "custom", i))
# Add default search paths
if include_defaults:
base_priority = len(custom_dirs) if custom_dirs else 0
for path, source, priority in self.DEFAULT_SEARCH_PATHS:
expanded = Path(os.path.expanduser(path))
self.search_paths.append((expanded, source, base_priority + priority))
self.include_package = include_package
self._package_path: Optional[Path] = None
@property
def package_templates_path(self) -> Optional[Path]:
"""Get path to package templates (lazy loaded)."""
if self._package_path is None and self.include_package:
try:
import importlib.resources
try:
# Python 3.9+
ref = importlib.resources.files("agent_recipes") / "templates"
self._package_path = Path(str(ref))
except (TypeError, AttributeError):
# Fallback for older Python
import agent_recipes
self._package_path = Path(agent_recipes.__file__).parent / "templates"
except ImportError:
pass
return self._package_path
def discover_all(self) -> Dict[str, DiscoveredTemplate]:
"""
Discover all templates from all search paths.
Returns:
Dict mapping template name to DiscoveredTemplate.
When duplicates exist, higher priority wins.
"""
templates: Dict[str, DiscoveredTemplate] = {}
# Scan search paths in reverse priority order (lowest first)
# so that higher priority overwrites lower
all_paths = list(self.search_paths)
# Add package templates with lowest priority
if self.include_package and self.package_templates_path:
max_priority = max((p[2] for p in all_paths), default=0) + 1
all_paths.append((self.package_templates_path, "package", max_priority))
# Sort by priority descending (lowest priority first)
all_paths.sort(key=lambda x: x[2], reverse=True)
for dir_path, source, priority in all_paths:
if dir_path.exists() and dir_path.is_dir():
discovered = self._scan_directory(dir_path, source, priority)
# Higher priority overwrites lower
templates.update(discovered)
return templates
def _scan_directory(
self,
directory: Path,
source: str,
priority: int
) -> Dict[str, DiscoveredTemplate]:
"""
Scan a directory for templates (shallow scan).
Only looks at direct subdirectories containing TEMPLATE.yaml.
Does NOT recursively scan nested directories.
Args:
directory: Directory to scan
source: Source identifier ('custom', 'builtin', 'package')
priority: Priority level
Returns:
Dict mapping template name to DiscoveredTemplate
"""
templates = {}
try:
for item in directory.iterdir():
if item.is_dir():
template_file = item / self.TEMPLATE_FILE
workflow_file = item / "workflow.yaml"
# A valid template has TEMPLATE.yaml or workflow.yaml
if template_file.exists() or workflow_file.exists():
template = DiscoveredTemplate(
name=item.name,
path=item,
source=source,
priority=priority
)
# Load basic metadata if TEMPLATE.yaml exists
if template_file.exists():
self._load_template_metadata(template, template_file)
templates[item.name] = template
except PermissionError:
pass # Skip directories we can't read
return templates
def _load_template_metadata(
self,
template: DiscoveredTemplate,
template_file: Path
) -> None:
"""Load basic metadata from TEMPLATE.yaml."""
try:
import yaml
with open(template_file) as f:
data = yaml.safe_load(f) or {}
template.description = data.get("description", "")
template.version = data.get("version", "1.0.0")
template.author = data.get("author")
except Exception:
pass # Metadata loading is optional
def find_template(self, name: str) -> Optional[DiscoveredTemplate]:
"""
Find a template by name.
Searches all directories in priority order and returns the
highest priority match.
Args:
name: Template name to find
Returns:
DiscoveredTemplate if found, None otherwise
"""
all_templates = self.discover_all()
return all_templates.get(name)
def resolve_template_path(self, name: str) -> Optional[Path]:
"""
Resolve a template name to its path.
Args:
name: Template name
Returns:
Path to template directory, or None if not found
"""
template = self.find_template(name)
return template.path if template else None
def list_templates(
self,
source_filter: Optional[str] = None
) -> List[DiscoveredTemplate]:
"""
List all discovered templates.
Args:
source_filter: Optional filter by source ('custom', 'package', etc.)
Returns:
List of DiscoveredTemplate objects
"""
templates = self.discover_all()
if source_filter:
return [t for t in templates.values() if t.source == source_filter]
return list(templates.values())
def get_search_paths(self) -> List[Tuple[str, str, bool]]:
"""
Get all search paths with their status.
Returns:
List of (path, source, exists) tuples
"""
paths = []
for dir_path, source, _ in self.search_paths:
paths.append((str(dir_path), source, dir_path.exists()))
if self.include_package and self.package_templates_path:
paths.append((
str(self.package_templates_path),
"package",
self.package_templates_path.exists()
))
return paths
# Convenience functions
def discover_templates(
custom_dirs: Optional[List[str]] = None
) -> Dict[str, DiscoveredTemplate]:
"""
Discover all templates from default and custom directories.
Args:
custom_dirs: Additional custom directories to search
Returns:
Dict mapping template name to DiscoveredTemplate
"""
discovery = TemplateDiscovery(custom_dirs=custom_dirs)
return discovery.discover_all()
def find_template_path(
name: str,
custom_dirs: Optional[List[str]] = None
) -> Optional[Path]:
"""
Find a template by name and return its path.
Args:
name: Template name
custom_dirs: Additional custom directories to search
Returns:
Path to template directory, or None if not found
"""
discovery = TemplateDiscovery(custom_dirs=custom_dirs)
return discovery.resolve_template_path(name)
+554
View File
@@ -0,0 +1,554 @@
"""
Template Loader
Loads and materializes templates into Agent/Workflow configurations.
Handles TEMPLATE.yaml parsing, config merging, and skills integration.
"""
import os
import re
import secrets
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from dataclasses import dataclass, field
from .resolver import ResolvedTemplate # noqa: F401 - used by registry
from .cache import TemplateCache
from .registry import TemplateRegistry
from .security import TemplateSecurity
@dataclass
class TemplateConfig:
"""Parsed template configuration."""
name: str
description: str = ""
version: str = "1.0.0"
author: Optional[str] = None
license: Optional[str] = None
tags: List[str] = field(default_factory=list)
# Dependencies
requires: Dict[str, Any] = field(default_factory=dict)
# Entry points
workflow_file: str = "workflow.yaml"
agents_file: str = "agents.yaml"
# Configuration schema
config_schema: Dict[str, Any] = field(default_factory=dict)
# Default configuration values
defaults: Dict[str, Any] = field(default_factory=dict)
# Skills to load
skills: List[str] = field(default_factory=list)
# CLI integration
cli: Dict[str, Any] = field(default_factory=dict)
# Raw config dict
raw: Dict[str, Any] = field(default_factory=dict)
# Source path
path: Optional[Path] = None
class TemplateLoader:
"""
Loads templates and materializes them into usable configurations.
Supports:
- TEMPLATE.yaml parsing
- Config override merging
- Skills integration
- Workflow/Agent file loading
"""
TEMPLATE_FILE = "TEMPLATE.yaml"
def __init__(
self,
cache: Optional[TemplateCache] = None,
registry: Optional[TemplateRegistry] = None,
security: Optional[TemplateSecurity] = None,
offline: bool = False
):
"""
Initialize the loader.
Args:
cache: Template cache instance
registry: Template registry instance
security: Security handler instance
offline: If True, only use cached templates
"""
self.cache = cache or TemplateCache()
self.registry = registry or TemplateRegistry(cache=self.cache, offline=offline)
self.security = security or TemplateSecurity()
self.offline = offline
def load(
self,
uri: str,
config: Optional[Dict[str, Any]] = None,
offline: bool = False
) -> TemplateConfig:
"""
Load a template by URI.
Args:
uri: Template URI
config: Optional config overrides
offline: If True, only use cache
Returns:
TemplateConfig with parsed configuration
Raises:
ValueError: If template not found or invalid
SecurityError: If template fails security checks
"""
# Security check
if not self.security.is_source_allowed(uri):
raise ValueError(f"Template source not allowed: {uri}")
# Get template (from cache or remote)
use_offline = offline or self.offline
cached = self.registry.get_template(uri, offline=use_offline)
# Validate security
errors = self.security.validate_template_directory(cached.path)
if errors:
raise ValueError("Template security validation failed:\n" + "\n".join(errors))
# Parse TEMPLATE.yaml
template_config = self._parse_template_file(cached.path)
template_config.path = cached.path
# Merge config overrides
if config:
template_config = self._merge_config(template_config, config)
return template_config
def _parse_template_file(self, template_dir: Path) -> TemplateConfig:
"""Parse TEMPLATE.yaml file."""
import yaml
template_file = template_dir / self.TEMPLATE_FILE
if not template_file.exists():
# Try to infer from directory name
return TemplateConfig(
name=template_dir.name,
path=template_dir
)
with open(template_file) as f:
raw = yaml.safe_load(f) or {}
# Sanitize config
raw = self.security.sanitize_template_config(raw)
# Extract requires
requires = raw.get("requires", {})
if isinstance(requires, list):
requires = {"tools": requires}
# Handle workflow - can be inline dict or file reference string
workflow = raw.get("workflow", "workflow.yaml")
# Handle agents - can be inline list or file reference string
agents = raw.get("agents", "agents.yaml")
return TemplateConfig(
name=raw.get("name", template_dir.name),
description=raw.get("description", ""),
version=raw.get("version", "1.0.0"),
author=raw.get("author"),
license=raw.get("license"),
tags=raw.get("tags", []),
requires=requires,
workflow_file=workflow,
agents_file=agents,
config_schema=raw.get("config", {}),
defaults=raw.get("defaults", {}),
skills=raw.get("skills", []),
cli=raw.get("cli", {}),
raw=raw,
path=template_dir
)
def _merge_config(
self,
template: TemplateConfig,
overrides: Dict[str, Any]
) -> TemplateConfig:
"""Merge config overrides into template config."""
# Start with defaults
merged = dict(template.defaults)
# Apply overrides
merged.update(overrides)
# Update template
template.defaults = merged
return template
def load_workflow_config(
self,
template: TemplateConfig
) -> Dict[str, Any]:
"""
Load workflow configuration from template.
Args:
template: Parsed template config
Returns:
Workflow configuration dict
Supports:
- Inline workflow definition in TEMPLATE.yaml (workflow: {agents: [...], tasks: [...]})
- Separate workflow file reference (workflow: "workflow.yaml")
"""
import yaml
# Check if workflow is inline (dict) or a file reference (string)
if isinstance(template.workflow_file, dict):
# Inline workflow definition
config = template.workflow_file
elif isinstance(template.workflow_file, str):
# File reference
workflow_file = template.path / template.workflow_file
if not workflow_file.exists():
# Check if workflow is in raw config
if "workflow" in template.raw and isinstance(template.raw["workflow"], dict):
config = template.raw["workflow"]
else:
raise ValueError(f"Workflow file not found: {workflow_file}")
else:
with open(workflow_file) as f:
config = yaml.safe_load(f) or {}
else:
raise ValueError(f"Invalid workflow configuration type: {type(template.workflow_file)}")
# Substitute variables from template config
config = self._substitute_variables(config, template.defaults)
return config
def load_agents_config(
self,
template: TemplateConfig
) -> Dict[str, Any]:
"""
Load agents configuration from template.
Args:
template: Parsed template config
Returns:
Agents configuration dict
"""
import yaml
agents_file = template.path / template.agents_file
if not agents_file.exists():
# Try workflow file for inline agents
return {}
with open(agents_file) as f:
config = yaml.safe_load(f) or {}
# Substitute variables
config = self._substitute_variables(config, template.defaults)
return config
def _substitute_variables(
self,
config: Any,
variables: Dict[str, Any]
) -> Any:
"""
Recursively substitute variables in config with sentinel protection.
This method implements a secure multi-phase substitution strategy:
1. Generate a unique sentinel token for this render
2. Protect user-provided variable values that contain {{...}} syntax
3. Perform template variable substitution on the config
4. Restore protected values
This prevents user input containing {{variable}} syntax from being
interpreted as template variables (injection protection).
"""
if isinstance(config, str):
# Generate per-render unique sentinel to prevent collision attacks
sentinel_id = secrets.token_hex(8)
sentinel_prefix = f"__PRAISONAI_SENTINEL_{sentinel_id}_"
# Phase 1: Protect variable values that contain template syntax
protected_values, safe_variables = self._protect_variable_values(
variables, sentinel_prefix
)
# Phase 2: Perform substitution with safe values
pattern = r'\{\{(\w+)\}\}'
def replace(match):
var_name = match.group(1)
return str(safe_variables.get(var_name, match.group(0)))
result = re.sub(pattern, replace, config)
# Phase 3: Restore protected values
result = self._restore_protected_values(result, protected_values)
return result
elif isinstance(config, dict):
return {k: self._substitute_variables(v, variables) for k, v in config.items()}
elif isinstance(config, list):
return [self._substitute_variables(item, variables) for item in config]
return config
def _protect_variable_values(
self,
variables: Dict[str, Any],
sentinel_prefix: str
) -> Tuple[Dict[str, str], Dict[str, Any]]:
"""
Protect variable values that contain template syntax.
Args:
variables: Original variable dict
sentinel_prefix: Unique prefix for this render
Returns:
Tuple of (protected_values mapping, safe_variables dict)
"""
protected_values = {} # sentinel -> original value
safe_variables = {}
template_pattern = re.compile(r'\{\{.*?\}\}')
for key, value in variables.items():
if isinstance(value, str) and template_pattern.search(value):
# This value contains template syntax - protect it
sentinel = f"{sentinel_prefix}{key}__"
protected_values[sentinel] = value
safe_variables[key] = sentinel
else:
safe_variables[key] = value
return protected_values, safe_variables
def _restore_protected_values(
self,
content: str,
protected_values: Dict[str, str]
) -> str:
"""
Restore protected values after substitution.
Args:
content: Content with sentinel tokens
protected_values: Mapping of sentinel -> original value
Returns:
Content with sentinels replaced by original values
"""
for sentinel, original in protected_values.items():
content = content.replace(sentinel, original)
return content
def get_required_tools(self, template: TemplateConfig) -> List[str]:
"""Get list of required tools for a template."""
tools = template.requires.get("tools", [])
if isinstance(tools, str):
tools = [tools]
return tools
def get_required_packages(self, template: TemplateConfig) -> List[str]:
"""Get list of required Python packages for a template."""
packages = template.requires.get("packages", [])
if isinstance(packages, str):
packages = [packages]
return packages
def get_required_env(self, template: TemplateConfig) -> List[str]:
"""Get list of required environment variables for a template."""
env = template.requires.get("env", [])
if isinstance(env, str):
env = [env]
return env
def check_requirements(
self,
template: TemplateConfig
) -> Dict[str, List[str]]:
"""
Check if template requirements are satisfied.
Returns:
Dict with 'missing_packages', 'missing_env', 'missing_tools' lists
"""
result = {
"missing_packages": [],
"missing_env": [],
"missing_tools": []
}
# Check packages
for package in self.get_required_packages(template):
try:
__import__(package.replace("-", "_"))
except ImportError:
result["missing_packages"].append(package)
# Check environment variables
for env_var in self.get_required_env(template):
if not os.environ.get(env_var):
result["missing_env"].append(env_var)
return result
def load_template(
uri: str,
config: Optional[Dict[str, Any]] = None,
offline: bool = False
) -> TemplateConfig:
"""
Convenience function to load a template.
Args:
uri: Template URI
config: Optional config overrides
offline: If True, only use cache
Returns:
TemplateConfig with parsed configuration
"""
loader = TemplateLoader(offline=offline)
return loader.load(uri, config=config, offline=offline)
def create_agent_from_template(
uri: str,
config: Optional[Dict[str, Any]] = None,
offline: bool = False,
**agent_kwargs
):
"""
Create an Agent from a template.
Args:
uri: Template URI
config: Optional config overrides
offline: If True, only use cache
**agent_kwargs: Additional Agent constructor arguments
Returns:
Configured Agent instance
"""
loader = TemplateLoader(offline=offline)
template = loader.load(uri, config=config, offline=offline)
# Load agents config
agents_config = loader.load_agents_config(template)
# Get first agent or use template defaults
if agents_config and "agents" in agents_config:
agent_list = list(agents_config["agents"].values())
if agent_list:
agent_config = agent_list[0]
else:
agent_config = {}
else:
agent_config = {}
# Merge with kwargs
final_config = {**agent_config, **agent_kwargs}
# Import Agent lazily
from praisonaiagents import Agent
# Handle skills
if template.skills:
final_config["skills"] = template.skills
return Agent(**final_config)
def create_workflow_from_template(
uri: str,
config: Optional[Dict[str, Any]] = None,
offline: bool = False,
**workflow_kwargs
):
"""
Create a Workflow from a template.
Args:
uri: Template URI
config: Optional config overrides
offline: If True, only use cache
**workflow_kwargs: Additional Workflow constructor arguments
Returns:
Configured Workflow instance
"""
loader = TemplateLoader(offline=offline)
template = loader.load(uri, config=config, offline=offline)
# Load workflow config
workflow_config = loader.load_workflow_config(template)
# Merge with kwargs
final_config = {**workflow_config, **workflow_kwargs}
# Import Workflow lazily
from praisonaiagents import Workflow
return Workflow(**final_config)
def create_agents_from_template(
uri: str,
config: Optional[Dict[str, Any]] = None,
offline: bool = False,
**agents_kwargs
):
"""
Create a PraisonAIAgents team from a template.
Args:
uri: Template URI
config: Optional config overrides
offline: If True, only use cache
**agents_kwargs: Additional PraisonAIAgents constructor arguments
Returns:
Configured PraisonAIAgents instance
"""
loader = TemplateLoader(offline=offline)
template = loader.load(uri, config=config, offline=offline)
# Load workflow config (contains agents and tasks)
workflow_config = loader.load_workflow_config(template)
# Import PraisonAIAgents lazily
from praisonaiagents import PraisonAIAgents
# Merge with kwargs
final_config = {**workflow_config, **agents_kwargs}
return PraisonAIAgents(**final_config)
@@ -0,0 +1,511 @@
"""
Template Registry
Handles fetching templates from remote sources (GitHub, HTTP).
All network operations are lazy and only performed when explicitly requested.
"""
import hashlib
import json
import os
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field
from .resolver import ResolvedTemplate, TemplateResolver, TemplateSource
from .cache import TemplateCache, CachedTemplate
@dataclass
class TemplateInfo:
"""Information about a template."""
name: str
description: str
version: str = "1.0.0"
author: Optional[str] = None
tags: List[str] = field(default_factory=list)
requires: Dict[str, Any] = field(default_factory=dict)
source: Optional[str] = None
path: Optional[str] = None
sha256: Optional[str] = None
class TemplateRegistry:
"""
Registry for discovering and fetching templates.
Supports:
- GitHub repositories (via contents API or raw.githubusercontent.com)
- HTTP URLs
- Local package installations
"""
GITHUB_API_BASE = "https://api.github.com"
GITHUB_RAW_BASE = "https://raw.githubusercontent.com"
# Default recipes repository
DEFAULT_OWNER = "MervinPraison"
DEFAULT_REPO = "agent-recipes"
def __init__(
self,
cache: Optional[TemplateCache] = None,
github_token: Optional[str] = None,
offline: bool = False
):
"""
Initialize the registry.
Args:
cache: Template cache instance
github_token: Optional GitHub token for API requests
offline: If True, only use cached templates
"""
self.cache = cache or TemplateCache()
self.github_token = github_token or os.environ.get("GITHUB_TOKEN")
self.offline = offline
self._http_client = None
@property
def http_client(self):
"""Lazy-load HTTP client."""
if self._http_client is None:
try:
import httpx
self._http_client = httpx.Client(timeout=30.0)
except ImportError:
import urllib.request
self._http_client = "urllib"
return self._http_client
def _make_request(
self,
url: str,
headers: Optional[Dict[str, str]] = None
) -> bytes:
"""Make an HTTP request."""
all_headers = headers or {}
if self.github_token and "api.github.com" in url:
all_headers["Authorization"] = f"token {self.github_token}"
all_headers["User-Agent"] = "PraisonAI-Templates/1.0"
if self.http_client == "urllib":
import urllib.request
req = urllib.request.Request(url, headers=all_headers)
with urllib.request.urlopen(req, timeout=30) as response:
return response.read()
else:
response = self.http_client.get(url, headers=all_headers)
response.raise_for_status()
return response.content
def fetch_github_template(
self,
owner: str,
repo: str,
template_path: str,
ref: str = "main"
) -> Path:
"""
Fetch a template from GitHub.
Args:
owner: Repository owner
repo: Repository name
template_path: Path to template within repo
ref: Git ref (branch, tag, or commit)
Returns:
Path to downloaded template directory
"""
# Create temp directory for download
temp_dir = Path(tempfile.mkdtemp(prefix="praison_template_"))
# Fetch template files using GitHub API
api_url = f"{self.GITHUB_API_BASE}/repos/{owner}/{repo}/contents/templates/{template_path}?ref={ref}"
try:
response = self._make_request(api_url)
contents = json.loads(response)
if isinstance(contents, list):
# Directory listing
for item in contents:
if item["type"] == "file":
file_content = self._fetch_github_file(item["download_url"])
file_path = temp_dir / item["name"]
file_path.write_bytes(file_content)
elif item["type"] == "dir":
# Recursively fetch subdirectory
subdir = temp_dir / item["name"]
subdir.mkdir(parents=True, exist_ok=True)
self._fetch_github_dir(
owner, repo, f"templates/{template_path}/{item['name']}",
ref, subdir
)
else:
# Single file
file_content = self._fetch_github_file(contents["download_url"])
file_path = temp_dir / contents["name"]
file_path.write_bytes(file_content)
except Exception:
# Fallback to raw.githubusercontent.com
raw_url = f"{self.GITHUB_RAW_BASE}/{owner}/{repo}/{ref}/templates/{template_path}"
self._fetch_raw_template(raw_url, temp_dir, template_path)
return temp_dir
def _fetch_github_file(self, url: str) -> bytes:
"""Fetch a single file from GitHub."""
return self._make_request(url)
def _fetch_github_dir(
self,
owner: str,
repo: str,
path: str,
ref: str,
target_dir: Path
) -> None:
"""Recursively fetch a directory from GitHub."""
api_url = f"{self.GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{path}?ref={ref}"
response = self._make_request(api_url)
contents = json.loads(response)
for item in contents:
if item["type"] == "file":
file_content = self._fetch_github_file(item["download_url"])
file_path = target_dir / item["name"]
file_path.write_bytes(file_content)
elif item["type"] == "dir":
subdir = target_dir / item["name"]
subdir.mkdir(parents=True, exist_ok=True)
self._fetch_github_dir(owner, repo, f"{path}/{item['name']}", ref, subdir)
def _fetch_raw_template(
self,
base_url: str,
target_dir: Path,
template_name: str
) -> None:
"""Fetch template using raw URLs (fallback)."""
# Try to fetch common template files
files_to_try = [
"TEMPLATE.yaml",
"workflow.yaml",
"agents.yaml",
"README.md"
]
for filename in files_to_try:
try:
url = f"{base_url}/{filename}"
content = self._make_request(url)
(target_dir / filename).write_bytes(content)
except Exception:
continue
def fetch_http_template(self, url: str) -> Path:
"""
Fetch a template from an HTTP URL.
Args:
url: URL to template (YAML file or directory index)
Returns:
Path to downloaded template
"""
temp_dir = Path(tempfile.mkdtemp(prefix="praison_template_"))
content = self._make_request(url)
# Determine filename from URL
filename = url.split("/")[-1]
if not filename.endswith((".yaml", ".yml")):
filename = "TEMPLATE.yaml"
(temp_dir / filename).write_bytes(content)
return temp_dir
def get_template(
self,
uri: str,
offline: bool = False
) -> CachedTemplate:
"""
Get a template by URI, fetching if necessary.
Args:
uri: Template URI
offline: If True, only use cache
Returns:
CachedTemplate with path and metadata
Raises:
ValueError: If template not found
"""
resolved = TemplateResolver.resolve(uri)
use_offline = offline or self.offline
# Check cache first
cached = self.cache.get(resolved, offline=use_offline)
if cached:
return cached
if use_offline:
raise ValueError(
f"Template not found in cache: {uri}\n"
"Run without --offline to fetch from remote."
)
# Fetch based on source
if resolved.source == TemplateSource.LOCAL:
path = Path(resolved.path)
if not path.exists():
raise ValueError(f"Local template not found: {resolved.path}")
return CachedTemplate(
path=path,
metadata=self.cache.put(resolved, path).metadata
)
elif resolved.source == TemplateSource.PACKAGE:
# Try to find in installed package
return self._get_package_template(resolved)
elif resolved.source == TemplateSource.GITHUB:
temp_dir = self.fetch_github_template(
resolved.owner,
resolved.repo,
resolved.path,
resolved.ref or "main"
)
# Calculate checksum
sha256 = self._calculate_dir_checksum(temp_dir)
return self.cache.put(resolved, temp_dir, sha256=sha256)
elif resolved.source == TemplateSource.HTTP:
temp_dir = self.fetch_http_template(resolved.url)
sha256 = self._calculate_dir_checksum(temp_dir)
return self.cache.put(resolved, temp_dir, sha256=sha256)
raise ValueError(f"Unknown template source: {resolved.source}")
def _get_package_template(self, resolved: ResolvedTemplate) -> CachedTemplate:
"""Get template from installed Python package."""
parts = resolved.path.split("/")
if len(parts) < 2:
raise ValueError(f"Invalid package template path: {resolved.path}")
package_name = parts[0]
template_name = "/".join(parts[1:])
try:
# Try importlib.resources first (Python 3.9+)
import importlib.resources as pkg_resources
package = __import__(package_name)
# Navigate to templates directory
templates_path = Path(package.__file__).parent / "templates" / template_name
if templates_path.exists():
return CachedTemplate(
path=templates_path,
metadata=self.cache.put(resolved, templates_path).metadata
)
except (ImportError, AttributeError):
pass
# Try pkg_resources fallback
try:
import pkg_resources as pkg_res
template_path = pkg_res.resource_filename(
package_name,
f"templates/{template_name}"
)
if Path(template_path).exists():
return CachedTemplate(
path=Path(template_path),
metadata=self.cache.put(resolved, Path(template_path)).metadata
)
except Exception:
pass
raise ValueError(
f"Package template not found: {resolved.path}\n"
f"Make sure package '{package_name}' is installed."
)
def _calculate_dir_checksum(self, directory: Path) -> str:
"""Calculate SHA256 checksum of directory contents."""
hasher = hashlib.sha256()
for file_path in sorted(directory.rglob("*")):
if file_path.is_file() and not file_path.name.startswith("."):
hasher.update(file_path.name.encode())
hasher.update(file_path.read_bytes())
return hasher.hexdigest()
def list_remote_templates(
self,
owner: str = None,
repo: str = None,
ref: str = "main"
) -> List[TemplateInfo]:
"""
List templates available in a GitHub repository.
Args:
owner: Repository owner (default: MervinPraison)
repo: Repository name (default: agent-recipes)
ref: Git ref
Returns:
List of TemplateInfo objects
"""
owner = owner or self.DEFAULT_OWNER
repo = repo or self.DEFAULT_REPO
if self.offline:
return []
templates = []
try:
# Fetch templates directory listing
api_url = f"{self.GITHUB_API_BASE}/repos/{owner}/{repo}/contents/templates?ref={ref}"
response = self._make_request(api_url)
contents = json.loads(response)
for item in contents:
if item["type"] == "dir":
# Try to fetch TEMPLATE.yaml for metadata
try:
template_url = f"{self.GITHUB_RAW_BASE}/{owner}/{repo}/{ref}/templates/{item['name']}/TEMPLATE.yaml"
template_content = self._make_request(template_url)
import yaml
config = yaml.safe_load(template_content)
templates.append(TemplateInfo(
name=config.get("name", item["name"]),
description=config.get("description", ""),
version=config.get("version", "1.0.0"),
author=config.get("author"),
tags=config.get("tags", []),
requires=config.get("requires", {}),
source=f"github:{owner}/{repo}/{item['name']}",
path=item["name"]
))
except Exception:
# Fallback to basic info
templates.append(TemplateInfo(
name=item["name"],
description="",
source=f"github:{owner}/{repo}/{item['name']}",
path=item["name"]
))
except Exception:
# Return empty list on error
pass
return templates
def search_templates(
self,
query: str,
owner: str = None,
repo: str = None
) -> List[TemplateInfo]:
"""
Search templates by name or tags.
Args:
query: Search query
owner: Repository owner
repo: Repository name
Returns:
List of matching TemplateInfo objects
"""
all_templates = self.list_remote_templates(owner, repo)
query_lower = query.lower()
return [
t for t in all_templates
if query_lower in t.name.lower() or
query_lower in t.description.lower() or
any(query_lower in tag.lower() for tag in t.tags)
]
def list_templates(
source: str = "all",
offline: bool = False
) -> List[TemplateInfo]:
"""
List available templates.
Args:
source: 'all', 'local', 'remote', or 'cached'
offline: If True, only list cached templates
Returns:
List of TemplateInfo objects
"""
registry = TemplateRegistry(offline=offline)
templates = []
if source in ("all", "cached"):
# List cached templates
cache = TemplateCache()
for path, metadata in cache.list_cached():
templates.append(TemplateInfo(
name=path.name,
description="(cached)",
version=metadata.version or "unknown",
source=str(path)
))
if source in ("all", "remote") and not offline:
# List remote templates
remote = registry.list_remote_templates()
templates.extend(remote)
return templates
def search_templates(query: str, offline: bool = False) -> List[TemplateInfo]:
"""
Search templates by query.
Args:
query: Search query
offline: If True, only search cached templates
Returns:
List of matching TemplateInfo objects
"""
registry = TemplateRegistry(offline=offline)
return registry.search_templates(query)
def install_template(
uri: str,
offline: bool = False
) -> CachedTemplate:
"""
Install (fetch and cache) a template.
Args:
uri: Template URI
offline: If True, fail if not cached
Returns:
CachedTemplate with path and metadata
"""
registry = TemplateRegistry(offline=offline)
return registry.get_template(uri, offline=offline)
@@ -0,0 +1,206 @@
"""
Template URI Resolver
Parses and resolves template URIs to their source locations.
Supports: local paths, package refs, GitHub refs, HTTP URLs.
"""
import os
import re
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Tuple
class TemplateSource(Enum):
"""Supported template sources."""
LOCAL = "local"
PACKAGE = "package"
GITHUB = "github"
HTTP = "http"
@dataclass
class ResolvedTemplate:
"""Resolved template location."""
source: TemplateSource
path: str
version: Optional[str] = None
owner: Optional[str] = None
repo: Optional[str] = None
ref: Optional[str] = None
url: Optional[str] = None
@property
def cache_key(self) -> str:
"""Generate a unique cache key for this template."""
if self.source == TemplateSource.LOCAL:
return f"local/{os.path.abspath(self.path)}"
elif self.source == TemplateSource.PACKAGE:
return f"package/{self.path}"
elif self.source == TemplateSource.GITHUB:
ref = self.ref or "main"
return f"github/{self.owner}/{self.repo}/{self.path}@{ref}"
elif self.source == TemplateSource.HTTP:
return f"http/{self.url}"
return f"unknown/{self.path}"
@property
def is_pinned(self) -> bool:
"""Check if this template reference is pinned (not 'latest')."""
if self.source == TemplateSource.LOCAL:
return True # Local is always "pinned"
if self.source == TemplateSource.PACKAGE:
return True # Package is always "pinned" to installed version
if self.source == TemplateSource.GITHUB:
# Pinned if ref is a commit hash (40 hex chars) or a version tag
if self.ref and (
re.match(r'^[a-f0-9]{40}$', self.ref) or
re.match(r'^v?\d+\.\d+', self.ref)
):
return True
return False
return False
class TemplateResolver:
"""
Resolves template URIs to their source locations.
Supported URI formats:
- Local path: ./my-template, /absolute/path/template, ~/templates/my-template
- Package ref: package:agent_recipes/transcript-generator
- GitHub ref: github:owner/repo/template-name[@ref]
- HTTP URL: https://example.com/template.yaml
Legacy formats (also supported):
- praison://local/./path
- praison://package/name/template
- praison://github/owner/repo/template@ref
"""
# URI patterns
GITHUB_PATTERN = re.compile(
r'^(?:github:|praison://github/)([^/]+)/([^/]+)/([^@]+)(?:@(.+))?$'
)
PACKAGE_PATTERN = re.compile(
r'^(?:package:|praison://package/)([^/]+)/(.+)$'
)
HTTP_PATTERN = re.compile(
r'^(?:https?://|praison://https?/).+'
)
LOCAL_PATTERN = re.compile(
r'^(?:praison://local/)?([.~]?/.+|[a-zA-Z]:\\.+)$'
)
@classmethod
def resolve(cls, uri: str) -> ResolvedTemplate:
"""
Resolve a template URI to its source location.
Args:
uri: Template URI string
Returns:
ResolvedTemplate with source details
Raises:
ValueError: If URI format is not recognized
"""
uri = uri.strip()
# Check GitHub pattern
match = cls.GITHUB_PATTERN.match(uri)
if match:
owner, repo, path, ref = match.groups()
return ResolvedTemplate(
source=TemplateSource.GITHUB,
path=path,
owner=owner,
repo=repo,
ref=ref or "main"
)
# Check package pattern
match = cls.PACKAGE_PATTERN.match(uri)
if match:
package, template = match.groups()
return ResolvedTemplate(
source=TemplateSource.PACKAGE,
path=f"{package}/{template}"
)
# Check HTTP pattern
if cls.HTTP_PATTERN.match(uri):
# Extract actual URL
url = uri
if uri.startswith("praison://"):
url = uri.replace("praison://", "")
return ResolvedTemplate(
source=TemplateSource.HTTP,
path=url,
url=url
)
# Check local pattern or assume local if nothing else matches
if cls.LOCAL_PATTERN.match(uri) or os.path.exists(uri) or uri.startswith(("./", "../", "~/")):
path = uri
if uri.startswith("praison://local/"):
path = uri.replace("praison://local/", "")
# Expand user home
path = os.path.expanduser(path)
return ResolvedTemplate(
source=TemplateSource.LOCAL,
path=path
)
# Try as simple template name (assume package:agent_recipes/name)
if re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', uri):
return ResolvedTemplate(
source=TemplateSource.PACKAGE,
path=f"agent_recipes/{uri}"
)
raise ValueError(
f"Unrecognized template URI format: {uri}\n"
"Supported formats:\n"
" - Local: ./path, /absolute/path, ~/path\n"
" - Package: package:agent_recipes/template-name\n"
" - GitHub: github:owner/repo/template[@ref]\n"
" - HTTP: https://example.com/template.yaml"
)
@classmethod
def parse_version(cls, uri: str) -> Tuple[str, Optional[str]]:
"""
Extract version/ref from a URI.
Args:
uri: Template URI string
Returns:
Tuple of (base_uri, version)
"""
if "@" in uri and not uri.startswith("http"):
parts = uri.rsplit("@", 1)
return parts[0], parts[1]
return uri, None
@classmethod
def build_github_uri(
cls,
owner: str,
repo: str,
template: str,
ref: Optional[str] = None
) -> str:
"""Build a GitHub template URI."""
uri = f"github:{owner}/{repo}/{template}"
if ref:
uri += f"@{ref}"
return uri
@classmethod
def build_package_uri(cls, package: str, template: str) -> str:
"""Build a package template URI."""
return f"package:{package}/{template}"
@@ -0,0 +1,327 @@
"""
Template Security
Security primitives for template validation and safe extraction.
Includes checksum verification, allowlists, and safe path handling.
"""
import hashlib
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
from dataclasses import dataclass, field
@dataclass
class SecurityConfig:
"""Security configuration for template operations."""
# Allowed template sources
allowed_sources: Set[str] = field(default_factory=lambda: {
"github:MervinPraison/agent-recipes",
"package:agent_recipes",
})
# Allow all local paths by default
allow_local: bool = True
# Allow all GitHub sources (not just allowlisted)
allow_any_github: bool = True
# Allow HTTP sources
allow_http: bool = False
# Require checksum verification for remote templates
require_checksum: bool = False
# Maximum template size in bytes (10MB default)
max_template_size: int = 10 * 1024 * 1024
# Blocked file patterns (security risk)
blocked_patterns: List[str] = field(default_factory=lambda: [
r"\.\.\/", # Path traversal
r"^\/", # Absolute paths in archives
r"\.exe$", # Executables
r"\.dll$",
r"\.so$",
r"\.dylib$",
r"\.sh$", # Shell scripts (unless explicitly allowed)
r"\.bat$",
r"\.cmd$",
r"\.ps1$",
])
# Allowed file extensions
allowed_extensions: Set[str] = field(default_factory=lambda: {
".yaml", ".yml", ".json", ".md", ".txt", ".py",
".toml", ".cfg", ".ini", ".env.example"
})
class TemplateSecurity:
"""
Security handler for template operations.
Provides:
- Source allowlist validation
- Checksum verification
- Safe path extraction
- File type validation
"""
CONFIG_FILE = ".praison/security.yaml"
def __init__(self, config: Optional[SecurityConfig] = None):
"""
Initialize security handler.
Args:
config: Security configuration (loads from file if not provided)
"""
self.config = config or self._load_config()
def _load_config(self) -> SecurityConfig:
"""Load security config from file or use defaults."""
config_path = Path.home() / self.CONFIG_FILE
if config_path.exists():
try:
import yaml
with open(config_path) as f:
data = yaml.safe_load(f)
return SecurityConfig(
allowed_sources=set(data.get("allowed_sources", [])),
allow_local=data.get("allow_local", True),
allow_any_github=data.get("allow_any_github", True),
allow_http=data.get("allow_http", False),
require_checksum=data.get("require_checksum", False),
max_template_size=data.get("max_template_size", 10 * 1024 * 1024),
)
except Exception:
pass
return SecurityConfig()
def is_source_allowed(self, uri: str) -> bool:
"""
Check if a template source is allowed.
Args:
uri: Template URI
Returns:
True if source is allowed
"""
# Local paths
if uri.startswith(("./", "../", "~/", "/")) or os.path.exists(uri):
return self.config.allow_local
# Package references
if uri.startswith("package:"):
package = uri.split(":")[1].split("/")[0]
return (
f"package:{package}" in self.config.allowed_sources or
uri in self.config.allowed_sources
)
# GitHub references
if uri.startswith("github:"):
if self.config.allow_any_github:
return True
# Check specific repo allowlist
parts = uri.replace("github:", "").split("/")
if len(parts) >= 2:
repo_ref = f"github:{parts[0]}/{parts[1]}"
return repo_ref in self.config.allowed_sources
return False
# HTTP references
if uri.startswith(("http://", "https://")):
return self.config.allow_http
# Simple template name (assumes default repo)
if re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', uri):
return True
return False
def verify_checksum(
self,
directory: Path,
expected_checksum: str,
algorithm: str = "sha256"
) -> bool:
"""
Verify checksum of template directory.
Args:
directory: Template directory
expected_checksum: Expected checksum value
algorithm: Hash algorithm (sha256, sha512, md5)
Returns:
True if checksum matches
"""
actual = self.calculate_checksum(directory, algorithm)
return actual == expected_checksum
def calculate_checksum(
self,
directory: Path,
algorithm: str = "sha256"
) -> str:
"""
Calculate checksum of template directory.
Args:
directory: Template directory
algorithm: Hash algorithm
Returns:
Hex digest of checksum
"""
if algorithm == "sha256":
hasher = hashlib.sha256()
elif algorithm == "sha512":
hasher = hashlib.sha512()
elif algorithm == "md5":
hasher = hashlib.md5()
else:
raise ValueError(f"Unsupported algorithm: {algorithm}")
for file_path in sorted(directory.rglob("*")):
if file_path.is_file() and not file_path.name.startswith("."):
hasher.update(file_path.name.encode())
hasher.update(file_path.read_bytes())
return hasher.hexdigest()
def validate_path(self, path: str) -> bool:
"""
Validate a path for security issues.
Args:
path: Path to validate
Returns:
True if path is safe
"""
for pattern in self.config.blocked_patterns:
if re.search(pattern, path, re.IGNORECASE):
return False
return True
def validate_file(self, file_path: Path) -> bool:
"""
Validate a file for security.
Args:
file_path: Path to file
Returns:
True if file is safe
"""
# Check extension
if file_path.suffix.lower() not in self.config.allowed_extensions:
# Allow files without extension (like README)
if file_path.suffix:
return False
# Files without extension are allowed (like README, LICENSE)
return True
# Check size
if file_path.exists() and file_path.stat().st_size > self.config.max_template_size:
return False
# Check path (only validate the filename, not the full path)
return self.validate_path(file_path.name)
def validate_template_directory(self, directory: Path) -> List[str]:
"""
Validate all files in a template directory.
Args:
directory: Template directory
Returns:
List of validation errors (empty if valid)
"""
errors = []
total_size = 0
for file_path in directory.rglob("*"):
if file_path.is_file():
# Check path safety
if not self.validate_path(str(file_path.relative_to(directory))):
errors.append(f"Unsafe path: {file_path.relative_to(directory)}")
continue
# Check extension
if file_path.suffix.lower() not in self.config.allowed_extensions:
if file_path.suffix: # Has extension but not allowed
errors.append(f"Blocked file type: {file_path.name}")
# Track size
total_size += file_path.stat().st_size
if total_size > self.config.max_template_size:
errors.append(
f"Template too large: {total_size} bytes "
f"(max: {self.config.max_template_size})"
)
return errors
def sanitize_template_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""
Sanitize template configuration.
Removes potentially dangerous fields and validates structure.
Args:
config: Raw template configuration
Returns:
Sanitized configuration
"""
# Fields that are safe to keep
safe_fields = {
"name", "description", "version", "author", "license",
"tags", "requires", "config", "workflow", "agents",
"skills", "cli", "metadata"
}
sanitized = {}
for key, value in config.items():
if key in safe_fields:
sanitized[key] = value
return sanitized
def add_allowed_source(self, source: str) -> None:
"""Add a source to the allowlist."""
self.config.allowed_sources.add(source)
def remove_allowed_source(self, source: str) -> None:
"""Remove a source from the allowlist."""
self.config.allowed_sources.discard(source)
def save_config(self) -> None:
"""Save current config to file."""
import yaml
config_path = Path.home() / self.CONFIG_FILE
config_path.parent.mkdir(parents=True, exist_ok=True)
data = {
"allowed_sources": list(self.config.allowed_sources),
"allow_local": self.config.allow_local,
"allow_any_github": self.config.allow_any_github,
"allow_http": self.config.allow_http,
"require_checksum": self.config.require_checksum,
"max_template_size": self.config.max_template_size,
}
with open(config_path, "w") as f:
yaml.dump(data, f, default_flow_style=False)
@@ -0,0 +1,331 @@
"""
Tool Override Loader for PraisonAI.
Allows loading custom tools from files, modules, and directories.
Supports runtime tool registration with context manager pattern.
"""
import ast
import importlib.util
import sys
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Callable, Dict, Generator, List, Optional
class SecurityError(Exception):
"""Raised when a security violation is detected."""
pass
class ToolOverrideLoader:
"""
Loads custom tools from various sources.
Supports:
- Python file paths
- Module import paths
- Directories containing tool files
Security:
- Only local paths allowed by default
- Remote URLs rejected
- No auto-execution of arbitrary code
"""
# Default custom tool directories
DEFAULT_TOOL_DIRS = [
"~/.praison/tools",
"~/.config/praison/tools",
]
def __init__(self):
"""Initialize tool override loader."""
self._loaded_tools: Dict[str, Callable] = {}
def get_default_tool_dirs(self) -> List[Path]:
"""
Get default custom tool directories.
Returns:
List of Path objects for default tool directories
"""
return [Path(d).expanduser() for d in self.DEFAULT_TOOL_DIRS]
def load_from_file(self, file_path: str) -> Dict[str, Callable]:
"""
Load tools from a Python file.
Args:
file_path: Path to Python file containing tool functions
Returns:
Dict mapping tool names to callable functions
Raises:
SecurityError: If path is a remote URL
FileNotFoundError: If file doesn't exist
"""
# Security check - reject remote URLs
if file_path.startswith(("http://", "https://", "ftp://")):
raise SecurityError(
f"Remote URLs not allowed for security: {file_path}. "
"Only local file paths are permitted."
)
path = Path(file_path).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"Tool file not found: {path}")
if not path.suffix == ".py":
raise ValueError(f"Tool file must be a Python file (.py): {path}")
# Load the module
spec = importlib.util.spec_from_file_location(
f"custom_tools_{path.stem}",
path
)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load module from: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
# Extract callable functions (tools)
tools = {}
for name in dir(module):
if name.startswith("_"):
continue
obj = getattr(module, name)
if callable(obj) and not isinstance(obj, type):
tools[name] = obj
self._loaded_tools.update(tools)
return tools
def load_from_module(self, module_path: str) -> Dict[str, Callable]:
"""
Load tools from a module import path.
Args:
module_path: Python module path (e.g., 'mypackage.tools')
Returns:
Dict mapping tool names to callable functions
"""
try:
module = importlib.import_module(module_path)
except ImportError as e:
raise ImportError(f"Could not import module: {module_path}") from e
# Extract callable functions (tools)
tools = {}
for name in dir(module):
if name.startswith("_"):
continue
obj = getattr(module, name)
if callable(obj) and not isinstance(obj, type):
tools[name] = obj
self._loaded_tools.update(tools)
return tools
def load_from_directory(self, dir_path: str) -> Dict[str, Callable]:
"""
Load tools from all Python files in a directory.
Args:
dir_path: Path to directory containing tool files
Returns:
Dict mapping tool names to callable functions
Raises:
SecurityError: If path is a remote URL
"""
# Security check
if dir_path.startswith(("http://", "https://", "ftp://")):
raise SecurityError(
f"Remote URLs not allowed for security: {dir_path}. "
"Only local directory paths are permitted."
)
path = Path(dir_path).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"Tool directory not found: {path}")
if not path.is_dir():
raise ValueError(f"Path is not a directory: {path}")
tools = {}
for py_file in path.glob("*.py"):
if py_file.name.startswith("_"):
continue
try:
file_tools = self.load_from_file(str(py_file))
tools.update(file_tools)
except Exception:
# Skip files that fail to load
pass
return tools
def discover_tools_in_directory(self, dir_path: str) -> List[str]:
"""
Discover tool names in a directory without executing code.
Uses AST parsing to find function definitions.
Args:
dir_path: Path to directory to scan
Returns:
List of discovered tool function names
"""
path = Path(dir_path).expanduser().resolve()
if not path.exists() or not path.is_dir():
return []
tool_names = []
for py_file in path.glob("*.py"):
if py_file.name.startswith("_"):
continue
try:
content = py_file.read_text()
tree = ast.parse(content)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
if not node.name.startswith("_"):
tool_names.append(node.name)
except Exception:
# Skip files that fail to parse
pass
return tool_names
@contextmanager
def override_context(
self,
files: Optional[List[str]] = None,
modules: Optional[List[str]] = None,
directories: Optional[List[str]] = None
) -> Generator[Dict[str, Callable], None, None]:
"""
Context manager for temporary tool overrides.
Tools loaded within this context are available only during
the context and are cleaned up afterward.
Args:
files: List of Python file paths to load
modules: List of module paths to import
directories: List of directories to scan
Yields:
Dict of loaded tools
"""
# Store original state
original_tools = self._loaded_tools.copy()
try:
# Load tools from all sources
loaded = {}
if files:
for f in files:
loaded.update(self.load_from_file(f))
if modules:
for m in modules:
loaded.update(self.load_from_module(m))
if directories:
for d in directories:
loaded.update(self.load_from_directory(d))
yield loaded
finally:
# Restore original state
self._loaded_tools = original_tools
def get_loaded_tools(self) -> Dict[str, Callable]:
"""
Get all currently loaded tools.
Returns:
Dict mapping tool names to callable functions
"""
return self._loaded_tools.copy()
def clear_loaded_tools(self) -> None:
"""Clear all loaded tools."""
self._loaded_tools.clear()
def create_tool_registry_with_overrides(
override_files: Optional[List[str]] = None,
override_dirs: Optional[List[str]] = None,
include_defaults: bool = True
) -> Dict[str, Callable]:
"""
Create a tool registry with custom overrides.
Resolution order (highest priority first):
1. Override files (explicit)
2. Override directories
3. Default custom dirs (~/.praison/tools, etc.)
4. Built-in tools
Args:
override_files: Explicit tool files to load
override_dirs: Directories to scan for tools
include_defaults: Whether to include default tool directories
Returns:
Dict mapping tool names to callable functions
"""
registry = {}
loader = ToolOverrideLoader()
# Start with built-in tools (lowest priority)
try:
from praisonaiagents.tools import TOOL_MAPPINGS
registry.update(TOOL_MAPPINGS)
except ImportError:
pass
# Add default custom dirs
if include_defaults:
for dir_path in loader.get_default_tool_dirs():
if dir_path.exists():
try:
tools = loader.load_from_directory(str(dir_path))
registry.update(tools)
except Exception:
pass
# Add override directories
if override_dirs:
for dir_path in override_dirs:
try:
tools = loader.load_from_directory(dir_path)
registry.update(tools)
except Exception:
pass
# Add override files (highest priority)
if override_files:
for file_path in override_files:
try:
tools = loader.load_from_file(file_path)
registry.update(tools)
except Exception:
pass
return registry
@@ -0,0 +1,256 @@
"""
Tools Doctor for PraisonAI.
Diagnoses tool availability, checks dependencies, and reports issues.
Supports both human-readable and JSON output formats.
"""
import importlib.util
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
class ToolsDoctor:
"""
Diagnoses tool availability and dependencies.
Checks:
- praisonai-tools installation
- Built-in tools availability
- Custom tool directories
- Optional dependency packages
"""
# Default custom tool directories
DEFAULT_TOOL_DIRS = [
"~/.praison/tools",
"~/.config/praison/tools",
]
# Known tools and their optional dependencies
TOOL_DEPENDENCIES = {
"youtube_tool": ["pytube", "youtube-transcript-api"],
"whisper_tool": ["openai-whisper", "torch"],
"vision_tool": ["pillow", "opencv-python"],
"tavily_search": ["tavily-python"],
"arxiv_search": ["arxiv"],
"wikipedia_search": ["wikipedia"],
}
def __init__(self, custom_dirs: Optional[List[str]] = None):
"""
Initialize tools doctor.
Args:
custom_dirs: Additional custom tool directories to check
"""
self._custom_dirs = custom_dirs or []
def diagnose(self) -> Dict[str, Any]:
"""
Run full diagnostics.
Returns:
Dict with diagnostic results
"""
result = {
"praisonai_tools_installed": self._check_praisonai_tools(),
"praisonaiagents_installed": self._check_praisonaiagents(),
"builtin_tools": self._get_builtin_tools(),
"praisonai_tools_available": self._get_praisonai_tools_list(),
"custom_tools_dirs": self._check_custom_dirs(),
"tool_dependencies": self._check_tool_dependencies(),
"issues": [],
}
# Collect issues
if not result["praisonaiagents_installed"]:
result["issues"].append({
"severity": "error",
"message": "praisonaiagents not installed",
"hint": "pip install praisonaiagents"
})
if not result["praisonai_tools_installed"]:
result["issues"].append({
"severity": "warning",
"message": "praisonai-tools not installed (optional)",
"hint": "pip install praisonai-tools"
})
# Check for missing dependencies
for tool, deps in result["tool_dependencies"].items():
missing = [d for d in deps if not d["available"]]
if missing:
result["issues"].append({
"severity": "info",
"message": f"Tool '{tool}' has missing optional dependencies",
"hint": f"pip install {' '.join(d['name'] for d in missing)}"
})
return result
def _check_praisonai_tools(self) -> bool:
"""Check if praisonai-tools is installed."""
spec = importlib.util.find_spec("praisonai_tools")
return spec is not None
def _check_praisonaiagents(self) -> bool:
"""Check if praisonaiagents is installed."""
spec = importlib.util.find_spec("praisonaiagents")
return spec is not None
def _get_builtin_tools(self) -> List[str]:
"""Get list of built-in tools."""
tools = []
try:
from praisonaiagents.tools import TOOL_MAPPINGS
tools = list(TOOL_MAPPINGS.keys())
except ImportError:
pass
return tools
def _get_praisonai_tools_list(self) -> List[str]:
"""Get list of tools from praisonai-tools package."""
tools = []
try:
import praisonai_tools
# Get all callable attributes that look like tools
for name in dir(praisonai_tools):
if not name.startswith("_"):
obj = getattr(praisonai_tools, name, None)
if callable(obj):
tools.append(name)
except ImportError:
pass
return tools
def _check_custom_dirs(self) -> List[Dict[str, Any]]:
"""Check custom tool directories."""
dirs = []
# Check default dirs
for dir_path in self.DEFAULT_TOOL_DIRS:
path = Path(dir_path).expanduser()
tool_count = 0
if path.exists():
# Count Python files
tool_count = len(list(path.glob("*.py")))
dirs.append({
"path": str(path),
"exists": path.exists(),
"tool_count": tool_count
})
# Check custom dirs
for dir_path in self._custom_dirs:
path = Path(dir_path).expanduser()
tool_count = 0
if path.exists():
tool_count = len(list(path.glob("*.py")))
dirs.append({
"path": str(path),
"exists": path.exists(),
"tool_count": tool_count,
"custom": True
})
return dirs
def _check_tool_dependencies(self) -> Dict[str, List[Dict[str, Any]]]:
"""Check dependencies for known tools."""
result = {}
for tool, deps in self.TOOL_DEPENDENCIES.items():
result[tool] = []
for dep in deps:
spec = importlib.util.find_spec(dep.replace("-", "_"))
result[tool].append({
"name": dep,
"available": spec is not None
})
return result
def diagnose_json(self) -> str:
"""
Run diagnostics and return JSON output.
Returns:
JSON string with diagnostic results
"""
result = self.diagnose()
return json.dumps(result, indent=2)
def diagnose_human(self) -> str:
"""
Run diagnostics and return human-readable output.
Returns:
Formatted string with diagnostic results
"""
result = self.diagnose()
lines = []
lines.append("=" * 60)
lines.append("PraisonAI Tools Doctor")
lines.append("=" * 60)
lines.append("")
# Core packages
lines.append("Core Packages:")
status = "✓" if result["praisonaiagents_installed"] else "✗"
lines.append(f" {status} praisonaiagents")
status = "✓" if result["praisonai_tools_installed"] else "✗"
lines.append(f" {status} praisonai-tools (optional)")
lines.append("")
# Built-in tools
lines.append(f"Built-in Tools ({len(result['builtin_tools'])}):")
if result["builtin_tools"]:
for tool in result["builtin_tools"][:10]:
lines.append(f" • {tool}")
if len(result["builtin_tools"]) > 10:
lines.append(f" ... and {len(result['builtin_tools']) - 10} more")
else:
lines.append(" (none found)")
lines.append("")
# praisonai-tools
if result["praisonai_tools_available"]:
lines.append(f"praisonai-tools ({len(result['praisonai_tools_available'])}):")
for tool in result["praisonai_tools_available"][:10]:
lines.append(f" • {tool}")
if len(result["praisonai_tools_available"]) > 10:
lines.append(f" ... and {len(result['praisonai_tools_available']) - 10} more")
lines.append("")
# Custom directories
lines.append("Custom Tool Directories:")
for dir_info in result["custom_tools_dirs"]:
status = "✓" if dir_info["exists"] else "✗"
count = f" ({dir_info['tool_count']} tools)" if dir_info["exists"] else ""
custom = " [custom]" if dir_info.get("custom") else ""
lines.append(f" {status} {dir_info['path']}{count}{custom}")
lines.append("")
# Issues
if result["issues"]:
lines.append("Issues Found:")
for issue in result["issues"]:
icon = {"error": "❌", "warning": "⚠️", "info": "ℹ️"}.get(issue["severity"], "•")
lines.append(f" {icon} {issue['message']}")
if issue.get("hint"):
lines.append(f" Hint: {issue['hint']}")
lines.append("")
else:
lines.append("✓ No issues found")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.5.7"
__version__ = "2.6.2"
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more