Compare commits

...
1 Commits
Author SHA1 Message Date
Mervin Praison f0f0f653fe Release v2.5.7 2025-12-26 13:31:45 +00:00
28 changed files with 2150 additions and 20 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.6" \
"praisonai>=2.5.7" \
"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.6" \
"praisonai>=2.5.7" \
"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.6" \
"praisonai>=2.5.7" \
"praisonai[ui]" \
"praisonai[crewai]"
+266
View File
@@ -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"
+114
View File
@@ -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);
}
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Config utilities index
*/
export * from './schema';
export * from './load';
export * from './resolve';
+213
View File
@@ -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 || {});
}
+80
View File
@@ -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;
}
+56
View File
@@ -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;
}
}
+48
View File
@@ -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';
+56
View File
@@ -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;
}
}
+204
View File
@@ -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.
+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.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
+17 -3
View File
@@ -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]")
+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.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'
+155 -2
View File
@@ -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)
)
+151 -4
View File
@@ -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)
+60
View File
@@ -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")
+155 -1
View File
@@ -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:
+139 -1
View File
@@ -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
View File
@@ -1 +1 @@
__version__ = "2.5.6"
__version__ = "2.5.7"