mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-11 03:31:27 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0f0f653fe |
No files matched your search
@@ -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.6" \
|
||||
"praisonai>=2.5.7" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -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.6" \
|
||||
"praisonai>=2.5.7" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -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.6" \
|
||||
"praisonai>=2.5.7" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
# PraisonAI CLI Specification v1.0.0
|
||||
# This is the canonical contract for CLI behavior across all runtimes (Python, TypeScript, etc.)
|
||||
# Any CLI implementation MUST conform to this spec for parity.
|
||||
|
||||
version: "1.0.0"
|
||||
spec_name: "praisonai-cli"
|
||||
|
||||
# Binary names per ecosystem (NO COLLISION)
|
||||
binaries:
|
||||
python: praisonai
|
||||
typescript: praisonai-ts
|
||||
|
||||
# Commands (all implementations MUST support)
|
||||
commands:
|
||||
chat:
|
||||
description: "Chat with an AI agent"
|
||||
args:
|
||||
- name: prompt
|
||||
type: string
|
||||
required: true
|
||||
position: 0
|
||||
flags:
|
||||
- name: model
|
||||
short: m
|
||||
type: string
|
||||
description: "Model to use (e.g., openai/gpt-4o-mini)"
|
||||
- name: stream
|
||||
short: s
|
||||
type: boolean
|
||||
default: false
|
||||
description: "Enable streaming output"
|
||||
- name: session
|
||||
type: string
|
||||
description: "Session ID for conversation continuity"
|
||||
|
||||
run:
|
||||
description: "Run an agent with a task"
|
||||
args:
|
||||
- name: task
|
||||
type: string
|
||||
required: true
|
||||
position: 0
|
||||
flags:
|
||||
- name: agent
|
||||
short: a
|
||||
type: string
|
||||
description: "Agent configuration file or name"
|
||||
- name: tools
|
||||
short: t
|
||||
type: string
|
||||
description: "Comma-separated list of tools"
|
||||
|
||||
workflow:
|
||||
description: "Execute a multi-agent workflow"
|
||||
args:
|
||||
- name: file
|
||||
type: string
|
||||
required: true
|
||||
position: 0
|
||||
description: "Workflow YAML file path"
|
||||
flags:
|
||||
- name: parallel
|
||||
type: boolean
|
||||
default: false
|
||||
description: "Run workflow steps in parallel where possible"
|
||||
|
||||
eval:
|
||||
description: "Evaluate agent performance"
|
||||
subcommands:
|
||||
accuracy:
|
||||
description: "Run accuracy evaluation"
|
||||
flags:
|
||||
- name: input
|
||||
type: string
|
||||
required: true
|
||||
- name: expected
|
||||
type: string
|
||||
required: true
|
||||
- name: iterations
|
||||
type: integer
|
||||
default: 1
|
||||
performance:
|
||||
description: "Run performance benchmark"
|
||||
flags:
|
||||
- name: iterations
|
||||
type: integer
|
||||
default: 10
|
||||
- name: warmup
|
||||
type: integer
|
||||
default: 2
|
||||
reliability:
|
||||
description: "Run reliability check"
|
||||
flags:
|
||||
- name: expected-tools
|
||||
type: string
|
||||
description: "Comma-separated expected tool calls"
|
||||
|
||||
providers:
|
||||
description: "List available LLM providers"
|
||||
flags: []
|
||||
|
||||
tools:
|
||||
description: "List or manage tools"
|
||||
subcommands:
|
||||
list:
|
||||
description: "List available tools"
|
||||
info:
|
||||
description: "Show tool information"
|
||||
args:
|
||||
- name: name
|
||||
type: string
|
||||
required: true
|
||||
position: 0
|
||||
|
||||
version:
|
||||
description: "Show CLI version"
|
||||
flags: []
|
||||
|
||||
help:
|
||||
description: "Show help information"
|
||||
args:
|
||||
- name: command
|
||||
type: string
|
||||
required: false
|
||||
position: 0
|
||||
|
||||
# Global flags (apply to all commands)
|
||||
global_flags:
|
||||
- name: verbose
|
||||
short: v
|
||||
type: boolean
|
||||
default: false
|
||||
description: "Enable verbose output"
|
||||
- name: config
|
||||
short: c
|
||||
type: string
|
||||
description: "Path to config file"
|
||||
- name: profile
|
||||
short: p
|
||||
type: string
|
||||
description: "Profile name to use"
|
||||
- name: output
|
||||
short: o
|
||||
type: string
|
||||
enum: [json, text, pretty]
|
||||
default: pretty
|
||||
description: "Output format"
|
||||
- name: json
|
||||
type: boolean
|
||||
default: false
|
||||
description: "Shorthand for --output json"
|
||||
|
||||
# Exit codes (MUST be identical across implementations)
|
||||
exit_codes:
|
||||
success: 0
|
||||
runtime_error: 1
|
||||
invalid_arguments: 2
|
||||
config_error: 3
|
||||
network_error: 4
|
||||
auth_error: 5
|
||||
|
||||
# Output schema for JSON mode (MUST be byte-compatible)
|
||||
output_schema:
|
||||
success:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
value: true
|
||||
data:
|
||||
type: object
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
duration_ms:
|
||||
type: number
|
||||
model:
|
||||
type: string
|
||||
tokens:
|
||||
type: object
|
||||
properties:
|
||||
input:
|
||||
type: integer
|
||||
output:
|
||||
type: integer
|
||||
error:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
value: false
|
||||
error:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
details:
|
||||
type: object
|
||||
|
||||
# Environment variables (MUST be respected)
|
||||
environment:
|
||||
PRAISONAI_MODEL:
|
||||
description: "Default model to use"
|
||||
example: "openai/gpt-4o-mini"
|
||||
PRAISONAI_PROFILE:
|
||||
description: "Default profile name"
|
||||
PRAISONAI_VERBOSE:
|
||||
description: "Enable verbose mode (true/false)"
|
||||
PRAISONAI_CONFIG:
|
||||
description: "Path to config file"
|
||||
OPENAI_API_KEY:
|
||||
description: "OpenAI API key"
|
||||
ANTHROPIC_API_KEY:
|
||||
description: "Anthropic API key"
|
||||
GOOGLE_API_KEY:
|
||||
description: "Google API key"
|
||||
|
||||
# Config file schema reference
|
||||
config:
|
||||
files:
|
||||
- .praisonai.yaml
|
||||
- .praisonai.json
|
||||
resolution_order:
|
||||
- cli_flags
|
||||
- environment_variables
|
||||
- project_config
|
||||
- user_config
|
||||
- defaults
|
||||
schema:
|
||||
model:
|
||||
type: string
|
||||
default: "openai/gpt-4o-mini"
|
||||
verbose:
|
||||
type: boolean
|
||||
default: false
|
||||
stream:
|
||||
type: boolean
|
||||
default: false
|
||||
profiles:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
model:
|
||||
type: string
|
||||
verbose:
|
||||
type: boolean
|
||||
stream:
|
||||
type: boolean
|
||||
|
||||
# Tool interface contract
|
||||
tool_schema:
|
||||
name:
|
||||
type: string
|
||||
required: true
|
||||
description:
|
||||
type: string
|
||||
required: true
|
||||
inputs:
|
||||
type: object
|
||||
description: "JSON Schema for tool inputs"
|
||||
outputs:
|
||||
type: object
|
||||
description: "JSON Schema for tool outputs"
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Chat command - Chat with an AI agent
|
||||
*/
|
||||
|
||||
import { createProvider } from '../../llm/providers';
|
||||
import { Session } from '../../session';
|
||||
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 ChatOptions {
|
||||
model?: string;
|
||||
stream?: boolean;
|
||||
session?: string;
|
||||
verbose?: boolean;
|
||||
profile?: string;
|
||||
config?: string;
|
||||
output?: 'json' | 'text' | 'pretty';
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function execute(args: string[], options: ChatOptions): Promise<void> {
|
||||
const prompt = args[0];
|
||||
|
||||
if (!prompt) {
|
||||
if (options.json || options.output === 'json') {
|
||||
printError(ERROR_CODES.MISSING_ARG, 'Please provide a prompt');
|
||||
} else {
|
||||
await pretty.error('Please provide a prompt');
|
||||
}
|
||||
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,
|
||||
stream: options.stream
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
|
||||
|
||||
try {
|
||||
const provider = createProvider(config.model);
|
||||
const session = new Session({ id: options.session });
|
||||
session.addMessage({ role: 'user', content: prompt });
|
||||
|
||||
let responseText = '';
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
|
||||
if (config.stream && outputFormat !== 'json') {
|
||||
// Streaming mode (only for non-JSON output)
|
||||
const stream = await provider.streamText({
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
onToken: (token: string) => {
|
||||
process.stdout.write(token);
|
||||
responseText += token;
|
||||
}
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// Stream is consumed by onToken
|
||||
}
|
||||
console.log(); // New line after streaming
|
||||
} else {
|
||||
// Non-streaming mode
|
||||
const result = await provider.generateText({
|
||||
messages: [{ role: 'user', content: prompt }]
|
||||
});
|
||||
responseText = result.text;
|
||||
inputTokens = result.usage?.promptTokens || 0;
|
||||
outputTokens = result.usage?.completionTokens || 0;
|
||||
|
||||
if (outputFormat === 'json') {
|
||||
const duration = Date.now() - startTime;
|
||||
outputJson(formatSuccess(
|
||||
{ response: responseText },
|
||||
{
|
||||
duration_ms: duration,
|
||||
model: config.model,
|
||||
tokens: {
|
||||
input: inputTokens,
|
||||
output: outputTokens
|
||||
}
|
||||
}
|
||||
));
|
||||
} else {
|
||||
console.log(responseText);
|
||||
}
|
||||
}
|
||||
|
||||
session.addMessage({ role: 'assistant', content: responseText });
|
||||
|
||||
} 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,7 @@
|
||||
/**
|
||||
* Config utilities index
|
||||
*/
|
||||
|
||||
export * from './schema';
|
||||
export * from './load';
|
||||
export * from './resolve';
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Config file loading
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CONFIG_FILES } from '../spec/cli-spec';
|
||||
import { FullConfig, validateConfig, DEFAULT_CONFIG } from './schema';
|
||||
|
||||
export interface LoadConfigOptions {
|
||||
configPath?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find config file in directory hierarchy
|
||||
*/
|
||||
function findConfigFile(startDir: string): string | null {
|
||||
let currentDir = startDir;
|
||||
|
||||
while (true) {
|
||||
for (const filename of CONFIG_FILES) {
|
||||
const filePath = path.join(currentDir, filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
break; // Reached root
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user config file
|
||||
*/
|
||||
function findUserConfigFile(): string | null {
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
||||
if (!homeDir) return null;
|
||||
|
||||
for (const filename of CONFIG_FILES) {
|
||||
const filePath = path.join(homeDir, filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse config file content
|
||||
*/
|
||||
function parseConfigFile(filePath: string): unknown {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
if (ext === '.json') {
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
if (ext === '.yaml' || ext === '.yml') {
|
||||
// Simple YAML parsing for basic config
|
||||
// For complex YAML, we'd need a proper parser
|
||||
return parseSimpleYaml(content);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported config file format: ${ext}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple YAML parser for basic key-value configs
|
||||
* Handles: strings, booleans, numbers, simple objects
|
||||
*/
|
||||
function parseSimpleYaml(content: string): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
const lines = content.split('\n');
|
||||
let currentObject: Record<string, unknown> | null = null;
|
||||
let currentKey: string | null = null;
|
||||
let baseIndent = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip empty lines and comments
|
||||
if (!line.trim() || line.trim().startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const indent = line.search(/\S/);
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check for key: value pattern
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
if (colonIndex === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, colonIndex).trim();
|
||||
const value = trimmed.slice(colonIndex + 1).trim();
|
||||
|
||||
if (indent === 0) {
|
||||
// Top-level key
|
||||
if (value === '' || value === '{}') {
|
||||
// Start of nested object
|
||||
currentObject = {};
|
||||
currentKey = key;
|
||||
baseIndent = indent;
|
||||
result[key] = currentObject;
|
||||
} else {
|
||||
result[key] = parseYamlValue(value);
|
||||
currentObject = null;
|
||||
currentKey = null;
|
||||
}
|
||||
} else if (currentObject !== null && indent > baseIndent) {
|
||||
// Nested key
|
||||
if (value === '' || value === '{}') {
|
||||
// Nested object within nested object
|
||||
const nestedObj: Record<string, unknown> = {};
|
||||
currentObject[key] = nestedObj;
|
||||
// For simplicity, we only support one level of nesting in profiles
|
||||
} else {
|
||||
currentObject[key] = parseYamlValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseYamlValue(value: string): string | number | boolean {
|
||||
// Remove quotes
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
|
||||
// Number
|
||||
const num = Number(value);
|
||||
if (!isNaN(num) && value !== '') return num;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from file
|
||||
*/
|
||||
export function loadConfigFile(filePath: string): FullConfig {
|
||||
try {
|
||||
const parsed = parseConfigFile(filePath);
|
||||
if (!validateConfig(parsed)) {
|
||||
throw new Error('Invalid config format');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load config from ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config with resolution order:
|
||||
* 1. Explicit config path
|
||||
* 2. Project config (current dir and parents)
|
||||
* 3. User config (~/.praisonai.yaml)
|
||||
* 4. Defaults
|
||||
*/
|
||||
export function loadConfig(options: LoadConfigOptions = {}): FullConfig {
|
||||
const configs: Partial<FullConfig>[] = [DEFAULT_CONFIG];
|
||||
|
||||
// User config
|
||||
const userConfigPath = findUserConfigFile();
|
||||
if (userConfigPath) {
|
||||
try {
|
||||
configs.push(loadConfigFile(userConfigPath));
|
||||
} catch {
|
||||
// Ignore user config errors
|
||||
}
|
||||
}
|
||||
|
||||
// Project config
|
||||
const cwd = options.cwd || process.cwd();
|
||||
const projectConfigPath = findConfigFile(cwd);
|
||||
if (projectConfigPath) {
|
||||
try {
|
||||
configs.push(loadConfigFile(projectConfigPath));
|
||||
} catch {
|
||||
// Ignore project config errors
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit config path
|
||||
if (options.configPath) {
|
||||
configs.push(loadConfigFile(options.configPath));
|
||||
}
|
||||
|
||||
// Merge all configs
|
||||
const result: FullConfig = { ...DEFAULT_CONFIG };
|
||||
for (const config of configs) {
|
||||
if (config.model !== undefined) result.model = config.model;
|
||||
if (config.verbose !== undefined) result.verbose = config.verbose;
|
||||
if (config.stream !== undefined) result.stream = config.stream;
|
||||
if (config.profiles !== undefined) {
|
||||
result.profiles = { ...result.profiles, ...config.profiles };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Config resolution with profiles and CLI overrides
|
||||
*/
|
||||
|
||||
import { loadConfig } from './load';
|
||||
import { loadEnvConfig } from '../runtime/env';
|
||||
import { FullConfig, ProfileConfig, DEFAULT_CONFIG } from './schema';
|
||||
|
||||
export interface ResolvedConfig {
|
||||
model: string;
|
||||
verbose: boolean;
|
||||
stream: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
configPath?: string;
|
||||
profile?: string;
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve final config with precedence:
|
||||
* 1. CLI flags (highest)
|
||||
* 2. Environment variables
|
||||
* 3. Profile settings
|
||||
* 4. Config file
|
||||
* 5. Defaults (lowest)
|
||||
*/
|
||||
export function resolveConfig(options: ResolveOptions = {}): ResolvedConfig {
|
||||
// Load base config from files
|
||||
const fileConfig = loadConfig({ configPath: options.configPath });
|
||||
|
||||
// Load env config
|
||||
const envConfig = loadEnvConfig();
|
||||
|
||||
// Get profile config if specified
|
||||
let profileConfig: ProfileConfig = {};
|
||||
const profileName = options.profile || envConfig.profile;
|
||||
if (profileName && fileConfig.profiles?.[profileName]) {
|
||||
profileConfig = fileConfig.profiles[profileName];
|
||||
}
|
||||
|
||||
// Resolve with precedence
|
||||
const resolved: ResolvedConfig = {
|
||||
model: DEFAULT_CONFIG.model!,
|
||||
verbose: DEFAULT_CONFIG.verbose!,
|
||||
stream: DEFAULT_CONFIG.stream!
|
||||
};
|
||||
|
||||
// Apply file config
|
||||
if (fileConfig.model !== undefined) resolved.model = fileConfig.model;
|
||||
if (fileConfig.verbose !== undefined) resolved.verbose = fileConfig.verbose;
|
||||
if (fileConfig.stream !== undefined) resolved.stream = fileConfig.stream;
|
||||
|
||||
// Apply profile config
|
||||
if (profileConfig.model !== undefined) resolved.model = profileConfig.model;
|
||||
if (profileConfig.verbose !== undefined) resolved.verbose = profileConfig.verbose;
|
||||
if (profileConfig.stream !== undefined) resolved.stream = profileConfig.stream;
|
||||
|
||||
// Apply env config
|
||||
if (envConfig.model !== undefined) resolved.model = envConfig.model;
|
||||
if (envConfig.verbose !== undefined) resolved.verbose = envConfig.verbose;
|
||||
|
||||
// Apply CLI flags (highest precedence)
|
||||
if (options.model !== undefined) resolved.model = options.model;
|
||||
if (options.verbose !== undefined) resolved.verbose = options.verbose;
|
||||
if (options.stream !== undefined) resolved.stream = options.stream;
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available profile names
|
||||
*/
|
||||
export function getAvailableProfiles(configPath?: string): string[] {
|
||||
const config = loadConfig({ configPath });
|
||||
return Object.keys(config.profiles || {});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Config schema definitions
|
||||
*/
|
||||
|
||||
import { ConfigSchema } from '../spec/cli-spec';
|
||||
|
||||
export interface ProfileConfig {
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export interface FullConfig extends ConfigSchema {
|
||||
profiles?: Record<string, ProfileConfig>;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: FullConfig = {
|
||||
model: 'openai/gpt-4o-mini',
|
||||
verbose: false,
|
||||
stream: false,
|
||||
profiles: {}
|
||||
};
|
||||
|
||||
export function validateConfig(config: unknown): config is FullConfig {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const c = config as Record<string, unknown>;
|
||||
|
||||
if (c.model !== undefined && typeof c.model !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (c.verbose !== undefined && typeof c.verbose !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (c.stream !== undefined && typeof c.stream !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (c.profiles !== undefined) {
|
||||
if (typeof c.profiles !== 'object' || c.profiles === null) {
|
||||
return false;
|
||||
}
|
||||
for (const profile of Object.values(c.profiles as Record<string, unknown>)) {
|
||||
if (typeof profile !== 'object' || profile === null) {
|
||||
return false;
|
||||
}
|
||||
const p = profile as Record<string, unknown>;
|
||||
if (p.model !== undefined && typeof p.model !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (p.verbose !== undefined && typeof p.verbose !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
if (p.stream !== undefined && typeof p.stream !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function mergeConfigs(...configs: Partial<FullConfig>[]): FullConfig {
|
||||
const result: FullConfig = { ...DEFAULT_CONFIG };
|
||||
|
||||
for (const config of configs) {
|
||||
if (config.model !== undefined) result.model = config.model;
|
||||
if (config.verbose !== undefined) result.verbose = config.verbose;
|
||||
if (config.stream !== undefined) result.stream = config.stream;
|
||||
if (config.profiles !== undefined) {
|
||||
result.profiles = { ...result.profiles, ...config.profiles };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Environment variable handling for CLI
|
||||
*/
|
||||
|
||||
import { ENV_VARS } from '../spec/cli-spec';
|
||||
|
||||
export interface EnvConfig {
|
||||
model?: string;
|
||||
profile?: string;
|
||||
verbose?: boolean;
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
export function loadEnvConfig(): EnvConfig {
|
||||
const config: EnvConfig = {};
|
||||
|
||||
const model = process.env[ENV_VARS.PRAISONAI_MODEL];
|
||||
if (model) config.model = model;
|
||||
|
||||
const profile = process.env[ENV_VARS.PRAISONAI_PROFILE];
|
||||
if (profile) config.profile = profile;
|
||||
|
||||
const verbose = process.env[ENV_VARS.PRAISONAI_VERBOSE];
|
||||
if (verbose) config.verbose = verbose.toLowerCase() === 'true';
|
||||
|
||||
const configPath = process.env[ENV_VARS.PRAISONAI_CONFIG];
|
||||
if (configPath) config.configPath = configPath;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function hasApiKey(provider: 'openai' | 'anthropic' | 'google'): boolean {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
return !!process.env[ENV_VARS.OPENAI_API_KEY];
|
||||
case 'anthropic':
|
||||
return !!process.env[ENV_VARS.ANTHROPIC_API_KEY];
|
||||
case 'google':
|
||||
return !!process.env[ENV_VARS.GOOGLE_API_KEY];
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getApiKey(provider: 'openai' | 'anthropic' | 'google'): string | undefined {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
return process.env[ENV_VARS.OPENAI_API_KEY];
|
||||
case 'anthropic':
|
||||
return process.env[ENV_VARS.ANTHROPIC_API_KEY];
|
||||
case 'google':
|
||||
return process.env[ENV_VARS.GOOGLE_API_KEY];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Exit code handling for CLI
|
||||
*/
|
||||
|
||||
import { EXIT_CODES, ExitCode } from '../spec/cli-spec';
|
||||
|
||||
export function exit(code: ExitCode): never {
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
export function exitSuccess(): never {
|
||||
exit(EXIT_CODES.SUCCESS);
|
||||
}
|
||||
|
||||
export function exitError(message?: string): never {
|
||||
if (message) {
|
||||
console.error(message);
|
||||
}
|
||||
exit(EXIT_CODES.RUNTIME_ERROR);
|
||||
}
|
||||
|
||||
export function exitInvalidArgs(message?: string): never {
|
||||
if (message) {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
exit(EXIT_CODES.INVALID_ARGUMENTS);
|
||||
}
|
||||
|
||||
export function exitConfigError(message?: string): never {
|
||||
if (message) {
|
||||
console.error(`Config Error: ${message}`);
|
||||
}
|
||||
exit(EXIT_CODES.CONFIG_ERROR);
|
||||
}
|
||||
|
||||
export function exitNetworkError(message?: string): never {
|
||||
if (message) {
|
||||
console.error(`Network Error: ${message}`);
|
||||
}
|
||||
exit(EXIT_CODES.NETWORK_ERROR);
|
||||
}
|
||||
|
||||
export function exitAuthError(message?: string): never {
|
||||
if (message) {
|
||||
console.error(`Auth Error: ${message}`);
|
||||
}
|
||||
exit(EXIT_CODES.AUTH_ERROR);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Runtime utilities index
|
||||
*/
|
||||
|
||||
export * from './env';
|
||||
export * from './exit';
|
||||
export * from './lazy';
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Lazy loading utilities for CLI commands
|
||||
* Ensures startup time stays <100ms by only loading command code when invoked
|
||||
*/
|
||||
|
||||
import { validateCommand } from '../spec/cli-spec';
|
||||
|
||||
export type CommandModule = {
|
||||
execute: (args: string[], options: Record<string, unknown>) => Promise<void>;
|
||||
};
|
||||
|
||||
const commandCache = new Map<string, CommandModule>();
|
||||
|
||||
export async function loadCommand(command: string): Promise<CommandModule> {
|
||||
if (!validateCommand(command)) {
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (commandCache.has(command)) {
|
||||
return commandCache.get(command)!;
|
||||
}
|
||||
|
||||
// Lazy import the command module
|
||||
const module = await import(`../commands/${command}`);
|
||||
commandCache.set(command, module);
|
||||
return module;
|
||||
}
|
||||
|
||||
export function clearCommandCache(): void {
|
||||
commandCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy load optional dependencies
|
||||
* Returns undefined if not available
|
||||
*/
|
||||
export async function tryLoadOptional<T>(moduleName: string): Promise<T | undefined> {
|
||||
try {
|
||||
return await import(moduleName);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an optional dependency is available
|
||||
*/
|
||||
export async function hasOptionalDep(moduleName: string): Promise<boolean> {
|
||||
try {
|
||||
await import(moduleName);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* PraisonAI CLI Specification - TypeScript Runtime
|
||||
* Parsed and validated spec from cli-spec.v1.yaml
|
||||
*/
|
||||
|
||||
export const CLI_SPEC_VERSION = '1.0.0';
|
||||
|
||||
export interface CommandArg {
|
||||
name: string;
|
||||
type: 'string' | 'integer' | 'boolean';
|
||||
required: boolean;
|
||||
position?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CommandFlag {
|
||||
name: string;
|
||||
short?: string;
|
||||
type: 'string' | 'integer' | 'boolean';
|
||||
default?: string | number | boolean;
|
||||
enum?: string[];
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface Subcommand {
|
||||
description: string;
|
||||
args?: CommandArg[];
|
||||
flags?: CommandFlag[];
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
description: string;
|
||||
args?: CommandArg[];
|
||||
flags?: CommandFlag[];
|
||||
subcommands?: Record<string, Subcommand>;
|
||||
}
|
||||
|
||||
export const COMMANDS: Record<string, Command> = {
|
||||
chat: {
|
||||
description: 'Chat with an AI agent',
|
||||
args: [
|
||||
{ name: 'prompt', type: 'string', required: true, position: 0 }
|
||||
],
|
||||
flags: [
|
||||
{ name: 'model', short: 'm', type: 'string', description: 'Model to use (e.g., openai/gpt-4o-mini)' },
|
||||
{ name: 'stream', short: 's', type: 'boolean', default: false, description: 'Enable streaming output' },
|
||||
{ name: 'session', type: 'string', description: 'Session ID for conversation continuity' }
|
||||
]
|
||||
},
|
||||
run: {
|
||||
description: 'Run an agent with a task',
|
||||
args: [
|
||||
{ name: 'task', type: 'string', required: true, position: 0 }
|
||||
],
|
||||
flags: [
|
||||
{ name: 'agent', short: 'a', type: 'string', description: 'Agent configuration file or name' },
|
||||
{ name: 'tools', short: 't', type: 'string', description: 'Comma-separated list of tools' }
|
||||
]
|
||||
},
|
||||
workflow: {
|
||||
description: 'Execute a multi-agent workflow',
|
||||
args: [
|
||||
{ name: 'file', type: 'string', required: true, position: 0, description: 'Workflow YAML file path' }
|
||||
],
|
||||
flags: [
|
||||
{ name: 'parallel', type: 'boolean', default: false, description: 'Run workflow steps in parallel where possible' }
|
||||
]
|
||||
},
|
||||
eval: {
|
||||
description: 'Evaluate agent performance',
|
||||
subcommands: {
|
||||
accuracy: {
|
||||
description: 'Run accuracy evaluation',
|
||||
flags: [
|
||||
{ name: 'input', type: 'string', required: true },
|
||||
{ name: 'expected', type: 'string', required: true },
|
||||
{ name: 'iterations', type: 'integer', default: 1 }
|
||||
]
|
||||
},
|
||||
performance: {
|
||||
description: 'Run performance benchmark',
|
||||
flags: [
|
||||
{ name: 'iterations', type: 'integer', default: 10 },
|
||||
{ name: 'warmup', type: 'integer', default: 2 }
|
||||
]
|
||||
},
|
||||
reliability: {
|
||||
description: 'Run reliability check',
|
||||
flags: [
|
||||
{ name: 'expected-tools', type: 'string', description: 'Comma-separated expected tool calls' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
providers: {
|
||||
description: 'List available LLM providers',
|
||||
flags: []
|
||||
},
|
||||
tools: {
|
||||
description: 'List or manage tools',
|
||||
subcommands: {
|
||||
list: {
|
||||
description: 'List available tools'
|
||||
},
|
||||
info: {
|
||||
description: 'Show tool information',
|
||||
args: [
|
||||
{ name: 'name', type: 'string', required: true, position: 0 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
version: {
|
||||
description: 'Show CLI version',
|
||||
flags: []
|
||||
},
|
||||
help: {
|
||||
description: 'Show help information',
|
||||
args: [
|
||||
{ name: 'command', type: 'string', required: false, position: 0 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export const GLOBAL_FLAGS: CommandFlag[] = [
|
||||
{ name: 'verbose', short: 'v', type: 'boolean', default: false, description: 'Enable verbose output' },
|
||||
{ name: 'config', short: 'c', type: 'string', description: 'Path to config file' },
|
||||
{ name: 'profile', short: 'p', type: 'string', description: 'Profile name to use' },
|
||||
{ name: 'output', short: 'o', type: 'string', enum: ['json', 'text', 'pretty'], default: 'pretty', description: 'Output format' },
|
||||
{ name: 'json', type: 'boolean', default: false, description: 'Shorthand for --output json' }
|
||||
];
|
||||
|
||||
export const EXIT_CODES = {
|
||||
SUCCESS: 0,
|
||||
RUNTIME_ERROR: 1,
|
||||
INVALID_ARGUMENTS: 2,
|
||||
CONFIG_ERROR: 3,
|
||||
NETWORK_ERROR: 4,
|
||||
AUTH_ERROR: 5
|
||||
} as const;
|
||||
|
||||
export type ExitCode = typeof EXIT_CODES[keyof typeof EXIT_CODES];
|
||||
|
||||
export interface SuccessOutput<T = unknown> {
|
||||
success: true;
|
||||
data: T;
|
||||
meta?: {
|
||||
duration_ms?: number;
|
||||
model?: string;
|
||||
tokens?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ErrorOutput {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export type CLIOutput<T = unknown> = SuccessOutput<T> | ErrorOutput;
|
||||
|
||||
export const ENV_VARS = {
|
||||
PRAISONAI_MODEL: 'PRAISONAI_MODEL',
|
||||
PRAISONAI_PROFILE: 'PRAISONAI_PROFILE',
|
||||
PRAISONAI_VERBOSE: 'PRAISONAI_VERBOSE',
|
||||
PRAISONAI_CONFIG: 'PRAISONAI_CONFIG',
|
||||
OPENAI_API_KEY: 'OPENAI_API_KEY',
|
||||
ANTHROPIC_API_KEY: 'ANTHROPIC_API_KEY',
|
||||
GOOGLE_API_KEY: 'GOOGLE_API_KEY'
|
||||
} as const;
|
||||
|
||||
export const CONFIG_FILES = ['.praisonai.yaml', '.praisonai.json'] as const;
|
||||
|
||||
export interface ConfigSchema {
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
stream?: boolean;
|
||||
profiles?: Record<string, {
|
||||
model?: string;
|
||||
verbose?: boolean;
|
||||
stream?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function validateCommand(command: string): command is keyof typeof COMMANDS {
|
||||
return command in COMMANDS;
|
||||
}
|
||||
|
||||
export function getCommandSpec(command: string): Command | undefined {
|
||||
return COMMANDS[command];
|
||||
}
|
||||
|
||||
export function getAllFlags(command: string): CommandFlag[] {
|
||||
const cmdSpec = COMMANDS[command];
|
||||
const cmdFlags = cmdSpec?.flags || [];
|
||||
return [...GLOBAL_FLAGS, ...cmdFlags];
|
||||
}
|
||||
Whitespace-only changes.
@@ -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.6.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.5.6.tar.gz | shasum -a 256`.split.first
|
||||
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
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -324,6 +324,135 @@ class DeployHandler:
|
||||
lines.append(f"[bold]{key}:[/bold] {value}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def handle_status(self, args):
|
||||
"""
|
||||
Handle deploy status command - show current deployment status.
|
||||
|
||||
Args:
|
||||
args: Parsed command-line arguments
|
||||
"""
|
||||
try:
|
||||
from praisonai.deploy import Deploy
|
||||
|
||||
console.print(f"\n[bold blue]📊 Checking deployment status...[/bold blue]\n")
|
||||
|
||||
deploy = Deploy.from_yaml(args.file)
|
||||
status = deploy.status()
|
||||
|
||||
if args.json:
|
||||
self._print_json(status.to_dict())
|
||||
else:
|
||||
# Create status table
|
||||
table = Table(title="Deployment Status")
|
||||
table.add_column("Property", style="cyan")
|
||||
table.add_column("Value", style="bold")
|
||||
|
||||
# State with color
|
||||
state_color = {
|
||||
"running": "green",
|
||||
"stopped": "yellow",
|
||||
"pending": "blue",
|
||||
"failed": "red",
|
||||
"not_found": "dim",
|
||||
"unknown": "dim"
|
||||
}.get(status.state.value, "white")
|
||||
|
||||
table.add_row("State", f"[{state_color}]{status.state.value.upper()}[/{state_color}]")
|
||||
table.add_row("Service Name", status.service_name or "N/A")
|
||||
table.add_row("Provider", status.provider or "N/A")
|
||||
table.add_row("Region", status.region or "N/A")
|
||||
|
||||
if status.url:
|
||||
table.add_row("URL", f"[cyan]{status.url}[/cyan]")
|
||||
|
||||
table.add_row("Healthy", "✅ Yes" if status.healthy else "❌ No")
|
||||
table.add_row("Instances", f"{status.instances_running}/{status.instances_desired}")
|
||||
|
||||
if status.created_at:
|
||||
table.add_row("Created", status.created_at)
|
||||
if status.updated_at:
|
||||
table.add_row("Updated", status.updated_at)
|
||||
|
||||
console.print(table)
|
||||
|
||||
if status.message:
|
||||
console.print(f"\n[bold]Message:[/bold] {status.message}")
|
||||
|
||||
# Show metadata if verbose
|
||||
if getattr(args, 'verbose', False) and status.metadata:
|
||||
console.print(f"\n[bold]Metadata:[/bold]")
|
||||
for key, value in status.metadata.items():
|
||||
console.print(f" • {key}: {value}")
|
||||
|
||||
except Exception as e:
|
||||
if getattr(args, 'json', False):
|
||||
self._print_json({"error": str(e)})
|
||||
else:
|
||||
console.print(f"[bold red]❌ Failed to get status: {e}[/bold red]")
|
||||
sys.exit(1)
|
||||
|
||||
def handle_destroy(self, args):
|
||||
"""
|
||||
Handle deploy destroy command - destroy/delete deployment.
|
||||
|
||||
Args:
|
||||
args: Parsed command-line arguments
|
||||
"""
|
||||
try:
|
||||
from praisonai.deploy import Deploy
|
||||
|
||||
deploy = Deploy.from_yaml(args.file)
|
||||
|
||||
# Confirmation check unless --yes is provided
|
||||
if not args.yes:
|
||||
console.print(f"\n[bold yellow]⚠️ Warning: This will destroy the deployment![/bold yellow]")
|
||||
console.print(f"[bold]File:[/bold] {args.file}")
|
||||
|
||||
# Show what will be destroyed
|
||||
status = deploy.status()
|
||||
if status.service_name:
|
||||
console.print(f"[bold]Service:[/bold] {status.service_name}")
|
||||
if status.provider:
|
||||
console.print(f"[bold]Provider:[/bold] {status.provider}")
|
||||
|
||||
confirm = input("\nType 'yes' to confirm destruction: ")
|
||||
if confirm.lower() != 'yes':
|
||||
console.print("[yellow]Destruction cancelled.[/yellow]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold red]🗑️ Destroying deployment...[/bold red]\n")
|
||||
|
||||
force = getattr(args, 'force', False)
|
||||
result = deploy.destroy(force=force)
|
||||
|
||||
if args.json:
|
||||
self._print_json({
|
||||
"success": result.success,
|
||||
"message": result.message,
|
||||
"resources_deleted": result.resources_deleted,
|
||||
"error": result.error,
|
||||
"metadata": result.metadata
|
||||
})
|
||||
else:
|
||||
if result.success:
|
||||
console.print(f"[bold green]✅ {result.message}[/bold green]")
|
||||
if result.resources_deleted:
|
||||
console.print(f"\n[bold]Deleted resources:[/bold]")
|
||||
for resource in result.resources_deleted:
|
||||
console.print(f" • {resource}")
|
||||
else:
|
||||
console.print(f"[bold red]❌ {result.message}[/bold red]")
|
||||
if result.error:
|
||||
console.print(f"[red]Error: {result.error}[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
if getattr(args, 'json', False):
|
||||
self._print_json({"success": False, "error": str(e)})
|
||||
else:
|
||||
console.print(f"[bold red]❌ Destruction failed: {e}[/bold red]")
|
||||
sys.exit(1)
|
||||
|
||||
def _print_json(self, data: dict):
|
||||
"""Print data as JSON."""
|
||||
print(json.dumps(data, indent=2))
|
||||
@@ -372,4 +501,17 @@ def add_deploy_subcommands(subparsers):
|
||||
plan_parser.add_argument('--file', '-f', default='agents.yaml', help='Path to agents.yaml')
|
||||
plan_parser.add_argument('--json', action='store_true', help='Output as JSON')
|
||||
|
||||
# deploy status
|
||||
status_parser = deploy_subparsers.add_parser('status', help='Show deployment status')
|
||||
status_parser.add_argument('--file', '-f', default='agents.yaml', help='Path to agents.yaml')
|
||||
status_parser.add_argument('--json', action='store_true', help='Output as JSON')
|
||||
status_parser.add_argument('--verbose', '-v', action='store_true', help='Show detailed metadata')
|
||||
|
||||
# deploy destroy
|
||||
destroy_parser = deploy_subparsers.add_parser('destroy', help='Destroy/delete deployment')
|
||||
destroy_parser.add_argument('--file', '-f', default='agents.yaml', help='Path to agents.yaml')
|
||||
destroy_parser.add_argument('--yes', '-y', action='store_true', help='Skip confirmation prompt')
|
||||
destroy_parser.add_argument('--force', action='store_true', help='Force deletion and remove all related resources')
|
||||
destroy_parser.add_argument('--json', action='store_true', help='Output as JSON')
|
||||
|
||||
return deploy_parser
|
||||
@@ -1206,9 +1206,11 @@ class PraisonAI:
|
||||
deploy_args = ap.Namespace()
|
||||
# Use file from main args if provided, otherwise default
|
||||
deploy_args.file = getattr(args, 'file', None) or 'agents.yaml'
|
||||
deploy_args.json = False
|
||||
deploy_args.verbose = getattr(args, 'verbose', False)
|
||||
deploy_args.all = False
|
||||
deploy_args.verbose = False
|
||||
deploy_args.background = False
|
||||
deploy_args.yes = False
|
||||
deploy_args.force = False
|
||||
deploy_args.provider = getattr(args, 'provider', None)
|
||||
deploy_args.type = None
|
||||
deploy_args.background = False
|
||||
@@ -1238,6 +1240,12 @@ class PraisonAI:
|
||||
elif arg == '--background':
|
||||
deploy_args.background = True
|
||||
i += 1
|
||||
elif arg in ['--yes', '-y']:
|
||||
deploy_args.yes = True
|
||||
i += 1
|
||||
elif arg == '--force':
|
||||
deploy_args.force = True
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
@@ -1249,13 +1257,17 @@ class PraisonAI:
|
||||
handler.handle_validate(deploy_args)
|
||||
elif subcommand == 'plan':
|
||||
handler.handle_plan(deploy_args)
|
||||
elif subcommand == 'status':
|
||||
handler.handle_status(deploy_args)
|
||||
elif subcommand == 'destroy':
|
||||
handler.handle_destroy(deploy_args)
|
||||
elif subcommand == 'run' or subcommand in ['api', 'docker', 'cloud']:
|
||||
if subcommand in ['api', 'docker', 'cloud']:
|
||||
deploy_args.type = subcommand
|
||||
handler.handle_deploy(deploy_args)
|
||||
else:
|
||||
print(f"Unknown deploy subcommand: {subcommand}")
|
||||
print("Available: doctor, init, validate, plan, run, api, docker, cloud")
|
||||
print("Available: doctor, init, validate, plan, status, destroy, run, api, docker, cloud")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# No subcommand - show help
|
||||
@@ -1264,6 +1276,8 @@ class PraisonAI:
|
||||
print(" praisonai deploy init [--file FILE] [--type api|docker|cloud]")
|
||||
print(" praisonai deploy validate [--file FILE] [--json]")
|
||||
print(" praisonai deploy plan [--file FILE] [--json]")
|
||||
print(" praisonai deploy status [--file FILE] [--json] [--verbose]")
|
||||
print(" praisonai deploy destroy [--file FILE] [--yes] [--force] [--json]")
|
||||
print(" praisonai deploy run [--file FILE] [--type api|docker|cloud] [--provider aws|azure|gcp]")
|
||||
print(" praisonai deploy api [--file FILE]")
|
||||
print(" praisonai deploy docker [--file 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.6 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.5.7 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@ def __getattr__(name):
|
||||
elif name == 'DeployResult':
|
||||
from .models import DeployResult
|
||||
return DeployResult
|
||||
elif name == 'DeployStatus':
|
||||
from .models import DeployStatus
|
||||
return DeployStatus
|
||||
elif name == 'DestroyResult':
|
||||
from .models import DestroyResult
|
||||
return DestroyResult
|
||||
elif name == 'ServiceState':
|
||||
from .models import ServiceState
|
||||
return ServiceState
|
||||
elif name == 'validate_agents_yaml':
|
||||
from .schema import validate_agents_yaml
|
||||
return validate_agents_yaml
|
||||
@@ -44,6 +53,9 @@ __all__ = [
|
||||
'DeployType',
|
||||
'CloudProvider',
|
||||
'DeployResult',
|
||||
'DeployStatus',
|
||||
'DestroyResult',
|
||||
'ServiceState',
|
||||
'validate_agents_yaml',
|
||||
'generate_sample_yaml',
|
||||
'run_all_checks'
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
Docker deployment functionality.
|
||||
"""
|
||||
import subprocess
|
||||
import os
|
||||
import json
|
||||
from typing import Optional, Dict
|
||||
from pathlib import Path
|
||||
from .models import DockerConfig, DeployResult
|
||||
from .models import DockerConfig, DeployResult, DeployStatus, DestroyResult, ServiceState
|
||||
|
||||
|
||||
def generate_dockerfile(agents_file: str, config: Optional[DockerConfig] = None) -> str:
|
||||
@@ -299,3 +299,156 @@ def save_dockerfile(agents_file: str, config: Optional[DockerConfig] = None, out
|
||||
path = Path(output_path)
|
||||
with open(path, 'w') as f:
|
||||
f.write(dockerfile_content)
|
||||
|
||||
|
||||
def get_docker_container_status(config: DockerConfig) -> DeployStatus:
|
||||
"""
|
||||
Get status of a Docker container.
|
||||
|
||||
Args:
|
||||
config: Docker configuration
|
||||
|
||||
Returns:
|
||||
DeployStatus with container information
|
||||
"""
|
||||
try:
|
||||
container_name = f"{config.image_name}-{config.tag}".replace(':', '-')
|
||||
|
||||
result = subprocess.run(
|
||||
['docker', 'inspect', container_name, '--format', '{{json .}}'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return DeployStatus(
|
||||
state=ServiceState.NOT_FOUND,
|
||||
message=f"Container not found: {container_name}",
|
||||
service_name=container_name,
|
||||
provider="docker"
|
||||
)
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
state_data = data.get('State', {})
|
||||
running = state_data.get('Running', False)
|
||||
status_str = state_data.get('Status', 'unknown')
|
||||
|
||||
# Map Docker status to ServiceState
|
||||
if running:
|
||||
state = ServiceState.RUNNING
|
||||
elif status_str == 'exited':
|
||||
state = ServiceState.STOPPED
|
||||
elif status_str == 'created':
|
||||
state = ServiceState.PENDING
|
||||
elif status_str == 'dead':
|
||||
state = ServiceState.FAILED
|
||||
else:
|
||||
state = ServiceState.UNKNOWN
|
||||
|
||||
# Get port mapping
|
||||
ports = data.get('NetworkSettings', {}).get('Ports', {})
|
||||
url = None
|
||||
if ports and config.expose:
|
||||
port_key = f"{config.expose[0]}/tcp"
|
||||
if port_key in ports and ports[port_key]:
|
||||
host_port = ports[port_key][0].get('HostPort')
|
||||
if host_port:
|
||||
url = f"http://localhost:{host_port}"
|
||||
|
||||
return DeployStatus(
|
||||
state=state,
|
||||
url=url,
|
||||
message=f"Status: {status_str}",
|
||||
service_name=container_name,
|
||||
provider="docker",
|
||||
healthy=running,
|
||||
instances_running=1 if running else 0,
|
||||
instances_desired=1,
|
||||
created_at=data.get('Created'),
|
||||
metadata={
|
||||
"container_id": data.get('Id', '')[:12],
|
||||
"image": data.get('Config', {}).get('Image'),
|
||||
"status": status_str,
|
||||
"started_at": state_data.get('StartedAt'),
|
||||
"finished_at": state_data.get('FinishedAt')
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return DeployStatus(
|
||||
state=ServiceState.UNKNOWN,
|
||||
message=f"Failed to get status: {e}",
|
||||
service_name=config.image_name,
|
||||
provider="docker"
|
||||
)
|
||||
|
||||
|
||||
def remove_docker_container(config: DockerConfig, force: bool = False) -> DestroyResult:
|
||||
"""
|
||||
Remove Docker container and optionally the image.
|
||||
|
||||
Args:
|
||||
config: Docker configuration
|
||||
force: Force removal and also remove image
|
||||
|
||||
Returns:
|
||||
DestroyResult with removal information
|
||||
"""
|
||||
try:
|
||||
container_name = f"{config.image_name}-{config.tag}".replace(':', '-')
|
||||
deleted_resources = []
|
||||
|
||||
# Stop container first
|
||||
print(f"🛑 Stopping container: {container_name}")
|
||||
subprocess.run(
|
||||
['docker', 'stop', container_name],
|
||||
capture_output=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Remove container
|
||||
print(f"🗑️ Removing container: {container_name}")
|
||||
result = subprocess.run(
|
||||
['docker', 'rm', container_name] + (['-f'] if force else []),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
deleted_resources.append(f"container:{container_name}")
|
||||
else:
|
||||
return DestroyResult(
|
||||
success=False,
|
||||
message="Failed to remove container",
|
||||
error=result.stderr,
|
||||
resources_deleted=deleted_resources
|
||||
)
|
||||
|
||||
# Optionally remove image
|
||||
if force:
|
||||
image_tag = f"{config.image_name}:{config.tag}"
|
||||
print(f"🗑️ Removing image: {image_tag}")
|
||||
img_result = subprocess.run(
|
||||
['docker', 'rmi', image_tag, '-f'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
if img_result.returncode == 0:
|
||||
deleted_resources.append(f"image:{image_tag}")
|
||||
|
||||
return DestroyResult(
|
||||
success=True,
|
||||
message="Successfully removed Docker container",
|
||||
resources_deleted=deleted_resources,
|
||||
metadata={"container_name": container_name}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return DestroyResult(
|
||||
success=False,
|
||||
message="Failed to remove Docker resources",
|
||||
error=str(e)
|
||||
)
|
||||
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
Main Deploy class for unified deployment interface.
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
from .models import DeployConfig, DeployResult, DeployType
|
||||
from typing import Dict, Any
|
||||
from .models import DeployConfig, DeployResult, DeployType, DeployStatus, DestroyResult, ServiceState
|
||||
from .schema import validate_agents_yaml
|
||||
from .api import start_api_server, generate_api_server_code
|
||||
from .docker import build_docker_image, run_docker_container, push_docker_image, save_dockerfile
|
||||
from .docker import (
|
||||
build_docker_image, run_docker_container, push_docker_image, save_dockerfile,
|
||||
get_docker_container_status, remove_docker_container
|
||||
)
|
||||
from .providers import get_provider
|
||||
|
||||
|
||||
@@ -178,3 +180,148 @@ class Deploy:
|
||||
return provider.doctor()
|
||||
else:
|
||||
return run_all_checks(self.agents_file)
|
||||
|
||||
def status(self) -> DeployStatus:
|
||||
"""
|
||||
Get current deployment status.
|
||||
|
||||
Returns:
|
||||
DeployStatus with current state and information
|
||||
"""
|
||||
if self.config.type == DeployType.API:
|
||||
return self._status_api()
|
||||
elif self.config.type == DeployType.DOCKER:
|
||||
return self._status_docker()
|
||||
elif self.config.type == DeployType.CLOUD:
|
||||
return self._status_cloud()
|
||||
else:
|
||||
return DeployStatus(
|
||||
state=ServiceState.UNKNOWN,
|
||||
message=f"Unsupported deployment type: {self.config.type}"
|
||||
)
|
||||
|
||||
def _status_api(self) -> DeployStatus:
|
||||
"""Get status of local API server."""
|
||||
import socket
|
||||
port = self.config.api.port if self.config.api else 8005
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
result = sock.connect_ex(('127.0.0.1', port))
|
||||
sock.close()
|
||||
|
||||
if result == 0:
|
||||
return DeployStatus(
|
||||
state=ServiceState.RUNNING,
|
||||
url=f"http://127.0.0.1:{port}",
|
||||
message=f"API server running on port {port}",
|
||||
service_name="praisonai-api",
|
||||
provider="api",
|
||||
healthy=True,
|
||||
instances_running=1,
|
||||
instances_desired=1
|
||||
)
|
||||
else:
|
||||
return DeployStatus(
|
||||
state=ServiceState.STOPPED,
|
||||
message=f"No service running on port {port}",
|
||||
service_name="praisonai-api",
|
||||
provider="api",
|
||||
healthy=False,
|
||||
instances_running=0,
|
||||
instances_desired=1
|
||||
)
|
||||
except Exception as e:
|
||||
return DeployStatus(
|
||||
state=ServiceState.UNKNOWN,
|
||||
message=f"Failed to check status: {e}",
|
||||
service_name="praisonai-api",
|
||||
provider="api"
|
||||
)
|
||||
|
||||
def _status_docker(self) -> DeployStatus:
|
||||
"""Get status of Docker container."""
|
||||
return get_docker_container_status(self.config.docker)
|
||||
|
||||
def _status_cloud(self) -> DeployStatus:
|
||||
"""Get status of cloud deployment."""
|
||||
provider = get_provider(self.config.cloud)
|
||||
return provider.status()
|
||||
|
||||
def destroy(self, force: bool = False) -> DestroyResult:
|
||||
"""
|
||||
Destroy/delete the deployment.
|
||||
|
||||
Args:
|
||||
force: Force deletion without confirmation
|
||||
|
||||
Returns:
|
||||
DestroyResult with deletion information
|
||||
"""
|
||||
if self.config.type == DeployType.API:
|
||||
return self._destroy_api()
|
||||
elif self.config.type == DeployType.DOCKER:
|
||||
return self._destroy_docker(force)
|
||||
elif self.config.type == DeployType.CLOUD:
|
||||
return self._destroy_cloud(force)
|
||||
else:
|
||||
return DestroyResult(
|
||||
success=False,
|
||||
message=f"Unsupported deployment type: {self.config.type}",
|
||||
error="Invalid deployment type"
|
||||
)
|
||||
|
||||
def _destroy_api(self) -> DestroyResult:
|
||||
"""Stop local API server."""
|
||||
import subprocess
|
||||
import signal
|
||||
import os
|
||||
|
||||
port = self.config.api.port if self.config.api else 8005
|
||||
|
||||
try:
|
||||
# Find process using the port
|
||||
result = subprocess.run(
|
||||
['lsof', '-ti', f':{port}'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
pids = result.stdout.strip().split('\n')
|
||||
deleted_resources = []
|
||||
|
||||
for pid in pids:
|
||||
try:
|
||||
os.kill(int(pid), signal.SIGTERM)
|
||||
deleted_resources.append(f"process:{pid}")
|
||||
except (ProcessLookupError, ValueError):
|
||||
pass
|
||||
|
||||
return DestroyResult(
|
||||
success=True,
|
||||
message=f"Stopped API server on port {port}",
|
||||
resources_deleted=deleted_resources
|
||||
)
|
||||
else:
|
||||
return DestroyResult(
|
||||
success=True,
|
||||
message=f"No API server running on port {port}",
|
||||
resources_deleted=[]
|
||||
)
|
||||
except Exception as e:
|
||||
return DestroyResult(
|
||||
success=False,
|
||||
message="Failed to stop API server",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
def _destroy_docker(self, force: bool = False) -> DestroyResult:
|
||||
"""Remove Docker container."""
|
||||
return remove_docker_container(self.config.docker, force)
|
||||
|
||||
def _destroy_cloud(self, force: bool = False) -> DestroyResult:
|
||||
"""Destroy cloud deployment."""
|
||||
provider = get_provider(self.config.cloud)
|
||||
return provider.destroy(force)
|
||||
@@ -117,3 +117,63 @@ class DeployResult(BaseModel):
|
||||
url: Optional[str] = Field(default=None, description="Deployed service URL")
|
||||
error: Optional[str] = Field(default=None, description="Error message if failed")
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
|
||||
|
||||
|
||||
class ServiceState(str, Enum):
|
||||
"""Service state enum."""
|
||||
RUNNING = "running"
|
||||
STOPPED = "stopped"
|
||||
PENDING = "pending"
|
||||
FAILED = "failed"
|
||||
NOT_FOUND = "not_found"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class DeployStatus(BaseModel):
|
||||
"""Status of a deployed service."""
|
||||
state: ServiceState = Field(..., description="Current service state")
|
||||
url: Optional[str] = Field(default=None, description="Service URL/endpoint")
|
||||
message: str = Field(default="", description="Status message")
|
||||
|
||||
# Resource identifiers
|
||||
service_name: Optional[str] = Field(default=None, description="Service name")
|
||||
provider: Optional[str] = Field(default=None, description="Provider (api/docker/aws/azure/gcp)")
|
||||
region: Optional[str] = Field(default=None, description="Deployment region")
|
||||
|
||||
# Health info
|
||||
healthy: bool = Field(default=False, description="Whether service is healthy")
|
||||
instances_running: int = Field(default=0, description="Number of running instances")
|
||||
instances_desired: int = Field(default=0, description="Desired number of instances")
|
||||
|
||||
# Timestamps
|
||||
created_at: Optional[str] = Field(default=None, description="Creation timestamp")
|
||||
updated_at: Optional[str] = Field(default=None, description="Last update timestamp")
|
||||
|
||||
# Provider-specific metadata
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="Provider-specific metadata")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON output."""
|
||||
return {
|
||||
"state": self.state.value,
|
||||
"url": self.url,
|
||||
"message": self.message,
|
||||
"service_name": self.service_name,
|
||||
"provider": self.provider,
|
||||
"region": self.region,
|
||||
"healthy": self.healthy,
|
||||
"instances_running": self.instances_running,
|
||||
"instances_desired": self.instances_desired,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
|
||||
class DestroyResult(BaseModel):
|
||||
"""Result of a destroy operation."""
|
||||
success: bool = Field(..., description="Whether destroy succeeded")
|
||||
message: str = Field(..., description="Result message")
|
||||
resources_deleted: List[str] = Field(default_factory=list, description="List of deleted resources")
|
||||
error: Optional[str] = Field(default=None, description="Error message if failed")
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
|
||||
@@ -5,7 +5,7 @@ import subprocess
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from .base import BaseProvider
|
||||
from ..models import CloudConfig, DeployResult
|
||||
from ..models import CloudConfig, DeployResult, DeployStatus, DestroyResult, ServiceState
|
||||
from ..doctor import DoctorReport, DoctorCheckResult, check_aws_cli
|
||||
|
||||
|
||||
@@ -175,3 +175,157 @@ class AWSProvider(BaseProvider):
|
||||
message="AWS deployment failed",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
def status(self) -> DeployStatus:
|
||||
"""Get current AWS ECS service status."""
|
||||
try:
|
||||
cluster_name = self.config.cluster_name or f"{self.config.service_name}-cluster"
|
||||
|
||||
result = subprocess.run(
|
||||
['aws', 'ecs', 'describe-services',
|
||||
'--cluster', cluster_name,
|
||||
'--services', self.config.service_name,
|
||||
'--region', self.config.region,
|
||||
'--output', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return DeployStatus(
|
||||
state=ServiceState.NOT_FOUND,
|
||||
message=f"Service not found or error: {result.stderr}",
|
||||
service_name=self.config.service_name,
|
||||
provider="aws",
|
||||
region=self.config.region
|
||||
)
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
services = data.get('services', [])
|
||||
|
||||
if not services:
|
||||
return DeployStatus(
|
||||
state=ServiceState.NOT_FOUND,
|
||||
message="Service not found",
|
||||
service_name=self.config.service_name,
|
||||
provider="aws",
|
||||
region=self.config.region
|
||||
)
|
||||
|
||||
service = services[0]
|
||||
status_str = service.get('status', 'UNKNOWN')
|
||||
running_count = service.get('runningCount', 0)
|
||||
desired_count = service.get('desiredCount', 0)
|
||||
|
||||
# Map AWS status to ServiceState
|
||||
if status_str == 'ACTIVE' and running_count > 0:
|
||||
state = ServiceState.RUNNING
|
||||
elif status_str == 'ACTIVE' and running_count == 0:
|
||||
state = ServiceState.STOPPED
|
||||
elif status_str == 'DRAINING':
|
||||
state = ServiceState.PENDING
|
||||
elif status_str == 'INACTIVE':
|
||||
state = ServiceState.STOPPED
|
||||
else:
|
||||
state = ServiceState.UNKNOWN
|
||||
|
||||
return DeployStatus(
|
||||
state=state,
|
||||
url=f"https://console.aws.amazon.com/ecs/home?region={self.config.region}#/clusters/{cluster_name}/services/{self.config.service_name}",
|
||||
message=f"Status: {status_str}",
|
||||
service_name=self.config.service_name,
|
||||
provider="aws",
|
||||
region=self.config.region,
|
||||
healthy=running_count >= desired_count and running_count > 0,
|
||||
instances_running=running_count,
|
||||
instances_desired=desired_count,
|
||||
created_at=service.get('createdAt'),
|
||||
metadata={
|
||||
"cluster": cluster_name,
|
||||
"task_definition": service.get('taskDefinition'),
|
||||
"launch_type": service.get('launchType'),
|
||||
"deployments": len(service.get('deployments', []))
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return DeployStatus(
|
||||
state=ServiceState.UNKNOWN,
|
||||
message=f"Failed to get status: {e}",
|
||||
service_name=self.config.service_name,
|
||||
provider="aws",
|
||||
region=self.config.region
|
||||
)
|
||||
|
||||
def destroy(self, force: bool = False) -> DestroyResult:
|
||||
"""Destroy AWS ECS service and related resources."""
|
||||
try:
|
||||
cluster_name = self.config.cluster_name or f"{self.config.service_name}-cluster"
|
||||
deleted_resources = []
|
||||
|
||||
# Step 1: Update service desired count to 0
|
||||
print(f"🛑 Stopping service: {self.config.service_name}")
|
||||
subprocess.run(
|
||||
['aws', 'ecs', 'update-service',
|
||||
'--cluster', cluster_name,
|
||||
'--service', self.config.service_name,
|
||||
'--desired-count', '0',
|
||||
'--region', self.config.region],
|
||||
capture_output=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Step 2: Delete the service
|
||||
print(f"🗑️ Deleting service: {self.config.service_name}")
|
||||
result = subprocess.run(
|
||||
['aws', 'ecs', 'delete-service',
|
||||
'--cluster', cluster_name,
|
||||
'--service', self.config.service_name,
|
||||
'--force' if force else '--no-force',
|
||||
'--region', self.config.region],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
deleted_resources.append(f"ecs-service:{self.config.service_name}")
|
||||
else:
|
||||
return DestroyResult(
|
||||
success=False,
|
||||
message="Failed to delete service",
|
||||
error=result.stderr,
|
||||
resources_deleted=deleted_resources
|
||||
)
|
||||
|
||||
# Step 3: Optionally delete cluster if empty
|
||||
if force:
|
||||
print(f"🗑️ Deleting cluster: {cluster_name}")
|
||||
cluster_result = subprocess.run(
|
||||
['aws', 'ecs', 'delete-cluster',
|
||||
'--cluster', cluster_name,
|
||||
'--region', self.config.region],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
if cluster_result.returncode == 0:
|
||||
deleted_resources.append(f"ecs-cluster:{cluster_name}")
|
||||
|
||||
return DestroyResult(
|
||||
success=True,
|
||||
message=f"Successfully destroyed AWS ECS service",
|
||||
resources_deleted=deleted_resources,
|
||||
metadata={
|
||||
"cluster": cluster_name,
|
||||
"region": self.config.region
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return DestroyResult(
|
||||
success=False,
|
||||
message="Failed to destroy AWS resources",
|
||||
error=str(e)
|
||||
)
|
||||
@@ -5,7 +5,7 @@ import subprocess
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from .base import BaseProvider
|
||||
from ..models import DeployResult
|
||||
from ..models import DeployResult, DeployStatus, DestroyResult, ServiceState
|
||||
from ..doctor import DoctorReport, DoctorCheckResult, check_azure_cli
|
||||
|
||||
|
||||
@@ -200,3 +200,159 @@ class AzureProvider(BaseProvider):
|
||||
message="Azure deployment failed",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
def status(self) -> DeployStatus:
|
||||
"""Get current Azure Container App status."""
|
||||
try:
|
||||
if not self.config.resource_group:
|
||||
return DeployStatus(
|
||||
state=ServiceState.UNKNOWN,
|
||||
message="Resource group not configured",
|
||||
service_name=self.config.service_name,
|
||||
provider="azure",
|
||||
region=self.config.region
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
['az', 'containerapp', 'show',
|
||||
'--name', self.config.service_name,
|
||||
'--resource-group', self.config.resource_group,
|
||||
'--output', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return DeployStatus(
|
||||
state=ServiceState.NOT_FOUND,
|
||||
message=f"Container App not found: {result.stderr}",
|
||||
service_name=self.config.service_name,
|
||||
provider="azure",
|
||||
region=self.config.region
|
||||
)
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
properties = data.get('properties', {})
|
||||
provisioning_state = properties.get('provisioningState', 'Unknown')
|
||||
running_status = properties.get('runningStatus', {}).get('state', 'Unknown')
|
||||
|
||||
# Get URL
|
||||
ingress = properties.get('configuration', {}).get('ingress', {})
|
||||
fqdn = ingress.get('fqdn')
|
||||
url = f"https://{fqdn}" if fqdn else None
|
||||
|
||||
# Get replica count
|
||||
template = properties.get('template', {})
|
||||
scale = template.get('scale', {})
|
||||
min_replicas = scale.get('minReplicas', 0)
|
||||
max_replicas = scale.get('maxReplicas', 0)
|
||||
|
||||
# Map status to ServiceState
|
||||
if provisioning_state == 'Succeeded' and running_status == 'Running':
|
||||
state = ServiceState.RUNNING
|
||||
elif provisioning_state == 'Succeeded' and running_status == 'Stopped':
|
||||
state = ServiceState.STOPPED
|
||||
elif provisioning_state in ['Creating', 'Updating']:
|
||||
state = ServiceState.PENDING
|
||||
elif provisioning_state == 'Failed':
|
||||
state = ServiceState.FAILED
|
||||
else:
|
||||
state = ServiceState.UNKNOWN
|
||||
|
||||
return DeployStatus(
|
||||
state=state,
|
||||
url=url,
|
||||
message=f"Provisioning: {provisioning_state}, Running: {running_status}",
|
||||
service_name=self.config.service_name,
|
||||
provider="azure",
|
||||
region=self.config.region,
|
||||
healthy=state == ServiceState.RUNNING,
|
||||
instances_running=min_replicas if state == ServiceState.RUNNING else 0,
|
||||
instances_desired=min_replicas,
|
||||
created_at=data.get('systemData', {}).get('createdAt'),
|
||||
updated_at=data.get('systemData', {}).get('lastModifiedAt'),
|
||||
metadata={
|
||||
"resource_group": self.config.resource_group,
|
||||
"provisioning_state": provisioning_state,
|
||||
"running_status": running_status,
|
||||
"min_replicas": min_replicas,
|
||||
"max_replicas": max_replicas
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return DeployStatus(
|
||||
state=ServiceState.UNKNOWN,
|
||||
message=f"Failed to get status: {e}",
|
||||
service_name=self.config.service_name,
|
||||
provider="azure",
|
||||
region=self.config.region
|
||||
)
|
||||
|
||||
def destroy(self, force: bool = False) -> DestroyResult:
|
||||
"""Destroy Azure Container App and related resources."""
|
||||
try:
|
||||
if not self.config.resource_group:
|
||||
return DestroyResult(
|
||||
success=False,
|
||||
message="Resource group not configured",
|
||||
error="Please specify resource_group in cloud config"
|
||||
)
|
||||
|
||||
deleted_resources = []
|
||||
|
||||
# Step 1: Delete the Container App
|
||||
print(f"🗑️ Deleting Container App: {self.config.service_name}")
|
||||
result = subprocess.run(
|
||||
['az', 'containerapp', 'delete',
|
||||
'--name', self.config.service_name,
|
||||
'--resource-group', self.config.resource_group,
|
||||
'--yes'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
deleted_resources.append(f"containerapp:{self.config.service_name}")
|
||||
else:
|
||||
return DestroyResult(
|
||||
success=False,
|
||||
message="Failed to delete Container App",
|
||||
error=result.stderr,
|
||||
resources_deleted=deleted_resources
|
||||
)
|
||||
|
||||
# Step 2: Optionally delete environment if force
|
||||
if force:
|
||||
env_name = f"{self.config.service_name}-env"
|
||||
print(f"🗑️ Deleting Container Apps environment: {env_name}")
|
||||
env_result = subprocess.run(
|
||||
['az', 'containerapp', 'env', 'delete',
|
||||
'--name', env_name,
|
||||
'--resource-group', self.config.resource_group,
|
||||
'--yes'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
if env_result.returncode == 0:
|
||||
deleted_resources.append(f"containerapp-env:{env_name}")
|
||||
|
||||
return DestroyResult(
|
||||
success=True,
|
||||
message="Successfully destroyed Azure Container App",
|
||||
resources_deleted=deleted_resources,
|
||||
metadata={
|
||||
"resource_group": self.config.resource_group,
|
||||
"region": self.config.region
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return DestroyResult(
|
||||
success=False,
|
||||
message="Failed to destroy Azure resources",
|
||||
error=str(e)
|
||||
)
|
||||
@@ -3,7 +3,7 @@ Base provider interface for cloud deployments.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any
|
||||
from ..models import CloudConfig, DeployResult
|
||||
from ..models import CloudConfig, DeployResult, DeployStatus, DestroyResult
|
||||
from ..doctor import DoctorReport
|
||||
|
||||
|
||||
@@ -48,6 +48,29 @@ class BaseProvider(ABC):
|
||||
Dictionary with planned deployment configuration
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def status(self) -> DeployStatus:
|
||||
"""
|
||||
Get current deployment status.
|
||||
|
||||
Returns:
|
||||
DeployStatus with current state and info
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def destroy(self, force: bool = False) -> DestroyResult:
|
||||
"""
|
||||
Destroy/delete the deployed service.
|
||||
|
||||
Args:
|
||||
force: Force deletion without confirmation
|
||||
|
||||
Returns:
|
||||
DestroyResult with deletion information
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def get_provider(config: CloudConfig) -> BaseProvider:
|
||||
|
||||
@@ -5,7 +5,7 @@ import subprocess
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from .base import BaseProvider
|
||||
from ..models import DeployResult
|
||||
from ..models import DeployResult, DeployStatus, DestroyResult, ServiceState
|
||||
from ..doctor import DoctorReport, DoctorCheckResult, check_gcp_cli
|
||||
|
||||
|
||||
@@ -174,3 +174,141 @@ class GCPProvider(BaseProvider):
|
||||
message="GCP deployment failed",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
def status(self) -> DeployStatus:
|
||||
"""Get current GCP Cloud Run service status."""
|
||||
try:
|
||||
if not self.config.project_id:
|
||||
return DeployStatus(
|
||||
state=ServiceState.UNKNOWN,
|
||||
message="Project ID not configured",
|
||||
service_name=self.config.service_name,
|
||||
provider="gcp",
|
||||
region=self.config.region
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
['gcloud', 'run', 'services', 'describe',
|
||||
self.config.service_name,
|
||||
'--platform', 'managed',
|
||||
'--region', self.config.region,
|
||||
'--project', self.config.project_id,
|
||||
'--format', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return DeployStatus(
|
||||
state=ServiceState.NOT_FOUND,
|
||||
message=f"Service not found: {result.stderr}",
|
||||
service_name=self.config.service_name,
|
||||
provider="gcp",
|
||||
region=self.config.region
|
||||
)
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
status_data = data.get('status', {})
|
||||
conditions = status_data.get('conditions', [])
|
||||
|
||||
# Get URL
|
||||
url = status_data.get('url')
|
||||
|
||||
# Parse conditions to determine state
|
||||
ready_condition = next((c for c in conditions if c.get('type') == 'Ready'), None)
|
||||
|
||||
if ready_condition:
|
||||
if ready_condition.get('status') == 'True':
|
||||
state = ServiceState.RUNNING
|
||||
elif ready_condition.get('status') == 'Unknown':
|
||||
state = ServiceState.PENDING
|
||||
else:
|
||||
state = ServiceState.FAILED
|
||||
else:
|
||||
state = ServiceState.UNKNOWN
|
||||
|
||||
# Get metadata
|
||||
metadata_info = data.get('metadata', {})
|
||||
spec = data.get('spec', {})
|
||||
template = spec.get('template', {})
|
||||
|
||||
return DeployStatus(
|
||||
state=state,
|
||||
url=url,
|
||||
message=f"Ready: {ready_condition.get('status', 'Unknown') if ready_condition else 'Unknown'}",
|
||||
service_name=self.config.service_name,
|
||||
provider="gcp",
|
||||
region=self.config.region,
|
||||
healthy=state == ServiceState.RUNNING,
|
||||
instances_running=1 if state == ServiceState.RUNNING else 0,
|
||||
instances_desired=1,
|
||||
created_at=metadata_info.get('creationTimestamp'),
|
||||
metadata={
|
||||
"project_id": self.config.project_id,
|
||||
"generation": metadata_info.get('generation'),
|
||||
"latest_revision": status_data.get('latestReadyRevisionName'),
|
||||
"traffic": status_data.get('traffic', [])
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return DeployStatus(
|
||||
state=ServiceState.UNKNOWN,
|
||||
message=f"Failed to get status: {e}",
|
||||
service_name=self.config.service_name,
|
||||
provider="gcp",
|
||||
region=self.config.region
|
||||
)
|
||||
|
||||
def destroy(self, force: bool = False) -> DestroyResult:
|
||||
"""Destroy GCP Cloud Run service."""
|
||||
try:
|
||||
if not self.config.project_id:
|
||||
return DestroyResult(
|
||||
success=False,
|
||||
message="Project ID not configured",
|
||||
error="Please specify project_id in cloud config"
|
||||
)
|
||||
|
||||
deleted_resources = []
|
||||
|
||||
# Delete the Cloud Run service
|
||||
print(f"🗑️ Deleting Cloud Run service: {self.config.service_name}")
|
||||
result = subprocess.run(
|
||||
['gcloud', 'run', 'services', 'delete',
|
||||
self.config.service_name,
|
||||
'--platform', 'managed',
|
||||
'--region', self.config.region,
|
||||
'--project', self.config.project_id,
|
||||
'--quiet'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
deleted_resources.append(f"cloud-run:{self.config.service_name}")
|
||||
return DestroyResult(
|
||||
success=True,
|
||||
message="Successfully destroyed GCP Cloud Run service",
|
||||
resources_deleted=deleted_resources,
|
||||
metadata={
|
||||
"project_id": self.config.project_id,
|
||||
"region": self.config.region
|
||||
}
|
||||
)
|
||||
else:
|
||||
return DestroyResult(
|
||||
success=False,
|
||||
message="Failed to delete Cloud Run service",
|
||||
error=result.stderr,
|
||||
resources_deleted=deleted_resources
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return DestroyResult(
|
||||
success=False,
|
||||
message="Failed to destroy GCP resources",
|
||||
error=str(e)
|
||||
)
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.5.6"
|
||||
__version__ = "2.5.7"
|
||||
Reference in new issue
Block a user