From b0e7950d6dd7ab3adcd828a9b1ab9e80789bb526 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Tue, 3 Mar 2026 18:51:47 +0000 Subject: [PATCH 1/2] feat: add `logs` command for streaming and searching agent runtime logs Add a new `agentcore logs` command that provides real-time streaming and historical search of agent runtime logs via CloudWatch Logs. - Stream mode (default): Uses StartLiveTail for real-time log streaming with auto-reconnect on 3-hour session timeout - Search mode (--since/--until): Uses FilterLogEvents with pagination for bounded time-range queries - Server-side filtering via --level (error/warn/info/debug) and --query - JSON Lines output with --json flag - Agent resolution from project config + deployed state - Time parser supporting relative durations (5m, 1h, 2d), ISO 8601, epoch ms, and "now" - Fix .gitignore to scope `logs` pattern to top-level only --- .gitignore | 2 +- package-lock.json | 1 + package.json | 1 + src/cli/aws/cloudwatch.ts | 133 +++++++++++ src/cli/aws/index.ts | 1 + src/cli/cli.ts | 2 + .../commands/logs/__tests__/action.test.ts | 170 ++++++++++++++ .../logs/__tests__/filter-pattern.test.ts | 47 ++++ .../logs/__tests__/time-parser.test.ts | 70 ++++++ src/cli/commands/logs/action.ts | 222 ++++++++++++++++++ src/cli/commands/logs/command.tsx | 37 +++ src/cli/commands/logs/filter-pattern.ts | 33 +++ src/cli/commands/logs/index.ts | 1 + src/cli/commands/logs/time-parser.ts | 53 +++++ src/cli/commands/logs/types.ts | 10 + src/cli/tui/copy.ts | 1 + 16 files changed, 783 insertions(+), 1 deletion(-) create mode 100644 src/cli/aws/cloudwatch.ts create mode 100644 src/cli/commands/logs/__tests__/action.test.ts create mode 100644 src/cli/commands/logs/__tests__/filter-pattern.test.ts create mode 100644 src/cli/commands/logs/__tests__/time-parser.test.ts create mode 100644 src/cli/commands/logs/action.ts create mode 100644 src/cli/commands/logs/command.tsx create mode 100644 src/cli/commands/logs/filter-pattern.ts create mode 100644 src/cli/commands/logs/index.ts create mode 100644 src/cli/commands/logs/time-parser.ts create mode 100644 src/cli/commands/logs/types.ts diff --git a/.gitignore b/.gitignore index 38457c29..1e5ef31c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ coverage *.lcov # logs -logs +/logs *.log report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/package-lock.json b/package-lock.json index eb36528f..36cc7cd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@aws-sdk/client-bedrock-agentcore-control": "^3.893.0", "@aws-sdk/client-bedrock-runtime": "^3.893.0", "@aws-sdk/client-cloudformation": "^3.893.0", + "@aws-sdk/client-cloudwatch-logs": "^3.893.0", "@aws-sdk/client-resource-groups-tagging-api": "^3.893.0", "@aws-sdk/client-sts": "^3.893.0", "@aws-sdk/credential-providers": "^3.893.0", diff --git a/package.json b/package.json index 2a80b07a..402afe51 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@aws-sdk/client-bedrock-agentcore-control": "^3.893.0", "@aws-sdk/client-bedrock-runtime": "^3.893.0", "@aws-sdk/client-cloudformation": "^3.893.0", + "@aws-sdk/client-cloudwatch-logs": "^3.893.0", "@aws-sdk/client-resource-groups-tagging-api": "^3.893.0", "@aws-sdk/client-sts": "^3.893.0", "@aws-sdk/credential-providers": "^3.893.0", diff --git a/src/cli/aws/cloudwatch.ts b/src/cli/aws/cloudwatch.ts new file mode 100644 index 00000000..580e73f2 --- /dev/null +++ b/src/cli/aws/cloudwatch.ts @@ -0,0 +1,133 @@ +import { getCredentialProvider } from './account'; +import { CloudWatchLogsClient, FilterLogEventsCommand, StartLiveTailCommand } from '@aws-sdk/client-cloudwatch-logs'; + +export interface LogEvent { + timestamp: number; + message: string; + logStreamName?: string; +} + +export interface StreamLogsOptions { + logGroupName: string; + region: string; + accountId: string; + filterPattern?: string; + abortSignal?: AbortSignal; +} + +export interface SearchLogsOptions { + logGroupName: string; + region: string; + startTimeMs: number; + endTimeMs: number; + filterPattern?: string; + limit?: number; +} + +/** + * Stream logs in real-time using StartLiveTail. + * Auto-reconnects on 3-hour session timeout. + */ +export async function* streamLogs(options: StreamLogsOptions): AsyncGenerator { + const { logGroupName, region, accountId, filterPattern, abortSignal } = options; + + // StartLiveTail requires ARN format for logGroupIdentifiers + const logGroupArn = `arn:aws:logs:${region}:${accountId}:log-group:${logGroupName}`; + + while (!abortSignal?.aborted) { + const client = new CloudWatchLogsClient({ + region, + credentials: getCredentialProvider(), + }); + + const command = new StartLiveTailCommand({ + logGroupIdentifiers: [logGroupArn], + ...(filterPattern ? { logEventFilterPattern: filterPattern } : {}), + }); + + const response = await client.send(command, { + abortSignal, + }); + + if (!response.responseStream) { + return; + } + + let sessionTimedOut = false; + + try { + for await (const event of response.responseStream) { + if (abortSignal?.aborted) break; + + if ('sessionUpdate' in event && event.sessionUpdate) { + const logEvents = event.sessionUpdate.sessionResults ?? []; + for (const logEvent of logEvents) { + yield { + timestamp: logEvent.timestamp ?? Date.now(), + message: logEvent.message ?? '', + logStreamName: logEvent.logStreamName, + }; + } + } + + if ('SessionTimeoutException' in event) { + sessionTimedOut = true; + break; + } + } + } catch (err: unknown) { + if (abortSignal?.aborted) return; + + const errorName = (err as { name?: string })?.name; + if (errorName === 'SessionTimeoutException') { + sessionTimedOut = true; + } else { + throw err; + } + } + + // Auto-reconnect on session timeout + if (!sessionTimedOut) return; + } +} + +/** + * Search logs using FilterLogEvents with pagination. + */ +export async function* searchLogs(options: SearchLogsOptions): AsyncGenerator { + const { logGroupName, region, startTimeMs, endTimeMs, filterPattern, limit } = options; + + const client = new CloudWatchLogsClient({ + region, + credentials: getCredentialProvider(), + }); + + let nextToken: string | undefined; + let yielded = 0; + + do { + const command = new FilterLogEventsCommand({ + logGroupName, + startTime: startTimeMs, + endTime: endTimeMs, + ...(filterPattern ? { filterPattern } : {}), + ...(nextToken ? { nextToken } : {}), + ...(limit ? { limit: Math.min(limit - yielded, 10000) } : {}), + }); + + const response = await client.send(command); + + for (const event of response.events ?? []) { + if (limit && yielded >= limit) return; + + yield { + timestamp: event.timestamp ?? Date.now(), + message: event.message ?? '', + logStreamName: event.logStreamName, + }; + yielded++; + } + + nextToken = response.nextToken; + } while (nextToken && (!limit || yielded < limit)); +} diff --git a/src/cli/aws/index.ts b/src/cli/aws/index.ts index 2bb00d13..4e4a556e 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -13,6 +13,7 @@ export { type AgentRuntimeStatusResult, type GetAgentRuntimeStatusOptions, } from './agentcore-control'; +export { streamLogs, searchLogs, type LogEvent, type StreamLogsOptions, type SearchLogsOptions } from './cloudwatch'; export { DEFAULT_RUNTIME_USER_ID, invokeAgentRuntime, diff --git a/src/cli/cli.ts b/src/cli/cli.ts index cc719f72..bf9477f5 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -4,6 +4,7 @@ import { registerDeploy } from './commands/deploy'; import { registerDev } from './commands/dev'; import { registerHelp } from './commands/help'; import { registerInvoke } from './commands/invoke'; +import { registerLogs } from './commands/logs'; import { registerPackage } from './commands/package'; import { registerRemove } from './commands/remove'; import { registerStatus } from './commands/status'; @@ -129,6 +130,7 @@ export function registerCommands(program: Command) { registerCreate(program); registerHelp(program); registerInvoke(program); + registerLogs(program); registerPackage(program); registerRemove(program); registerStatus(program); diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts new file mode 100644 index 00000000..59cc5e68 --- /dev/null +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -0,0 +1,170 @@ +import { detectMode, formatLogLine, resolveAgentContext } from '../action'; +import type { LogsContext } from '../action'; +import { describe, expect, it } from 'vitest'; + +describe('detectMode', () => { + it('returns "stream" when no time flags', () => { + expect(detectMode({})).toBe('stream'); + }); + + it('returns "search" when --since is provided', () => { + expect(detectMode({ since: '1h' })).toBe('search'); + }); + + it('returns "search" when --until is provided', () => { + expect(detectMode({ until: 'now' })).toBe('search'); + }); + + it('returns "search" when both --since and --until are provided', () => { + expect(detectMode({ since: '1h', until: 'now' })).toBe('search'); + }); +}); + +describe('formatLogLine', () => { + const event = { timestamp: 1709391000000, message: 'Hello world' }; + + it('formats human-readable line with timestamp', () => { + const line = formatLogLine(event, false); + expect(line).toContain('Hello world'); + expect(line).toContain('2024-03-02'); + }); + + it('formats JSON line', () => { + const line = formatLogLine(event, true); + const parsed = JSON.parse(line); + expect(parsed.message).toBe('Hello world'); + expect(parsed.timestamp).toBeDefined(); + }); +}); + +describe('resolveAgentContext', () => { + // Use 'as any' to avoid branded type issues with FilePath/DirectoryPath + const makeContext = (overrides?: Partial): LogsContext => ({ + project: { + name: 'TestProject', + version: 1, + agents: [ + { + type: 'AgentCoreRuntime' as const, + name: 'MyAgent', + build: 'CodeZip' as const, + entrypoint: 'main.py' as any, + codeLocation: './agents/my-agent' as any, + runtimeVersion: 'PYTHON_3_12' as const, + }, + ], + memories: [], + credentials: [], + }, + deployedState: { + targets: { + default: { + resources: { + agents: { + MyAgent: { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123:runtime/rt-123', + roleArn: 'arn:aws:iam::123:role/test', + }, + }, + }, + }, + }, + }, + awsTargets: [{ name: 'default', account: '123456789012', region: 'us-east-1' as const }], + ...overrides, + }); + + it('auto-selects single agent', () => { + const result = resolveAgentContext(makeContext(), {}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.agentContext.agentName).toBe('MyAgent'); + expect(result.agentContext.agentId).toBe('rt-123'); + expect(result.agentContext.accountId).toBe('123456789012'); + expect(result.agentContext.logGroupName).toContain('rt-123'); + } + }); + + it('errors for multiple agents without --agent flag', () => { + const context = makeContext({ + project: { + name: 'TestProject', + version: 1, + agents: [ + { + type: 'AgentCoreRuntime' as const, + name: 'AgentA', + build: 'CodeZip' as const, + entrypoint: 'main.py' as any, + codeLocation: './agents/a' as any, + runtimeVersion: 'PYTHON_3_12' as const, + }, + { + type: 'AgentCoreRuntime' as const, + name: 'AgentB', + build: 'CodeZip' as const, + entrypoint: 'main.py' as any, + codeLocation: './agents/b' as any, + runtimeVersion: 'PYTHON_3_12' as const, + }, + ], + memories: [], + credentials: [], + }, + }); + const result = resolveAgentContext(context, {}); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('Multiple agents found'); + expect(result.error).toContain('AgentA'); + expect(result.error).toContain('AgentB'); + } + }); + + it('selects correct agent with --agent flag', () => { + const result = resolveAgentContext(makeContext(), { agent: 'MyAgent' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.agentContext.agentName).toBe('MyAgent'); + } + }); + + it('errors for unknown agent name', () => { + const result = resolveAgentContext(makeContext(), { agent: 'UnknownAgent' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Agent 'UnknownAgent' not found"); + } + }); + + it('errors when no agents defined', () => { + const context = makeContext({ + project: { name: 'TestProject', version: 1, agents: [], memories: [], credentials: [] }, + }); + const result = resolveAgentContext(context, {}); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('No agents defined'); + } + }); + + it('errors when agent is not deployed', () => { + const context = makeContext({ + deployedState: { + targets: { + default: { + resources: { + agents: {}, + }, + }, + }, + }, + }); + const result = resolveAgentContext(context, {}); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('is not deployed'); + } + }); +}); diff --git a/src/cli/commands/logs/__tests__/filter-pattern.test.ts b/src/cli/commands/logs/__tests__/filter-pattern.test.ts new file mode 100644 index 00000000..fac8844a --- /dev/null +++ b/src/cli/commands/logs/__tests__/filter-pattern.test.ts @@ -0,0 +1,47 @@ +import { buildFilterPattern } from '../filter-pattern'; +import { describe, expect, it } from 'vitest'; + +describe('buildFilterPattern', () => { + it('returns level pattern for level only', () => { + expect(buildFilterPattern({ level: 'error' })).toBe('ERROR'); + }); + + it('returns query for query only', () => { + expect(buildFilterPattern({ query: 'timeout' })).toBe('timeout'); + }); + + it('combines level and query with space', () => { + expect(buildFilterPattern({ level: 'error', query: 'timeout' })).toBe('ERROR timeout'); + }); + + it('returns undefined for no options', () => { + expect(buildFilterPattern({})).toBeUndefined(); + }); + + describe('all levels', () => { + it('maps "error" to "ERROR"', () => { + expect(buildFilterPattern({ level: 'error' })).toBe('ERROR'); + }); + + it('maps "warn" to "WARN"', () => { + expect(buildFilterPattern({ level: 'warn' })).toBe('WARN'); + }); + + it('maps "info" to "INFO"', () => { + expect(buildFilterPattern({ level: 'info' })).toBe('INFO'); + }); + + it('maps "debug" to "DEBUG"', () => { + expect(buildFilterPattern({ level: 'debug' })).toBe('DEBUG'); + }); + }); + + it('is case-insensitive for level', () => { + expect(buildFilterPattern({ level: 'ERROR' })).toBe('ERROR'); + expect(buildFilterPattern({ level: 'Error' })).toBe('ERROR'); + }); + + it('throws for invalid level', () => { + expect(() => buildFilterPattern({ level: 'trace' })).toThrow('Invalid log level'); + }); +}); diff --git a/src/cli/commands/logs/__tests__/time-parser.test.ts b/src/cli/commands/logs/__tests__/time-parser.test.ts new file mode 100644 index 00000000..82bd8aca --- /dev/null +++ b/src/cli/commands/logs/__tests__/time-parser.test.ts @@ -0,0 +1,70 @@ +import { parseTimeString } from '../time-parser'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('parseTimeString', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-03T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('relative durations', () => { + it('parses "5m" as 5 minutes ago', () => { + const result = parseTimeString('5m'); + expect(result).toBe(Date.now() - 5 * 60_000); + }); + + it('parses "1h" as 1 hour ago', () => { + const result = parseTimeString('1h'); + expect(result).toBe(Date.now() - 3_600_000); + }); + + it('parses "2d" as 2 days ago', () => { + const result = parseTimeString('2d'); + expect(result).toBe(Date.now() - 2 * 86_400_000); + }); + + it('parses "30s" as 30 seconds ago', () => { + const result = parseTimeString('30s'); + expect(result).toBe(Date.now() - 30_000); + }); + }); + + describe('ISO 8601', () => { + it('parses ISO timestamp', () => { + const result = parseTimeString('2026-03-02T14:30:00Z'); + expect(result).toBe(new Date('2026-03-02T14:30:00Z').getTime()); + }); + }); + + describe('epoch milliseconds', () => { + it('passes through epoch ms', () => { + const result = parseTimeString('1709391000000'); + expect(result).toBe(1709391000000); + }); + }); + + describe('"now"', () => { + it('returns approximately Date.now()', () => { + const result = parseTimeString('now'); + expect(result).toBe(Date.now()); + }); + }); + + describe('invalid input', () => { + it('throws for "abc"', () => { + expect(() => parseTimeString('abc')).toThrow('Invalid time string'); + }); + + it('throws for empty string', () => { + expect(() => parseTimeString('')).toThrow('Time string cannot be empty'); + }); + + it('throws for "5x" (invalid unit)', () => { + expect(() => parseTimeString('5x')).toThrow('Invalid time string'); + }); + }); +}); diff --git a/src/cli/commands/logs/action.ts b/src/cli/commands/logs/action.ts new file mode 100644 index 00000000..61eee30f --- /dev/null +++ b/src/cli/commands/logs/action.ts @@ -0,0 +1,222 @@ +import { ConfigIO } from '../../../lib'; +import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../schema'; +import { searchLogs, streamLogs } from '../../aws/cloudwatch'; +import { VALID_LEVELS, buildFilterPattern } from './filter-pattern'; +import { parseTimeString } from './time-parser'; +import type { LogsOptions } from './types'; + +export interface LogsContext { + project: AgentCoreProjectSpec; + deployedState: DeployedState; + awsTargets: AwsDeploymentTargets; +} + +export interface AgentContext { + agentId: string; + agentName: string; + accountId: string; + region: string; + endpointName: string; + logGroupName: string; +} + +export interface LogsResult { + success: boolean; + error?: string; +} + +/** + * Loads configuration required for logs + */ +export async function loadLogsConfig(configIO: ConfigIO = new ConfigIO()): Promise { + return { + project: await configIO.readProjectSpec(), + deployedState: await configIO.readDeployedState(), + awsTargets: await configIO.readAWSDeploymentTargets(), + }; +} + +/** + * Detect whether to stream or search based on options + */ +export function detectMode(options: LogsOptions): 'stream' | 'search' { + if (options.since || options.until) { + return 'search'; + } + return 'stream'; +} + +/** + * Format a log event for display + */ +export function formatLogLine(event: { timestamp: number; message: string }, json: boolean): string { + if (json) { + return JSON.stringify({ timestamp: new Date(event.timestamp).toISOString(), message: event.message }); + } + const ts = new Date(event.timestamp).toISOString(); + return `${ts} ${event.message}`; +} + +/** + * Resolve agent context from config + options + */ +export function resolveAgentContext( + context: LogsContext, + options: LogsOptions +): { success: true; agentContext: AgentContext } | { success: false; error: string } { + const { project, deployedState, awsTargets } = context; + + if (project.agents.length === 0) { + return { success: false, error: 'No agents defined in agentcore.json' }; + } + + // Resolve agent + const agentNames = project.agents.map(a => a.name); + + if (!options.agent && project.agents.length > 1) { + return { + success: false, + error: `Multiple agents found. Use --agent to specify one: ${agentNames.join(', ')}`, + }; + } + + const agentSpec = options.agent ? project.agents.find(a => a.name === options.agent) : project.agents[0]; + + if (options.agent && !agentSpec) { + return { + success: false, + error: `Agent '${options.agent}' not found. Available: ${agentNames.join(', ')}`, + }; + } + + if (!agentSpec) { + return { success: false, error: 'No agents defined in agentcore.json' }; + } + + // Resolve target + const targetNames = Object.keys(deployedState.targets); + if (targetNames.length === 0) { + return { success: false, error: 'No deployed targets found. Run `agentcore deploy` first.' }; + } + const selectedTargetName = targetNames[0]!; + + const targetState = deployedState.targets[selectedTargetName]; + const targetConfig = awsTargets.find(t => t.name === selectedTargetName); + + if (!targetConfig) { + return { success: false, error: `Target config '${selectedTargetName}' not found in aws-targets` }; + } + + // Get the deployed state for this specific agent + const agentState = targetState?.resources?.agents?.[agentSpec.name]; + + if (!agentState) { + return { + success: false, + error: `Agent '${agentSpec.name}' is not deployed to target '${selectedTargetName}'. Run 'agentcore deploy' first.`, + }; + } + + const agentId = agentState.runtimeId; + const endpointName = 'DEFAULT'; + const logGroupName = `/aws/bedrock-agentcore/runtimes/${agentId}-${endpointName}`; + + return { + success: true, + agentContext: { + agentId, + agentName: agentSpec.name, + accountId: targetConfig.account, + region: targetConfig.region, + endpointName, + logGroupName, + }, + }; +} + +/** + * Main logs handler + */ +export async function handleLogs(options: LogsOptions): Promise { + // Validate level early + if (options.level && !VALID_LEVELS.includes(options.level.toLowerCase())) { + return { + success: false, + error: `Invalid log level: "${options.level}". Valid levels: ${VALID_LEVELS.join(', ')}`, + }; + } + + const context = await loadLogsConfig(); + const resolution = resolveAgentContext(context, options); + + if (!resolution.success) { + return { success: false, error: resolution.error }; + } + + const { agentContext } = resolution; + + // Build filter pattern + let filterPattern: string | undefined; + try { + filterPattern = buildFilterPattern({ level: options.level, query: options.query }); + } catch (err) { + return { success: false, error: (err as Error).message }; + } + + const mode = detectMode(options); + const isJson = options.json ?? false; + + const ac = new AbortController(); + const onSignal = () => ac.abort(); + process.on('SIGINT', onSignal); + + try { + if (mode === 'search') { + const startTimeMs = options.since ? parseTimeString(options.since) : Date.now() - 3_600_000; + const endTimeMs = options.until ? parseTimeString(options.until) : Date.now(); + const limit = options.lines ? parseInt(options.lines, 10) : undefined; + + for await (const event of searchLogs({ + logGroupName: agentContext.logGroupName, + region: agentContext.region, + startTimeMs, + endTimeMs, + filterPattern, + limit, + })) { + console.log(formatLogLine(event, isJson)); + } + } else { + console.error(`Streaming logs for ${agentContext.agentName}... (Ctrl+C to stop)`); + + for await (const event of streamLogs({ + logGroupName: agentContext.logGroupName, + region: agentContext.region, + accountId: agentContext.accountId, + filterPattern, + abortSignal: ac.signal, + })) { + console.log(formatLogLine(event, isJson)); + } + } + + return { success: true }; + } catch (err: unknown) { + const errorName = (err as { name?: string })?.name; + + if (errorName === 'ResourceNotFoundException') { + return { + success: false, + error: `No logs found for agent '${agentContext.agentName}'. Has the agent been invoked?`, + }; + } + + if (errorName === 'AbortError' || ac.signal.aborted) { + return { success: true }; + } + + throw err; + } finally { + process.removeListener('SIGINT', onSignal); + } +} diff --git a/src/cli/commands/logs/command.tsx b/src/cli/commands/logs/command.tsx new file mode 100644 index 00000000..77fcdb8a --- /dev/null +++ b/src/cli/commands/logs/command.tsx @@ -0,0 +1,37 @@ +import { getErrorMessage } from '../../errors'; +import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; +import { requireProject } from '../../tui/guards'; +import { handleLogs } from './action'; +import type { LogsOptions } from './types'; +import type { Command } from '@commander-js/extra-typings'; +import { Text, render } from 'ink'; + +export const registerLogs = (program: Command) => { + program + .command('logs') + .alias('l') + .description(COMMAND_DESCRIPTIONS.logs) + .option('--agent ', 'Select specific agent') + .option('--agent-id ', 'Specify agent runtime ID directly') + .option('--since