feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
27
dexto/packages/cli/src/analytics/constants.ts
Normal file
27
dexto/packages/cli/src/analytics/constants.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// packages/cli/src/analytics/constants.ts
|
||||
|
||||
// Single source for PostHog configuration.
|
||||
// Embed your public key here (safe to publish). Host can stay default.
|
||||
export const DEFAULT_POSTHOG_KEY = 'phc_IJHITHjBKOjDyFiVeilfdumcGniXMuLeXeiLQhYvwDW';
|
||||
export const DEFAULT_POSTHOG_HOST = 'https://app.posthog.com';
|
||||
|
||||
/**
|
||||
* Single opt-out switch for analytics.
|
||||
*
|
||||
* Usage:
|
||||
* DEXTO_ANALYTICS_DISABLED=1 dexto ...
|
||||
*
|
||||
* When set to a truthy value ("1", "true", "yes"), analytics are fully disabled.
|
||||
*/
|
||||
export function isAnalyticsDisabled(): boolean {
|
||||
const v = process.env.DEXTO_ANALYTICS_DISABLED;
|
||||
return typeof v === 'string' && /^(1|true|yes)$/i.test(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic per-command timeout (in milliseconds) used by the analytics wrapper.
|
||||
*
|
||||
* This does NOT terminate the command. It emits a non-terminating timeout
|
||||
* event when the duration threshold is crossed to help diagnose long runs.
|
||||
*/
|
||||
export const COMMAND_TIMEOUT_MS = 120000; // 2 minutes (default for quick commands)
|
||||
145
dexto/packages/cli/src/analytics/events.ts
Normal file
145
dexto/packages/cli/src/analytics/events.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
// packages/cli/src/analytics/events.ts
|
||||
// Typed payload scaffolding for PostHog events emitted by the CLI.
|
||||
// These types describe the additional properties supplied when we call
|
||||
// `capture(event, properties)`. Base context (app, version, OS, execution context,
|
||||
// session_id, etc.) is merged automatically in analytics/index.ts.
|
||||
|
||||
import type { ExecutionContext } from '@dexto/agent-management';
|
||||
import type { SharedAnalyticsEventMap } from '@dexto/analytics';
|
||||
|
||||
export interface BaseEventContext {
|
||||
app?: 'dexto';
|
||||
app_version?: string;
|
||||
node_version?: string;
|
||||
os_platform?: NodeJS.Platform;
|
||||
os_release?: string;
|
||||
os_arch?: string;
|
||||
execution_context?: ExecutionContext;
|
||||
session_id?: string | null;
|
||||
}
|
||||
|
||||
export interface CommandArgsMeta {
|
||||
argTypes: string[];
|
||||
positionalRaw?: string[];
|
||||
positionalCount?: number;
|
||||
optionKeys?: string[];
|
||||
options?: Record<string, SanitizedOptionValue>;
|
||||
}
|
||||
|
||||
export type SanitizedOptionValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { type: 'array'; length: number }
|
||||
| { type: 'object' };
|
||||
|
||||
export type CliCommandPhase = 'start' | 'end' | 'timeout';
|
||||
|
||||
interface CliCommandBaseEvent {
|
||||
name: string;
|
||||
phase: CliCommandPhase;
|
||||
args?: CommandArgsMeta;
|
||||
}
|
||||
|
||||
export interface CliCommandStartEvent extends CliCommandBaseEvent {
|
||||
phase: 'start';
|
||||
}
|
||||
|
||||
export interface CliCommandEndEvent extends CliCommandBaseEvent {
|
||||
phase: 'end';
|
||||
success: boolean;
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
reason?: string;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
export interface CliCommandTimeoutEvent extends CliCommandBaseEvent {
|
||||
phase: 'timeout';
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export type CliCommandEvent = CliCommandStartEvent | CliCommandEndEvent | CliCommandTimeoutEvent;
|
||||
|
||||
export interface PromptEvent {
|
||||
mode: 'cli' | 'headless';
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface SetupEvent {
|
||||
provider: string;
|
||||
model: string;
|
||||
hadApiKeyBefore?: boolean;
|
||||
setupMode: 'interactive' | 'non-interactive';
|
||||
setupVariant?: 'quick-start' | 'custom' | 'dexto';
|
||||
defaultMode?: string;
|
||||
hasBaseURL?: boolean;
|
||||
apiKeySkipped?: boolean;
|
||||
}
|
||||
|
||||
export interface InstallAgentEvent {
|
||||
agent: string;
|
||||
status: 'installed' | 'skipped' | 'failed';
|
||||
force: boolean;
|
||||
reason?: string;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export interface InstallAggregateEvent {
|
||||
requested: string[];
|
||||
installed: string[];
|
||||
skipped: string[];
|
||||
failed: string[];
|
||||
successCount: number;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
export interface UninstallAgentEvent {
|
||||
agent: string;
|
||||
status: 'uninstalled' | 'failed';
|
||||
force: boolean;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export interface UninstallAggregateEvent {
|
||||
requested: string[];
|
||||
uninstalled: string[];
|
||||
failed: string[];
|
||||
successCount: number;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
export interface CreateProjectEvent {
|
||||
provider: string;
|
||||
providedKey: boolean;
|
||||
}
|
||||
|
||||
export interface InitProjectEvent {
|
||||
provider: string;
|
||||
providedKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI analytics event map extending shared events with CLI-specific events.
|
||||
*
|
||||
* IMPORTANT: If an event is also tracked by WebUI, move it to SharedAnalyticsEventMap
|
||||
* in @dexto/analytics to avoid duplication.
|
||||
*/
|
||||
export interface DextoAnalyticsEventMap extends SharedAnalyticsEventMap {
|
||||
// CLI-specific events
|
||||
dexto_cli_command: CliCommandEvent;
|
||||
dexto_prompt: PromptEvent;
|
||||
dexto_setup: SetupEvent;
|
||||
dexto_install_agent: InstallAgentEvent;
|
||||
dexto_install: InstallAggregateEvent;
|
||||
dexto_uninstall_agent: UninstallAgentEvent;
|
||||
dexto_uninstall: UninstallAggregateEvent;
|
||||
dexto_create: CreateProjectEvent;
|
||||
dexto_init: InitProjectEvent;
|
||||
}
|
||||
|
||||
export type AnalyticsEventName = keyof DextoAnalyticsEventMap;
|
||||
|
||||
export type AnalyticsEventPayload<Name extends AnalyticsEventName> = DextoAnalyticsEventMap[Name];
|
||||
214
dexto/packages/cli/src/analytics/index.ts
Normal file
214
dexto/packages/cli/src/analytics/index.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
// packages/cli/src/analytics/index.ts
|
||||
|
||||
import { PostHog } from 'posthog-node';
|
||||
import os from 'os';
|
||||
import {
|
||||
isAnalyticsDisabled,
|
||||
DEFAULT_POSTHOG_HOST,
|
||||
DEFAULT_POSTHOG_KEY,
|
||||
loadState,
|
||||
saveState,
|
||||
} from '@dexto/analytics';
|
||||
import type { AnalyticsState } from '@dexto/analytics';
|
||||
import { getExecutionContext } from '@dexto/agent-management';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
AnalyticsEventName,
|
||||
AnalyticsEventPayload,
|
||||
BaseEventContext,
|
||||
CliCommandEndEvent,
|
||||
CliCommandStartEvent,
|
||||
} from './events.js';
|
||||
|
||||
interface InitOptions {
|
||||
appVersion: string;
|
||||
}
|
||||
|
||||
let client: PostHog | null = null;
|
||||
let enabled = false;
|
||||
let state: AnalyticsState | null = null;
|
||||
let sessionId: string | null = null;
|
||||
let appVersion: string | null = null;
|
||||
|
||||
function baseProps(): BaseEventContext {
|
||||
return {
|
||||
app: 'dexto',
|
||||
app_version: appVersion || 'unknown',
|
||||
node_version: process.version,
|
||||
os_platform: os.platform(),
|
||||
os_release: os.release(),
|
||||
os_arch: os.arch(),
|
||||
execution_context: getExecutionContext(),
|
||||
session_id: sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the analytics client for the CLI.
|
||||
*
|
||||
* - Respects DEXTO_ANALYTICS_DISABLED.
|
||||
* - Creates/loads the anonymous distinctId and a per-process session_id.
|
||||
* - Emits a dexto_session_created event for each process run.
|
||||
*/
|
||||
export async function initAnalytics(opts: InitOptions): Promise<void> {
|
||||
if (enabled || client) return; // idempotent
|
||||
if (isAnalyticsDisabled()) {
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
// Load or create state
|
||||
state = await loadState();
|
||||
sessionId = randomUUID();
|
||||
appVersion = opts.appVersion;
|
||||
|
||||
const key = process.env.DEXTO_POSTHOG_KEY ?? DEFAULT_POSTHOG_KEY;
|
||||
const host = process.env.DEXTO_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST;
|
||||
if (typeof key !== 'string' || !/^phc_[A-Za-z0-9]+/.test(key) || !host) {
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
client = new PostHog(key, {
|
||||
host,
|
||||
flushAt: 1,
|
||||
flushInterval: 0,
|
||||
disableGeoip: false,
|
||||
});
|
||||
enabled = true;
|
||||
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
client?.flush?.();
|
||||
} catch {
|
||||
// Ignore flush errors: analytics should never block process exit.
|
||||
}
|
||||
});
|
||||
|
||||
capture('dexto_session_created', {
|
||||
source: 'cli',
|
||||
sessionId: sessionId || 'unknown',
|
||||
trigger: 'manual',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a single analytics event with optional properties.
|
||||
* Automatically enriches events with base context (app/os/node/session).
|
||||
*/
|
||||
export function capture<Name extends AnalyticsEventName>(
|
||||
event: Name,
|
||||
properties: AnalyticsEventPayload<Name> = {} as AnalyticsEventPayload<Name>
|
||||
): void {
|
||||
if (!enabled || !client || !state) return;
|
||||
try {
|
||||
client.capture({
|
||||
distinctId: state.distinctId,
|
||||
event,
|
||||
properties: { ...baseProps(), ...properties },
|
||||
});
|
||||
} catch {
|
||||
// swallow
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt a graceful shutdown of the analytics client, flushing queued events.
|
||||
*/
|
||||
export async function shutdownAnalytics(): Promise<void> {
|
||||
if (client) {
|
||||
try {
|
||||
await client.shutdown();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commander hooks
|
||||
type TimerMap = Map<string, number>;
|
||||
const timers: TimerMap = new Map();
|
||||
|
||||
/**
|
||||
* Mark the start of a command for timing and emit a lightweight start event.
|
||||
* Adds local counters as a coarse diagnostic aid.
|
||||
*/
|
||||
export async function onCommandStart(
|
||||
name: string,
|
||||
extra: Partial<Omit<CliCommandStartEvent, 'name' | 'phase'>> = {}
|
||||
): Promise<void> {
|
||||
if (!enabled) return;
|
||||
timers.set(name, Date.now());
|
||||
if (state) {
|
||||
await saveState(state);
|
||||
}
|
||||
const payload: CliCommandStartEvent = {
|
||||
name,
|
||||
phase: 'start',
|
||||
...extra,
|
||||
};
|
||||
capture('dexto_cli_command', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the end of a command and emit a completion event with success/failure
|
||||
* and measured duration. Accepts optional extra properties.
|
||||
*/
|
||||
export async function onCommandEnd(
|
||||
name: string,
|
||||
success: boolean,
|
||||
extra: Partial<Omit<CliCommandEndEvent, 'name' | 'phase' | 'success' | 'durationMs'>> = {}
|
||||
): Promise<void> {
|
||||
if (!enabled) return;
|
||||
const start = timers.get(name) ?? Date.now();
|
||||
const durationMs = Date.now() - start;
|
||||
timers.delete(name);
|
||||
|
||||
const payload: CliCommandEndEvent = {
|
||||
name,
|
||||
phase: 'end',
|
||||
success,
|
||||
durationMs,
|
||||
...extra,
|
||||
};
|
||||
capture('dexto_cli_command', payload);
|
||||
|
||||
if (state) {
|
||||
state.commandRunCounts = state.commandRunCounts || {};
|
||||
state.commandRunCounts[name] = (state.commandRunCounts[name] || 0) + 1;
|
||||
await saveState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether analytics are currently enabled for this process.
|
||||
*/
|
||||
export function getEnabled(): boolean {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the analytics configuration for WebUI injection.
|
||||
* Returns the config needed by the WebUI's PostHog client, or null if disabled.
|
||||
*/
|
||||
export async function getWebUIAnalyticsConfig(): Promise<{
|
||||
distinctId: string;
|
||||
posthogKey: string;
|
||||
posthogHost: string;
|
||||
appVersion: string;
|
||||
} | null> {
|
||||
if (isAnalyticsDisabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const analyticsState = await loadState();
|
||||
return {
|
||||
distinctId: analyticsState.distinctId,
|
||||
posthogKey: process.env.DEXTO_POSTHOG_KEY ?? DEFAULT_POSTHOG_KEY,
|
||||
posthogHost: process.env.DEXTO_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST,
|
||||
appVersion: appVersion || 'unknown',
|
||||
};
|
||||
} catch {
|
||||
// If analytics state loading fails, return null to disable analytics
|
||||
return null;
|
||||
}
|
||||
}
|
||||
90
dexto/packages/cli/src/analytics/state.ts
Normal file
90
dexto/packages/cli/src/analytics/state.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// packages/cli/src/analytics/state.ts
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import os from 'os';
|
||||
import { randomUUID, createHash } from 'crypto';
|
||||
import { createRequire } from 'module';
|
||||
const requireCJS = createRequire(import.meta.url);
|
||||
// node-machine-id is CommonJS; import via createRequire to avoid ESM interop issues
|
||||
const { machineIdSync } = requireCJS('node-machine-id') as {
|
||||
machineIdSync: (original?: boolean) => string;
|
||||
};
|
||||
import { getDextoGlobalPath } from '@dexto/agent-management';
|
||||
|
||||
/**
|
||||
* Shape of the persisted analytics state written to
|
||||
* ~/.dexto/telemetry/state.json.
|
||||
*
|
||||
* - distinctId: Anonymous ID (UUID) for grouping events by machine.
|
||||
* - createdAt: ISO timestamp when the state was first created.
|
||||
* - commandRunCounts: Local counters per command for coarse diagnostics.
|
||||
*/
|
||||
export interface AnalyticsState {
|
||||
distinctId: string;
|
||||
createdAt: string; // ISO string
|
||||
commandRunCounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
const STATE_DIR = getDextoGlobalPath('telemetry');
|
||||
const STATE_FILE = path.join(STATE_DIR, 'state.json');
|
||||
|
||||
/**
|
||||
* Load the persisted analytics state, creating a new file if missing.
|
||||
* Returns a valid state object with defaults populated.
|
||||
*/
|
||||
export async function loadState(): Promise<AnalyticsState> {
|
||||
try {
|
||||
const content = await fs.readFile(STATE_FILE, 'utf8');
|
||||
const parsed = JSON.parse(content) as Partial<AnalyticsState>;
|
||||
// Validate minimal shape
|
||||
if (!parsed.distinctId) throw new Error('invalid state');
|
||||
return {
|
||||
distinctId: parsed.distinctId,
|
||||
createdAt: parsed.createdAt || new Date().toISOString(),
|
||||
commandRunCounts: parsed.commandRunCounts ?? {},
|
||||
};
|
||||
} catch {
|
||||
await fs.mkdir(STATE_DIR, { recursive: true });
|
||||
const state: AnalyticsState = {
|
||||
distinctId: computeDistinctId(),
|
||||
createdAt: new Date().toISOString(),
|
||||
commandRunCounts: {},
|
||||
};
|
||||
await saveState(state);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the analytics state to ~/.dexto/telemetry/state.json.
|
||||
*/
|
||||
export async function saveState(state: AnalyticsState): Promise<void> {
|
||||
await fs.mkdir(STATE_DIR, { recursive: true });
|
||||
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a stable, privacy‑safe machine identifier so identity
|
||||
* survives ~/.dexto deletion by default.
|
||||
*
|
||||
* Strategy:
|
||||
* - Prefer node-machine-id (hashed), which abstracts platform differences.
|
||||
* - Fallback to a salted/hashed hostname.
|
||||
* - As a last resort, generate a random UUID.
|
||||
*/
|
||||
function computeDistinctId(): string {
|
||||
try {
|
||||
// machineIdSync(true) returns a hashed, stable identifier
|
||||
const id = machineIdSync(true);
|
||||
if (typeof id === 'string' && id.length > 0) return `DEXTO-${id}`;
|
||||
} catch {
|
||||
// fall through to hostname hash
|
||||
}
|
||||
// Fallback: hash hostname to avoid exposing raw value
|
||||
const hostname = os.hostname() || 'unknown-host';
|
||||
const digest = createHash('sha256').update(hostname).digest('hex');
|
||||
if (digest) return `DEXTO-${digest.slice(0, 32)}`;
|
||||
// Last resort
|
||||
return `DEXTO-${randomUUID()}`;
|
||||
}
|
||||
137
dexto/packages/cli/src/analytics/wrapper.ts
Normal file
137
dexto/packages/cli/src/analytics/wrapper.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// packages/cli/src/analytics/wrapper.ts
|
||||
import { onCommandStart, onCommandEnd, capture } from './index.js';
|
||||
import { COMMAND_TIMEOUT_MS } from './constants.js';
|
||||
import type {
|
||||
CliCommandEndEvent,
|
||||
CommandArgsMeta,
|
||||
SanitizedOptionValue,
|
||||
CliCommandTimeoutEvent,
|
||||
} from './events.js';
|
||||
|
||||
function sanitizeOptions(obj: Record<string, unknown>): Record<string, SanitizedOptionValue> {
|
||||
const redactedKeys = /key|token|secret|password|api[_-]?key|authorization|auth/i;
|
||||
const truncate = (s: string, max = 256) => (s.length > max ? s.slice(0, max) + '…' : s);
|
||||
const out: Record<string, SanitizedOptionValue> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (typeof v === 'string') {
|
||||
out[k] = redactedKeys.test(k) ? '[REDACTED]' : truncate(v);
|
||||
} else if (Array.isArray(v)) {
|
||||
out[k] = { type: 'array', length: v.length };
|
||||
} else if (typeof v === 'number' || typeof v === 'boolean' || v === null) {
|
||||
out[k] = v as SanitizedOptionValue;
|
||||
} else if (typeof v === 'object' && v) {
|
||||
out[k] = { type: 'object' };
|
||||
} else {
|
||||
out[k] = String(v ?? 'unknown');
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildArgsPayload(args: unknown[]): CommandArgsMeta {
|
||||
const meta: CommandArgsMeta = {
|
||||
argTypes: args.map((a) => (Array.isArray(a) ? 'array' : typeof a)),
|
||||
};
|
||||
|
||||
if (args.length > 0 && Array.isArray(args[0])) {
|
||||
const list = (args[0] as unknown[]).map((x) => String(x));
|
||||
const trimmed = list.map((s) => (s.length > 512 ? s.slice(0, 512) + '…' : s)).slice(0, 10);
|
||||
meta.positionalRaw = trimmed;
|
||||
meta.positionalCount = list.length;
|
||||
}
|
||||
|
||||
const last = args[args.length - 1];
|
||||
if (last && typeof last === 'object' && !Array.isArray(last)) {
|
||||
meta.optionKeys = Object.keys(last as Record<string, unknown>);
|
||||
meta.options = sanitizeOptions(last as Record<string, unknown>);
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
export function withAnalytics<A extends unknown[], R = unknown>(
|
||||
commandName: string,
|
||||
handler: (...args: A) => Promise<R> | R,
|
||||
opts?: { timeoutMs?: number }
|
||||
): (...args: A) => Promise<R> {
|
||||
const timeoutMs = opts?.timeoutMs ?? COMMAND_TIMEOUT_MS;
|
||||
return async (...args: A): Promise<R> => {
|
||||
const argsMeta = buildArgsPayload(args as unknown[]);
|
||||
await onCommandStart(commandName, { args: argsMeta });
|
||||
const timeout =
|
||||
timeoutMs > 0
|
||||
? (() => {
|
||||
const t = setTimeout(() => {
|
||||
try {
|
||||
const payload: CliCommandTimeoutEvent = {
|
||||
name: commandName,
|
||||
phase: 'timeout',
|
||||
timeoutMs,
|
||||
args: argsMeta,
|
||||
};
|
||||
capture('dexto_cli_command', payload);
|
||||
} catch {
|
||||
// Timeout instrumentation is best-effort.
|
||||
}
|
||||
}, timeoutMs);
|
||||
// Prevent timeout from keeping process alive
|
||||
t.unref();
|
||||
return t;
|
||||
})()
|
||||
: null;
|
||||
try {
|
||||
const result = await handler(...args);
|
||||
const success = (typeof process.exitCode === 'number' ? process.exitCode : 0) === 0;
|
||||
await onCommandEnd(commandName, success, { args: argsMeta });
|
||||
return result as R;
|
||||
} catch (err) {
|
||||
if (err instanceof ExitSignal) {
|
||||
const exitCode = err.code ?? 0;
|
||||
process.exitCode = exitCode;
|
||||
try {
|
||||
const endMeta: Partial<
|
||||
Omit<CliCommandEndEvent, 'name' | 'phase' | 'success' | 'durationMs'>
|
||||
> & { args: CommandArgsMeta } = { args: argsMeta };
|
||||
if (typeof err.reason === 'string') {
|
||||
endMeta.reason = err.reason;
|
||||
}
|
||||
if (err.commandName) {
|
||||
endMeta.command = err.commandName;
|
||||
}
|
||||
await onCommandEnd(commandName, exitCode === 0, endMeta);
|
||||
} catch {
|
||||
// Ignore analytics errors when propagating ExitSignal.
|
||||
}
|
||||
// Actually exit the process after analytics
|
||||
process.exit(exitCode);
|
||||
}
|
||||
try {
|
||||
await onCommandEnd(commandName, false, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
args: argsMeta,
|
||||
});
|
||||
} catch {
|
||||
// Ignore analytics errors when recording failures.
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class ExitSignal extends Error {
|
||||
code: number;
|
||||
reason?: string | undefined;
|
||||
commandName?: string | undefined;
|
||||
constructor(code: number = 0, reason?: string, commandName?: string) {
|
||||
super('ExitSignal');
|
||||
this.name = 'ExitSignal';
|
||||
this.code = code;
|
||||
this.reason = reason;
|
||||
this.commandName = commandName;
|
||||
}
|
||||
}
|
||||
|
||||
export function safeExit(commandName: string, code: number = 0, reason?: string): never {
|
||||
throw new ExitSignal(code, reason, commandName);
|
||||
}
|
||||
127
dexto/packages/cli/src/api/mcp/tool-aggregation-handler.ts
Normal file
127
dexto/packages/cli/src/api/mcp/tool-aggregation-handler.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import {
|
||||
MCPManager,
|
||||
logger,
|
||||
type ValidatedServerConfigs,
|
||||
jsonSchemaToZodShape,
|
||||
createLogger,
|
||||
DextoLogComponent,
|
||||
} from '@dexto/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Initializes MCP server for tool aggregation mode.
|
||||
* Instead of exposing an AI agent, this directly exposes all tools from connected MCP servers.
|
||||
*/
|
||||
export async function initializeMcpToolAggregationServer(
|
||||
serverConfigs: ValidatedServerConfigs,
|
||||
mcpTransport: Transport,
|
||||
serverName: string,
|
||||
serverVersion: string,
|
||||
_strict: boolean
|
||||
): Promise<McpServer> {
|
||||
// Create MCP manager with no confirmation provider (tools are auto-approved)
|
||||
const mcpLogger = createLogger({
|
||||
config: {
|
||||
level: 'info',
|
||||
transports: [{ type: 'console', colorize: true }],
|
||||
},
|
||||
agentId: 'mcp-tool-aggregation',
|
||||
component: DextoLogComponent.MCP,
|
||||
});
|
||||
const mcpManager = new MCPManager(mcpLogger);
|
||||
|
||||
// Initialize all MCP server connections from config
|
||||
logger.info('Connecting to configured MCP servers for tool aggregation...');
|
||||
await mcpManager.initializeFromConfig(serverConfigs);
|
||||
|
||||
// Create the aggregation MCP server
|
||||
const mcpServer = new McpServer(
|
||||
{ name: serverName, version: serverVersion },
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const toolDefinitions = await mcpManager.getAllTools();
|
||||
let toolCount = 0;
|
||||
|
||||
for (const [toolName, toolDef] of Object.entries(toolDefinitions)) {
|
||||
toolCount++;
|
||||
const jsonSchema = toolDef.parameters ?? { type: 'object', properties: {} };
|
||||
const paramsShape = jsonSchemaToZodShape(jsonSchema);
|
||||
const _paramsSchema = z.object(paramsShape);
|
||||
type ToolArgs = z.output<typeof _paramsSchema>;
|
||||
|
||||
logger.debug(`Registering tool '${toolName}' with schema: ${JSON.stringify(jsonSchema)}`);
|
||||
|
||||
mcpServer.tool(
|
||||
toolName,
|
||||
toolDef.description || `Tool: ${toolName}`,
|
||||
paramsShape,
|
||||
async (args: ToolArgs) => {
|
||||
logger.info(`Tool aggregation: executing ${toolName}`);
|
||||
try {
|
||||
const result = await mcpManager.executeTool(toolName, args);
|
||||
logger.info(`Tool aggregation: ${toolName} completed successfully`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Tool aggregation: ${toolName} failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Registered ${toolCount} tools from connected MCP servers`);
|
||||
|
||||
// Register resources if available
|
||||
try {
|
||||
const allResources = await mcpManager.listAllResources();
|
||||
logger.info(`Registering ${allResources.length} resources from connected MCP servers`);
|
||||
|
||||
// Collision handling verified:
|
||||
// - Tools/Prompts: Names come from mcpManager which handles collisions at source
|
||||
// - Resources: Index prefix ensures uniqueness even if multiple clients have same key
|
||||
allResources.forEach((resource, index) => {
|
||||
const safeId = resource.key.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
mcpServer.resource(`resource_${index}_${safeId}`, resource.key, async () => {
|
||||
logger.info(`Resource aggregation: reading ${resource.key}`);
|
||||
return await mcpManager.readResource(resource.key);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.debug(`Skipping resource aggregation: ${error}`);
|
||||
}
|
||||
|
||||
// Register prompts if available
|
||||
try {
|
||||
const allPrompts = await mcpManager.listAllPrompts();
|
||||
logger.info(`Registering ${allPrompts.length} prompts from connected MCP servers`);
|
||||
|
||||
for (const promptName of allPrompts) {
|
||||
mcpServer.prompt(promptName, `Prompt: ${promptName}`, async (extra) => {
|
||||
logger.info(`Prompt aggregation: resolving ${promptName}`);
|
||||
const promptArgs: Record<string, unknown> | undefined =
|
||||
extra && 'arguments' in extra
|
||||
? (extra.arguments as Record<string, unknown>)
|
||||
: undefined;
|
||||
return await mcpManager.getPrompt(promptName, promptArgs);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Skipping prompt aggregation: ${error}`);
|
||||
}
|
||||
|
||||
// Connect server to transport
|
||||
logger.info(`Connecting MCP tool aggregation server...`);
|
||||
await mcpServer.connect(mcpTransport);
|
||||
logger.info(`✅ MCP tool aggregation server connected with ${toolCount} tools exposed`);
|
||||
|
||||
return mcpServer;
|
||||
}
|
||||
0
dexto/packages/cli/src/api/mcp/types.ts
Normal file
0
dexto/packages/cli/src/api/mcp/types.ts
Normal file
590
dexto/packages/cli/src/api/server-hono.ts
Normal file
590
dexto/packages/cli/src/api/server-hono.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
import os from 'node:os';
|
||||
import type { Context } from 'hono';
|
||||
import type { AgentCard } from '@dexto/core';
|
||||
import { DextoAgent, createAgentCard, logger, AgentError } from '@dexto/core';
|
||||
import {
|
||||
loadAgentConfig,
|
||||
enrichAgentConfig,
|
||||
deriveDisplayName,
|
||||
getAgentRegistry,
|
||||
AgentFactory,
|
||||
globalPreferencesExist,
|
||||
loadGlobalPreferences,
|
||||
} from '@dexto/agent-management';
|
||||
import { applyUserPreferences } from '../config/cli-overrides.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import {
|
||||
createDextoApp,
|
||||
createNodeServer,
|
||||
createMcpTransport as createServerMcpTransport,
|
||||
createMcpHttpHandlers,
|
||||
initializeMcpServer as initializeServerMcpServer,
|
||||
createManualApprovalHandler,
|
||||
WebhookEventSubscriber,
|
||||
A2ASseEventSubscriber,
|
||||
ApprovalCoordinator,
|
||||
type McpTransportType,
|
||||
type WebUIRuntimeConfig,
|
||||
} from '@dexto/server';
|
||||
import { registerGracefulShutdown } from '../utils/graceful-shutdown.js';
|
||||
|
||||
const DEFAULT_AGENT_VERSION = '1.0.0';
|
||||
|
||||
/**
|
||||
* Load image dynamically based on config and environment
|
||||
* Priority: Config image field > Environment variable > Default
|
||||
* Images are optional, but default to image-local for convenience
|
||||
*
|
||||
* @returns Image metadata including bundled plugins, or null if image has no metadata export
|
||||
*/
|
||||
async function loadImageForConfig(config: {
|
||||
image?: string | undefined;
|
||||
}): Promise<{ bundledPlugins?: string[] } | null> {
|
||||
const imageName = config.image || process.env.DEXTO_IMAGE || '@dexto/image-local';
|
||||
|
||||
try {
|
||||
const imageModule = await import(imageName);
|
||||
logger.debug(`Loaded image: ${imageName}`);
|
||||
|
||||
// Extract metadata if available (built images export imageMetadata)
|
||||
if (imageModule.imageMetadata) {
|
||||
return imageModule.imageMetadata;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
const errorMsg = `Failed to load image '${imageName}': ${err instanceof Error ? err.message : String(err)}`;
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all agents (installed and available)
|
||||
* Replacement for old Dexto.listAgents()
|
||||
*/
|
||||
async function listAgents(): Promise<{
|
||||
installed: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
type: 'builtin' | 'custom';
|
||||
}>;
|
||||
available: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
type: 'builtin' | 'custom';
|
||||
}>;
|
||||
}> {
|
||||
return AgentFactory.listAgents({
|
||||
descriptionFallback: 'No description',
|
||||
customAgentDescriptionFallback: 'Custom agent',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent from an agent ID
|
||||
* Replacement for old Dexto.createAgent()
|
||||
* Uses registry.resolveAgent() which auto-installs if needed
|
||||
*
|
||||
* Applies user preferences (preferences.yml) to ALL agents, not just the default.
|
||||
* See feature-plans/auto-update.md section 8.11 - Three-Layer LLM Resolution.
|
||||
*/
|
||||
async function createAgentFromId(agentId: string): Promise<DextoAgent> {
|
||||
try {
|
||||
// Use registry to resolve agent path (auto-installs if not present)
|
||||
const registry = getAgentRegistry();
|
||||
const agentPath = await registry.resolveAgent(agentId, true);
|
||||
|
||||
// Load agent config
|
||||
let config = await loadAgentConfig(agentPath);
|
||||
|
||||
// Apply user's LLM preferences to ALL agents
|
||||
// Three-Layer Resolution: local.llm ?? preferences.llm ?? bundled.llm
|
||||
if (globalPreferencesExist()) {
|
||||
try {
|
||||
const preferences = await loadGlobalPreferences();
|
||||
if (preferences?.llm?.provider && preferences?.llm?.model) {
|
||||
config = applyUserPreferences(config, preferences);
|
||||
logger.debug(`Applied user preferences to ${agentId}`, {
|
||||
provider: preferences.llm.provider,
|
||||
model: preferences.llm.model,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
logger.debug('Could not load preferences, using bundled config');
|
||||
}
|
||||
}
|
||||
|
||||
// Load image to get bundled plugins
|
||||
const imageMetadata = await loadImageForConfig(config);
|
||||
|
||||
// Enrich config with per-agent paths and bundled plugins
|
||||
const enrichedConfig = enrichAgentConfig(config, agentPath, {
|
||||
logLevel: 'info', // Server uses info-level logging for visibility
|
||||
bundledPlugins: imageMetadata?.bundledPlugins || [],
|
||||
});
|
||||
|
||||
// Create agent instance
|
||||
logger.info(`Creating agent: ${agentId} from ${agentPath}`);
|
||||
return new DextoAgent(enrichedConfig, agentPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create agent '${agentId}': ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePort(listenPort?: number): number {
|
||||
if (typeof listenPort === 'number') {
|
||||
return listenPort;
|
||||
}
|
||||
const envPort = Number(process.env.PORT);
|
||||
return Number.isFinite(envPort) && envPort > 0 ? envPort : 3000;
|
||||
}
|
||||
|
||||
function resolveBaseUrl(port: number): string {
|
||||
return process.env.DEXTO_BASE_URL ?? `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
export type HonoInitializationResult = {
|
||||
app: ReturnType<typeof createDextoApp>;
|
||||
server: ReturnType<typeof createNodeServer>['server'];
|
||||
webhookSubscriber?: NonNullable<ReturnType<typeof createNodeServer>['webhookSubscriber']>;
|
||||
agentCard: AgentCard;
|
||||
mcpTransport?: Transport;
|
||||
switchAgentById: (agentId: string) => Promise<{ id: string; name: string }>;
|
||||
switchAgentByPath: (filePath: string) => Promise<{ id: string; name: string }>;
|
||||
resolveAgentInfo: (agentId: string) => Promise<{ id: string; name: string }>;
|
||||
ensureAgentAvailable: () => void;
|
||||
getActiveAgentId: () => string | undefined;
|
||||
};
|
||||
|
||||
//TODO (migration): consider moving this to the server package
|
||||
export async function initializeHonoApi(
|
||||
agent: DextoAgent,
|
||||
agentCardOverride?: Partial<AgentCard>,
|
||||
listenPort?: number,
|
||||
agentId?: string,
|
||||
webRoot?: string,
|
||||
webUIConfig?: WebUIRuntimeConfig
|
||||
): Promise<HonoInitializationResult> {
|
||||
// Declare before registering shutdown hook to avoid TDZ on signals
|
||||
let activeAgent: DextoAgent = agent;
|
||||
let activeAgentId: string | undefined = agentId || 'coding-agent';
|
||||
let isSwitchingAgent = false;
|
||||
registerGracefulShutdown(() => activeAgent);
|
||||
|
||||
const resolvedPort = resolvePort(listenPort);
|
||||
const baseApiUrl = resolveBaseUrl(resolvedPort);
|
||||
|
||||
// Apply agentCard overrides (if any)
|
||||
const overrides = agentCardOverride ?? {};
|
||||
let agentCardData = createAgentCard(
|
||||
{
|
||||
defaultName: overrides.name ?? activeAgentId,
|
||||
defaultVersion: overrides.version ?? DEFAULT_AGENT_VERSION,
|
||||
defaultBaseUrl: baseApiUrl,
|
||||
},
|
||||
overrides
|
||||
);
|
||||
|
||||
// Create event subscribers and approval coordinator (shared across agent switches)
|
||||
const webhookSubscriber = new WebhookEventSubscriber();
|
||||
const sseSubscriber = new A2ASseEventSubscriber();
|
||||
const approvalCoordinator = new ApprovalCoordinator();
|
||||
|
||||
/**
|
||||
* Wire services (SSE subscribers) to an agent.
|
||||
* Called for agent switching to re-subscribe to the new agent's event bus.
|
||||
* Note: Approval handler and coordinator are set before agent.start() for each agent.
|
||||
*/
|
||||
async function wireServicesToAgent(agent: DextoAgent): Promise<void> {
|
||||
logger.debug('Wiring services to agent...');
|
||||
|
||||
// Subscribe to event bus (methods handle aborting previous subscriptions)
|
||||
webhookSubscriber.subscribe(agent.agentEventBus);
|
||||
sseSubscriber.subscribe(agent.agentEventBus);
|
||||
// Note: ApprovalCoordinator doesn't subscribe to agent event bus
|
||||
// It's a separate coordination channel between handler and server
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to resolve agent ID to { id, name } by looking up in registry
|
||||
*/
|
||||
async function resolveAgentInfo(agentId: string): Promise<{ id: string; name: string }> {
|
||||
const agents = await listAgents();
|
||||
const agent =
|
||||
agents.installed.find((a) => a.id === agentId) ??
|
||||
agents.available.find((a) => a.id === agentId);
|
||||
return {
|
||||
id: agentId,
|
||||
name: agent?.name ?? deriveDisplayName(agentId),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureAgentAvailable(): void {
|
||||
// Gate requests during agent switching
|
||||
if (isSwitchingAgent) {
|
||||
throw AgentError.switchInProgress();
|
||||
}
|
||||
|
||||
// Fast path: most common case is agent is started and running
|
||||
if (activeAgent.isStarted() && !activeAgent.isStopped()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Provide specific error messages for better debugging
|
||||
if (activeAgent.isStopped()) {
|
||||
throw AgentError.stopped();
|
||||
}
|
||||
if (!activeAgent.isStarted()) {
|
||||
throw AgentError.notStarted();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common agent switching logic shared by switchAgentById and switchAgentByPath.
|
||||
*/
|
||||
async function performAgentSwitch(
|
||||
newAgent: DextoAgent,
|
||||
agentId: string,
|
||||
bridge: ReturnType<typeof createNodeServer>
|
||||
) {
|
||||
logger.info('Preparing new agent for switch...');
|
||||
|
||||
// Register webhook subscriber for LLM streaming events
|
||||
if (bridge.webhookSubscriber) {
|
||||
newAgent.registerSubscriber(bridge.webhookSubscriber);
|
||||
}
|
||||
|
||||
// Switch activeAgent reference first
|
||||
const previousAgent = activeAgent;
|
||||
activeAgent = newAgent;
|
||||
activeAgentId = agentId;
|
||||
|
||||
// Set approval handler if manual mode OR elicitation enabled (before start() for validation)
|
||||
const needsHandler =
|
||||
newAgent.config.toolConfirmation?.mode === 'manual' ||
|
||||
newAgent.config.elicitation.enabled;
|
||||
|
||||
if (needsHandler) {
|
||||
logger.debug('Setting up manual approval handler for new agent...');
|
||||
const handler = createManualApprovalHandler(approvalCoordinator);
|
||||
newAgent.setApprovalHandler(handler);
|
||||
}
|
||||
|
||||
// Wire SSE subscribers BEFORE starting
|
||||
logger.info('Wiring services to new agent...');
|
||||
await wireServicesToAgent(newAgent);
|
||||
|
||||
logger.info(`Starting new agent: ${agentId}`);
|
||||
await newAgent.start();
|
||||
|
||||
// Update agent card for A2A and MCP routes
|
||||
agentCardData = createAgentCard(
|
||||
{
|
||||
defaultName: agentId,
|
||||
defaultVersion: overrides.version ?? DEFAULT_AGENT_VERSION,
|
||||
defaultBaseUrl: baseApiUrl,
|
||||
},
|
||||
overrides
|
||||
);
|
||||
|
||||
logger.info(`Successfully switched to agent: ${agentId}`);
|
||||
|
||||
// Now safely stop the previous agent
|
||||
try {
|
||||
if (previousAgent && previousAgent !== newAgent) {
|
||||
logger.info('Stopping previous agent...');
|
||||
await previousAgent.stop();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Stopping previous agent failed: ${err}`);
|
||||
// Don't throw here as the switch was successful
|
||||
}
|
||||
|
||||
return await resolveAgentInfo(agentId);
|
||||
}
|
||||
|
||||
async function switchAgentById(agentId: string, bridge: ReturnType<typeof createNodeServer>) {
|
||||
if (isSwitchingAgent) {
|
||||
throw AgentError.switchInProgress();
|
||||
}
|
||||
isSwitchingAgent = true;
|
||||
|
||||
let newAgent: DextoAgent | undefined;
|
||||
try {
|
||||
// 1. SHUTDOWN OLD TELEMETRY FIRST (before creating new agent)
|
||||
logger.info('Shutting down telemetry for agent switch...');
|
||||
const { Telemetry } = await import('@dexto/core');
|
||||
await Telemetry.shutdownGlobal();
|
||||
|
||||
// 2. Create new agent from registry (will initialize fresh telemetry in createAgentServices)
|
||||
newAgent = await createAgentFromId(agentId);
|
||||
|
||||
// 3. Use common switch logic (register subscribers, start agent, stop previous)
|
||||
return await performAgentSwitch(newAgent, agentId, bridge);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to switch to agent '${agentId}': ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ error }
|
||||
);
|
||||
|
||||
// Clean up the failed new agent if it was created
|
||||
if (newAgent) {
|
||||
try {
|
||||
await newAgent.stop();
|
||||
} catch (cleanupErr) {
|
||||
logger.warn(`Failed to cleanup new agent: ${cleanupErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
isSwitchingAgent = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function switchAgentByPath(
|
||||
filePath: string,
|
||||
bridge: ReturnType<typeof createNodeServer>
|
||||
) {
|
||||
if (isSwitchingAgent) {
|
||||
throw AgentError.switchInProgress();
|
||||
}
|
||||
isSwitchingAgent = true;
|
||||
|
||||
let newAgent: DextoAgent | undefined;
|
||||
try {
|
||||
// 1. SHUTDOWN OLD TELEMETRY FIRST (before creating new agent)
|
||||
logger.info('Shutting down telemetry for agent switch...');
|
||||
const { Telemetry } = await import('@dexto/core');
|
||||
await Telemetry.shutdownGlobal();
|
||||
|
||||
// 2. Load agent configuration from file path
|
||||
let config = await loadAgentConfig(filePath);
|
||||
|
||||
// 2.5. Apply user's LLM preferences to ALL agents
|
||||
// Three-Layer Resolution: local.llm ?? preferences.llm ?? bundled.llm
|
||||
if (globalPreferencesExist()) {
|
||||
try {
|
||||
const preferences = await loadGlobalPreferences();
|
||||
if (preferences?.llm?.provider && preferences?.llm?.model) {
|
||||
config = applyUserPreferences(config, preferences);
|
||||
logger.debug(
|
||||
`Applied user preferences to agent from ${filePath} (provider=${preferences.llm.provider}, model=${preferences.llm.model})`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
logger.debug('Could not load preferences, using bundled config');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Load image first to get bundled plugins
|
||||
const imageMetadata = await loadImageForConfig(config);
|
||||
|
||||
// 3.5. Enrich config with per-agent paths and bundled plugins from image
|
||||
const enrichedConfig = enrichAgentConfig(config, filePath, {
|
||||
logLevel: 'info', // Server uses info-level logging for visibility
|
||||
bundledPlugins: imageMetadata?.bundledPlugins || [],
|
||||
});
|
||||
|
||||
// 4. Create new agent instance directly (will initialize fresh telemetry in createAgentServices)
|
||||
newAgent = new DextoAgent(enrichedConfig, filePath);
|
||||
|
||||
// 5. Use enriched agentId (derived from config or filename during enrichment)
|
||||
// enrichAgentConfig always sets agentId, so it's safe to assert non-null
|
||||
const agentId = enrichedConfig.agentId!;
|
||||
|
||||
// 6. Use common switch logic (register subscribers, start agent, stop previous)
|
||||
return await performAgentSwitch(newAgent, agentId, bridge);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to switch to agent from path '${filePath}': ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ error }
|
||||
);
|
||||
|
||||
// Clean up the failed new agent if it was created
|
||||
if (newAgent) {
|
||||
try {
|
||||
await newAgent.stop();
|
||||
} catch (cleanupErr) {
|
||||
logger.warn(`Failed to cleanup new agent: ${cleanupErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
isSwitchingAgent = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Getter functions for routes (always use current agent)
|
||||
// getAgent automatically ensures agent is available before returning it
|
||||
// Accepts Context parameter for compatibility with GetAgentFn type
|
||||
const getAgent = (_ctx: Context): DextoAgent => {
|
||||
// CRITICAL: Check agent availability before every access to prevent race conditions
|
||||
// during agent switching, stopping, or startup failures
|
||||
ensureAgentAvailable();
|
||||
return activeAgent;
|
||||
};
|
||||
const getAgentCard = () => agentCardData;
|
||||
|
||||
// Declare bridge variable that will be set later
|
||||
let bridgeRef: ReturnType<typeof createNodeServer> | null = null;
|
||||
|
||||
// Create app with agentsContext using closure
|
||||
const app = createDextoApp({
|
||||
apiPrefix: '/api',
|
||||
getAgent,
|
||||
getAgentCard,
|
||||
approvalCoordinator,
|
||||
webhookSubscriber,
|
||||
sseSubscriber,
|
||||
...(webRoot ? { webRoot } : {}),
|
||||
...(webUIConfig ? { webUIConfig } : {}),
|
||||
agentsContext: {
|
||||
switchAgentById: (id: string) => {
|
||||
if (!bridgeRef) throw new Error('Bridge not initialized');
|
||||
return switchAgentById(id, bridgeRef);
|
||||
},
|
||||
switchAgentByPath: (filePath: string) => {
|
||||
if (!bridgeRef) throw new Error('Bridge not initialized');
|
||||
return switchAgentByPath(filePath, bridgeRef);
|
||||
},
|
||||
resolveAgentInfo,
|
||||
ensureAgentAvailable,
|
||||
getActiveAgentId: () => activeAgentId,
|
||||
},
|
||||
});
|
||||
|
||||
let mcpTransport: Transport | undefined;
|
||||
const transportType = (process.env.DEXTO_MCP_TRANSPORT_TYPE as McpTransportType) || 'http';
|
||||
try {
|
||||
mcpTransport = await createServerMcpTransport(transportType);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to create MCP transport: ${errorMessage}`);
|
||||
mcpTransport = undefined;
|
||||
}
|
||||
|
||||
// Create bridge with app
|
||||
bridgeRef = createNodeServer(app, {
|
||||
getAgent: () => activeAgent,
|
||||
mcpHandlers: mcpTransport ? createMcpHttpHandlers(mcpTransport) : null,
|
||||
});
|
||||
|
||||
// Register webhook subscriber for LLM streaming events
|
||||
logger.info('Registering webhook subscriber with agent...');
|
||||
if (bridgeRef.webhookSubscriber) {
|
||||
activeAgent.registerSubscriber(bridgeRef.webhookSubscriber);
|
||||
}
|
||||
|
||||
// Update agent card
|
||||
agentCardData = createAgentCard(
|
||||
{
|
||||
defaultName: overrides.name ?? activeAgentId,
|
||||
defaultVersion: overrides.version ?? DEFAULT_AGENT_VERSION,
|
||||
defaultBaseUrl: baseApiUrl,
|
||||
},
|
||||
overrides
|
||||
);
|
||||
|
||||
// Set approval handler for initial agent if manual mode OR elicitation enabled (before start() for validation)
|
||||
const needsHandler =
|
||||
activeAgent.config.toolConfirmation?.mode === 'manual' ||
|
||||
activeAgent.config.elicitation.enabled;
|
||||
|
||||
if (needsHandler) {
|
||||
logger.debug('Setting up manual approval handler for initial agent...');
|
||||
const handler = createManualApprovalHandler(approvalCoordinator);
|
||||
activeAgent.setApprovalHandler(handler);
|
||||
}
|
||||
|
||||
// Wire SSE subscribers to initial agent
|
||||
logger.info('Wiring SSE subscribers to initial agent...');
|
||||
await wireServicesToAgent(activeAgent);
|
||||
|
||||
// Start the initial agent now that approval handler is set and subscribers are wired
|
||||
logger.info('Starting initial agent...');
|
||||
await activeAgent.start();
|
||||
|
||||
// Initialize MCP server after agent has started
|
||||
if (mcpTransport) {
|
||||
try {
|
||||
await initializeServerMcpServer(activeAgent, getAgentCard(), mcpTransport);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to initialize MCP server: ${errorMessage}`);
|
||||
mcpTransport = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
app,
|
||||
server: bridgeRef.server,
|
||||
...(bridgeRef.webhookSubscriber ? { webhookSubscriber: bridgeRef.webhookSubscriber } : {}),
|
||||
agentCard: agentCardData,
|
||||
...(mcpTransport ? { mcpTransport } : {}),
|
||||
// Expose switching functions for agent routes
|
||||
switchAgentById: (id: string) => switchAgentById(id, bridgeRef!),
|
||||
switchAgentByPath: (filePath: string) => switchAgentByPath(filePath, bridgeRef!),
|
||||
resolveAgentInfo,
|
||||
ensureAgentAvailable,
|
||||
getActiveAgentId: () => activeAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startHonoApiServer(
|
||||
agent: DextoAgent,
|
||||
port = 3000,
|
||||
agentCardOverride?: Partial<AgentCard>,
|
||||
agentId?: string,
|
||||
webRoot?: string,
|
||||
webUIConfig?: WebUIRuntimeConfig
|
||||
): Promise<{
|
||||
server: ReturnType<typeof createNodeServer>['server'];
|
||||
webhookSubscriber?: NonNullable<ReturnType<typeof createNodeServer>['webhookSubscriber']>;
|
||||
}> {
|
||||
const { server, webhookSubscriber } = await initializeHonoApi(
|
||||
agent,
|
||||
agentCardOverride,
|
||||
port,
|
||||
agentId,
|
||||
webRoot,
|
||||
webUIConfig
|
||||
);
|
||||
|
||||
server.listen(port, '0.0.0.0', () => {
|
||||
const networkInterfaces = os.networkInterfaces();
|
||||
let localIp = 'localhost';
|
||||
Object.values(networkInterfaces).forEach((ifaceList) => {
|
||||
ifaceList?.forEach((iface) => {
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
localIp = iface.address;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Hono server started successfully. Accessible at: http://localhost:${port} and http://${localIp}:${port} on your local network.`,
|
||||
null,
|
||||
'green'
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
...(webhookSubscriber ? { webhookSubscriber } : {}),
|
||||
};
|
||||
}
|
||||
245
dexto/packages/cli/src/api/webhooks.md
Normal file
245
dexto/packages/cli/src/api/webhooks.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Webhook API Documentation
|
||||
|
||||
The Dexto webhook system provides HTTP-based event delivery for agent events, offering an alternative to SSE streams for cloud integrations.
|
||||
|
||||
## Overview
|
||||
|
||||
Webhooks allow you to receive real-time notifications about agent events by registering HTTP endpoints that will receive POST requests when events occur. This is similar to how Stripe webhooks work - when something happens in your Dexto agent, we'll send a POST request to your configured webhook URL.
|
||||
|
||||
## Event Structure
|
||||
|
||||
All webhook events follow a consistent structure inspired by Stripe's webhook events:
|
||||
|
||||
```typescript
|
||||
interface DextoWebhookEvent<T extends AgentEventName = AgentEventName> {
|
||||
id: string; // Unique event ID (e.g., "evt_1234567890_abc123def")
|
||||
type: T; // Event type with TypeScript autocomplete
|
||||
data: AgentEventMap[T]; // Event-specific payload
|
||||
created: string; // ISO-8601 timestamp of when the event occurred
|
||||
apiVersion: string; // API version (currently "2025-07-03")
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript Autocomplete Support
|
||||
|
||||
The webhook system provides full TypeScript autocomplete support for event types, similar to Stripe's implementation:
|
||||
|
||||
```typescript
|
||||
// Your IDE will autocomplete available event types
|
||||
if (event.type === "llm:response") {
|
||||
// TypeScript knows event.data has response-specific fields
|
||||
console.log(event.data.content);
|
||||
console.log(event.data.tokenUsage?.totalTokens);
|
||||
}
|
||||
```
|
||||
|
||||
## Available Event Types
|
||||
|
||||
The webhook system supports all integration events (Tier 2 events):
|
||||
|
||||
**LLM Events:**
|
||||
- `llm:thinking` - AI model is processing
|
||||
- `llm:chunk` - Streaming response chunk received
|
||||
- `llm:tool-call` - Tool execution requested
|
||||
- `llm:tool-result` - Tool execution completed
|
||||
- `llm:response` - Final AI response received
|
||||
- `llm:error` - Error during AI processing
|
||||
- `llm:unsupported-input` - Input type not supported by selected model
|
||||
- `llm:switched` - LLM provider or model switched
|
||||
|
||||
**Session Events:**
|
||||
- `session:title-updated` - Session title was automatically updated
|
||||
- `session:created` - New conversation session created
|
||||
- `session:reset` - Conversation history cleared
|
||||
|
||||
**MCP Events:**
|
||||
- `mcp:server-connected` - MCP server connection established
|
||||
- `mcp:server-restarted` - MCP server was restarted
|
||||
- `mcp:tools-list-changed` - MCP server tools changed
|
||||
- `mcp:prompts-list-changed` - MCP server prompts changed
|
||||
|
||||
**Tool Events:**
|
||||
- `tools:available-updated` - Available tools changed (MCP + built-in)
|
||||
|
||||
**State Events:**
|
||||
- `state:changed` - Agent state updated
|
||||
|
||||
## Webhook Management API
|
||||
|
||||
### Register a Webhook
|
||||
|
||||
```bash
|
||||
POST /api/webhooks
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"url": "https://your-app.com/webhooks/dexto",
|
||||
"secret": "whsec_your_secret_key", // Optional for signature verification
|
||||
"description": "Production webhook" // Optional description
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"webhook": {
|
||||
"id": "wh_1703123456_abc123def",
|
||||
"url": "https://your-app.com/webhooks/dexto",
|
||||
"description": "Production webhook",
|
||||
"createdAt": "2025-01-01T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### List Webhooks
|
||||
|
||||
```bash
|
||||
GET /api/webhooks
|
||||
```
|
||||
|
||||
### Get Specific Webhook
|
||||
|
||||
```bash
|
||||
GET /api/webhooks/{webhook_id}
|
||||
```
|
||||
|
||||
### Remove Webhook
|
||||
|
||||
```bash
|
||||
DELETE /api/webhooks/{webhook_id}
|
||||
```
|
||||
|
||||
### Test Webhook
|
||||
|
||||
```bash
|
||||
POST /api/webhooks/{webhook_id}/test
|
||||
```
|
||||
|
||||
This sends a test `tools:available-updated` event to verify your endpoint is working.
|
||||
|
||||
## Security & Signature Verification
|
||||
|
||||
When you provide a `secret` during webhook registration, Dexto will include an HMAC signature in the `X-Dexto-Signature-256` header for verification:
|
||||
|
||||
```
|
||||
X-Dexto-Signature-256: sha256=a1b2c3d4e5f6...
|
||||
```
|
||||
|
||||
### Verifying Signatures (Node.js Example)
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
|
||||
function verifyWebhookSignature(payload, signature, secret) {
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload, 'utf8')
|
||||
.digest('hex');
|
||||
|
||||
const expected = `sha256=${expectedSignature}`;
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature, 'utf8'),
|
||||
Buffer.from(expected, 'utf8')
|
||||
);
|
||||
}
|
||||
|
||||
// In your webhook handler
|
||||
app.post('/webhooks/dexto', async (c) => {
|
||||
const signature = c.req.header('x-dexto-signature-256');
|
||||
const payload = await c.req.text();
|
||||
|
||||
if (!verifyWebhookSignature(payload, signature, 'your_secret')) {
|
||||
return c.text('Unauthorized', 401);
|
||||
}
|
||||
|
||||
// Parse only *after* signature verification
|
||||
const event = JSON.parse(payload);
|
||||
console.log(`Received ${event.type} event:`, event.data);
|
||||
|
||||
return c.text('OK', 200);
|
||||
});
|
||||
```
|
||||
|
||||
## HTTP Headers
|
||||
|
||||
Each webhook request includes these headers:
|
||||
|
||||
- `Content-Type: application/json`
|
||||
- `User-Agent: DextoAgent/1.0`
|
||||
- `X-Dexto-Event-Type: {event_type}`
|
||||
- `X-Dexto-Event-Id: {event_id}`
|
||||
- `X-Dexto-Delivery-Attempt: {attempt_number}`
|
||||
- `X-Dexto-Signature-256: sha256={signature}` (if secret provided)
|
||||
|
||||
## Delivery & Retry Logic
|
||||
|
||||
- **Delivery**: Webhooks are delivered asynchronously and don't block agent operations
|
||||
- **Timeout**: 10 second timeout per request
|
||||
- **Retries**: Up to 3 attempts with exponential backoff (1s, 2s, 4s)
|
||||
- **Success**: HTTP 2xx status codes are considered successful
|
||||
- **Failure**: Non-2xx responses or network errors trigger retries
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Respond Quickly**: Return a 2xx status code as fast as possible. Process events asynchronously if needed.
|
||||
|
||||
2. **Handle Duplicates**: Due to retries, you might receive the same event multiple times. Use the `event.id` for deduplication.
|
||||
|
||||
3. **Verify Signatures**: Always verify webhook signatures in production to ensure requests are from Dexto.
|
||||
|
||||
4. **Use HTTPS**: Always use HTTPS URLs for webhook endpoints to ensure secure delivery.
|
||||
|
||||
5. **Handle All Event Types**: Your webhook should handle unknown event types gracefully as new events may be added.
|
||||
|
||||
## Example Webhook Handler
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono';
|
||||
import type { DextoWebhookEvent } from '@dexto/server';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.post('/webhooks/dexto', async (c) => {
|
||||
// Parse JSON from request body
|
||||
const event: DextoWebhookEvent = await c.req.json();
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'llm:response':
|
||||
console.log('AI Response:', event.data.content);
|
||||
break;
|
||||
|
||||
case 'llm:tool-call':
|
||||
console.log('Tool Called:', event.data.toolName);
|
||||
break;
|
||||
|
||||
case 'session:reset':
|
||||
console.log('Conversation reset for session:', event.data.sessionId);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown event type:', event.type);
|
||||
}
|
||||
|
||||
return c.text('OK', 200);
|
||||
} catch (error) {
|
||||
console.error('Webhook error:', error);
|
||||
return c.text('Internal Server Error', 500);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Differences from SSE
|
||||
|
||||
| Feature | SSE | Webhooks |
|
||||
|---------|-----|----------|
|
||||
| Connection | Persistent connection required | Stateless HTTP requests |
|
||||
| Delivery | Real-time | Near real-time with retries |
|
||||
| Scalability | Limited by connection count | Scales with HTTP infrastructure |
|
||||
| Reliability | Connection can drop | Built-in retry mechanism |
|
||||
| Development | Requires EventSource client | Standard HTTP endpoint |
|
||||
| Cloud-friendly | Requires persistent connections | Works with serverless functions |
|
||||
|
||||
## Server Mode Requirement
|
||||
|
||||
Webhooks are only available when Dexto is running in server mode (`dexto --mode server` command). They are not available in CLI or other modes since webhooks require the HTTP API server to be running.
|
||||
188
dexto/packages/cli/src/cli/approval/cli-approval-handler.ts
Normal file
188
dexto/packages/cli/src/cli/approval/cli-approval-handler.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* CLI-specific Approval Handler
|
||||
*
|
||||
* Creates a manual approval handler that works directly with AgentEventBus
|
||||
* for the CLI/TUI mode. Unlike the server's ManualApprovalHandler which uses
|
||||
* ApprovalCoordinator for HTTP-based flows, this handler emits events directly
|
||||
* to the event bus that the TUI listens to.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Handler emits 'approval:request' → EventBus → TUI shows prompt
|
||||
* 2. User responds in TUI → EventBus emits 'approval:response' → Handler resolves
|
||||
* 3. For auto-approvals (parallel tools), handler emits 'approval:response' → TUI dismisses
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApprovalHandler,
|
||||
ApprovalRequest,
|
||||
ApprovalResponse,
|
||||
AgentEventBus,
|
||||
} from '@dexto/core';
|
||||
import { ApprovalStatus, DenialReason } from '@dexto/core';
|
||||
|
||||
/**
|
||||
* Creates a manual approval handler for CLI mode that uses AgentEventBus directly.
|
||||
*
|
||||
* @param eventBus The agent event bus for request/response communication
|
||||
* @returns ApprovalHandler with cancellation and auto-approve support
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const handler = createCLIApprovalHandler(agent.agentEventBus);
|
||||
* agent.setApprovalHandler(handler);
|
||||
* ```
|
||||
*/
|
||||
export function createCLIApprovalHandler(eventBus: AgentEventBus): ApprovalHandler {
|
||||
// Track pending approvals for cancellation support
|
||||
const pendingApprovals = new Map<
|
||||
string,
|
||||
{
|
||||
cleanup: () => void;
|
||||
resolve: (response: ApprovalResponse) => void;
|
||||
request: ApprovalRequest;
|
||||
}
|
||||
>();
|
||||
|
||||
const handleApproval = (request: ApprovalRequest): Promise<ApprovalResponse> => {
|
||||
return new Promise<ApprovalResponse>((resolve) => {
|
||||
// Use per-request timeout (optional - undefined means no timeout)
|
||||
const effectiveTimeout = request.timeout;
|
||||
|
||||
// Set timeout timer ONLY if timeout is specified
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
if (effectiveTimeout !== undefined) {
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
pendingApprovals.delete(request.approvalId);
|
||||
|
||||
// Create timeout response
|
||||
const timeoutResponse: ApprovalResponse = {
|
||||
approvalId: request.approvalId,
|
||||
status: ApprovalStatus.CANCELLED,
|
||||
sessionId: request.sessionId,
|
||||
reason: DenialReason.TIMEOUT,
|
||||
message: `Approval request timed out after ${effectiveTimeout}ms`,
|
||||
timeoutMs: effectiveTimeout,
|
||||
};
|
||||
|
||||
// Emit timeout response so TUI can dismiss the prompt
|
||||
eventBus.emit('approval:response', timeoutResponse);
|
||||
|
||||
resolve(timeoutResponse);
|
||||
}, effectiveTimeout);
|
||||
}
|
||||
|
||||
// Cleanup function to remove listener and clear timeout
|
||||
const controller = new AbortController();
|
||||
const cleanup = () => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
controller.abort();
|
||||
};
|
||||
|
||||
// Listen for approval:response events
|
||||
const listener = (res: ApprovalResponse) => {
|
||||
// Only handle responses for this specific approval
|
||||
if (res.approvalId === request.approvalId) {
|
||||
cleanup();
|
||||
pendingApprovals.delete(request.approvalId);
|
||||
resolve(res);
|
||||
}
|
||||
};
|
||||
|
||||
// Register listener with abort signal for cleanup
|
||||
eventBus.on('approval:response', listener, { signal: controller.signal });
|
||||
|
||||
// Store for cancellation support
|
||||
pendingApprovals.set(request.approvalId, {
|
||||
cleanup,
|
||||
resolve,
|
||||
request,
|
||||
});
|
||||
|
||||
// Emit the approval:request event for TUI to receive
|
||||
eventBus.emit('approval:request', request);
|
||||
});
|
||||
};
|
||||
|
||||
const handler: ApprovalHandler = Object.assign(handleApproval, {
|
||||
cancel: (approvalId: string): void => {
|
||||
const pending = pendingApprovals.get(approvalId);
|
||||
if (pending) {
|
||||
pending.cleanup();
|
||||
pendingApprovals.delete(approvalId);
|
||||
|
||||
// Create cancellation response
|
||||
const cancelResponse: ApprovalResponse = {
|
||||
approvalId,
|
||||
status: ApprovalStatus.CANCELLED,
|
||||
sessionId: pending.request.sessionId,
|
||||
reason: DenialReason.SYSTEM_CANCELLED,
|
||||
message: 'Approval request was cancelled',
|
||||
};
|
||||
|
||||
// Emit cancellation event so TUI can dismiss the prompt
|
||||
eventBus.emit('approval:response', cancelResponse);
|
||||
|
||||
// Resolve with CANCELLED response
|
||||
pending.resolve(cancelResponse);
|
||||
}
|
||||
},
|
||||
|
||||
cancelAll: (): void => {
|
||||
for (const [approvalId] of pendingApprovals) {
|
||||
handler.cancel?.(approvalId);
|
||||
}
|
||||
},
|
||||
|
||||
getPending: (): string[] => {
|
||||
return Array.from(pendingApprovals.keys());
|
||||
},
|
||||
|
||||
getPendingRequests: (): ApprovalRequest[] => {
|
||||
return Array.from(pendingApprovals.values()).map((p) => p.request);
|
||||
},
|
||||
|
||||
/**
|
||||
* Auto-approve pending requests that match a predicate.
|
||||
* Used when a pattern is remembered to auto-approve other parallel requests
|
||||
* that would now match the same pattern.
|
||||
*/
|
||||
autoApprovePending: (
|
||||
predicate: (request: ApprovalRequest) => boolean,
|
||||
responseData?: Record<string, unknown>
|
||||
): number => {
|
||||
let count = 0;
|
||||
|
||||
// Find all pending approvals that match the predicate
|
||||
for (const [approvalId, pending] of pendingApprovals) {
|
||||
if (predicate(pending.request)) {
|
||||
// Clean up the pending state
|
||||
pending.cleanup();
|
||||
pendingApprovals.delete(approvalId);
|
||||
|
||||
// Create auto-approval response
|
||||
const autoApproveResponse: ApprovalResponse = {
|
||||
approvalId,
|
||||
status: ApprovalStatus.APPROVED,
|
||||
sessionId: pending.request.sessionId,
|
||||
message: 'Auto-approved due to matching remembered pattern',
|
||||
data: responseData,
|
||||
};
|
||||
|
||||
// Emit response so TUI can dismiss the prompt
|
||||
eventBus.emit('approval:response', autoApproveResponse);
|
||||
|
||||
// Resolve the pending promise
|
||||
pending.resolve(autoApproveResponse);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
},
|
||||
});
|
||||
|
||||
return handler;
|
||||
}
|
||||
7
dexto/packages/cli/src/cli/approval/index.ts
Normal file
7
dexto/packages/cli/src/cli/approval/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* CLI Approval Module
|
||||
*
|
||||
* Provides CLI-specific approval handling that works directly with AgentEventBus.
|
||||
*/
|
||||
|
||||
export { createCLIApprovalHandler } from './cli-approval-handler.js';
|
||||
31
dexto/packages/cli/src/cli/assets/dexto-logo.svg
Normal file
31
dexto/packages/cli/src/cli/assets/dexto-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
183
dexto/packages/cli/src/cli/auth/api-client.ts
Normal file
183
dexto/packages/cli/src/cli/auth/api-client.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
// packages/cli/src/cli/auth/api-client.ts
|
||||
// Dexto API client for key management and usage
|
||||
|
||||
// TODO: Migrate to typed client for type safety and better DX
|
||||
// Options:
|
||||
// 1. Migrate dexto-web APIs to Hono and use @hono/client (like packages/server)
|
||||
// 2. Use openapi-fetch with generated types from OpenAPI spec
|
||||
// 3. Use tRPC if we want full-stack type safety
|
||||
// Currently using plain fetch() with manual type definitions.
|
||||
|
||||
import { logger } from '@dexto/core';
|
||||
import { DEXTO_API_URL } from './constants.js';
|
||||
|
||||
interface ProvisionResponse {
|
||||
success: boolean;
|
||||
dextoApiKey?: string;
|
||||
keyId?: string;
|
||||
isNewKey?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UsageSummaryResponse {
|
||||
credits_usd: number;
|
||||
mtd_usage: {
|
||||
total_cost_usd: number;
|
||||
total_requests: number;
|
||||
by_model: Record<
|
||||
string,
|
||||
{
|
||||
requests: number;
|
||||
cost_usd: number;
|
||||
tokens: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
recent: Array<{
|
||||
timestamp: string;
|
||||
model: string;
|
||||
cost_usd: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dexto API client for key management
|
||||
*/
|
||||
export class DextoApiClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly timeoutMs = 10_000; // 10 second timeout for API calls
|
||||
|
||||
constructor(baseUrl: string = DEXTO_API_URL) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a Dexto API key is valid
|
||||
*/
|
||||
async validateDextoApiKey(apiKey: string): Promise<boolean> {
|
||||
try {
|
||||
logger.debug('Validating DEXTO_API_KEY');
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/keys/validate`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result: { valid: boolean } = await response.json();
|
||||
return result.valid;
|
||||
} catch (error) {
|
||||
logger.error(`Error validating Dexto API key: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision Dexto API key (get existing or create new with given name)
|
||||
* @param regenerate - If true, delete existing key and create new one
|
||||
*/
|
||||
async provisionDextoApiKey(
|
||||
authToken: string,
|
||||
name: string = 'Dexto CLI Key',
|
||||
regenerate: boolean = false
|
||||
): Promise<{ dextoApiKey: string; keyId: string; isNewKey: boolean }> {
|
||||
try {
|
||||
logger.debug(
|
||||
`Provisioning DEXTO_API_KEY with name: ${name}, regenerate: ${regenerate}`
|
||||
);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/keys/provision`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name, regenerate }),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`API request failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result: ProvisionResponse = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to provision Dexto API key');
|
||||
}
|
||||
|
||||
if (!result.keyId) {
|
||||
throw new Error('Invalid response from API');
|
||||
}
|
||||
|
||||
// If isNewKey is false, the key already exists (we don't get the key value back)
|
||||
// This is expected - the key was already provisioned
|
||||
if (!result.isNewKey && !result.dextoApiKey) {
|
||||
logger.debug(`DEXTO_API_KEY already exists: ${result.keyId}`);
|
||||
return {
|
||||
dextoApiKey: '', // Empty - key already exists, not returned for security
|
||||
keyId: result.keyId,
|
||||
isNewKey: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.dextoApiKey) {
|
||||
throw new Error('Invalid response from API - missing key');
|
||||
}
|
||||
|
||||
logger.debug(`Successfully provisioned DEXTO_API_KEY: ${result.keyId}`);
|
||||
return {
|
||||
dextoApiKey: result.dextoApiKey,
|
||||
keyId: result.keyId,
|
||||
isNewKey: result.isNewKey ?? true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error provisioning Dexto API key: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage summary (balance + MTD usage + recent history)
|
||||
*/
|
||||
async getUsageSummary(apiKey: string): Promise<UsageSummaryResponse> {
|
||||
try {
|
||||
logger.debug('Fetching usage summary');
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/me/usage`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`API request failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result: UsageSummaryResponse = await response.json();
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching usage summary: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default Dexto API client
|
||||
*/
|
||||
export function getDextoApiClient(): DextoApiClient {
|
||||
return new DextoApiClient();
|
||||
}
|
||||
27
dexto/packages/cli/src/cli/auth/constants.ts
Normal file
27
dexto/packages/cli/src/cli/auth/constants.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// packages/cli/src/cli/auth/constants.ts
|
||||
|
||||
/**
|
||||
* Dexto's Supabase configuration for CLI authentication.
|
||||
*
|
||||
* SECURITY NOTE:
|
||||
* The Supabase anon key is safe to hardcode in distributed code because:
|
||||
* 1. It's designed for client-side use (web browsers, mobile apps, CLIs)
|
||||
* 2. It only grants anonymous access - real security is enforced by Row Level Security (RLS)
|
||||
* 3. This is standard practice (Vercel CLI, Supabase CLI, Firebase CLI all do the same)
|
||||
*
|
||||
* The service role key (which has admin access) is NEVER in this codebase.
|
||||
*
|
||||
* Environment variable overrides (for local development):
|
||||
* - SUPABASE_URL: Override Supabase URL (e.g., http://localhost:54321)
|
||||
* - SUPABASE_ANON_KEY: Override anon key (from `supabase start` output)
|
||||
* - DEXTO_API_URL: Override Dexto API URL (e.g., http://localhost:3001)
|
||||
*/
|
||||
export const SUPABASE_URL = process.env.SUPABASE_URL || 'https://gdfbxznhnnsamvsrtwjq.supabase.co';
|
||||
export const SUPABASE_ANON_KEY =
|
||||
process.env.SUPABASE_ANON_KEY ||
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImdkZmJ4em5obm5zYW12c3J0d2pxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjQwNjkzNjksImV4cCI6MjA3OTY0NTM2OX0.j2NWOJDOy8gTT84XeomalkGSPpLdPvTCBnQMrTgdlI4';
|
||||
|
||||
/**
|
||||
* Dexto API URL for key provisioning
|
||||
*/
|
||||
export const DEXTO_API_URL = process.env.DEXTO_API_URL || 'https://api.dexto.ai';
|
||||
19
dexto/packages/cli/src/cli/auth/index.ts
Normal file
19
dexto/packages/cli/src/cli/auth/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// packages/cli/src/cli/auth/index.ts
|
||||
// Public exports for auth module
|
||||
|
||||
export {
|
||||
type AuthConfig,
|
||||
storeAuth,
|
||||
loadAuth,
|
||||
removeAuth,
|
||||
isAuthenticated,
|
||||
getAuthToken,
|
||||
getDextoApiKey,
|
||||
getAuthFilePath,
|
||||
} from './service.js';
|
||||
|
||||
export { type OAuthResult, performOAuthLogin, DEFAULT_OAUTH_CONFIG } from './oauth.js';
|
||||
|
||||
export { type UsageSummaryResponse, DextoApiClient, getDextoApiClient } from './api-client.js';
|
||||
|
||||
export { SUPABASE_URL, SUPABASE_ANON_KEY, DEXTO_API_URL } from './constants.js';
|
||||
376
dexto/packages/cli/src/cli/auth/oauth.ts
Normal file
376
dexto/packages/cli/src/cli/auth/oauth.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
// packages/cli/src/cli/auth/oauth.ts
|
||||
// OAuth flow implementation with local callback server
|
||||
//
|
||||
// TODO: Add CSRF protection via Device Code Flow (RFC 8628)
|
||||
// Current localhost callback pattern lacks CSRF protection. We attempted to add
|
||||
// a `state` parameter but Supabase uses `state` internally for its own OAuth CSRF
|
||||
// with Google, causing conflicts. The proper solution is Device Code Flow:
|
||||
// 1. CLI requests device code from server
|
||||
// 2. User visits URL and enters code
|
||||
// 3. CLI polls for authorization completion
|
||||
// This is what Supabase CLI, GitHub CLI, and others use for secure CLI auth.
|
||||
|
||||
import * as http from 'http';
|
||||
import * as url from 'url';
|
||||
import * as querystring from 'querystring';
|
||||
import chalk from 'chalk';
|
||||
import * as p from '@clack/prompts';
|
||||
import { logger } from '@dexto/core';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { SUPABASE_URL, SUPABASE_ANON_KEY } from './constants.js';
|
||||
|
||||
// Track active OAuth callback servers by port for cleanup
|
||||
const oauthStateStore = new Map<number, string>();
|
||||
|
||||
const DEXTO_LOGO_DATA_URL = (() => {
|
||||
try {
|
||||
const svg = readFileSync(new URL('../assets/dexto-logo.svg', import.meta.url), 'utf-8');
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to load Dexto logo asset for OAuth screen: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
|
||||
const LOGO_FALLBACK_TEXT = DEXTO_LOGO_DATA_URL ? '' : 'D';
|
||||
const LOGO_HTML = `<div class="logo">${LOGO_FALLBACK_TEXT}</div>`;
|
||||
|
||||
// Pre-generate HTML strings with logo
|
||||
const ERROR_HTML = `${LOGO_HTML}<div class="error-icon">✕</div><h1 class="error-title">Authentication Failed</h1><p>You can close this window and try again in your terminal.</p>`;
|
||||
const SUCCESS_HTML = `${LOGO_HTML}<div class="success-icon">✓</div><h1 class="success-title">Login Successful!</h1><p>Welcome to Dexto! You can close this window and return to your terminal.</p>`;
|
||||
const NO_DATA_HTML = `${LOGO_HTML}<div class="error-icon">✕</div><h1 class="error-title">No Authentication Data</h1><p>Please try the login process again in your terminal.</p>`;
|
||||
|
||||
interface OAuthConfig {
|
||||
authUrl: string;
|
||||
clientId: string;
|
||||
provider?: string;
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
export interface OAuthResult {
|
||||
accessToken: string;
|
||||
refreshToken?: string | undefined;
|
||||
expiresIn?: number | undefined;
|
||||
user?:
|
||||
| {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
// Fixed port for OAuth callback - must match Supabase redirect URL config
|
||||
const OAUTH_CALLBACK_PORT = 48102;
|
||||
|
||||
/**
|
||||
* Check if fixed port is available, throw if not
|
||||
*/
|
||||
function ensurePortAvailable(port: number): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer();
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
reject(
|
||||
new Error(
|
||||
`Port ${port} is already in use. Please close the application using it and try again.`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start local HTTP server to receive OAuth callback
|
||||
*/
|
||||
function startCallbackServer(port: number, config: OAuthConfig): Promise<OAuthResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (!req.url) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad Request');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
|
||||
if (req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Dexto Authentication</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Geist', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
|
||||
color: #fafafa;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
padding: 48px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.logo {
|
||||
width: 240px;
|
||||
height: 70px;
|
||||
margin: 0 auto 32px;
|
||||
background-image: ${DEXTO_LOGO_DATA_URL ? `url("${DEXTO_LOGO_DATA_URL}")` : 'none'};
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #4ec9b0;
|
||||
}
|
||||
.spinner {
|
||||
font-size: 48px;
|
||||
margin-bottom: 24px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
h1 { font-size: 24px; font-weight: 600; margin-bottom: 12px; color: #fafafa; }
|
||||
p { color: #a1a1aa; font-size: 16px; line-height: 1.5; }
|
||||
.success-icon { color: #22c55e; font-size: 64px; margin-bottom: 24px; }
|
||||
.error-icon { color: #dc2626; font-size: 64px; margin-bottom: 24px; }
|
||||
.success-title { color: #22c55e; }
|
||||
.error-title { color: #dc2626; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
${LOGO_HTML}
|
||||
<div class="spinner">◐</div>
|
||||
<h1>Processing Authentication...</h1>
|
||||
<p>Please wait while we complete your Dexto login.</p>
|
||||
</div>
|
||||
<script>
|
||||
const hashParams = new URLSearchParams(window.location.hash.substring(1));
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
||||
const refreshToken = hashParams.get('refresh_token') || urlParams.get('refresh_token');
|
||||
const expiresIn = hashParams.get('expires_in') || urlParams.get('expires_in');
|
||||
const error = hashParams.get('error') || urlParams.get('error');
|
||||
|
||||
if (window.location.hash || window.location.search) {
|
||||
window.history.replaceState(null, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
fetch('/callback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ error: error })
|
||||
}).then(() => {
|
||||
document.querySelector('.container').innerHTML = ${JSON.stringify(ERROR_HTML)};
|
||||
});
|
||||
} else if (accessToken) {
|
||||
fetch('/callback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
expires_in: expiresIn ? parseInt(expiresIn) : undefined
|
||||
})
|
||||
}).then(() => {
|
||||
document.querySelector('.container').innerHTML = ${JSON.stringify(SUCCESS_HTML)};
|
||||
});
|
||||
} else {
|
||||
document.querySelector('.container').innerHTML = ${JSON.stringify(NO_DATA_HTML)};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (req.method === 'POST' && parsedUrl.pathname === '/callback') {
|
||||
let body = '';
|
||||
const MAX_BODY_SIZE = 10 * 1024; // 10KB - plenty for OAuth tokens
|
||||
req.on('data', (chunk) => {
|
||||
if (body.length + chunk.length > MAX_BODY_SIZE) {
|
||||
req.destroy();
|
||||
res.writeHead(413);
|
||||
res.end('Request too large');
|
||||
return;
|
||||
}
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
|
||||
if (data.error) {
|
||||
res.writeHead(200);
|
||||
res.end('OK');
|
||||
server.close();
|
||||
reject(new Error(`OAuth error: ${data.error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.access_token) {
|
||||
const userResponse = await fetch(`${config.authUrl}/auth/v1/user`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${data.access_token}`,
|
||||
apikey: config.clientId,
|
||||
},
|
||||
});
|
||||
|
||||
const userData = userResponse.ok ? await userResponse.json() : null;
|
||||
|
||||
const result: OAuthResult = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresIn: data.expires_in,
|
||||
user: userData
|
||||
? {
|
||||
id: userData.id,
|
||||
email: userData.email,
|
||||
name:
|
||||
userData.user_metadata?.full_name ||
|
||||
userData.email,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
res.writeHead(200);
|
||||
res.end('OK');
|
||||
server.close();
|
||||
resolve(result);
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('Missing tokens');
|
||||
server.close();
|
||||
reject(new Error('No access token received'));
|
||||
}
|
||||
} catch (_err) {
|
||||
res.writeHead(400);
|
||||
res.end('Invalid data');
|
||||
server.close();
|
||||
reject(new Error('Invalid callback data'));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Callback server error: ${error}`);
|
||||
res.writeHead(500);
|
||||
res.end('Internal Server Error');
|
||||
server.close();
|
||||
oauthStateStore.delete(port);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000;
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
server.close();
|
||||
oauthStateStore.delete(port);
|
||||
reject(new Error('Authentication timed out'));
|
||||
}, timeoutMs);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutHandle);
|
||||
oauthStateStore.delete(port);
|
||||
};
|
||||
|
||||
server.listen(port, 'localhost', () => {
|
||||
logger.debug(`OAuth callback server listening on http://localhost:${port}`);
|
||||
});
|
||||
|
||||
server.on('close', cleanup);
|
||||
server.on('error', (error) => {
|
||||
cleanup();
|
||||
reject(new Error(`Failed to start callback server: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform OAuth login flow with Supabase
|
||||
*/
|
||||
export async function performOAuthLogin(config: OAuthConfig): Promise<OAuthResult> {
|
||||
try {
|
||||
const port = await ensurePortAvailable(OAUTH_CALLBACK_PORT);
|
||||
const redirectUri = `http://localhost:${port}`;
|
||||
|
||||
oauthStateStore.set(port, 'active');
|
||||
logger.debug(`Registered OAuth callback server on port ${port}`);
|
||||
|
||||
const provider = config.provider || 'google';
|
||||
const authParams = querystring.stringify({
|
||||
redirect_to: redirectUri,
|
||||
});
|
||||
|
||||
const authUrl = `${config.authUrl}/auth/v1/authorize?provider=${provider}&${authParams}`;
|
||||
|
||||
const tokenPromise = startCallbackServer(port, config);
|
||||
|
||||
console.log(chalk.cyan('🌐 Opening browser for authentication...'));
|
||||
|
||||
try {
|
||||
const { default: open } = await import('open');
|
||||
await open(authUrl);
|
||||
console.log(chalk.green('✅ Browser opened'));
|
||||
} catch (_error) {
|
||||
console.log(chalk.yellow(`💡 Please open manually: ${authUrl}`));
|
||||
}
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Waiting for authentication...');
|
||||
|
||||
try {
|
||||
const result = await tokenPromise;
|
||||
spinner.stop('Authentication successful!');
|
||||
return result;
|
||||
} catch (error) {
|
||||
spinner.stop('Authentication failed');
|
||||
throw error;
|
||||
}
|
||||
} catch (_error) {
|
||||
throw new Error(
|
||||
`OAuth login failed: ${_error instanceof Error ? _error.message : String(_error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Supabase OAuth configuration for Dexto CLI
|
||||
*/
|
||||
export const DEFAULT_OAUTH_CONFIG: OAuthConfig = {
|
||||
authUrl: SUPABASE_URL,
|
||||
clientId: SUPABASE_ANON_KEY,
|
||||
provider: 'google',
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
};
|
||||
196
dexto/packages/cli/src/cli/auth/service.ts
Normal file
196
dexto/packages/cli/src/cli/auth/service.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// packages/cli/src/cli/auth/service.ts
|
||||
// Auth storage, token management, and refresh logic
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { existsSync, promises as fs } from 'fs';
|
||||
import { z } from 'zod';
|
||||
import { getDextoGlobalPath, logger } from '@dexto/core';
|
||||
import { SUPABASE_URL, SUPABASE_ANON_KEY } from './constants.js';
|
||||
|
||||
const AUTH_CONFIG_FILE = 'auth.json';
|
||||
|
||||
export interface AuthConfig {
|
||||
/** Supabase access token from OAuth login (optional if using --api-key) */
|
||||
token?: string | undefined;
|
||||
refreshToken?: string | undefined;
|
||||
userId?: string | undefined;
|
||||
email?: string | undefined;
|
||||
expiresAt?: number | undefined;
|
||||
createdAt: number;
|
||||
/** Dexto API key for gateway access (from --api-key or provisioned after OAuth) */
|
||||
dextoApiKey?: string | undefined;
|
||||
dextoKeyId?: string | undefined;
|
||||
}
|
||||
|
||||
const AuthConfigSchema = z
|
||||
.object({
|
||||
token: z.string().min(1).optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
expiresAt: z.number().optional(),
|
||||
createdAt: z.number(),
|
||||
dextoApiKey: z.string().optional(),
|
||||
dextoKeyId: z.string().optional(),
|
||||
})
|
||||
.refine((data) => data.token || data.dextoApiKey, {
|
||||
message: 'Either token (from OAuth) or dextoApiKey (from --api-key) is required',
|
||||
});
|
||||
|
||||
export async function storeAuth(config: AuthConfig): Promise<void> {
|
||||
const authPath = getDextoGlobalPath('', AUTH_CONFIG_FILE);
|
||||
const dextoDir = getDextoGlobalPath('', '');
|
||||
|
||||
await fs.mkdir(dextoDir, { recursive: true });
|
||||
await fs.writeFile(authPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
||||
|
||||
logger.debug(`Stored auth config at: ${authPath}`);
|
||||
}
|
||||
|
||||
export async function loadAuth(): Promise<AuthConfig | null> {
|
||||
const authPath = getDextoGlobalPath('', AUTH_CONFIG_FILE);
|
||||
|
||||
if (!existsSync(authPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(authPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
|
||||
const validated = AuthConfigSchema.parse(config);
|
||||
|
||||
if (validated.expiresAt && validated.expiresAt < Date.now()) {
|
||||
// Only remove auth if there's no refresh token available
|
||||
// If refresh token exists, return the expired auth and let getAuthToken() handle refresh
|
||||
if (!validated.refreshToken) {
|
||||
await removeAuth();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return validated;
|
||||
} catch (error) {
|
||||
logger.warn(`Invalid auth config, removing: ${error}`);
|
||||
await removeAuth();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAuth(): Promise<void> {
|
||||
const authPath = getDextoGlobalPath('', AUTH_CONFIG_FILE);
|
||||
|
||||
if (existsSync(authPath)) {
|
||||
await fs.unlink(authPath);
|
||||
logger.debug(`Removed auth config from: ${authPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const auth = await loadAuth();
|
||||
return auth !== null;
|
||||
}
|
||||
|
||||
export async function getAuthToken(): Promise<string | null> {
|
||||
const auth = await loadAuth();
|
||||
|
||||
if (!auth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
const isExpiringSoon = auth.expiresAt && auth.expiresAt < now + fiveMinutes;
|
||||
|
||||
if (!isExpiringSoon) {
|
||||
return auth.token ?? null;
|
||||
}
|
||||
|
||||
if (!auth.refreshToken) {
|
||||
logger.debug('Token expired but no refresh token available');
|
||||
await removeAuth();
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('Access token expired or expiring soon, refreshing...');
|
||||
console.log(chalk.cyan('🔄 Access token expiring soon, refreshing...'));
|
||||
|
||||
const refreshResult = await refreshAccessToken(auth.refreshToken);
|
||||
|
||||
if (!refreshResult) {
|
||||
logger.debug('Token refresh failed, removing auth');
|
||||
console.log(chalk.red('❌ Token refresh failed. Please login again.'));
|
||||
await removeAuth();
|
||||
return null;
|
||||
}
|
||||
|
||||
const newExpiresAt = Date.now() + refreshResult.expiresIn * 1000;
|
||||
await storeAuth({
|
||||
...auth,
|
||||
token: refreshResult.accessToken,
|
||||
refreshToken: refreshResult.refreshToken,
|
||||
expiresAt: newExpiresAt,
|
||||
});
|
||||
|
||||
logger.debug('Token refreshed successfully');
|
||||
console.log(chalk.green('✅ Access token refreshed successfully'));
|
||||
return refreshResult.accessToken;
|
||||
}
|
||||
|
||||
export async function getDextoApiKey(): Promise<string | null> {
|
||||
// Explicit env var takes priority (for CI, testing, account override)
|
||||
if (process.env.DEXTO_API_KEY?.trim()) {
|
||||
return process.env.DEXTO_API_KEY;
|
||||
}
|
||||
// Fall back to auth.json (from `dexto login`)
|
||||
const auth = await loadAuth();
|
||||
return auth?.dextoApiKey || null;
|
||||
}
|
||||
|
||||
async function refreshAccessToken(refreshToken: string): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
} | null> {
|
||||
try {
|
||||
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.debug(`Token refresh failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.access_token || !data.refresh_token) {
|
||||
logger.debug('Token refresh response missing required tokens');
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('Successfully refreshed access token');
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresIn: data.expires_in || 3600,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Token refresh error: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthFilePath(): string {
|
||||
return getDextoGlobalPath('', AUTH_CONFIG_FILE);
|
||||
}
|
||||
251
dexto/packages/cli/src/cli/cli-subscriber.ts
Normal file
251
dexto/packages/cli/src/cli/cli-subscriber.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* CLI Event Subscriber for headless mode
|
||||
* Handles agent events and outputs to stdout/stderr
|
||||
*
|
||||
* Simple, composable output suitable for piping and scripting
|
||||
* No TUI, no boxes - just clean text output
|
||||
*/
|
||||
|
||||
import { logger, DextoAgent } from '@dexto/core';
|
||||
import { EventSubscriber } from '@dexto/server';
|
||||
import { AgentEventBus } from '@dexto/core';
|
||||
import type { SanitizedToolResult, AgentEventMap } from '@dexto/core';
|
||||
import { capture } from '../analytics/index.js';
|
||||
|
||||
/**
|
||||
* Event subscriber for CLI headless mode
|
||||
* Implements the standard EventSubscriber pattern used throughout the codebase
|
||||
*/
|
||||
export class CLISubscriber implements EventSubscriber {
|
||||
private streamingContent: string = '';
|
||||
private completionResolve?: () => void;
|
||||
private completionReject?: (error: Error) => void;
|
||||
|
||||
subscribe(eventBus: AgentEventBus): void {
|
||||
eventBus.on('llm:thinking', this.onThinking.bind(this));
|
||||
eventBus.on('llm:chunk', (payload) => {
|
||||
if (payload.chunkType === 'text') {
|
||||
this.onChunk(payload.content);
|
||||
}
|
||||
// Ignore reasoning chunks for headless mode
|
||||
});
|
||||
eventBus.on('llm:tool-call', (payload) => this.onToolCall(payload.toolName, payload.args));
|
||||
eventBus.on('llm:tool-result', (payload) => {
|
||||
// Only call onToolResult when we have sanitized result (success case)
|
||||
if (payload.sanitized) {
|
||||
this.onToolResult(
|
||||
payload.toolName,
|
||||
payload.sanitized,
|
||||
payload.rawResult,
|
||||
payload.success
|
||||
);
|
||||
}
|
||||
// For error case (success=false), the error is handled via llm:error event
|
||||
});
|
||||
eventBus.on('llm:response', (payload) => {
|
||||
this.onResponse(payload.content);
|
||||
this.captureTokenUsage(payload);
|
||||
});
|
||||
eventBus.on('llm:error', (payload) => this.onError(payload.error));
|
||||
eventBus.on('session:reset', this.onConversationReset.bind(this));
|
||||
eventBus.on('context:compacting', this.onContextCompacting.bind(this));
|
||||
eventBus.on('context:compacted', this.onContextCompacted.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up internal state
|
||||
* Called when the CLI subscriber is being disposed of
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.streamingContent = '';
|
||||
|
||||
// Reject any pending promises to prevent resource leaks
|
||||
if (this.completionReject) {
|
||||
const reject = this.completionReject;
|
||||
delete this.completionResolve;
|
||||
delete this.completionReject;
|
||||
reject(new Error('CLI subscriber cleaned up while operation pending'));
|
||||
}
|
||||
|
||||
logger.debug('CLI event subscriber cleaned up');
|
||||
}
|
||||
|
||||
onThinking(): void {
|
||||
// Silent in headless mode - no "thinking..." messages
|
||||
}
|
||||
|
||||
onChunk(text: string): void {
|
||||
// Stream directly to stdout for real-time output
|
||||
this.streamingContent += text;
|
||||
process.stdout.write(text);
|
||||
}
|
||||
|
||||
onToolCall(toolName: string, _args: any): void {
|
||||
// Simple tool indicator to stderr (doesn't interfere with stdout)
|
||||
process.stderr.write(`[Tool: ${toolName}]\n`);
|
||||
}
|
||||
|
||||
onToolResult(
|
||||
toolName: string,
|
||||
sanitized: SanitizedToolResult,
|
||||
rawResult?: unknown,
|
||||
success?: boolean
|
||||
): void {
|
||||
// Simple completion indicator to stderr
|
||||
const status = success ? '✓' : '✗';
|
||||
process.stderr.write(`[${status}] ${toolName} complete\n`);
|
||||
}
|
||||
|
||||
onResponse(text: string): void {
|
||||
// If we didn't stream anything (no chunks), output the full response now
|
||||
if (!this.streamingContent) {
|
||||
process.stdout.write(text);
|
||||
if (!text.endsWith('\n')) {
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
} else {
|
||||
// We already streamed the content, just add newline if needed
|
||||
if (!this.streamingContent.endsWith('\n')) {
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear accumulated state
|
||||
this.streamingContent = '';
|
||||
|
||||
// Resolve completion promise if waiting
|
||||
if (this.completionResolve) {
|
||||
const resolve = this.completionResolve;
|
||||
delete this.completionResolve;
|
||||
delete this.completionReject;
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
onError(error: Error): void {
|
||||
// Clear any partial response state
|
||||
this.streamingContent = '';
|
||||
|
||||
// Show error to stderr for immediate user feedback
|
||||
console.error(`❌ Error: ${error.message}`);
|
||||
|
||||
// Show recovery guidance if available (for DextoRuntimeError)
|
||||
if ('recovery' in error && error.recovery) {
|
||||
const recoveryMessages = Array.isArray(error.recovery)
|
||||
? error.recovery
|
||||
: [error.recovery];
|
||||
console.error('');
|
||||
recoveryMessages.forEach((msg) => {
|
||||
console.error(`💡 ${msg}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Show stack for debugging if available
|
||||
if (error.stack) {
|
||||
console.error('');
|
||||
console.error(error.stack);
|
||||
}
|
||||
|
||||
// Log details to file
|
||||
logger.error(`Error: ${error.message}`, {
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
cause: error.cause,
|
||||
recovery: 'recovery' in error ? error.recovery : undefined,
|
||||
});
|
||||
|
||||
// Reject completion promise if waiting
|
||||
if (this.completionReject) {
|
||||
const reject = this.completionReject;
|
||||
delete this.completionResolve;
|
||||
delete this.completionReject;
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
onConversationReset(): void {
|
||||
// Clear any partial response state
|
||||
this.streamingContent = '';
|
||||
logger.info('🔄 Conversation history cleared.', null, 'blue');
|
||||
}
|
||||
|
||||
onContextCompacting(payload: AgentEventMap['context:compacting']): void {
|
||||
// Output to stderr (doesn't interfere with stdout response stream)
|
||||
process.stderr.write(
|
||||
`[📦 Compacting context (~${payload.estimatedTokens.toLocaleString()} tokens)...]\n`
|
||||
);
|
||||
}
|
||||
|
||||
onContextCompacted(payload: AgentEventMap['context:compacted']): void {
|
||||
const { originalTokens, compactedTokens, originalMessages, compactedMessages, reason } =
|
||||
payload;
|
||||
const reductionPercent =
|
||||
originalTokens > 0
|
||||
? Math.round(((originalTokens - compactedTokens) / originalTokens) * 100)
|
||||
: 0;
|
||||
|
||||
// Output to stderr (doesn't interfere with stdout response stream)
|
||||
process.stderr.write(
|
||||
`[📦 Context compacted (${reason}): ${originalTokens.toLocaleString()} → ~${compactedTokens.toLocaleString()} tokens (${reductionPercent}% reduction), ${originalMessages} → ${compactedMessages} messages]\n`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture LLM token usage analytics
|
||||
*/
|
||||
private captureTokenUsage(payload: AgentEventMap['llm:response']): void {
|
||||
const { tokenUsage, provider, model, sessionId, estimatedInputTokens } = payload;
|
||||
if (!tokenUsage || (!tokenUsage.inputTokens && !tokenUsage.outputTokens)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate estimate accuracy if both estimate and actual are available
|
||||
let estimateAccuracyPercent: number | undefined;
|
||||
if (estimatedInputTokens !== undefined && tokenUsage.inputTokens) {
|
||||
const diff = estimatedInputTokens - tokenUsage.inputTokens;
|
||||
estimateAccuracyPercent = Math.round((diff / tokenUsage.inputTokens) * 100);
|
||||
}
|
||||
|
||||
capture('dexto_llm_tokens_consumed', {
|
||||
source: 'cli',
|
||||
sessionId,
|
||||
provider,
|
||||
model,
|
||||
inputTokens: tokenUsage.inputTokens,
|
||||
outputTokens: tokenUsage.outputTokens,
|
||||
reasoningTokens: tokenUsage.reasoningTokens,
|
||||
totalTokens: tokenUsage.totalTokens,
|
||||
cacheReadTokens: tokenUsage.cacheReadTokens,
|
||||
cacheWriteTokens: tokenUsage.cacheWriteTokens,
|
||||
estimatedInputTokens,
|
||||
estimateAccuracyPercent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run agent in headless mode and wait for completion
|
||||
* Returns a promise that resolves when the response is complete
|
||||
*/
|
||||
async runAndWait(agent: DextoAgent, prompt: string, sessionId: string): Promise<void> {
|
||||
// Prevent concurrent calls
|
||||
if (this.completionResolve || this.completionReject) {
|
||||
throw new Error('Cannot call runAndWait while another operation is pending');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.completionResolve = resolve;
|
||||
this.completionReject = reject;
|
||||
|
||||
// Execute the prompt
|
||||
agent.run(prompt, undefined, undefined, sessionId).catch((error) => {
|
||||
// If agent.run() rejects but we haven't already rejected via event
|
||||
if (this.completionReject) {
|
||||
const rejectHandler = this.completionReject;
|
||||
delete this.completionResolve;
|
||||
delete this.completionReject;
|
||||
rejectHandler(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
5
dexto/packages/cli/src/cli/commands/auth/index.ts
Normal file
5
dexto/packages/cli/src/cli/commands/auth/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// packages/cli/src/cli/commands/auth/index.ts
|
||||
|
||||
export { handleLoginCommand, handleBrowserLogin } from './login.js';
|
||||
export { handleLogoutCommand } from './logout.js';
|
||||
export { handleStatusCommand } from './status.js';
|
||||
314
dexto/packages/cli/src/cli/commands/auth/login.ts
Normal file
314
dexto/packages/cli/src/cli/commands/auth/login.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
// packages/cli/src/cli/commands/auth/login.ts
|
||||
|
||||
import chalk from 'chalk';
|
||||
import * as p from '@clack/prompts';
|
||||
import {
|
||||
isAuthenticated,
|
||||
loadAuth,
|
||||
storeAuth,
|
||||
getDextoApiClient,
|
||||
SUPABASE_URL,
|
||||
SUPABASE_ANON_KEY,
|
||||
} from '../../auth/index.js';
|
||||
import { logger } from '@dexto/core';
|
||||
|
||||
/**
|
||||
* Handle login command - multiple methods supported
|
||||
*/
|
||||
export async function handleLoginCommand(
|
||||
options: {
|
||||
apiKey?: string;
|
||||
interactive?: boolean;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (await isAuthenticated()) {
|
||||
const auth = await loadAuth();
|
||||
const userInfo = auth?.email || auth?.userId || 'user';
|
||||
console.log(chalk.green(`✅ Already logged in as: ${userInfo}`));
|
||||
|
||||
// In non-interactive mode, already authenticated = success (idempotent)
|
||||
if (options.interactive === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldContinue = await p.confirm({
|
||||
message: 'Do you want to login with a different account?',
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (p.isCancel(shouldContinue) || !shouldContinue) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.apiKey) {
|
||||
// Validate the Dexto API key before storing
|
||||
const client = getDextoApiClient();
|
||||
const isValid = await client.validateDextoApiKey(options.apiKey);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid API key provided - validation failed');
|
||||
}
|
||||
await storeAuth({ dextoApiKey: options.apiKey, createdAt: Date.now() });
|
||||
console.log(chalk.green('✅ Dexto API key saved'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.interactive === false) {
|
||||
throw new Error('--api-key is required when --no-interactive is used');
|
||||
}
|
||||
|
||||
p.intro(chalk.inverse(' Login to Dexto '));
|
||||
console.log(chalk.dim('This will open your browser for authentication.'));
|
||||
|
||||
const shouldUseOAuth = await p.confirm({
|
||||
message: 'Continue with browser authentication?',
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (p.isCancel(shouldUseOAuth)) {
|
||||
p.cancel('Login cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldUseOAuth) {
|
||||
await handleBrowserLogin();
|
||||
} else {
|
||||
console.log(chalk.dim('\nAlternatively, you can enter a token manually:'));
|
||||
await handleTokenLogin();
|
||||
}
|
||||
|
||||
p.outro(chalk.green('🎉 Login successful!'));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
p.outro(chalk.red(`❌ Login failed: ${errorMessage}`));
|
||||
// Re-throw to let CLI wrapper handle exit and analytics tracking
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleBrowserLogin(): Promise<void> {
|
||||
const { performOAuthLogin, DEFAULT_OAUTH_CONFIG } = await import('../../auth/oauth.js');
|
||||
|
||||
try {
|
||||
const result = await performOAuthLogin(DEFAULT_OAUTH_CONFIG);
|
||||
const expiresAt = result.expiresIn ? Date.now() + result.expiresIn * 1000 : undefined;
|
||||
|
||||
await storeAuth({
|
||||
token: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
userId: result.user?.id,
|
||||
email: result.user?.email,
|
||||
createdAt: Date.now(),
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
if (result.user?.email) {
|
||||
console.log(chalk.dim(`\nWelcome back, ${result.user.email}`));
|
||||
}
|
||||
|
||||
await provisionKeys(result.accessToken, result.user?.email);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (errorMessage.includes('timed out')) {
|
||||
throw new Error('Login timed out. Please try again.');
|
||||
} else if (errorMessage.includes('user denied')) {
|
||||
throw new Error('Login was cancelled.');
|
||||
} else {
|
||||
throw new Error(`Login failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTokenLogin(): Promise<void> {
|
||||
const token = await p.password({
|
||||
message: 'Enter your API token:',
|
||||
validate: (value) => {
|
||||
if (!value) return 'Token is required';
|
||||
if (value.length < 10) return 'Token seems too short';
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(token)) {
|
||||
p.cancel('Token entry cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Verifying token...');
|
||||
|
||||
try {
|
||||
const isValid = await verifyToken(token as string);
|
||||
|
||||
if (!isValid) {
|
||||
spinner.stop('Invalid token');
|
||||
throw new Error('Token verification failed');
|
||||
}
|
||||
|
||||
spinner.stop('Token verified!');
|
||||
|
||||
await storeAuth({
|
||||
token: token as string,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
// Provision Dexto API key for gateway access
|
||||
await provisionKeys(token as string);
|
||||
} catch (error) {
|
||||
spinner.stop('Verification failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
'User-Agent': 'dexto-cli/1.0.0',
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
return !!userData.id;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Token verification failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to save Dexto API key to ~/.dexto/.env
|
||||
* This ensures the key is available for the layered env loading at startup.
|
||||
*/
|
||||
async function saveDextoApiKey(apiKey: string): Promise<void> {
|
||||
const { getDextoEnvPath, ensureDextoGlobalDirectory } = await import('@dexto/core');
|
||||
const path = await import('path');
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
const envVar = 'DEXTO_API_KEY';
|
||||
const targetEnvPath = getDextoEnvPath();
|
||||
|
||||
// Ensure directory exists
|
||||
await ensureDextoGlobalDirectory();
|
||||
await fs.mkdir(path.dirname(targetEnvPath), { recursive: true });
|
||||
|
||||
// Read existing .env or create empty
|
||||
let envContent = '';
|
||||
try {
|
||||
envContent = await fs.readFile(targetEnvPath, 'utf-8');
|
||||
} catch {
|
||||
// File doesn't exist, start fresh
|
||||
}
|
||||
|
||||
// Update or add the key
|
||||
const lines = envContent.split('\n');
|
||||
const keyPattern = new RegExp(`^${envVar}=`);
|
||||
const keyIndex = lines.findIndex((line) => keyPattern.test(line));
|
||||
|
||||
if (keyIndex >= 0) {
|
||||
lines[keyIndex] = `${envVar}=${apiKey}`;
|
||||
} else {
|
||||
lines.push(`${envVar}=${apiKey}`);
|
||||
}
|
||||
|
||||
// Write back
|
||||
await fs.writeFile(targetEnvPath, lines.filter(Boolean).join('\n') + '\n', 'utf-8');
|
||||
|
||||
// Make available in current process immediately
|
||||
process.env[envVar] = apiKey;
|
||||
}
|
||||
|
||||
async function provisionKeys(authToken: string, _userEmail?: string): Promise<void> {
|
||||
try {
|
||||
const apiClient = await getDextoApiClient();
|
||||
const auth = await loadAuth();
|
||||
|
||||
// 1. Check if we already have a local key
|
||||
if (auth?.dextoApiKey) {
|
||||
console.log(chalk.cyan('🔍 Validating existing API key...'));
|
||||
|
||||
try {
|
||||
const isValid = await apiClient.validateDextoApiKey(auth.dextoApiKey);
|
||||
|
||||
if (isValid) {
|
||||
console.log(chalk.green('✅ Existing key is valid'));
|
||||
// Ensure .env is in sync
|
||||
await saveDextoApiKey(auth.dextoApiKey);
|
||||
return; // All good, we're done
|
||||
}
|
||||
|
||||
// Key is invalid - need new one
|
||||
console.log(chalk.yellow('⚠️ Existing key is invalid, provisioning new one...'));
|
||||
const provisionResult = await apiClient.provisionDextoApiKey(authToken);
|
||||
|
||||
if (!provisionResult.dextoApiKey) {
|
||||
throw new Error('Failed to get new API key');
|
||||
}
|
||||
|
||||
await storeAuth({
|
||||
...auth,
|
||||
dextoApiKey: provisionResult.dextoApiKey,
|
||||
dextoKeyId: provisionResult.keyId,
|
||||
});
|
||||
await saveDextoApiKey(provisionResult.dextoApiKey);
|
||||
|
||||
console.log(chalk.green('✅ New key provisioned'));
|
||||
console.log(chalk.dim(` Key ID: ${provisionResult.keyId}`));
|
||||
return;
|
||||
} catch (error) {
|
||||
// Validation or rotation failed - this is a critical error
|
||||
logger.warn(`Key validation/rotation failed: ${error}`);
|
||||
throw new Error(
|
||||
`Failed to validate or rotate key: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. No local key - provision one
|
||||
console.log(chalk.cyan('🔑 Provisioning Dexto API key...'));
|
||||
let provisionResult = await apiClient.provisionDextoApiKey(authToken);
|
||||
|
||||
if (!auth) {
|
||||
throw new Error('Authentication state not found');
|
||||
}
|
||||
|
||||
// If key already exists server-side but we don't have it locally, regenerate it
|
||||
if (!provisionResult.isNewKey) {
|
||||
console.log(
|
||||
chalk.yellow('⚠️ CLI key exists on server but not locally, regenerating...')
|
||||
);
|
||||
provisionResult = await apiClient.provisionDextoApiKey(
|
||||
authToken,
|
||||
'Dexto CLI Key',
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
await storeAuth({
|
||||
...auth,
|
||||
dextoApiKey: provisionResult.dextoApiKey,
|
||||
dextoKeyId: provisionResult.keyId,
|
||||
});
|
||||
await saveDextoApiKey(provisionResult.dextoApiKey);
|
||||
|
||||
console.log(chalk.green('✅ Dexto API key provisioned!'));
|
||||
console.log(chalk.dim(` Key ID: ${provisionResult.keyId}`));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.log(chalk.red(`❌ Failed to provision Dexto API key: ${errorMessage}`));
|
||||
console.log(chalk.dim(' You can still use Dexto with your own API keys'));
|
||||
logger.warn(`Provisioning failed: ${errorMessage}`);
|
||||
// Don't throw - login should still succeed even if key provisioning fails
|
||||
}
|
||||
}
|
||||
73
dexto/packages/cli/src/cli/commands/auth/logout.ts
Normal file
73
dexto/packages/cli/src/cli/commands/auth/logout.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// packages/cli/src/cli/commands/auth/logout.ts
|
||||
|
||||
import chalk from 'chalk';
|
||||
import * as p from '@clack/prompts';
|
||||
import { isAuthenticated, removeAuth } from '../../auth/index.js';
|
||||
import { isUsingDextoCredits } from '../../../config/effective-llm.js';
|
||||
import { logger } from '@dexto/core';
|
||||
|
||||
export async function handleLogoutCommand(
|
||||
options: {
|
||||
force?: boolean;
|
||||
interactive?: boolean;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!(await isAuthenticated())) {
|
||||
console.log(chalk.yellow('ℹ️ Not currently logged in'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is configured to use Dexto credits
|
||||
// Uses getEffectiveLLMConfig() to check all config layers
|
||||
const usingDextoCredits = await isUsingDextoCredits();
|
||||
|
||||
if (options.interactive !== false && !options.force) {
|
||||
p.intro(chalk.inverse(' Logout '));
|
||||
|
||||
// Warn if using Dexto credits
|
||||
if (usingDextoCredits) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'\n⚠️ You are currently configured to use Dexto credits (provider: dexto)'
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(' After logout, you will need to run `dexto setup` to configure')
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(' a different provider, or `dexto login` to log back in.\n')
|
||||
);
|
||||
}
|
||||
|
||||
const shouldLogout = await p.confirm({
|
||||
message: usingDextoCredits
|
||||
? 'Logout will disable Dexto credits. Continue?'
|
||||
: 'Are you sure you want to logout?',
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (p.isCancel(shouldLogout) || !shouldLogout) {
|
||||
p.cancel('Logout cancelled');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await removeAuth();
|
||||
console.log(chalk.green('✅ Successfully logged out'));
|
||||
|
||||
if (usingDextoCredits) {
|
||||
console.log();
|
||||
console.log(chalk.cyan('Next steps:'));
|
||||
console.log(chalk.dim(' • Run `dexto login` to log back in'));
|
||||
console.log(chalk.dim(' • Or run `dexto setup` to configure a different provider'));
|
||||
}
|
||||
|
||||
logger.info('User logged out');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(chalk.red(`❌ Logout failed: ${errorMessage}`));
|
||||
// Re-throw to let CLI wrapper handle exit and analytics tracking
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
29
dexto/packages/cli/src/cli/commands/auth/status.ts
Normal file
29
dexto/packages/cli/src/cli/commands/auth/status.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// packages/cli/src/cli/commands/auth/status.ts
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { loadAuth } from '../../auth/index.js';
|
||||
|
||||
export async function handleStatusCommand(): Promise<void> {
|
||||
const auth = await loadAuth();
|
||||
|
||||
if (!auth) {
|
||||
console.log(chalk.yellow('❌ Not logged in'));
|
||||
console.log(chalk.dim('Run `dexto login` to authenticate'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.green('✅ Logged in'));
|
||||
|
||||
if (auth.email) {
|
||||
console.log(chalk.dim(`Email: ${auth.email}`));
|
||||
}
|
||||
|
||||
if (auth.userId) {
|
||||
console.log(chalk.dim(`User ID: ${auth.userId}`));
|
||||
}
|
||||
|
||||
if (auth.expiresAt) {
|
||||
const expiresDate = new Date(auth.expiresAt);
|
||||
console.log(chalk.dim(`Expires: ${expiresDate.toLocaleDateString()}`));
|
||||
}
|
||||
}
|
||||
3
dexto/packages/cli/src/cli/commands/billing/index.ts
Normal file
3
dexto/packages/cli/src/cli/commands/billing/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// packages/cli/src/cli/commands/billing/index.ts
|
||||
|
||||
export { handleBillingStatusCommand } from './status.js';
|
||||
75
dexto/packages/cli/src/cli/commands/billing/status.ts
Normal file
75
dexto/packages/cli/src/cli/commands/billing/status.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// packages/cli/src/cli/commands/billing/status.ts
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { loadAuth, getDextoApiClient } from '../../auth/index.js';
|
||||
|
||||
/**
|
||||
* Handle the `dexto billing` command.
|
||||
* Shows Dexto account billing information including balance and usage.
|
||||
*/
|
||||
export async function handleBillingStatusCommand(): Promise<void> {
|
||||
const auth = await loadAuth();
|
||||
|
||||
if (!auth) {
|
||||
console.log(chalk.yellow('❌ Not logged in to Dexto'));
|
||||
console.log(chalk.dim('Run `dexto login` to authenticate'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!auth.dextoApiKey) {
|
||||
console.log(chalk.yellow('❌ No Dexto API key found'));
|
||||
console.log(chalk.dim('Run `dexto login` to provision an API key'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.green('✅ Logged in to Dexto'));
|
||||
|
||||
if (auth.email) {
|
||||
console.log(chalk.dim(`Account: ${auth.email}`));
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
try {
|
||||
const apiClient = getDextoApiClient();
|
||||
const usage = await apiClient.getUsageSummary(auth.dextoApiKey);
|
||||
|
||||
// Display balance
|
||||
console.log(chalk.cyan('💰 Balance'));
|
||||
console.log(` ${chalk.bold('$' + usage.credits_usd.toFixed(2))} remaining`);
|
||||
console.log();
|
||||
|
||||
// Display month-to-date usage
|
||||
console.log(chalk.cyan('📊 This Month'));
|
||||
console.log(` Spent: ${chalk.yellow('$' + usage.mtd_usage.total_cost_usd.toFixed(4))}`);
|
||||
console.log(` Requests: ${chalk.yellow(usage.mtd_usage.total_requests.toString())}`);
|
||||
|
||||
// Show usage by model if there's any
|
||||
const modelEntries = Object.entries(usage.mtd_usage.by_model);
|
||||
if (modelEntries.length > 0) {
|
||||
console.log();
|
||||
console.log(chalk.cyan('📈 Usage by Model'));
|
||||
for (const [model, stats] of modelEntries) {
|
||||
console.log(
|
||||
` ${chalk.dim(model)}: $${stats.cost_usd.toFixed(4)} (${stats.requests} requests)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show recent usage if any
|
||||
if (usage.recent.length > 0) {
|
||||
console.log();
|
||||
console.log(chalk.cyan('🕐 Recent Activity'));
|
||||
for (const entry of usage.recent.slice(0, 5)) {
|
||||
const date = new Date(entry.timestamp).toLocaleString();
|
||||
console.log(
|
||||
` ${chalk.dim(date)} - ${entry.model}: $${entry.cost_usd.toFixed(4)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.log(chalk.red(`❌ Failed to fetch billing info: ${errorMessage}`));
|
||||
console.log(chalk.dim('Your API key may be invalid. Try `dexto login` to refresh.'));
|
||||
}
|
||||
}
|
||||
780
dexto/packages/cli/src/cli/commands/create-app.ts
Normal file
780
dexto/packages/cli/src/cli/commands/create-app.ts
Normal file
@@ -0,0 +1,780 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import * as p from '@clack/prompts';
|
||||
import { logger } from '@dexto/core';
|
||||
import { selectOrExit, textOrExit } from '../utils/prompt-helpers.js';
|
||||
import {
|
||||
promptForProjectName,
|
||||
createProjectDirectory,
|
||||
setupGitRepo,
|
||||
createGitignore,
|
||||
initPackageJson,
|
||||
createTsconfigForApp,
|
||||
installDependencies,
|
||||
createEnvExample,
|
||||
ensureDirectory,
|
||||
} from '../utils/scaffolding-utils.js';
|
||||
import {
|
||||
generateIndexForImage,
|
||||
generateWebServerIndex,
|
||||
generateWebAppHTML,
|
||||
generateWebAppJS,
|
||||
generateWebAppCSS,
|
||||
generateAppReadme,
|
||||
generateExampleTool,
|
||||
generateDiscoveryScript,
|
||||
} from '../utils/template-engine.js';
|
||||
import { getExecutionContext } from '@dexto/agent-management';
|
||||
|
||||
type AppMode = 'from-image' | 'from-core';
|
||||
type AppType = 'script' | 'webapp';
|
||||
|
||||
export interface CreateAppOptions {
|
||||
fromImage?: string;
|
||||
fromCore?: boolean;
|
||||
type?: AppType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Dexto application with two possible modes:
|
||||
* - from-image: Use existing image (recommended)
|
||||
* - from-core: Build from @dexto/core with custom providers (advanced)
|
||||
*
|
||||
* Note: To create a new image that extends another image, use `dexto create-image` instead.
|
||||
*
|
||||
* @param name - Optional name of the app project
|
||||
* @param options - Optional flags to specify mode and base image
|
||||
* @returns The absolute path to the created project directory
|
||||
*/
|
||||
export async function createDextoProject(
|
||||
name?: string,
|
||||
options?: CreateAppOptions
|
||||
): Promise<string> {
|
||||
console.log(chalk.blue('🚀 Creating a Dexto application\n'));
|
||||
|
||||
// Step 1: Get project name
|
||||
const projectName = name
|
||||
? name
|
||||
: await promptForProjectName('my-dexto-app', 'What do you want to name your app?');
|
||||
|
||||
// Step 2: Determine app type
|
||||
let appType: AppType = options?.type || 'script';
|
||||
|
||||
if (!options?.type) {
|
||||
appType = await selectOrExit<AppType>(
|
||||
{
|
||||
message: 'What type of app?',
|
||||
options: [
|
||||
{
|
||||
value: 'script',
|
||||
label: 'Script',
|
||||
hint: 'Simple script (default)',
|
||||
},
|
||||
{
|
||||
value: 'webapp',
|
||||
label: 'Web App',
|
||||
hint: 'REST API server with web frontend',
|
||||
},
|
||||
],
|
||||
},
|
||||
'App creation cancelled'
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Determine app mode (from flags or prompt)
|
||||
let mode: AppMode;
|
||||
let baseImage: string | undefined;
|
||||
|
||||
if (options?.fromCore) {
|
||||
mode = 'from-core';
|
||||
} else if (options?.fromImage) {
|
||||
mode = 'from-image';
|
||||
baseImage = options.fromImage;
|
||||
} else {
|
||||
// No flags provided, use interactive prompt
|
||||
mode = await selectOrExit<AppMode>(
|
||||
{
|
||||
message: 'How do you want to start?',
|
||||
options: [
|
||||
{
|
||||
value: 'from-image',
|
||||
label: 'Use existing image (recommended)',
|
||||
hint: 'Pre-built image with providers',
|
||||
},
|
||||
{
|
||||
value: 'from-core',
|
||||
label: 'Build from core (advanced)',
|
||||
hint: 'Custom standalone app with your own providers',
|
||||
},
|
||||
],
|
||||
},
|
||||
'App creation cancelled'
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = p.spinner();
|
||||
let projectPath: string;
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
// Create project directory
|
||||
projectPath = await createProjectDirectory(projectName, spinner);
|
||||
|
||||
// Change to project directory
|
||||
process.chdir(projectPath);
|
||||
|
||||
if (mode === 'from-core') {
|
||||
// Mode C: Build from core - custom image with bundler
|
||||
await scaffoldFromCore(projectPath, projectName, spinner);
|
||||
|
||||
spinner.stop(chalk.green(`✓ Successfully created app: ${projectName}`));
|
||||
|
||||
console.log(`\n${chalk.cyan('Next steps:')}`);
|
||||
console.log(` ${chalk.gray('$')} cd ${projectName}`);
|
||||
console.log(
|
||||
` ${chalk.gray('$')} pnpm start ${chalk.gray('(discovers providers, builds, and runs)')}`
|
||||
);
|
||||
console.log(`\n${chalk.gray('Learn more:')} https://docs.dexto.ai\n`);
|
||||
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
// For from-image mode, select the image (if not already provided via flag)
|
||||
if (!baseImage) {
|
||||
const imageChoice = await selectOrExit<string>(
|
||||
{
|
||||
message: 'Which image?',
|
||||
options: [
|
||||
{
|
||||
value: '@dexto/image-local',
|
||||
label: '@dexto/image-local (recommended)',
|
||||
hint: 'Local dev - SQLite, filesystem',
|
||||
},
|
||||
{ value: 'custom', label: 'Custom npm package...' },
|
||||
],
|
||||
},
|
||||
'App creation cancelled'
|
||||
);
|
||||
|
||||
if (imageChoice === 'custom') {
|
||||
const customImage = await textOrExit(
|
||||
{
|
||||
message: 'Enter the npm package name:',
|
||||
placeholder: '@myorg/image-custom',
|
||||
validate: (value) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return 'Package name is required';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
'App creation cancelled'
|
||||
);
|
||||
|
||||
baseImage = customImage;
|
||||
} else {
|
||||
baseImage = imageChoice;
|
||||
}
|
||||
}
|
||||
|
||||
// Scaffold from existing image
|
||||
await scaffoldFromImage(projectPath, projectName, baseImage, appType, originalCwd, spinner);
|
||||
|
||||
spinner.stop(chalk.green(`✓ Successfully created app: ${projectName}`));
|
||||
|
||||
console.log(`\n${chalk.cyan('Next steps:')}`);
|
||||
console.log(` ${chalk.gray('$')} cd ${projectName}`);
|
||||
console.log(` ${chalk.gray('$')} pnpm start`);
|
||||
console.log(`\n${chalk.gray('Learn more:')} https://docs.dexto.ai\n`);
|
||||
|
||||
return projectPath;
|
||||
} catch (error) {
|
||||
// Restore original directory on error
|
||||
if (originalCwd) {
|
||||
try {
|
||||
process.chdir(originalCwd);
|
||||
} catch {
|
||||
// Ignore if we can't restore - likely a more serious issue
|
||||
}
|
||||
}
|
||||
|
||||
if (spinner) {
|
||||
spinner.stop(chalk.red('✗ Failed to create app'));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode A: Scaffold app using existing image
|
||||
*/
|
||||
async function scaffoldFromImage(
|
||||
projectPath: string,
|
||||
projectName: string,
|
||||
imageName: string,
|
||||
appType: AppType,
|
||||
originalCwd: string,
|
||||
spinner: ReturnType<typeof p.spinner>
|
||||
): Promise<void> {
|
||||
spinner.start('Setting up app structure...');
|
||||
|
||||
// Resolve package name for local images (needed for import statements)
|
||||
let packageNameForImport = imageName;
|
||||
if (imageName.startsWith('.')) {
|
||||
const fullPath = path.resolve(originalCwd, imageName);
|
||||
let packageDir = fullPath;
|
||||
|
||||
// If path ends with /dist/index.js, resolve to package root (parent of dist)
|
||||
if (fullPath.endsWith('/dist/index.js') || fullPath.endsWith('\\dist\\index.js')) {
|
||||
packageDir = path.dirname(path.dirname(fullPath));
|
||||
} else if (fullPath.endsWith('.js')) {
|
||||
packageDir = path.dirname(fullPath);
|
||||
}
|
||||
|
||||
// Read package.json to get the actual package name for imports
|
||||
try {
|
||||
const pkgJsonPath = path.join(packageDir, 'package.json');
|
||||
const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8'));
|
||||
packageNameForImport = pkgJson.name;
|
||||
} catch (_error) {
|
||||
logger.warn(`Could not read package.json from ${packageDir}, using path as import`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create folders
|
||||
await ensureDirectory('src');
|
||||
await ensureDirectory('agents');
|
||||
|
||||
// Create src/index.ts based on app type
|
||||
let indexContent: string;
|
||||
if (appType === 'webapp') {
|
||||
indexContent = generateWebServerIndex({
|
||||
projectName,
|
||||
packageName: projectName,
|
||||
description: 'Dexto web server application',
|
||||
imageName: packageNameForImport,
|
||||
});
|
||||
|
||||
// Create web app directory and files
|
||||
await ensureDirectory('app');
|
||||
await ensureDirectory('app/assets');
|
||||
await fs.writeFile('app/index.html', generateWebAppHTML(projectName));
|
||||
await fs.writeFile('app/assets/main.js', generateWebAppJS());
|
||||
await fs.writeFile('app/assets/style.css', generateWebAppCSS());
|
||||
} else {
|
||||
indexContent = generateIndexForImage({
|
||||
projectName,
|
||||
packageName: projectName,
|
||||
description: 'Dexto application',
|
||||
imageName: packageNameForImport,
|
||||
});
|
||||
}
|
||||
await fs.writeFile('src/index.ts', indexContent);
|
||||
|
||||
// Create default agent config
|
||||
const agentConfig = `# Default Agent Configuration
|
||||
|
||||
# Image: Specifies the provider bundle for this agent
|
||||
image: '${imageName}'
|
||||
|
||||
# System prompt
|
||||
systemPrompt:
|
||||
contributors:
|
||||
- id: primary
|
||||
type: static
|
||||
priority: 0
|
||||
content: |
|
||||
You are a helpful AI assistant.
|
||||
|
||||
# LLM configuration
|
||||
llm:
|
||||
provider: openai
|
||||
model: gpt-4o
|
||||
apiKey: $OPENAI_API_KEY
|
||||
|
||||
# Storage
|
||||
storage:
|
||||
cache:
|
||||
type: in-memory
|
||||
database:
|
||||
type: sqlite
|
||||
path: ./data/agent.db
|
||||
blob:
|
||||
type: local
|
||||
storePath: ./data/blobs
|
||||
|
||||
# Custom tools - uncomment to enable filesystem and process tools
|
||||
# customTools:
|
||||
# - type: filesystem-tools
|
||||
# allowedPaths: ['.']
|
||||
# blockedPaths: ['.git', 'node_modules']
|
||||
# - type: process-tools
|
||||
# securityLevel: moderate
|
||||
|
||||
# MCP servers - add external tools here
|
||||
# mcpServers:
|
||||
# filesystem:
|
||||
# type: stdio
|
||||
# command: npx
|
||||
# args:
|
||||
# - -y
|
||||
# - "@modelcontextprotocol/server-filesystem"
|
||||
# - .
|
||||
`;
|
||||
await fs.writeFile('agents/default.yml', agentConfig);
|
||||
|
||||
spinner.message('Creating configuration files...');
|
||||
|
||||
// Create package.json
|
||||
await initPackageJson(projectPath, projectName, 'app');
|
||||
|
||||
// Add scripts
|
||||
const packageJsonPath = path.join(projectPath, 'package.json');
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
||||
|
||||
packageJson.scripts = {
|
||||
start: 'tsx src/index.ts',
|
||||
build: 'tsc',
|
||||
...packageJson.scripts,
|
||||
};
|
||||
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||
|
||||
// Create tsconfig.json
|
||||
await createTsconfigForApp(projectPath, 'src');
|
||||
|
||||
// Create README
|
||||
const readmeContent = generateAppReadme({
|
||||
projectName,
|
||||
packageName: projectName,
|
||||
description: 'Dexto application using official image',
|
||||
imageName,
|
||||
});
|
||||
await fs.writeFile('README.md', readmeContent);
|
||||
|
||||
// Create .env.example
|
||||
await createEnvExample(projectPath, {
|
||||
OPENAI_API_KEY: 'sk-...',
|
||||
ANTHROPIC_API_KEY: 'sk-ant-...',
|
||||
});
|
||||
|
||||
// Create .gitignore
|
||||
await createGitignore(projectPath);
|
||||
|
||||
// Initialize git
|
||||
spinner.message('Initializing git repository...');
|
||||
await setupGitRepo(projectPath);
|
||||
|
||||
spinner.message('Installing dependencies...');
|
||||
|
||||
// Detect if we're in dexto source - use workspace protocol for local development
|
||||
const executionContext = getExecutionContext();
|
||||
const isDextoSource = executionContext === 'dexto-source';
|
||||
|
||||
const agentMgmtVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
|
||||
|
||||
// Resolve relative paths to absolute for local images
|
||||
// (npm/pnpm need absolute paths to package directories when installing from file system)
|
||||
let resolvedImageName = imageName;
|
||||
if (imageName.startsWith('.')) {
|
||||
const fullPath = path.resolve(originalCwd, imageName);
|
||||
// If path ends with /dist/index.js, resolve to package root (parent of dist)
|
||||
if (fullPath.endsWith('/dist/index.js') || fullPath.endsWith('\\dist\\index.js')) {
|
||||
resolvedImageName = path.dirname(path.dirname(fullPath));
|
||||
} else if (fullPath.endsWith('.js')) {
|
||||
// If it's a .js file but not the standard structure, use the directory
|
||||
resolvedImageName = path.dirname(fullPath);
|
||||
} else {
|
||||
// It's already a directory
|
||||
resolvedImageName = fullPath;
|
||||
}
|
||||
} else if (isDextoSource && imageName.startsWith('@dexto/image-')) {
|
||||
// In dexto source, resolve official images to local workspace packages
|
||||
// e.g., @dexto/image-local -> packages/image-local
|
||||
const imagePkgName = imageName.replace('@dexto/', '');
|
||||
const imagePkgPath = path.resolve(originalCwd, 'packages', imagePkgName);
|
||||
if (await fs.pathExists(imagePkgPath)) {
|
||||
resolvedImageName = imagePkgPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Install dependencies (use pnpm in dexto source for workspace protocol support)
|
||||
// Image is loaded as "environment" - we import from core packages directly
|
||||
const coreVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
|
||||
const serverVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
|
||||
|
||||
const dependencies = [
|
||||
resolvedImageName, // Image provides the environment/providers
|
||||
`@dexto/core@${coreVersion}`, // Import DextoAgent from here
|
||||
`@dexto/agent-management@${agentMgmtVersion}`, // Import loadAgentConfig from here
|
||||
'tsx',
|
||||
];
|
||||
|
||||
// Add @dexto/server dependency for webapp type
|
||||
if (appType === 'webapp') {
|
||||
dependencies.push(`@dexto/server@${serverVersion}`);
|
||||
}
|
||||
|
||||
await installDependencies(
|
||||
projectPath,
|
||||
{
|
||||
dependencies,
|
||||
devDependencies: ['typescript@^5.0.0', '@types/node@^20.0.0'],
|
||||
},
|
||||
isDextoSource ? 'pnpm' : undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode B: Scaffold standalone app built from @dexto/core
|
||||
* Supports both dev (runtime discovery) and production (build-time discovery) workflows
|
||||
*/
|
||||
async function scaffoldFromCore(
|
||||
projectPath: string,
|
||||
projectName: string,
|
||||
spinner: ReturnType<typeof p.spinner>
|
||||
): Promise<void> {
|
||||
spinner.start('Setting up app structure...');
|
||||
|
||||
// Always include example tool for from-core mode
|
||||
const includeExample = true;
|
||||
|
||||
// Create convention-based folders
|
||||
await ensureDirectory('src');
|
||||
await ensureDirectory('scripts');
|
||||
await ensureDirectory('tools');
|
||||
await ensureDirectory('blob-store');
|
||||
await ensureDirectory('compression');
|
||||
await ensureDirectory('plugins');
|
||||
await ensureDirectory('agents');
|
||||
|
||||
// Create .gitkeep files for empty directories
|
||||
await fs.writeFile('blob-store/.gitkeep', '');
|
||||
await fs.writeFile('compression/.gitkeep', '');
|
||||
await fs.writeFile('plugins/.gitkeep', '');
|
||||
|
||||
// Create example tool if requested
|
||||
if (includeExample) {
|
||||
await ensureDirectory('tools/example-tool');
|
||||
const exampleToolCode = generateExampleTool('example-tool');
|
||||
await fs.writeFile('tools/example-tool/index.ts', exampleToolCode);
|
||||
} else {
|
||||
await fs.writeFile('tools/.gitkeep', '');
|
||||
}
|
||||
|
||||
spinner.message('Generating app files...');
|
||||
|
||||
// Create dexto.config.ts for provider discovery configuration
|
||||
const dextoConfigContent = `import { defineConfig } from '@dexto/core';
|
||||
|
||||
/**
|
||||
* Dexto Configuration
|
||||
*
|
||||
* Provider Discovery Modes:
|
||||
* - Development (pnpm dev): Runtime discovery - fast iteration, no rebuild needed
|
||||
* - Production (pnpm build): Build-time discovery - validates and optimizes everything
|
||||
*
|
||||
* This mirrors Next.js approach:
|
||||
* - next dev: Runtime compilation
|
||||
* - next build + next start: Pre-built production bundle
|
||||
*/
|
||||
export default defineConfig({
|
||||
providers: {
|
||||
// Auto-discover providers from convention-based folders
|
||||
autoDiscover: true,
|
||||
folders: ['tools', 'blob-store', 'compression', 'plugins'],
|
||||
},
|
||||
});
|
||||
`;
|
||||
await fs.writeFile('dexto.config.ts', dextoConfigContent);
|
||||
|
||||
// Create build-time discovery script
|
||||
const discoveryScript = generateDiscoveryScript();
|
||||
await fs.writeFile('scripts/discover-providers.ts', discoveryScript);
|
||||
|
||||
// Create app entry point - completely clean, no provider registration code
|
||||
const appIndexContent = `// Standalone Dexto app
|
||||
// Development: Providers auto-discovered at runtime (pnpm dev)
|
||||
// Production: Providers bundled at build time (pnpm build + pnpm start)
|
||||
|
||||
import { DextoAgent } from '@dexto/core';
|
||||
import { loadAgentConfig } from '@dexto/agent-management';
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Starting ${projectName}\\n');
|
||||
|
||||
// Load agent configuration
|
||||
// In dev mode: providers discovered at runtime from dexto.config.ts
|
||||
// In production: providers pre-registered at build time
|
||||
const config = await loadAgentConfig('./agents/default.yml');
|
||||
|
||||
// Create agent
|
||||
const agent = new DextoAgent(config, './agents/default.yml');
|
||||
|
||||
await agent.start();
|
||||
console.log('✅ Agent started\\n');
|
||||
|
||||
// Create a session
|
||||
const session = await agent.createSession();
|
||||
|
||||
// Example interaction
|
||||
const response = await agent.run(
|
||||
'Hello! Can you help me understand what custom tools are available?',
|
||||
undefined, // imageDataInput
|
||||
undefined, // fileDataInput
|
||||
session.id // sessionId
|
||||
);
|
||||
|
||||
console.log('Agent response:', response);
|
||||
|
||||
// Cleanup
|
||||
await agent.stop();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
`;
|
||||
await fs.writeFile('src/index.ts', appIndexContent);
|
||||
|
||||
// Create default agent config
|
||||
const agentConfig = `# Default Agent Configuration
|
||||
|
||||
# System prompt
|
||||
systemPrompt:
|
||||
contributors:
|
||||
- id: primary
|
||||
type: static
|
||||
priority: 0
|
||||
content: |
|
||||
You are a helpful AI assistant with custom tools.
|
||||
|
||||
# LLM configuration
|
||||
llm:
|
||||
provider: openai
|
||||
model: gpt-4o
|
||||
apiKey: $OPENAI_API_KEY
|
||||
|
||||
# Storage
|
||||
storage:
|
||||
cache:
|
||||
type: in-memory
|
||||
database:
|
||||
type: sqlite
|
||||
path: ./data/agent.db
|
||||
blob:
|
||||
type: local
|
||||
storePath: ./data/blobs
|
||||
|
||||
# Custom tools are auto-discovered at runtime from tools/ folder
|
||||
# See dexto.config.ts for provider discovery configuration
|
||||
|
||||
# MCP servers - add external tools here
|
||||
# mcpServers:
|
||||
# filesystem:
|
||||
# type: stdio
|
||||
# command: npx
|
||||
# args:
|
||||
# - -y
|
||||
# - "@modelcontextprotocol/server-filesystem"
|
||||
# - .
|
||||
`;
|
||||
await fs.writeFile('agents/default.yml', agentConfig);
|
||||
|
||||
spinner.message('Creating configuration files...');
|
||||
|
||||
// Create package.json for standalone app
|
||||
await initPackageJson(projectPath, projectName, 'app');
|
||||
|
||||
// Add scripts for both development and production workflows
|
||||
const packageJsonPath = path.join(projectPath, 'package.json');
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
||||
packageJson.scripts = {
|
||||
// Development: runtime discovery (fast iteration)
|
||||
dev: 'tsx src/index.ts',
|
||||
// Production: build-time discovery + bundling
|
||||
build: 'tsx scripts/discover-providers.ts && tsup',
|
||||
start: 'node dist/_entry.js',
|
||||
typecheck: 'tsc --noEmit',
|
||||
discover: 'tsx scripts/discover-providers.ts',
|
||||
...packageJson.scripts,
|
||||
};
|
||||
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||
|
||||
// Create tsconfig.json
|
||||
const tsconfigContent = {
|
||||
compilerOptions: {
|
||||
target: 'ES2022',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'bundler',
|
||||
lib: ['ES2022'],
|
||||
outDir: './dist',
|
||||
rootDir: './src',
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
resolveJsonModule: true,
|
||||
declaration: true,
|
||||
declarationMap: true,
|
||||
sourceMap: true,
|
||||
},
|
||||
include: ['src/**/*', 'tools/**/*', 'blob-store/**/*', 'compression/**/*', 'plugins/**/*'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
};
|
||||
await fs.writeFile('tsconfig.json', JSON.stringify(tsconfigContent, null, 2));
|
||||
|
||||
// Create tsup.config.ts - builds from generated _entry.ts for production
|
||||
const tsupConfig = `import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/_entry.ts'], // Generated by scripts/discover-providers.ts
|
||||
format: ['esm'],
|
||||
dts: false, // Skip DTS for build artifacts
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ['@dexto/core', '@dexto/agent-management'],
|
||||
noExternal: [],
|
||||
});
|
||||
`;
|
||||
await fs.writeFile('tsup.config.ts', tsupConfig);
|
||||
|
||||
// Create .gitignore - ignore generated build artifacts
|
||||
await createGitignore(projectPath, [
|
||||
'*.tsbuildinfo',
|
||||
'dist/',
|
||||
'src/_entry.ts',
|
||||
'src/_providers.ts',
|
||||
]);
|
||||
|
||||
// Create .env.example
|
||||
await createEnvExample(projectPath, {
|
||||
OPENAI_API_KEY: 'sk-...',
|
||||
ANTHROPIC_API_KEY: 'sk-ant-...',
|
||||
});
|
||||
|
||||
// Create README
|
||||
const readmeContent = `# ${projectName}
|
||||
|
||||
Standalone Dexto app with convention-based auto-discovery.
|
||||
|
||||
## Getting Started
|
||||
|
||||
\`\`\`bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Add your API key
|
||||
cp .env.example .env
|
||||
# Edit .env and add your OPENAI_API_KEY
|
||||
|
||||
# Development (runtime discovery - fast iteration)
|
||||
pnpm dev
|
||||
|
||||
# Production (build-time discovery + optimized bundle)
|
||||
pnpm build
|
||||
pnpm start
|
||||
\`\`\`
|
||||
|
||||
That's it! Custom providers are discovered automatically:
|
||||
- **Dev mode** (\`pnpm dev\`): Runtime discovery - add/modify providers without rebuilding
|
||||
- **Production** (\`pnpm build\`): Build-time discovery - validates and bundles everything
|
||||
|
||||
## Project Structure
|
||||
|
||||
\`\`\`
|
||||
${projectName}/
|
||||
├── src/
|
||||
│ ├── index.ts # Your app code (clean, no boilerplate!)
|
||||
│ ├── _entry.ts # Auto-generated (build only, gitignored)
|
||||
│ └── _providers.ts # Auto-generated (build only, gitignored)
|
||||
├── scripts/
|
||||
│ └── discover-providers.ts # Build-time discovery script
|
||||
├── dexto.config.ts # Provider discovery configuration
|
||||
├── tools/ # Add custom tool providers here
|
||||
├── blob-store/ # Add custom blob storage providers here
|
||||
├── compression/ # Add custom compression providers here
|
||||
├── plugins/ # Add custom plugins here
|
||||
└── agents/
|
||||
└── default.yml # Agent configuration
|
||||
\`\`\`
|
||||
|
||||
## Adding Custom Providers
|
||||
|
||||
1. Create a provider in the appropriate folder (tools/, blob-store/, compression/, plugins/)
|
||||
2. Export it with the naming convention: \`<folderName>Provider\`
|
||||
3. Run \`pnpm dev\` (instant) or \`pnpm build\` (validated) - everything is auto-discovered!
|
||||
|
||||
**Example** - Adding a custom tool:
|
||||
\`\`\`typescript
|
||||
// tools/my-tool/index.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const myToolProvider = {
|
||||
type: 'my-tool',
|
||||
configSchema: z.object({ type: z.literal('my-tool') }),
|
||||
tools: [
|
||||
{
|
||||
name: 'do_something',
|
||||
description: 'Does something useful',
|
||||
parameters: z.object({ input: z.string() }),
|
||||
execute: async ({ input }) => {
|
||||
return \`Processed: \${input}\`;
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
That's it! No imports, no registration code needed.
|
||||
|
||||
## Scripts
|
||||
|
||||
- \`pnpm start\` - Build and run (auto-discovers providers)
|
||||
- \`pnpm run dev\` - Development mode with hot reload
|
||||
- \`pnpm run build\` - Build only
|
||||
- \`pnpm run discover\` - Manually run provider discovery
|
||||
- \`pnpm run typecheck\` - Type check
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Discovery**: Scans conventional folders for providers
|
||||
2. **Generation**: Creates \`src/_providers.ts\` (registrations) and \`src/_entry.ts\` (wiring)
|
||||
3. **Build**: Bundles everything into \`dist/_entry.js\`
|
||||
4. **Run**: Your clean app code runs with all providers pre-registered
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Dexto Documentation](https://docs.dexto.ai)
|
||||
- [Custom Tools Guide](https://docs.dexto.ai/docs/guides/custom-tools)
|
||||
`;
|
||||
await fs.writeFile('README.md', readmeContent);
|
||||
|
||||
// Initialize git
|
||||
spinner.message('Initializing git repository...');
|
||||
await setupGitRepo(projectPath);
|
||||
|
||||
spinner.message('Installing dependencies...');
|
||||
|
||||
// Detect if we're in dexto source - use workspace protocol for local development
|
||||
const executionContext = getExecutionContext();
|
||||
const isDextoSource = executionContext === 'dexto-source';
|
||||
|
||||
const coreVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
|
||||
const agentMgmtVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
|
||||
|
||||
// Install dependencies (use pnpm in dexto source for workspace protocol support)
|
||||
await installDependencies(
|
||||
projectPath,
|
||||
{
|
||||
dependencies: [
|
||||
`@dexto/core@${coreVersion}`,
|
||||
'zod',
|
||||
`@dexto/agent-management@${agentMgmtVersion}`,
|
||||
],
|
||||
devDependencies: ['typescript@^5.0.0', '@types/node@^20.0.0', 'tsx', 'tsup'],
|
||||
},
|
||||
isDextoSource ? 'pnpm' : undefined
|
||||
);
|
||||
}
|
||||
270
dexto/packages/cli/src/cli/commands/create-image.ts
Normal file
270
dexto/packages/cli/src/cli/commands/create-image.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import path from 'path';
|
||||
import * as p from '@clack/prompts';
|
||||
import chalk from 'chalk';
|
||||
import { selectOrExit, textOrExit, confirmOrExit } from '../utils/prompt-helpers.js';
|
||||
import {
|
||||
promptForProjectName,
|
||||
createProjectDirectory,
|
||||
setupGitRepo,
|
||||
createGitignore,
|
||||
initPackageJson,
|
||||
createTsconfigForImage,
|
||||
installDependencies,
|
||||
ensureDirectory,
|
||||
} from '../utils/scaffolding-utils.js';
|
||||
import {
|
||||
generateDextoImageFile,
|
||||
generateImageReadme,
|
||||
generateExampleTool,
|
||||
} from '../utils/template-engine.js';
|
||||
import fs from 'fs-extra';
|
||||
import { getExecutionContext } from '@dexto/agent-management';
|
||||
|
||||
/**
|
||||
* Creates a Dexto image project - a distributable agent harness package
|
||||
* @param name - Optional name of the image project
|
||||
* @returns The absolute path to the created project directory
|
||||
*/
|
||||
export async function createImage(name?: string): Promise<string> {
|
||||
console.log(chalk.blue('🎨 Creating a Dexto image - a distributable agent harness package\n'));
|
||||
|
||||
// Step 1: Get project name
|
||||
const projectName = name
|
||||
? name
|
||||
: await promptForProjectName('my-dexto-image', 'What do you want to name your image?');
|
||||
|
||||
// Step 2: Get description
|
||||
const description = await textOrExit(
|
||||
{
|
||||
message: 'Describe your image:',
|
||||
placeholder: 'Custom agent harness for my organization',
|
||||
defaultValue: 'Custom agent harness for my organization',
|
||||
},
|
||||
'Image creation cancelled'
|
||||
);
|
||||
|
||||
// Step 3: Starting point - new base or extend existing
|
||||
const startingPoint = await selectOrExit<'base' | 'extend'>(
|
||||
{
|
||||
message: 'Starting point:',
|
||||
options: [
|
||||
{ value: 'base', label: 'New base image (build from scratch)' },
|
||||
{ value: 'extend', label: 'Extend existing image (add providers to base)' },
|
||||
],
|
||||
},
|
||||
'Image creation cancelled'
|
||||
);
|
||||
|
||||
let baseImage: string | undefined;
|
||||
if (startingPoint === 'extend') {
|
||||
// Step 4: Which image to extend?
|
||||
const baseImageChoice = await selectOrExit<string>(
|
||||
{
|
||||
message: 'Which image to extend?',
|
||||
options: [
|
||||
{
|
||||
value: '@dexto/image-local',
|
||||
label: '@dexto/image-local (local development)',
|
||||
},
|
||||
{ value: '@dexto/image-cloud', label: '@dexto/image-cloud (cloud production)' },
|
||||
{ value: '@dexto/image-edge', label: '@dexto/image-edge (edge/serverless)' },
|
||||
{ value: 'custom', label: 'Custom npm package...' },
|
||||
],
|
||||
},
|
||||
'Image creation cancelled'
|
||||
);
|
||||
|
||||
if (baseImageChoice === 'custom') {
|
||||
const customBase = await textOrExit(
|
||||
{
|
||||
message: 'Enter the npm package name:',
|
||||
placeholder: '@myorg/image-base',
|
||||
validate: (value) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return 'Package name is required';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
'Image creation cancelled'
|
||||
);
|
||||
|
||||
baseImage = customBase;
|
||||
} else {
|
||||
baseImage = baseImageChoice;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Target environment
|
||||
const target = await selectOrExit<string>(
|
||||
{
|
||||
message: 'Target environment:',
|
||||
options: [
|
||||
{ value: 'local-development', label: 'Local development' },
|
||||
{ value: 'cloud-production', label: 'Cloud production' },
|
||||
{ value: 'edge-serverless', label: 'Edge/serverless' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
],
|
||||
},
|
||||
'Image creation cancelled'
|
||||
);
|
||||
|
||||
// Step 6: Include example providers?
|
||||
const includeExamples = await confirmOrExit(
|
||||
{
|
||||
message: 'Include example tool provider?',
|
||||
initialValue: true,
|
||||
},
|
||||
'Image creation cancelled'
|
||||
);
|
||||
|
||||
// Start scaffolding
|
||||
const spinner = p.spinner();
|
||||
let projectPath: string | undefined;
|
||||
|
||||
try {
|
||||
// Save original cwd before changing directories (for resolving relative paths)
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
// Create project directory
|
||||
projectPath = await createProjectDirectory(projectName, spinner);
|
||||
|
||||
// Change to project directory
|
||||
process.chdir(projectPath);
|
||||
|
||||
spinner.start('Setting up project structure...');
|
||||
|
||||
// Create convention-based folders
|
||||
await ensureDirectory('tools');
|
||||
await ensureDirectory('blob-store');
|
||||
await ensureDirectory('compression');
|
||||
await ensureDirectory('plugins');
|
||||
|
||||
// Create .gitkeep files for empty directories
|
||||
await fs.writeFile('blob-store/.gitkeep', '');
|
||||
await fs.writeFile('compression/.gitkeep', '');
|
||||
await fs.writeFile('plugins/.gitkeep', '');
|
||||
|
||||
// Create example tool if requested
|
||||
if (includeExamples) {
|
||||
await ensureDirectory('tools/example-tool');
|
||||
const exampleToolCode = generateExampleTool('example-tool');
|
||||
await fs.writeFile('tools/example-tool/index.ts', exampleToolCode);
|
||||
} else {
|
||||
await fs.writeFile('tools/.gitkeep', '');
|
||||
}
|
||||
|
||||
spinner.message('Generating configuration files...');
|
||||
|
||||
// Create dexto.image.ts
|
||||
const dextoImageContent = generateDextoImageFile({
|
||||
projectName,
|
||||
packageName: projectName,
|
||||
description,
|
||||
imageName: projectName,
|
||||
...(baseImage ? { baseImage } : {}),
|
||||
target,
|
||||
});
|
||||
await fs.writeFile('dexto.image.ts', dextoImageContent);
|
||||
|
||||
// Create package.json
|
||||
await initPackageJson(projectPath, projectName, 'image');
|
||||
|
||||
// Update package.json with build script
|
||||
const packageJsonPath = path.join(projectPath, 'package.json');
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
||||
packageJson.scripts = {
|
||||
build: 'dexto-bundle build',
|
||||
typecheck: 'tsc --noEmit',
|
||||
...packageJson.scripts,
|
||||
};
|
||||
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||
|
||||
// Create tsconfig.json
|
||||
await createTsconfigForImage(projectPath);
|
||||
|
||||
// Create README
|
||||
const readmeContent = generateImageReadme({
|
||||
projectName,
|
||||
packageName: projectName,
|
||||
description,
|
||||
imageName: projectName,
|
||||
...(baseImage ? { baseImage } : {}),
|
||||
});
|
||||
await fs.writeFile('README.md', readmeContent);
|
||||
|
||||
// Create .gitignore
|
||||
await createGitignore(projectPath, ['*.tsbuildinfo']);
|
||||
|
||||
// Initialize git
|
||||
spinner.message('Initializing git repository...');
|
||||
await setupGitRepo(projectPath);
|
||||
|
||||
spinner.message('Installing dependencies...');
|
||||
|
||||
// Detect if we're in dexto source - use workspace protocol for local development
|
||||
const executionContext = getExecutionContext();
|
||||
const isDextoSource = executionContext === 'dexto-source';
|
||||
|
||||
const coreVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
|
||||
const bundlerVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
|
||||
|
||||
// Determine dependencies based on whether extending
|
||||
const dependencies: string[] = [`@dexto/core@${coreVersion}`, 'zod'];
|
||||
const devDependencies = [
|
||||
'typescript@^5.0.0',
|
||||
'@types/node@^20.0.0',
|
||||
`@dexto/image-bundler@${bundlerVersion}`,
|
||||
];
|
||||
|
||||
if (baseImage) {
|
||||
// Resolve base image path if we're in dexto source
|
||||
let resolvedBaseImage = baseImage;
|
||||
if (isDextoSource && baseImage.startsWith('@dexto/image-')) {
|
||||
// In dexto source, resolve official images to local workspace packages
|
||||
// e.g., @dexto/image-local -> packages/image-local
|
||||
const imagePkgName = baseImage.replace('@dexto/', '');
|
||||
const imagePkgPath = path.resolve(originalCwd, 'packages', imagePkgName);
|
||||
if (await fs.pathExists(imagePkgPath)) {
|
||||
resolvedBaseImage = imagePkgPath;
|
||||
}
|
||||
}
|
||||
dependencies.push(resolvedBaseImage);
|
||||
}
|
||||
|
||||
// Install dependencies (use pnpm in dexto source for workspace protocol support)
|
||||
await installDependencies(
|
||||
projectPath,
|
||||
{
|
||||
dependencies,
|
||||
devDependencies,
|
||||
},
|
||||
isDextoSource ? 'pnpm' : undefined
|
||||
);
|
||||
|
||||
spinner.stop(chalk.green(`✓ Successfully created image: ${projectName}`));
|
||||
|
||||
console.log(`\n${chalk.cyan('Next steps:')}`);
|
||||
console.log(` ${chalk.gray('$')} cd ${projectName}`);
|
||||
console.log(` ${chalk.gray('$')} pnpm run build`);
|
||||
console.log(
|
||||
`\n${chalk.gray('Add your custom providers to the convention-based folders:')}`
|
||||
);
|
||||
console.log(` ${chalk.gray('tools/')} - Custom tool providers`);
|
||||
console.log(` ${chalk.gray('blob-store/')} - Blob storage providers`);
|
||||
console.log(` ${chalk.gray('compression/')} - Compression strategies`);
|
||||
console.log(` ${chalk.gray('plugins/')} - Plugin providers`);
|
||||
console.log(`\n${chalk.gray('Learn more:')} https://docs.dexto.ai/docs/guides/images\n`);
|
||||
} catch (error) {
|
||||
if (spinner) {
|
||||
spinner.stop(chalk.red('✗ Failed to create image'));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!projectPath) {
|
||||
throw new Error('Failed to create project directory');
|
||||
}
|
||||
|
||||
return projectPath;
|
||||
}
|
||||
107
dexto/packages/cli/src/cli/commands/helpers/formatters.ts
Normal file
107
dexto/packages/cli/src/cli/commands/helpers/formatters.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Session Formatting Utilities
|
||||
*
|
||||
* This module contains formatting functions for session-related CLI output.
|
||||
* Shared between interactive and non-interactive session commands.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import type { SessionMetadata, InternalMessage, ToolCall } from '@dexto/core';
|
||||
import { isAssistantMessage } from '@dexto/core';
|
||||
|
||||
/**
|
||||
* Helper to format session information consistently
|
||||
*/
|
||||
export function formatSessionInfo(
|
||||
sessionId: string,
|
||||
metadata?: SessionMetadata,
|
||||
isCurrent: boolean = false
|
||||
): string {
|
||||
const prefix = isCurrent ? chalk.green('→') : ' ';
|
||||
const name = isCurrent ? chalk.green.bold(sessionId) : chalk.cyan(sessionId);
|
||||
|
||||
let info = `${prefix} ${name}`;
|
||||
|
||||
if (metadata) {
|
||||
const messages = metadata.messageCount || 0;
|
||||
const activity =
|
||||
metadata.lastActivity && metadata.lastActivity > 0
|
||||
? new Date(metadata.lastActivity).toLocaleString()
|
||||
: 'Never';
|
||||
|
||||
info += chalk.gray(` (${messages} messages, last: ${activity})`);
|
||||
|
||||
if (isCurrent) {
|
||||
info += chalk.rgb(255, 165, 0)(' [ACTIVE]');
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to format conversation history
|
||||
*/
|
||||
export function formatHistoryMessage(message: InternalMessage, index: number): string {
|
||||
const timestamp = message.timestamp
|
||||
? new Date(message.timestamp).toLocaleTimeString()
|
||||
: `#${index + 1}`;
|
||||
|
||||
let roleColor = chalk.gray;
|
||||
let displayLabel: string = message.role;
|
||||
|
||||
switch (message.role) {
|
||||
case 'user':
|
||||
roleColor = chalk.blue;
|
||||
displayLabel = 'You';
|
||||
break;
|
||||
case 'assistant':
|
||||
roleColor = chalk.green;
|
||||
displayLabel = 'Assistant';
|
||||
break;
|
||||
case 'system':
|
||||
roleColor = chalk.rgb(255, 165, 0);
|
||||
displayLabel = 'System';
|
||||
break;
|
||||
case 'tool':
|
||||
roleColor = chalk.green;
|
||||
displayLabel = 'Tool';
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle content formatting
|
||||
let content = '';
|
||||
if (typeof message.content === 'string') {
|
||||
content = message.content;
|
||||
} else if (message.content === null) {
|
||||
content = '[No content]';
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// Handle multimodal content
|
||||
content = message.content
|
||||
.map((part) => {
|
||||
if (part.type === 'text') return part.text;
|
||||
if (part.type === 'image') return '[Image]';
|
||||
if (part.type === 'file') return `[File: ${part.filename || 'unknown'}]`;
|
||||
return '[Unknown content]';
|
||||
})
|
||||
.join(' ');
|
||||
} else {
|
||||
content = '[No content]';
|
||||
}
|
||||
|
||||
// Truncate very long messages
|
||||
if (content.length > 200) {
|
||||
content = content.substring(0, 200) + '...';
|
||||
}
|
||||
|
||||
// Format tool calls if present
|
||||
let toolInfo = '';
|
||||
if (isAssistantMessage(message) && message.toolCalls && message.toolCalls.length > 0) {
|
||||
const toolNames = message.toolCalls
|
||||
.map((tc: ToolCall) => tc.function?.name || 'unknown')
|
||||
.join(', ');
|
||||
toolInfo = chalk.gray(` [Tools: ${toolNames}]`);
|
||||
}
|
||||
|
||||
return ` ${chalk.gray(timestamp)} ${roleColor.bold(displayLabel)}: ${content}${toolInfo}`;
|
||||
}
|
||||
60
dexto/packages/cli/src/cli/commands/index.ts
Normal file
60
dexto/packages/cli/src/cli/commands/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// packages/cli/src/cli/commands/index.ts
|
||||
|
||||
// Project setup commands
|
||||
export { createDextoProject, type CreateAppOptions } from './create-app.js';
|
||||
|
||||
export { createImage } from './create-image.js';
|
||||
|
||||
export { getUserInputToInitDextoApp, initDexto, postInitDexto } from './init-app.js';
|
||||
|
||||
export { handleSetupCommand, type CLISetupOptions, type CLISetupOptionsInput } from './setup.js';
|
||||
export { handleInstallCommand, type InstallCommandOptions } from './install.js';
|
||||
export { handleUninstallCommand, type UninstallCommandOptions } from './uninstall.js';
|
||||
export {
|
||||
handleListAgentsCommand,
|
||||
type ListAgentsCommandOptions,
|
||||
type ListAgentsCommandOptionsInput,
|
||||
} from './list-agents.js';
|
||||
export { handleWhichCommand, type WhichCommandOptions } from './which.js';
|
||||
export {
|
||||
handleSyncAgentsCommand,
|
||||
shouldPromptForSync,
|
||||
markSyncDismissed,
|
||||
clearSyncDismissed,
|
||||
type SyncAgentsCommandOptions,
|
||||
} from './sync-agents.js';
|
||||
|
||||
// Auth commands
|
||||
export { handleLoginCommand, handleLogoutCommand, handleStatusCommand } from './auth/index.js';
|
||||
|
||||
// Billing commands
|
||||
export { handleBillingStatusCommand } from './billing/index.js';
|
||||
|
||||
// Plugin commands
|
||||
export {
|
||||
handlePluginListCommand,
|
||||
handlePluginInstallCommand,
|
||||
handlePluginUninstallCommand,
|
||||
handlePluginValidateCommand,
|
||||
// Marketplace handlers
|
||||
handleMarketplaceAddCommand,
|
||||
handleMarketplaceRemoveCommand,
|
||||
handleMarketplaceUpdateCommand,
|
||||
handleMarketplaceListCommand,
|
||||
handleMarketplacePluginsCommand,
|
||||
handleMarketplaceInstallCommand,
|
||||
type PluginListCommandOptions,
|
||||
type PluginListCommandOptionsInput,
|
||||
type PluginInstallCommandOptions,
|
||||
type PluginInstallCommandOptionsInput,
|
||||
type PluginUninstallCommandOptions,
|
||||
type PluginUninstallCommandOptionsInput,
|
||||
type PluginValidateCommandOptions,
|
||||
type PluginValidateCommandOptionsInput,
|
||||
// Marketplace types
|
||||
type MarketplaceAddCommandOptionsInput,
|
||||
type MarketplaceRemoveCommandOptionsInput,
|
||||
type MarketplaceUpdateCommandOptionsInput,
|
||||
type MarketplaceListCommandOptionsInput,
|
||||
type MarketplaceInstallCommandOptionsInput,
|
||||
} from './plugin.js';
|
||||
117
dexto/packages/cli/src/cli/commands/init-app.test.ts
Normal file
117
dexto/packages/cli/src/cli/commands/init-app.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { createDextoDirectories, createDextoExampleFile, postInitDexto } from './init-app.js';
|
||||
|
||||
describe('Init Module', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary directory for testing
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-init-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup the temporary directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('createDextoDirectories', () => {
|
||||
it('creates dexto and agents directories when they do not exist', async () => {
|
||||
const result = await createDextoDirectories(tempDir);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.dirPath).toBe(path.join(tempDir, 'dexto'));
|
||||
}
|
||||
|
||||
// Verify directories exist
|
||||
const dextoDir = path.join(tempDir, 'dexto');
|
||||
const agentsDir = path.join(tempDir, 'dexto', 'agents');
|
||||
|
||||
expect(
|
||||
await fs
|
||||
.access(dextoDir)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
).toBe(true);
|
||||
expect(
|
||||
await fs
|
||||
.access(agentsDir)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when dexto directory already exists', async () => {
|
||||
// Create the dexto directory first
|
||||
const dextoDir = path.join(tempDir, 'dexto');
|
||||
await fs.mkdir(dextoDir, { recursive: true });
|
||||
|
||||
const result = await createDextoDirectories(tempDir);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDextoExampleFile', () => {
|
||||
it('creates example file with correct content', async () => {
|
||||
// Change to temp directory to simulate real usage where paths are relative to cwd
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
|
||||
try {
|
||||
const dextoDir = path.join('src', 'dexto'); // Relative path like real usage
|
||||
await fs.mkdir(dextoDir, { recursive: true });
|
||||
|
||||
const examplePath = await createDextoExampleFile(dextoDir);
|
||||
|
||||
expect(examplePath).toBe(path.join(dextoDir, 'dexto-example.ts'));
|
||||
|
||||
// Verify file exists
|
||||
expect(
|
||||
await fs
|
||||
.access(examplePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
).toBe(true);
|
||||
|
||||
// Verify content contains expected elements
|
||||
const content = await fs.readFile(examplePath, 'utf8');
|
||||
expect(content).toContain(
|
||||
"import { DextoAgent, loadAgentConfig } from '@dexto/core'"
|
||||
);
|
||||
expect(content).toContain("console.log('🚀 Starting Dexto Basic Example");
|
||||
expect(content).toContain('./src/dexto/agents/coding-agent.yml'); // Correct relative path
|
||||
expect(content).toContain('const agent = new DextoAgent(config)');
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates correct config path for different directory structures', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
|
||||
try {
|
||||
const dextoDir = path.join('custom', 'dexto'); // Relative path
|
||||
await fs.mkdir(dextoDir, { recursive: true });
|
||||
|
||||
const examplePath = await createDextoExampleFile(dextoDir);
|
||||
const content = await fs.readFile(examplePath, 'utf8');
|
||||
|
||||
expect(content).toContain('./custom/dexto/agents/coding-agent.yml');
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('postInitDexto', () => {
|
||||
it('runs without throwing errors', async () => {
|
||||
// This function just prints output, so we mainly test it doesn't crash
|
||||
expect(() => postInitDexto('src')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
401
dexto/packages/cli/src/cli/commands/init-app.ts
Normal file
401
dexto/packages/cli/src/cli/commands/init-app.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import * as p from '@clack/prompts';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'node:fs/promises';
|
||||
import fsExtra from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
import { getPackageManager, getPackageManagerInstallCommand } from '../utils/package-mgmt.js';
|
||||
import { executeWithTimeout } from '../utils/execute.js';
|
||||
import { createRequire } from 'module';
|
||||
import { type LLMProvider, logger } from '@dexto/core';
|
||||
import { updateDextoConfigFile } from '../utils/project-utils.js';
|
||||
import { saveProviderApiKey } from '@dexto/agent-management';
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
isValidApiKeyFormat,
|
||||
PROVIDER_OPTIONS,
|
||||
} from '../utils/provider-setup.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Get user preferences needed to initialize a Dexto app
|
||||
* @returns The user preferences
|
||||
*/
|
||||
export async function getUserInputToInitDextoApp(): Promise<{
|
||||
llmProvider: LLMProvider;
|
||||
llmApiKey: string;
|
||||
directory: string;
|
||||
createExampleFile: boolean;
|
||||
}> {
|
||||
const answers = await p.group(
|
||||
{
|
||||
llmProvider: () =>
|
||||
p.select({
|
||||
message: 'Choose your AI provider',
|
||||
options: PROVIDER_OPTIONS,
|
||||
}),
|
||||
llmApiKey: async ({ results }) => {
|
||||
const llmProvider = results.llmProvider as LLMProvider;
|
||||
const selection = await p.select({
|
||||
message: `Enter your API key for ${getProviderDisplayName(llmProvider)}?`,
|
||||
options: [
|
||||
{ value: 'enter', label: 'Enter', hint: 'recommended' },
|
||||
{ value: 'skip', label: 'Skip', hint: '' },
|
||||
],
|
||||
initialValue: 'enter',
|
||||
});
|
||||
|
||||
if (p.isCancel(selection)) {
|
||||
p.cancel('Dexto initialization cancelled');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (selection === 'enter') {
|
||||
const apiKey = await p.password({
|
||||
message: `Enter your ${getProviderDisplayName(llmProvider)} API key`,
|
||||
mask: '*',
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'API key is required';
|
||||
}
|
||||
if (!isValidApiKeyFormat(value.trim(), llmProvider)) {
|
||||
return `Invalid ${getProviderDisplayName(llmProvider)} API key format`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(apiKey)) {
|
||||
p.cancel('Dexto initialization cancelled');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
directory: () =>
|
||||
p.text({
|
||||
message: 'Enter the directory to add the dexto files in',
|
||||
placeholder: 'src/',
|
||||
defaultValue: 'src/',
|
||||
}),
|
||||
createExampleFile: () =>
|
||||
p.confirm({
|
||||
message: 'Create a dexto example file? [Recommended]',
|
||||
initialValue: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
onCancel: () => {
|
||||
p.cancel('Dexto initialization cancelled');
|
||||
process.exit(0);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Type assertion to bypass the possible 'Symbol' type returned by p.group which is handled in onCancel
|
||||
return answers as {
|
||||
llmProvider: LLMProvider;
|
||||
directory: string;
|
||||
llmApiKey: string;
|
||||
createExampleFile: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes an existing project with Dexto in the given directory.
|
||||
* @param directory - The directory to initialize the Dexto project in
|
||||
* @param llmProvider - The LLM provider to use
|
||||
* @param llmApiKey - The API key for the LLM provider
|
||||
* @returns The path to the created Dexto project
|
||||
*/
|
||||
export async function initDexto(
|
||||
directory: string,
|
||||
createExampleFile = true,
|
||||
llmProvider?: LLMProvider,
|
||||
llmApiKey?: string
|
||||
): Promise<void> {
|
||||
const spinner = p.spinner();
|
||||
|
||||
try {
|
||||
// install dexto
|
||||
const packageManager = getPackageManager();
|
||||
const installCommand = getPackageManagerInstallCommand(packageManager);
|
||||
spinner.start('Installing Dexto...');
|
||||
const label = 'latest';
|
||||
logger.debug(
|
||||
`Installing Dexto using ${packageManager} with install command: ${installCommand} and label: ${label}`
|
||||
);
|
||||
try {
|
||||
await executeWithTimeout(packageManager, [installCommand, `@dexto/core@${label}`], {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
} catch (installError) {
|
||||
// Handle pnpm workspace root add error specifically
|
||||
console.error(
|
||||
`Install error: ${
|
||||
installError instanceof Error ? installError.message : String(installError)
|
||||
}`
|
||||
);
|
||||
if (
|
||||
packageManager === 'pnpm' &&
|
||||
installError instanceof Error &&
|
||||
/\bERR_PNPM_ADDING_TO_ROOT\b/.test(installError.message)
|
||||
) {
|
||||
spinner.stop(chalk.red('Error: Cannot install in pnpm workspace root'));
|
||||
p.note(
|
||||
'You are initializing dexto in a pnpm workspace root. Go to a specific workspace package and run "pnpm add @dexto/core" there.',
|
||||
chalk.rgb(255, 165, 0)('Workspace Error')
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
throw installError; // Re-throw other errors
|
||||
}
|
||||
|
||||
spinner.stop('Dexto installed successfully!');
|
||||
|
||||
spinner.start('Creating Dexto files...');
|
||||
// create dexto directories (dexto, dexto/agents)
|
||||
const result = await createDextoDirectories(directory);
|
||||
|
||||
if (!result.ok) {
|
||||
spinner.stop(
|
||||
chalk.inverse(
|
||||
`Dexto already initialized in ${path.join(directory, 'dexto')}. Would you like to overwrite it?`
|
||||
)
|
||||
);
|
||||
const overwrite = await p.confirm({
|
||||
message: 'Overwrite Dexto?',
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (p.isCancel(overwrite) || !overwrite) {
|
||||
p.cancel('Dexto initialization cancelled');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// create dexto config file
|
||||
logger.debug('Creating dexto config file...');
|
||||
const dextoDir = path.join(directory, 'dexto');
|
||||
const agentsDir = path.join(dextoDir, 'agents');
|
||||
|
||||
let configPath: string;
|
||||
try {
|
||||
configPath = await createDextoConfigFile(agentsDir);
|
||||
logger.debug(`Dexto config file created at ${configPath}`);
|
||||
} catch (configError) {
|
||||
spinner.stop(chalk.red('Failed to create agent config file'));
|
||||
logger.error(`Config creation error: ${configError}`);
|
||||
throw new Error(
|
||||
`Failed to create coding-agent.yml: ${configError instanceof Error ? configError.message : String(configError)}`
|
||||
);
|
||||
}
|
||||
|
||||
// update dexto config file based on llmProvider
|
||||
if (llmProvider) {
|
||||
logger.debug(`Updating dexto config file based on llmProvider: ${llmProvider}`);
|
||||
await updateDextoConfigFile(configPath, llmProvider);
|
||||
logger.debug(`Dexto config file updated with llmProvider: ${llmProvider}`);
|
||||
}
|
||||
// create dexto example file if requested
|
||||
if (createExampleFile) {
|
||||
logger.debug('Creating dexto example file...');
|
||||
await createDextoExampleFile(dextoDir);
|
||||
logger.debug('Dexto example file created successfully!');
|
||||
}
|
||||
|
||||
// add/update .env file (only if user provided a key)
|
||||
spinner.start('Saving API key to .env file...');
|
||||
logger.debug(
|
||||
`Saving API key: provider=${llmProvider ?? 'none'}, hasApiKey=${Boolean(llmApiKey)}`
|
||||
);
|
||||
if (llmProvider && llmApiKey) {
|
||||
await saveProviderApiKey(llmProvider, llmApiKey, process.cwd());
|
||||
}
|
||||
spinner.stop('Saved .env updates');
|
||||
} catch (err) {
|
||||
spinner.stop(chalk.inverse(`An error occurred initializing Dexto project - ${err}`));
|
||||
logger.debug(`Error: ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds notes for users to get started with their new initialized Dexto project */
|
||||
export async function postInitDexto(directory: string) {
|
||||
const nextSteps = [
|
||||
`1. Run the example: ${chalk.cyan(`node --loader ts-node/esm ${path.join(directory, 'dexto', 'dexto-example.ts')}`)}`,
|
||||
`2. Add/update your API key(s) in ${chalk.cyan('.env')}`,
|
||||
`3. Check out the agent configuration file ${chalk.cyan(path.join(directory, 'dexto', 'agents', 'coding-agent.yml'))}`,
|
||||
`4. Try out different LLMs and MCP servers in the coding-agent.yml file`,
|
||||
`5. Read more about Dexto: ${chalk.cyan('https://github.com/truffle-ai/dexto')}`,
|
||||
].join('\n');
|
||||
p.note(nextSteps, chalk.rgb(255, 165, 0)('Next steps:'));
|
||||
}
|
||||
/**
|
||||
* Creates the dexto directories (dexto, dexto/agents) in the given directory.
|
||||
* @param directory - The directory to create the dexto directories in
|
||||
* @returns The path to the created dexto directory
|
||||
*/
|
||||
export async function createDextoDirectories(
|
||||
directory: string
|
||||
): Promise<{ ok: true; dirPath: string } | { ok: false }> {
|
||||
const dirPath = path.join(directory, 'dexto');
|
||||
const agentsPath = path.join(directory, 'dexto', 'agents');
|
||||
|
||||
try {
|
||||
await fs.access(dirPath);
|
||||
return { ok: false };
|
||||
} catch {
|
||||
// fsExtra.ensureDir creates directories recursively if they don't exist
|
||||
await fsExtra.ensureDir(dirPath);
|
||||
await fsExtra.ensureDir(agentsPath);
|
||||
return { ok: true, dirPath };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dexto config file in the given directory. Pulls the config file from the installed Dexto package.
|
||||
* @param directory - The directory to create the config file in
|
||||
* @returns The path to the created config file
|
||||
*/
|
||||
export async function createDextoConfigFile(directory: string): Promise<string> {
|
||||
// Ensure the directory exists
|
||||
await fsExtra.ensureDir(directory);
|
||||
|
||||
try {
|
||||
// Locate the Dexto package installation directory
|
||||
const pkgJsonPath = require.resolve('dexto/package.json');
|
||||
const pkgDir = path.dirname(pkgJsonPath);
|
||||
logger.debug(`Package directory: ${pkgDir}`);
|
||||
|
||||
// Build path to the configuration template for create-app (with auto-approve toolConfirmation)
|
||||
const templateConfigSrc = path.join(pkgDir, 'dist', 'agents', 'agent-template.yml');
|
||||
logger.debug(`Looking for template at: ${templateConfigSrc}`);
|
||||
|
||||
// Check if template exists - fail if not found
|
||||
const templateExists = await fsExtra.pathExists(templateConfigSrc);
|
||||
if (!templateExists) {
|
||||
throw new Error(
|
||||
`Template file not found at: ${templateConfigSrc}. This indicates a build issue - the template should be included in the package.`
|
||||
);
|
||||
}
|
||||
|
||||
// Path to the destination config file
|
||||
const destConfigPath = path.join(directory, 'coding-agent.yml');
|
||||
logger.debug(`Copying template to: ${destConfigPath}`);
|
||||
|
||||
// Copy the config file from the Dexto package
|
||||
await fsExtra.copy(templateConfigSrc, destConfigPath);
|
||||
logger.debug(`Successfully created config file at: ${destConfigPath}`);
|
||||
|
||||
return destConfigPath;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create Dexto config file: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an example file in the given directory showing how to use Dexto in code. This file has example code to get you started.
|
||||
* @param directory - The directory to create the example index file in
|
||||
* @returns The path to the created example index file
|
||||
*/
|
||||
export async function createDextoExampleFile(directory: string): Promise<string> {
|
||||
// Extract the base directory from the given path (e.g., "src" from "src/dexto")
|
||||
const baseDir = path.dirname(directory);
|
||||
|
||||
const configPath = `./${path.posix.join(baseDir, 'dexto/agents/coding-agent.yml')}`;
|
||||
|
||||
const indexTsLines = [
|
||||
"import 'dotenv/config';",
|
||||
"import { DextoAgent, loadAgentConfig } from '@dexto/core';",
|
||||
'',
|
||||
"console.log('🚀 Starting Dexto Basic Example\\n');",
|
||||
'',
|
||||
'try {',
|
||||
' // Load the agent configuration',
|
||||
` const config = await loadAgentConfig('${configPath}');`,
|
||||
'',
|
||||
' // Create a new DextoAgent instance',
|
||||
' const agent = new DextoAgent(config);',
|
||||
'',
|
||||
' // Start the agent (connects to MCP servers)',
|
||||
" console.log('🔗 Connecting to MCP servers...');",
|
||||
' await agent.start();',
|
||||
" console.log('✅ Agent started successfully!\\n');",
|
||||
'',
|
||||
' // Create a session for this conversation',
|
||||
' const session = await agent.createSession();',
|
||||
" console.log('📝 Session created:', session.id, '\\n');",
|
||||
'',
|
||||
' // Example 1: Simple task',
|
||||
" console.log('📋 Example 1: Simple information request');",
|
||||
" const request1 = 'What tools do you have available?';",
|
||||
" console.log('Request:', request1);",
|
||||
' const response1 = await agent.run(request1, undefined, undefined, session.id);',
|
||||
" console.log('Response:', response1);",
|
||||
" console.log('\\n——————\\n');",
|
||||
'',
|
||||
' // Example 2: File operation',
|
||||
" console.log('📄 Example 2: File creation');",
|
||||
' const request2 = \'Create a file called test-output.txt with the content "Hello from Dexto!"\';',
|
||||
" console.log('Request:', request2);",
|
||||
' const response2 = await agent.run(request2, undefined, undefined, session.id);',
|
||||
" console.log('Response:', response2);",
|
||||
" console.log('\\n——————\\n');",
|
||||
'',
|
||||
' // Example 3: Multi-step conversation',
|
||||
" console.log('🗣️ Example 3: Multi-step conversation');",
|
||||
' const request3a = \'Create a simple HTML file called demo.html with a heading that says "Dexto Demo"\';',
|
||||
" console.log('Request 3a:', request3a);",
|
||||
' const response3a = await agent.run(request3a, undefined, undefined, session.id);',
|
||||
" console.log('Response:', response3a);",
|
||||
" console.log('\\n\\n');",
|
||||
" const request3b = 'Now add a paragraph to that HTML file explaining what Dexto is';",
|
||||
" console.log('Request 3b:', request3b);",
|
||||
' const response3b = await agent.run(request3b, undefined, undefined, session.id);',
|
||||
" console.log('Response:', response3b);",
|
||||
" console.log('\\n——————\\n');",
|
||||
'',
|
||||
' // Reset conversation (clear context)',
|
||||
" console.log('🔄 Resetting conversation context...');",
|
||||
' await agent.resetConversation(session.id);',
|
||||
" console.log('🔄 Conversation context reset');",
|
||||
" console.log('\\n——————\\n');",
|
||||
'',
|
||||
' // Example 4: Complex task',
|
||||
" console.log('🏗️ Example 4: Complex multi-tool task');",
|
||||
' const request4 = ',
|
||||
" 'Create a simple webpage about AI agents with HTML, CSS, and JavaScript. ' +",
|
||||
" 'The page should have a title, some content about what AI agents are, ' +",
|
||||
" 'and a button that shows an alert when clicked.';",
|
||||
" console.log('Request:', request4);",
|
||||
' const response4 = await agent.run(request4, undefined, undefined, session.id);',
|
||||
" console.log('Response:', response4);",
|
||||
" console.log('\\n——————\\n');",
|
||||
'',
|
||||
' // Stop the agent (disconnect from MCP servers)',
|
||||
" console.log('\\n🛑 Stopping agent...');",
|
||||
' await agent.stop();',
|
||||
" console.log('✅ Agent stopped successfully!');",
|
||||
'',
|
||||
'} catch (error) {',
|
||||
" console.error('❌ Error:', error);",
|
||||
'}',
|
||||
'',
|
||||
"console.log('\\n📖 Read Dexto documentation to understand more about using Dexto: https://docs.dexto.ai');",
|
||||
];
|
||||
const indexTsContent = indexTsLines.join('\n');
|
||||
const outputPath = path.join(directory, 'dexto-example.ts');
|
||||
|
||||
// Log the generated file content and paths for debugging
|
||||
logger.debug(`Creating example file with config path: ${configPath}`);
|
||||
logger.debug(`Base directory: ${baseDir}, Output path: ${outputPath}`);
|
||||
logger.debug(`Generated file content:\n${indexTsContent}`);
|
||||
|
||||
// Ensure the directory exists before writing the file
|
||||
await fs.writeFile(outputPath, indexTsContent);
|
||||
return outputPath;
|
||||
}
|
||||
283
dexto/packages/cli/src/cli/commands/install.test.ts
Normal file
283
dexto/packages/cli/src/cli/commands/install.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// Mock @dexto/core partially: preserve real exports and override specific functions
|
||||
vi.mock('@dexto/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
...actual,
|
||||
getDextoGlobalPath: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock @dexto/agent-management
|
||||
vi.mock('@dexto/agent-management', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
...actual,
|
||||
loadBundledRegistryAgents: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock agent-helpers
|
||||
vi.mock('../../utils/agent-helpers.js', () => ({
|
||||
installBundledAgent: vi.fn(),
|
||||
installCustomAgent: vi.fn(),
|
||||
listInstalledAgents: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @clack/prompts
|
||||
vi.mock('@clack/prompts', () => ({
|
||||
intro: vi.fn(),
|
||||
text: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
isCancel: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock analytics
|
||||
vi.mock('../../analytics/index.js', () => ({
|
||||
capture: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import SUT after mocks
|
||||
import { handleInstallCommand } from './install.js';
|
||||
import {
|
||||
installBundledAgent,
|
||||
installCustomAgent,
|
||||
listInstalledAgents,
|
||||
} from '../../utils/agent-helpers.js';
|
||||
import { loadBundledRegistryAgents } from '@dexto/agent-management';
|
||||
|
||||
describe('Install Command', () => {
|
||||
let consoleSpy: any;
|
||||
const mockBundledRegistry = {
|
||||
'test-agent': {
|
||||
id: 'test-agent',
|
||||
name: 'Test Agent',
|
||||
description: 'Test agent',
|
||||
author: 'Test',
|
||||
tags: ['test'],
|
||||
source: 'test.yml',
|
||||
type: 'builtin' as const,
|
||||
},
|
||||
'other-agent': {
|
||||
id: 'other-agent',
|
||||
name: 'Other Agent',
|
||||
description: 'Other agent',
|
||||
author: 'Test',
|
||||
tags: ['test'],
|
||||
source: 'other.yml',
|
||||
type: 'builtin' as const,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock loadBundledRegistryAgents to return the mock registry directly
|
||||
vi.mocked(loadBundledRegistryAgents).mockReturnValue(mockBundledRegistry);
|
||||
|
||||
// Mock agent helper functions
|
||||
vi.mocked(installBundledAgent).mockResolvedValue('/mock/path/agent.yml');
|
||||
vi.mocked(installCustomAgent).mockResolvedValue('/mock/path/custom-agent.yml');
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue([]);
|
||||
|
||||
// Mock existsSync to return false by default (agent not installed)
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
// Mock statSync to return file stats (default: file, not directory)
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => false,
|
||||
} as any);
|
||||
|
||||
// Mock console
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
it('throws error when no agents specified and all flag is false', async () => {
|
||||
await expect(handleInstallCommand([], {})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws error for unknown agents', async () => {
|
||||
await expect(handleInstallCommand(['test-agent', 'unknown-agent'], {})).rejects.toThrow(
|
||||
/Unknown agents.*unknown-agent/
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts valid agents', async () => {
|
||||
// Should not throw
|
||||
await handleInstallCommand(['test-agent'], {});
|
||||
|
||||
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Single agent installation', () => {
|
||||
it('installs single agent', async () => {
|
||||
await handleInstallCommand(['test-agent'], {});
|
||||
|
||||
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('test-agent'));
|
||||
});
|
||||
|
||||
it('respects force flag', async () => {
|
||||
await handleInstallCommand(['test-agent'], { force: true });
|
||||
|
||||
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
|
||||
});
|
||||
|
||||
it('skips already installed agents when force is false', async () => {
|
||||
// Mock agent as already installed
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
await handleInstallCommand(['test-agent'], { force: false });
|
||||
|
||||
expect(installBundledAgent).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already installed'));
|
||||
});
|
||||
|
||||
it('reinstalls already installed agents when force is true', async () => {
|
||||
// Mock agent as already installed
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
await handleInstallCommand(['test-agent'], { force: true });
|
||||
|
||||
// Should still install despite existing
|
||||
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bulk installation (--all flag)', () => {
|
||||
it('installs all available agents when --all flag is used', async () => {
|
||||
await handleInstallCommand([], { all: true });
|
||||
|
||||
// Should install both agents from mockBundledRegistry
|
||||
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
|
||||
expect(installBundledAgent).toHaveBeenCalledWith('other-agent');
|
||||
expect(installBundledAgent).toHaveBeenCalledTimes(2);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Installing all 2 available agents')
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores agent list when --all flag is used', async () => {
|
||||
await handleInstallCommand(['should-be-ignored'], { all: true });
|
||||
|
||||
// Should install bundled agents, not the specified one
|
||||
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
|
||||
expect(installBundledAgent).toHaveBeenCalledWith('other-agent');
|
||||
expect(installBundledAgent).not.toHaveBeenCalledWith('should-be-ignored');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('continues installing other agents when one fails', async () => {
|
||||
vi.mocked(installBundledAgent).mockImplementation(async (agentId: string) => {
|
||||
if (agentId === 'other-agent') {
|
||||
throw new Error('Installation failed');
|
||||
}
|
||||
return '/path/to/agent.yml';
|
||||
});
|
||||
|
||||
// Should not throw - partial success is acceptable
|
||||
await handleInstallCommand(['test-agent', 'other-agent'], {});
|
||||
|
||||
expect(installBundledAgent).toHaveBeenCalledTimes(2);
|
||||
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
|
||||
expect(installBundledAgent).toHaveBeenCalledWith('other-agent');
|
||||
});
|
||||
|
||||
it('throws when single agent installation fails', async () => {
|
||||
vi.mocked(installBundledAgent).mockRejectedValue(new Error('Installation failed'));
|
||||
|
||||
// Single agent failure should propagate the error directly
|
||||
await expect(handleInstallCommand(['test-agent'], {})).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom agent installation from file paths', () => {
|
||||
let mockPrompts: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const prompts = await import('@clack/prompts');
|
||||
mockPrompts = {
|
||||
intro: vi.mocked(prompts.intro),
|
||||
text: vi.mocked(prompts.text),
|
||||
outro: vi.mocked(prompts.outro),
|
||||
isCancel: vi.mocked(prompts.isCancel),
|
||||
};
|
||||
|
||||
// Default prompt responses
|
||||
mockPrompts.text.mockImplementation(async (opts: any) => {
|
||||
if (opts.message.includes('Agent name')) return 'my-custom-agent';
|
||||
if (opts.message.includes('Description')) return 'Test description';
|
||||
if (opts.message.includes('Author')) return 'Test Author';
|
||||
if (opts.message.includes('Tags')) return 'custom, test';
|
||||
return '';
|
||||
});
|
||||
mockPrompts.isCancel.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('detects file paths and installs custom agent', async () => {
|
||||
// Mock existsSync: source file exists, installed path does not
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
if (path.toString().includes('my-agent.yml')) return true;
|
||||
return false; // Not installed yet
|
||||
});
|
||||
|
||||
await handleInstallCommand(['./my-agent.yml'], {});
|
||||
|
||||
expect(installCustomAgent).toHaveBeenCalledWith(
|
||||
'my-custom-agent',
|
||||
expect.stringContaining('my-agent.yml'),
|
||||
{
|
||||
name: 'my-custom-agent',
|
||||
description: 'Test description',
|
||||
author: 'Test Author',
|
||||
tags: ['custom', 'test'],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('detects paths with forward slashes', async () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
if (path.toString().includes('custom.yml')) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
await handleInstallCommand(['./agents/custom.yml'], {});
|
||||
|
||||
expect(installCustomAgent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates file exists before installation', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
await expect(handleInstallCommand(['./nonexistent.yml'], {})).rejects.toThrow(
|
||||
/File not found/
|
||||
);
|
||||
|
||||
expect(installCustomAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats non-path strings as registry names', async () => {
|
||||
await handleInstallCommand(['test-agent'], {});
|
||||
|
||||
// Should use bundled agent installation, not custom
|
||||
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
|
||||
expect(installCustomAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
359
dexto/packages/cli/src/cli/commands/install.ts
Normal file
359
dexto/packages/cli/src/cli/commands/install.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
// packages/cli/src/cli/commands/install.ts
|
||||
|
||||
import { existsSync, statSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import * as p from '@clack/prompts';
|
||||
import { getDextoGlobalPath, loadBundledRegistryAgents } from '@dexto/agent-management';
|
||||
import { textOrExit } from '../utils/prompt-helpers.js';
|
||||
import { installBundledAgent, installCustomAgent } from '../../utils/agent-helpers.js';
|
||||
import { capture } from '../../analytics/index.js';
|
||||
|
||||
// Zod schema for install command validation
|
||||
const InstallCommandSchema = z
|
||||
.object({
|
||||
agents: z.array(z.string().min(1, 'Agent name cannot be empty')),
|
||||
all: z.boolean().default(false),
|
||||
force: z.boolean().default(false),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type InstallCommandOptions = z.output<typeof InstallCommandSchema>;
|
||||
|
||||
/**
|
||||
* Check if a string is a file path (contains path separators or ends with .yml)
|
||||
*/
|
||||
function isFilePath(input: string): boolean {
|
||||
return (
|
||||
input.includes('/') ||
|
||||
input.includes('\\') ||
|
||||
input.endsWith('.yml') ||
|
||||
input.endsWith('.yaml')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract agent name from file path and sanitize for validity
|
||||
* Agent names must be lowercase alphanumeric with hyphens only.
|
||||
* Examples:
|
||||
* './my-agent.yml' -> 'my-agent'
|
||||
* './my_agent.yml' -> 'my-agent' (underscore converted)
|
||||
* './agents/foo/agent.yml' -> 'agent'
|
||||
* './MyAgent.yml' -> 'myagent'
|
||||
*/
|
||||
function extractAgentNameFromPath(filePath: string): string {
|
||||
const basename = path.basename(filePath);
|
||||
|
||||
// If it's a file, remove the extension
|
||||
let name = basename;
|
||||
if (basename.endsWith('.yml') || basename.endsWith('.yaml')) {
|
||||
name = basename.replace(/\.(yml|yaml)$/, '');
|
||||
}
|
||||
|
||||
// Sanitize: lowercase, replace underscores and invalid chars with hyphens
|
||||
name = name
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, '-') // Replace underscores and spaces with hyphens
|
||||
.replace(/[^a-z0-9-]/g, '') // Remove any other invalid characters
|
||||
.replace(/-+/g, '-') // Collapse multiple hyphens
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for custom agent metadata
|
||||
*/
|
||||
async function promptForMetadata(suggestedName: string): Promise<{
|
||||
agentName: string;
|
||||
description: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
}> {
|
||||
p.intro('📝 Custom Agent Installation');
|
||||
|
||||
const agentName = await textOrExit(
|
||||
{
|
||||
message: 'Agent name:',
|
||||
placeholder: suggestedName,
|
||||
defaultValue: suggestedName,
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'Agent name is required';
|
||||
}
|
||||
if (!/^[a-z0-9-]+$/.test(value)) {
|
||||
return 'Agent name must contain only lowercase letters, numbers, and hyphens';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
'Installation cancelled'
|
||||
);
|
||||
|
||||
const description = await textOrExit(
|
||||
{
|
||||
message: 'Description:',
|
||||
placeholder: 'A custom agent for...',
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'Description is required';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
'Installation cancelled'
|
||||
);
|
||||
|
||||
const author = await textOrExit(
|
||||
{
|
||||
message: 'Author:',
|
||||
placeholder: 'Your Name',
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'Author is required';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
'Installation cancelled'
|
||||
);
|
||||
|
||||
const tagsInput = await textOrExit(
|
||||
{
|
||||
message: 'Tags (comma-separated):',
|
||||
placeholder: 'custom, coding, productivity',
|
||||
defaultValue: 'custom',
|
||||
},
|
||||
'Installation cancelled'
|
||||
);
|
||||
|
||||
const tags = tagsInput
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
|
||||
// Ask about main config file for directory-based agents
|
||||
// We'll determine if it's a directory later in the flow
|
||||
|
||||
return { agentName, description, author, tags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate install command arguments with registry-aware validation
|
||||
*/
|
||||
function validateInstallCommand(
|
||||
agents: string[],
|
||||
options: Partial<InstallCommandOptions>
|
||||
): InstallCommandOptions {
|
||||
// Basic structure validation
|
||||
const validated = InstallCommandSchema.parse({
|
||||
...options,
|
||||
agents,
|
||||
});
|
||||
|
||||
// Business logic validation
|
||||
const availableAgents = loadBundledRegistryAgents();
|
||||
if (!validated.all && validated.agents.length === 0) {
|
||||
throw new Error(
|
||||
`No agents specified. Use agent names or --all flag. Available agents: ${Object.keys(availableAgents).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!validated.all) {
|
||||
// Separate file paths from registry names
|
||||
const filePaths = validated.agents.filter(isFilePath);
|
||||
const registryNames = validated.agents.filter((agent) => !isFilePath(agent));
|
||||
|
||||
// Validate registry names exist in registry
|
||||
const invalidAgents = registryNames.filter((agent) => !(agent in availableAgents));
|
||||
if (invalidAgents.length > 0) {
|
||||
throw new Error(
|
||||
`Unknown agents: ${invalidAgents.join(', ')}. ` +
|
||||
`Available agents: ${Object.keys(availableAgents).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file paths exist
|
||||
for (const filePath of filePaths) {
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!existsSync(resolved)) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
// TODO: move registry code into CLI and move dexto_install_agent metric into registry
|
||||
export async function handleInstallCommand(
|
||||
agents: string[],
|
||||
options: Partial<InstallCommandOptions>
|
||||
): Promise<void> {
|
||||
// Validate command with Zod
|
||||
const validated = validateInstallCommand(agents, options);
|
||||
|
||||
// Determine which agents to install
|
||||
let agentsToInstall: string[];
|
||||
if (validated.all) {
|
||||
// --all flag only works with registry agents, not file paths
|
||||
const availableAgents = loadBundledRegistryAgents();
|
||||
agentsToInstall = Object.keys(availableAgents);
|
||||
console.log(`📋 Installing all ${agentsToInstall.length} available agents...`);
|
||||
} else {
|
||||
agentsToInstall = validated.agents;
|
||||
}
|
||||
|
||||
console.log(`🚀 Installing ${agentsToInstall.length} agents...`);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const errors: string[] = [];
|
||||
const installed: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const failed: string[] = [];
|
||||
|
||||
// Install each agent
|
||||
for (const agentInput of agentsToInstall) {
|
||||
try {
|
||||
// Check if this is a file path or registry name
|
||||
if (isFilePath(agentInput)) {
|
||||
// Custom agent installation from file path
|
||||
console.log(`\n📦 Installing custom agent from ${agentInput}...`);
|
||||
|
||||
const resolvedPath = path.resolve(agentInput);
|
||||
|
||||
// Detect if source is directory or file
|
||||
const stats = statSync(resolvedPath);
|
||||
const isDirectory = stats.isDirectory();
|
||||
|
||||
// Extract suggested name based on whether it's a directory or file
|
||||
const suggestedName = isDirectory
|
||||
? path.basename(resolvedPath)
|
||||
: extractAgentNameFromPath(resolvedPath);
|
||||
|
||||
// Prompt for metadata
|
||||
const metadata = await promptForMetadata(suggestedName);
|
||||
|
||||
// Check if already installed (unless --force)
|
||||
const globalAgentsDir = getDextoGlobalPath('agents');
|
||||
const installedPath = path.join(globalAgentsDir, metadata.agentName);
|
||||
if (existsSync(installedPath) && !validated.force) {
|
||||
console.log(
|
||||
`⏭️ ${metadata.agentName} already installed (use --force to reinstall)`
|
||||
);
|
||||
skipped.push(metadata.agentName);
|
||||
capture('dexto_install_agent', {
|
||||
agent: metadata.agentName,
|
||||
status: 'skipped',
|
||||
reason: 'already_installed',
|
||||
force: validated.force,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Install custom agent
|
||||
await installCustomAgent(metadata.agentName, resolvedPath, {
|
||||
name: metadata.agentName,
|
||||
description: metadata.description,
|
||||
author: metadata.author,
|
||||
tags: metadata.tags,
|
||||
});
|
||||
|
||||
successCount++;
|
||||
console.log(`✅ ${metadata.agentName} installed successfully`);
|
||||
installed.push(metadata.agentName);
|
||||
|
||||
p.outro('🎉 Custom agent installed successfully!');
|
||||
|
||||
capture('dexto_install_agent', {
|
||||
agent: metadata.agentName,
|
||||
status: 'installed',
|
||||
force: validated.force,
|
||||
});
|
||||
} else {
|
||||
// Bundled agent installation from registry
|
||||
console.log(`\n📦 Installing ${agentInput}...`);
|
||||
|
||||
// Check if already installed (unless --force)
|
||||
const globalAgentsDir = getDextoGlobalPath('agents');
|
||||
const installedPath = path.join(globalAgentsDir, agentInput);
|
||||
if (existsSync(installedPath) && !validated.force) {
|
||||
console.log(`⏭️ ${agentInput} already installed (use --force to reinstall)`);
|
||||
skipped.push(agentInput);
|
||||
capture('dexto_install_agent', {
|
||||
agent: agentInput,
|
||||
status: 'skipped',
|
||||
reason: 'already_installed',
|
||||
force: validated.force,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await installBundledAgent(agentInput);
|
||||
successCount++;
|
||||
console.log(`✅ ${agentInput} installed successfully`);
|
||||
installed.push(agentInput);
|
||||
|
||||
capture('dexto_install_agent', {
|
||||
agent: agentInput,
|
||||
status: 'installed',
|
||||
force: validated.force,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
const errorMsg = `Failed to install ${agentInput}: ${error instanceof Error ? error.message : String(error)}`;
|
||||
errors.push(errorMsg);
|
||||
failed.push(agentInput);
|
||||
console.error(`❌ ${errorMsg}`);
|
||||
|
||||
// Sanitize agent identifier for analytics (avoid sending full local paths)
|
||||
const safeAgentId = isFilePath(agentInput) ? path.basename(agentInput) : agentInput;
|
||||
capture('dexto_install_agent', {
|
||||
agent: safeAgentId,
|
||||
status: 'failed',
|
||||
error_message: error instanceof Error ? error.message : String(error),
|
||||
force: validated.force,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Emit analytics for both single- and multi-agent cases
|
||||
try {
|
||||
capture('dexto_install', {
|
||||
requested: agentsToInstall,
|
||||
installed,
|
||||
skipped,
|
||||
failed,
|
||||
successCount,
|
||||
errorCount,
|
||||
});
|
||||
} catch {
|
||||
// Analytics failures should not block CLI execution.
|
||||
}
|
||||
|
||||
// For single agent operations, throw error if it failed (after emitting analytics)
|
||||
if (agentsToInstall.length === 1) {
|
||||
if (errorCount > 0) {
|
||||
throw new Error(errors[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show summary if more than 1 agent installed
|
||||
console.log(`\n📊 Installation Summary:`);
|
||||
console.log(`✅ Successfully installed: ${successCount}`);
|
||||
if (errorCount > 0) {
|
||||
console.log(`❌ Failed to install: ${errorCount}`);
|
||||
errors.forEach((error) => console.log(` • ${error}`));
|
||||
}
|
||||
|
||||
if (errorCount > 0 && successCount === 0) {
|
||||
throw new Error('All installations failed');
|
||||
} else if (errorCount > 0) {
|
||||
console.log(`⚠️ Some installations failed, but ${successCount} succeeded.`);
|
||||
} else {
|
||||
console.log(`🎉 All agents installed successfully!`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Auth Commands Module
|
||||
*
|
||||
* Authentication commands for interactive CLI.
|
||||
*/
|
||||
|
||||
import type { CommandDefinition } from '../command-parser.js';
|
||||
import { handleLoginCommand } from '../../auth/login.js';
|
||||
|
||||
/**
|
||||
* Login command - triggers OAuth flow for Dexto authentication
|
||||
* Only available when DEXTO_FEATURE_AUTH=true
|
||||
*/
|
||||
export const loginCommand: CommandDefinition = {
|
||||
name: 'login',
|
||||
description: 'Login to Dexto',
|
||||
usage: '/login',
|
||||
category: 'General',
|
||||
handler: async () => {
|
||||
await handleLoginCommand({ interactive: true });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import chalk from 'chalk';
|
||||
import type { DextoAgent } from '@dexto/core';
|
||||
import type { StyledOutput, SendMessageMarker } from '../../ink-cli/services/CommandService.js';
|
||||
|
||||
export interface CommandResult {
|
||||
type: 'command' | 'prompt';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
rawInput: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command handler return type:
|
||||
* - boolean: Command handled (true) or not found (false)
|
||||
* - string: Output text to display
|
||||
* - StyledOutput: Styled output with structured data for rich rendering
|
||||
* - SendMessageMarker: Send text through normal streaming flow (for prompt commands)
|
||||
*/
|
||||
export type CommandHandlerResult = boolean | string | StyledOutput | SendMessageMarker;
|
||||
|
||||
/**
|
||||
* Context passed to command handlers
|
||||
*/
|
||||
export interface CommandContext {
|
||||
/** Current session ID, or null if no active session */
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export interface CommandDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
usage: string;
|
||||
category?: string;
|
||||
aliases?: string[];
|
||||
subcommands?: CommandDefinition[];
|
||||
handler: (
|
||||
args: string[],
|
||||
agent: DextoAgent,
|
||||
ctx: CommandContext
|
||||
) => Promise<CommandHandlerResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op handler for overlay-only commands.
|
||||
* Used by commands in ALWAYS_OVERLAY that are handled entirely by the overlay system.
|
||||
*/
|
||||
export const overlayOnlyHandler = async (): Promise<CommandHandlerResult> => true;
|
||||
|
||||
/**
|
||||
* Parse arguments respecting quotes and escape sequences
|
||||
*/
|
||||
function parseQuotedArguments(input: string): string[] {
|
||||
const args: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < input.length) {
|
||||
const char = input[i];
|
||||
const nextChar = input[i + 1];
|
||||
|
||||
if (!inQuotes && (char === '"' || char === "'")) {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
} else if (inQuotes && char === quoteChar) {
|
||||
inQuotes = false;
|
||||
quoteChar = '';
|
||||
} else if (!inQuotes && char === ' ') {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
current = '';
|
||||
}
|
||||
} else if (char === '\\' && nextChar) {
|
||||
// Handle escape sequences
|
||||
current += nextChar;
|
||||
i++; // Skip next character
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
args.push(current);
|
||||
}
|
||||
|
||||
return args.filter((arg) => arg.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses user input to determine if it's a slash command, shell command, or regular prompt
|
||||
*/
|
||||
export function parseInput(input: string): CommandResult {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Check if it's a shell command (! prefix)
|
||||
if (trimmed.startsWith('!')) {
|
||||
const shellCommand = trimmed.slice(1).trim();
|
||||
return {
|
||||
type: 'command',
|
||||
command: 'shell',
|
||||
args: [shellCommand],
|
||||
rawInput: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's a slash command
|
||||
if (trimmed.startsWith('/')) {
|
||||
const args = parseQuotedArguments(trimmed.slice(1));
|
||||
const command = args[0] || '';
|
||||
const commandArgs = args.slice(1);
|
||||
|
||||
return {
|
||||
type: 'command',
|
||||
command,
|
||||
args: commandArgs,
|
||||
rawInput: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
// Regular user prompt
|
||||
return {
|
||||
type: 'prompt',
|
||||
rawInput: input,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds command suggestions based on partial input
|
||||
*/
|
||||
export function getCommandSuggestions(partial: string, commands: CommandDefinition[]): string[] {
|
||||
const suggestions: string[] = [];
|
||||
|
||||
for (const cmd of commands) {
|
||||
// Check main command name
|
||||
if (cmd.name.startsWith(partial)) {
|
||||
suggestions.push(cmd.name);
|
||||
}
|
||||
|
||||
// Check aliases
|
||||
if (cmd.aliases) {
|
||||
for (const alias of cmd.aliases) {
|
||||
if (alias.startsWith(partial)) {
|
||||
suggestions.push(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats help text for a command
|
||||
*/
|
||||
export function formatCommandHelp(cmd: CommandDefinition, detailed: boolean = false): string {
|
||||
let help = chalk.cyan(`/${cmd.name}`) + ' - ' + cmd.description;
|
||||
|
||||
if (detailed) {
|
||||
help += '\n' + chalk.gray(`Usage: ${cmd.usage}`);
|
||||
|
||||
if (cmd.aliases && cmd.aliases.length > 0) {
|
||||
help += '\n' + chalk.gray(`Aliases: ${cmd.aliases.map((a) => `/${a}`).join(', ')}`);
|
||||
}
|
||||
|
||||
if (cmd.subcommands && cmd.subcommands.length > 0) {
|
||||
help += '\n' + chalk.gray('Subcommands:');
|
||||
for (const sub of cmd.subcommands) {
|
||||
help += '\n ' + chalk.cyan(`/${cmd.name} ${sub.name}`) + ' - ' + sub.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return help;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a formatted list of all available commands
|
||||
*/
|
||||
export function displayAllCommands(commands: CommandDefinition[]): void {
|
||||
console.log(chalk.bold.green('\n📋 Available Commands:\n'));
|
||||
|
||||
// Define category order for consistent display
|
||||
const categoryOrder = [
|
||||
'General',
|
||||
'Session Management',
|
||||
'Model Management',
|
||||
'MCP Management',
|
||||
'Plugin Management',
|
||||
'Tool Management',
|
||||
'Prompt Management',
|
||||
'System',
|
||||
];
|
||||
|
||||
const categories: { [key: string]: CommandDefinition[] } = {};
|
||||
|
||||
// Initialize categories
|
||||
for (const category of categoryOrder) {
|
||||
categories[category] = [];
|
||||
}
|
||||
|
||||
// Categorize commands using metadata
|
||||
for (const cmd of commands) {
|
||||
const category = cmd.category || 'General';
|
||||
if (!categories[category]) {
|
||||
categories[category] = [];
|
||||
}
|
||||
categories[category]!.push(cmd);
|
||||
}
|
||||
|
||||
// Display by category in order
|
||||
for (const category of categoryOrder) {
|
||||
const cmds = categories[category];
|
||||
if (cmds && cmds.length > 0) {
|
||||
console.log(chalk.bold.rgb(255, 165, 0)(`${category}:`));
|
||||
for (const cmd of cmds) {
|
||||
console.log(' ' + formatCommandHelp(cmd));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
// Display any uncategorized commands (fallback)
|
||||
for (const [category, cmds] of Object.entries(categories)) {
|
||||
if (!categoryOrder.includes(category) && cmds.length > 0) {
|
||||
console.log(chalk.bold.rgb(255, 165, 0)(`${category}:`));
|
||||
for (const cmd of cmds) {
|
||||
console.log(' ' + formatCommandHelp(cmd));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.gray('💡 Tip: Use /help <command> for detailed help on any command'));
|
||||
console.log(chalk.gray('💡 Tip: Type your message normally (without /) to chat with the AI\n'));
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* CLI Commands Module (Modular Version)
|
||||
*
|
||||
* This module aggregates all CLI commands from extracted modular components.
|
||||
* It maintains the same external interface as the original monolithic commands.ts
|
||||
* while using the new modular structure internally.
|
||||
*
|
||||
* The commands are organized into logical modules:
|
||||
* - General Commands: Basic CLI functionality (help, exit, clear)
|
||||
* - Conversation Commands: Session management, history, and search
|
||||
* - Model Commands: Model switching and configuration
|
||||
* - MCP Commands: MCP server management
|
||||
* - Plugin Commands: Claude Code plugin management
|
||||
* - System Commands: Configuration, logging, and statistics
|
||||
* - Tool Commands: Tool listing and management
|
||||
* - Prompt Commands: System prompt management
|
||||
* - Documentation Commands: Help and documentation access
|
||||
*
|
||||
* This file serves as the integration layer that combines all modular commands
|
||||
* into a single CLI_COMMANDS array for the command execution system.
|
||||
*/
|
||||
|
||||
import type { DextoAgent } from '@dexto/core';
|
||||
import type { CommandDefinition, CommandHandlerResult } from './command-parser.js';
|
||||
import { isDextoAuthEnabled } from '@dexto/agent-management';
|
||||
|
||||
// Import modular command definitions
|
||||
import { generalCommands, createHelpCommand } from './general-commands.js';
|
||||
import { searchCommand, resumeCommand, renameCommand } from './session/index.js';
|
||||
import { exportCommand } from './export/index.js';
|
||||
import { modelCommands } from './model/index.js';
|
||||
import { mcpCommands } from './mcp/index.js';
|
||||
import { pluginCommands } from './plugin/index.js';
|
||||
import { systemCommands } from './system/index.js';
|
||||
import { toolCommands } from './tool-commands.js';
|
||||
import { promptCommands } from './prompt-commands.js';
|
||||
import { documentationCommands } from './documentation-commands.js';
|
||||
import { loginCommand } from './auth/index.js';
|
||||
|
||||
/**
|
||||
* Complete list of all available CLI commands.
|
||||
* This array combines commands from all extracted modules to maintain
|
||||
* the same interface as the original monolithic implementation.
|
||||
*
|
||||
* Commands are organized by category:
|
||||
* - General: help, exit, clear
|
||||
* - Session Management: session, history, search
|
||||
* - Model Management: model
|
||||
* - MCP Management: mcp
|
||||
* - Tool Management: tools
|
||||
* - Prompt Management: prompt
|
||||
* - System: log, config, stats
|
||||
* - Documentation: docs
|
||||
*/
|
||||
export const CLI_COMMANDS: CommandDefinition[] = [];
|
||||
|
||||
// Build the commands array with proper help command that can access all commands
|
||||
// All commands here use interactive overlays - no text-based subcommands
|
||||
const baseCommands: CommandDefinition[] = [
|
||||
// General commands (without help)
|
||||
...generalCommands,
|
||||
|
||||
// Session management
|
||||
searchCommand, // /search - opens search overlay
|
||||
resumeCommand, // /resume - opens session selector overlay
|
||||
renameCommand, // /rename <title> - rename current session
|
||||
exportCommand, // /export - opens export wizard overlay
|
||||
|
||||
// Model management
|
||||
modelCommands, // /model - opens model selector overlay
|
||||
|
||||
// MCP server management
|
||||
mcpCommands, // /mcp - opens MCP server list overlay
|
||||
|
||||
// Plugin management
|
||||
pluginCommands, // /plugin - manage Claude Code compatible plugins
|
||||
|
||||
// Tool management commands
|
||||
...toolCommands,
|
||||
|
||||
// Prompt management commands
|
||||
...promptCommands,
|
||||
|
||||
// System commands
|
||||
...systemCommands,
|
||||
|
||||
// Documentation commands
|
||||
...documentationCommands,
|
||||
|
||||
// Auth commands (feature-flagged)
|
||||
...(isDextoAuthEnabled() ? [loginCommand] : []),
|
||||
];
|
||||
|
||||
// Add help command that can see all commands
|
||||
CLI_COMMANDS.push(createHelpCommand(() => CLI_COMMANDS));
|
||||
|
||||
// Add all other commands
|
||||
CLI_COMMANDS.push(...baseCommands);
|
||||
|
||||
/**
|
||||
* Execute a slash command
|
||||
*
|
||||
* @param sessionId - Session ID to use for agent.run() calls
|
||||
* @returns CommandHandlerResult - boolean, string, or StyledOutput
|
||||
*/
|
||||
export async function executeCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
agent: DextoAgent,
|
||||
sessionId?: string
|
||||
): Promise<CommandHandlerResult> {
|
||||
// Create command context with sessionId
|
||||
const ctx = { sessionId: sessionId ?? null };
|
||||
|
||||
// Find the command (including aliases)
|
||||
const cmd = CLI_COMMANDS.find(
|
||||
(c) => c.name === command || (c.aliases && c.aliases.includes(command))
|
||||
);
|
||||
|
||||
if (cmd) {
|
||||
try {
|
||||
// Execute the handler with context
|
||||
const result = await cmd.handler(args, agent, ctx);
|
||||
// If handler returns a string, it's formatted output for ink-cli
|
||||
// If it returns boolean, it's the old behavior (handled or not)
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMsg = `❌ Error executing command /${command}:\n${error instanceof Error ? error.message : String(error)}`;
|
||||
agent.logger.error(
|
||||
`Error executing command /${command}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return errorMsg; // Return for ink-cli
|
||||
}
|
||||
}
|
||||
|
||||
// Command not found in static commands - check if it's a dynamic prompt command
|
||||
// Dynamic commands use displayName (e.g., "quick-start" instead of "config:quick-start")
|
||||
try {
|
||||
// Import prompt command creation dynamically to avoid circular dependencies
|
||||
const { getDynamicPromptCommands } = await import('./prompt-commands.js');
|
||||
const dynamicCommands = await getDynamicPromptCommands(agent);
|
||||
// Commands are registered by displayName, so search by command name directly
|
||||
const promptCmd = dynamicCommands.find((c) => c.name === command);
|
||||
|
||||
if (promptCmd) {
|
||||
try {
|
||||
const result = await promptCmd.handler(args, agent, ctx);
|
||||
// Return the result directly - can be string, boolean, StyledOutput, or SendMessageMarker
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMsg = `❌ Error executing prompt /${command}:\n${error instanceof Error ? error.message : String(error)}`;
|
||||
agent.logger.error(
|
||||
`Error executing prompt /${command}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return errorMsg;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If loading dynamic commands fails, continue to unknown command error
|
||||
agent.logger.debug(
|
||||
`Failed to check dynamic commands for ${command}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Command not found and not a prompt
|
||||
const errorMsg = `❌ Unknown command: /${command}\nType / to see available commands, /prompts to add new ones`;
|
||||
return errorMsg; // Return for ink-cli
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available command definitions
|
||||
* This is used by external systems that need to inspect available commands
|
||||
*/
|
||||
export function getAllCommands(): CommandDefinition[] {
|
||||
return CLI_COMMANDS;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Documentation Commands Module
|
||||
*
|
||||
* This module defines documentation-related slash commands for the Dexto CLI interface.
|
||||
* These commands provide functionality for accessing documentation and help resources.
|
||||
*
|
||||
* Available Documentation Commands:
|
||||
* - /docs, /doc - Open Dexto documentation in browser
|
||||
*/
|
||||
|
||||
import type { DextoAgent } from '@dexto/core';
|
||||
import type { CommandDefinition, CommandContext } from './command-parser.js';
|
||||
import { CommandOutputHelper } from './utils/command-output.js';
|
||||
|
||||
/**
|
||||
* Documentation commands
|
||||
*/
|
||||
export const documentationCommands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'docs',
|
||||
description: 'Open Dexto documentation in browser',
|
||||
usage: '/docs',
|
||||
category: 'Documentation',
|
||||
aliases: ['doc'],
|
||||
handler: async (
|
||||
_args: string[],
|
||||
_agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
const docsUrl = 'https://docs.dexto.ai/docs/category/getting-started';
|
||||
try {
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
// Cross-platform browser opening
|
||||
const command =
|
||||
process.platform === 'darwin'
|
||||
? 'open'
|
||||
: process.platform === 'win32'
|
||||
? 'start'
|
||||
: 'xdg-open';
|
||||
|
||||
spawn(command, [docsUrl], { detached: true, stdio: 'ignore' });
|
||||
return true; // Silent success
|
||||
} catch {
|
||||
return CommandOutputHelper.error(
|
||||
new Error(`Could not open browser. Visit: ${docsUrl}`),
|
||||
'Failed to open documentation'
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Export Command Module
|
||||
*
|
||||
* Provides the /export command for exporting conversation history.
|
||||
* Always shows the interactive export wizard overlay.
|
||||
*/
|
||||
|
||||
import type { CommandDefinition, CommandContext, CommandHandlerResult } from '../command-parser.js';
|
||||
import type { DextoAgent } from '@dexto/core';
|
||||
|
||||
/**
|
||||
* Export command definition
|
||||
* Always shows the interactive export wizard overlay (handled by ALWAYS_OVERLAY)
|
||||
*/
|
||||
export const exportCommand: CommandDefinition = {
|
||||
name: 'export',
|
||||
description: 'Export conversation to markdown or JSON',
|
||||
usage: '/export',
|
||||
category: 'Session',
|
||||
handler: async (
|
||||
_args: string[],
|
||||
_agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<CommandHandlerResult> => {
|
||||
// This handler is never called - export is in ALWAYS_OVERLAY
|
||||
// which intercepts and shows the export wizard overlay instead
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* General Commands Module
|
||||
*
|
||||
* This module defines general-purpose slash commands for the Dexto CLI interface.
|
||||
* These are basic commands that don't fit into specific categories.
|
||||
*
|
||||
* Available General Commands:
|
||||
* - /help [command] - Show help information
|
||||
* - /exit, /quit, /q - Exit the CLI application
|
||||
* - /clear, /reset - Clear conversation history
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { spawn } from 'child_process';
|
||||
import type { DextoAgent } from '@dexto/core';
|
||||
import type { CommandDefinition, CommandHandlerResult, CommandContext } from './command-parser.js';
|
||||
import { formatForInkCli } from './utils/format-output.js';
|
||||
import { CommandOutputHelper } from './utils/command-output.js';
|
||||
import type { HelpStyledData, ShortcutsStyledData } from '../../ink-cli/state/types.js';
|
||||
import { writeToClipboard } from '../../ink-cli/utils/clipboardUtils.js';
|
||||
|
||||
/**
|
||||
* Get the shell rc file path for the given shell
|
||||
*/
|
||||
function getShellRcFile(shell: string): string | null {
|
||||
const home = process.env.HOME;
|
||||
if (!home) return null;
|
||||
|
||||
if (shell.includes('zsh')) {
|
||||
return `${home}/.zshrc`;
|
||||
} else if (shell.includes('bash')) {
|
||||
return `${home}/.bashrc`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap command to source shell rc file for alias support
|
||||
* This mimics how Claude Code handles shell aliases - by sourcing the rc file
|
||||
* before executing the command, we get aliases without using -i flag.
|
||||
* We use eval to force the command to be re-parsed after sourcing, since
|
||||
* aliases are expanded at parse time, not execution time.
|
||||
*/
|
||||
function wrapCommandWithRcSource(command: string, shell: string): string {
|
||||
const rcFile = getShellRcFile(shell);
|
||||
if (!rcFile) {
|
||||
return command;
|
||||
}
|
||||
|
||||
// Escape single quotes in the command for safe eval
|
||||
const escapedCommand = command.replace(/'/g, "'\\''");
|
||||
|
||||
// Source the rc file (suppressing errors if it doesn't exist), then eval the command
|
||||
// eval forces re-parsing after sourcing, allowing aliases to expand
|
||||
// For bash, we also need to enable expand_aliases
|
||||
if (shell.includes('bash')) {
|
||||
return `source "${rcFile}" 2>/dev/null; shopt -s expand_aliases 2>/dev/null; eval '${escapedCommand}'`;
|
||||
}
|
||||
return `source "${rcFile}" 2>/dev/null; eval '${escapedCommand}'`;
|
||||
}
|
||||
|
||||
async function executeShellCommand(
|
||||
command: string,
|
||||
cwd: string,
|
||||
timeoutMs: number = 30000
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
return new Promise((resolve) => {
|
||||
// Use user's default shell from SHELL env var, fallback to /bin/sh
|
||||
// Avoid -i (interactive) as it sets up job control which causes SIGTTIN
|
||||
// when the parent process tries to read stdin while shell runs.
|
||||
// Instead, source the shell's rc file to get aliases (similar to Claude Code).
|
||||
// Use detached: true to create a new process group, preventing the child
|
||||
// from interfering with the parent's terminal control.
|
||||
const userShell = process.env.SHELL || '/bin/sh';
|
||||
const wrappedCommand = wrapCommandWithRcSource(command, userShell);
|
||||
|
||||
const child = spawn(userShell, ['-c', wrappedCommand], {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
detached: true,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
resolve({ stdout, stderr: `Command timed out after ${timeoutMs}ms`, exitCode: -1 });
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error: Error) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr: error.message, exitCode: -1 });
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, exitCode: code ?? -1 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the help command with access to all commands for display
|
||||
*/
|
||||
export function createHelpCommand(getAllCommands: () => CommandDefinition[]): CommandDefinition {
|
||||
return {
|
||||
name: 'help',
|
||||
description: 'Show help information',
|
||||
usage: '/help',
|
||||
category: 'General',
|
||||
aliases: ['h', '?'],
|
||||
handler: async (
|
||||
_args: string[],
|
||||
_agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<CommandHandlerResult> => {
|
||||
const allCommands = getAllCommands();
|
||||
|
||||
// Build styled data for help
|
||||
const styledData: HelpStyledData = {
|
||||
commands: allCommands.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
category: cmd.category || 'General',
|
||||
})),
|
||||
};
|
||||
|
||||
// Build fallback text
|
||||
const fallbackLines: string[] = ['Available Commands:'];
|
||||
for (const cmd of allCommands) {
|
||||
fallbackLines.push(` /${cmd.name} - ${cmd.description}`);
|
||||
}
|
||||
|
||||
return CommandOutputHelper.styled('help', styledData, fallbackLines.join('\n'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* General commands that are available across all contexts
|
||||
* Note: The help command is created separately to avoid circular dependencies
|
||||
*/
|
||||
export const generalCommands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'shell',
|
||||
description: 'Execute shell command directly (use !command as shortcut)',
|
||||
usage: '!<command> or /shell <command>',
|
||||
category: 'General',
|
||||
handler: async (
|
||||
args: string[],
|
||||
agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
const command = args.join(' ').trim();
|
||||
if (!command) {
|
||||
return formatForInkCli('❌ No command provided. Usage: !<command>');
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
agent.logger.debug(`Executing shell command: ${command}`);
|
||||
|
||||
const { stdout, stderr, exitCode } = await executeShellCommand(command, cwd);
|
||||
|
||||
// Build output
|
||||
const lines: string[] = [];
|
||||
|
||||
if (stdout.trim()) {
|
||||
lines.push(stdout.trim());
|
||||
}
|
||||
if (stderr.trim()) {
|
||||
lines.push(chalk.yellow(stderr.trim()));
|
||||
}
|
||||
if (exitCode !== 0) {
|
||||
lines.push(chalk.red(`Exit code: ${exitCode}`));
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return formatForInkCli(chalk.gray('(no output)'));
|
||||
}
|
||||
|
||||
return formatForInkCli(lines.join('\n'));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'exit',
|
||||
description: 'Exit the CLI',
|
||||
usage: '/exit',
|
||||
category: 'General',
|
||||
aliases: ['quit', 'q'],
|
||||
handler: async (
|
||||
_args: string[],
|
||||
_agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
console.log(chalk.rgb(255, 165, 0)('Exiting AI CLI. Goodbye!'));
|
||||
process.exit(0);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
description: 'Start a new conversation',
|
||||
usage: '/new',
|
||||
category: 'General',
|
||||
handler: async (
|
||||
_args: string[],
|
||||
agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
try {
|
||||
// Create a new session
|
||||
const newSession = await agent.createSession();
|
||||
const newSessionId = newSession.id;
|
||||
|
||||
// Emit session:created to switch the CLI to the new session
|
||||
agent.agentEventBus.emit('session:created', {
|
||||
sessionId: newSessionId,
|
||||
switchTo: true,
|
||||
});
|
||||
|
||||
return formatForInkCli(
|
||||
`✨ New conversation started\n💡 Use /resume to see previous conversations`
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to create new session: ${error instanceof Error ? error.message : String(error)}`;
|
||||
agent.logger.error(errorMsg);
|
||||
return formatForInkCli(`❌ ${errorMsg}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
description: 'Continue conversation, free up AI context window',
|
||||
usage: '/clear',
|
||||
category: 'General',
|
||||
handler: async (
|
||||
_args: string[],
|
||||
agent: DextoAgent,
|
||||
ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
try {
|
||||
const { sessionId } = ctx;
|
||||
if (!sessionId) {
|
||||
return formatForInkCli('⚠️ No active session to clear');
|
||||
}
|
||||
|
||||
// Clear context window - adds a marker so filterCompacted skips prior messages
|
||||
// History stays in DB for review, but LLM won't see it
|
||||
await agent.clearContext(sessionId);
|
||||
|
||||
return formatForInkCli(
|
||||
'🧹 Context window cleared\n💡 Conversation continues - AI will not see older messages'
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to clear context: ${error instanceof Error ? error.message : String(error)}`;
|
||||
agent.logger.error(errorMsg);
|
||||
return formatForInkCli(`❌ ${errorMsg}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'compact',
|
||||
description: 'Compact context by summarizing older messages',
|
||||
usage: '/compact',
|
||||
category: 'General',
|
||||
aliases: ['summarize'],
|
||||
handler: async (
|
||||
_args: string[],
|
||||
agent: DextoAgent,
|
||||
ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
try {
|
||||
const { sessionId } = ctx;
|
||||
if (!sessionId) {
|
||||
return formatForInkCli('⚠️ No active session to compact');
|
||||
}
|
||||
|
||||
// Compact context - generates summary and hides older messages
|
||||
// The context:compacting and context:compacted events are handled by useAgentEvents
|
||||
// which shows the compacting indicator and notification message
|
||||
const result = await agent.compactContext(sessionId);
|
||||
|
||||
if (!result) {
|
||||
return formatForInkCli(
|
||||
'💡 Nothing to compact - history is too short or compaction is not configured.'
|
||||
);
|
||||
}
|
||||
|
||||
// Return true - notification is shown via context:compacted event handler
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to compact context: ${error instanceof Error ? error.message : String(error)}`;
|
||||
agent.logger.error(errorMsg);
|
||||
return formatForInkCli(`❌ ${errorMsg}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'context',
|
||||
description: 'Show context window usage statistics',
|
||||
usage: '/context',
|
||||
category: 'General',
|
||||
aliases: ['ctx', 'tokens'],
|
||||
handler: async (
|
||||
_args: string[],
|
||||
agent: DextoAgent,
|
||||
ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
try {
|
||||
const { sessionId } = ctx;
|
||||
if (!sessionId) {
|
||||
return formatForInkCli('⚠️ No active session');
|
||||
}
|
||||
|
||||
const stats = await agent.getContextStats(sessionId);
|
||||
|
||||
// Create a visual progress bar (clamped to 0-100% for display)
|
||||
const barWidth = 20;
|
||||
const displayPercent = Math.min(Math.max(stats.usagePercent, 0), 100);
|
||||
const filledWidth = Math.round((displayPercent / 100) * barWidth);
|
||||
const emptyWidth = barWidth - filledWidth;
|
||||
const progressBar = '█'.repeat(filledWidth) + '░'.repeat(emptyWidth);
|
||||
|
||||
// Color based on usage
|
||||
let usageColor = chalk.green;
|
||||
if (stats.usagePercent > 80) usageColor = chalk.red;
|
||||
else if (stats.usagePercent > 60) usageColor = chalk.yellow;
|
||||
|
||||
// Helper to format token counts
|
||||
const formatTokens = (tokens: number): string => {
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return tokens.toLocaleString();
|
||||
};
|
||||
|
||||
// Calculate auto compact buffer (reserved space before compaction triggers)
|
||||
// maxContextTokens already has thresholdPercent applied, so we need to derive
|
||||
// the buffer as: maxContextTokens * (1 - thresholdPercent) / thresholdPercent
|
||||
const autoCompactBuffer =
|
||||
stats.thresholdPercent > 0 && stats.thresholdPercent < 1.0
|
||||
? Math.floor(
|
||||
(stats.maxContextTokens * (1 - stats.thresholdPercent)) /
|
||||
stats.thresholdPercent
|
||||
)
|
||||
: 0;
|
||||
const bufferPercent = Math.round((1 - stats.thresholdPercent) * 100);
|
||||
const bufferLabel =
|
||||
bufferPercent > 0
|
||||
? `Auto compact buffer (${bufferPercent}%)`
|
||||
: 'Auto compact buffer';
|
||||
|
||||
const totalTokenSpace = stats.maxContextTokens + autoCompactBuffer;
|
||||
const usedTokens = stats.estimatedTokens + autoCompactBuffer;
|
||||
|
||||
// Helper to calculate percentage of total token space
|
||||
const pct = (tokens: number): string => {
|
||||
const percent =
|
||||
totalTokenSpace > 0 ? ((tokens / totalTokenSpace) * 100).toFixed(1) : '0.0';
|
||||
return `${percent}%`;
|
||||
};
|
||||
|
||||
const overflowWarning = stats.usagePercent > 100 ? ' ⚠️ OVERFLOW' : '';
|
||||
const { breakdown } = stats;
|
||||
|
||||
const tokenDisplay = `~${formatTokens(usedTokens)}`;
|
||||
|
||||
const breakdownLabel = chalk.dim('(estimated)');
|
||||
const lines = [
|
||||
`📊 Context Usage`,
|
||||
` ${usageColor(progressBar)} ${stats.usagePercent}%${overflowWarning}`,
|
||||
` ${chalk.dim(stats.modelDisplayName)} · ${tokenDisplay} / ${formatTokens(totalTokenSpace)} tokens`,
|
||||
``,
|
||||
` ${chalk.cyan('Breakdown:')} ${breakdownLabel}`,
|
||||
` ├─ System prompt: ${formatTokens(breakdown.systemPrompt)} (${pct(breakdown.systemPrompt)})`,
|
||||
` ├─ Tools: ${formatTokens(breakdown.tools.total)} (${pct(breakdown.tools.total)})`,
|
||||
` ├─ Messages: ${formatTokens(breakdown.messages)} (${pct(breakdown.messages)})`,
|
||||
` └─ ${bufferLabel}: ${formatTokens(autoCompactBuffer)} (${pct(autoCompactBuffer)})`,
|
||||
``,
|
||||
` Messages: ${stats.filteredMessageCount} visible (${stats.messageCount} total)`,
|
||||
];
|
||||
|
||||
// Show pruned tool count if any
|
||||
if (stats.prunedToolCount > 0) {
|
||||
lines.push(` 🗑️ ${stats.prunedToolCount} tool output(s) pruned`);
|
||||
}
|
||||
|
||||
if (stats.hasSummary) {
|
||||
lines.push(` 📦 Context has been compacted`);
|
||||
}
|
||||
|
||||
if (stats.usagePercent > 100) {
|
||||
lines.push(
|
||||
` 💡 Use /compact to manually compact, or send a message to trigger auto-compaction`
|
||||
);
|
||||
}
|
||||
|
||||
return formatForInkCli(lines.join('\n'));
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to get context stats: ${error instanceof Error ? error.message : String(error)}`;
|
||||
agent.logger.error(errorMsg);
|
||||
return formatForInkCli(`❌ ${errorMsg}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'copy',
|
||||
description: 'Copy the last assistant response to clipboard',
|
||||
usage: '/copy',
|
||||
category: 'General',
|
||||
aliases: ['cp'],
|
||||
handler: async (
|
||||
_args: string[],
|
||||
agent: DextoAgent,
|
||||
ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
try {
|
||||
const { sessionId } = ctx;
|
||||
if (!sessionId) {
|
||||
return formatForInkCli('❌ No active session');
|
||||
}
|
||||
|
||||
// Get session history
|
||||
const history = await agent.getSessionHistory(sessionId);
|
||||
if (!history || history.length === 0) {
|
||||
return formatForInkCli('❌ No messages in current session');
|
||||
}
|
||||
|
||||
// Find the last assistant message
|
||||
const lastAssistantMessage = [...history]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === 'assistant');
|
||||
|
||||
if (!lastAssistantMessage) {
|
||||
return formatForInkCli('❌ No assistant response to copy');
|
||||
}
|
||||
|
||||
// Extract text content from the message
|
||||
let textContent = '';
|
||||
if (typeof lastAssistantMessage.content === 'string') {
|
||||
textContent = lastAssistantMessage.content;
|
||||
} else if (Array.isArray(lastAssistantMessage.content)) {
|
||||
// Handle multi-part content
|
||||
textContent = lastAssistantMessage.content
|
||||
.filter(
|
||||
(part): part is { type: 'text'; text: string } => part.type === 'text'
|
||||
)
|
||||
.map((part) => part.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
if (!textContent) {
|
||||
return formatForInkCli('❌ No text content to copy');
|
||||
}
|
||||
|
||||
// Copy to clipboard
|
||||
const success = await writeToClipboard(textContent);
|
||||
if (success) {
|
||||
const preview =
|
||||
textContent.length > 50
|
||||
? textContent.substring(0, 50) + '...'
|
||||
: textContent;
|
||||
return formatForInkCli(
|
||||
`📋 Copied to clipboard (${textContent.length} chars)\n${preview.replace(/\n/g, ' ')}`
|
||||
);
|
||||
} else {
|
||||
return formatForInkCli('❌ Failed to copy to clipboard');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to copy: ${error instanceof Error ? error.message : String(error)}`;
|
||||
agent.logger.error(errorMsg);
|
||||
return formatForInkCli(`❌ ${errorMsg}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'shortcuts',
|
||||
description: 'Show keyboard shortcuts',
|
||||
usage: '/shortcuts',
|
||||
category: 'General',
|
||||
aliases: ['keys', 'hotkeys'],
|
||||
handler: async (
|
||||
_args: string[],
|
||||
_agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<CommandHandlerResult> => {
|
||||
const styledData: ShortcutsStyledData = {
|
||||
categories: [
|
||||
{
|
||||
name: 'Global',
|
||||
shortcuts: [
|
||||
{ keys: 'Ctrl+C', description: 'Clear input, then exit (press twice)' },
|
||||
{ keys: 'Ctrl+T', description: 'Toggle task list (show/hide tasks)' },
|
||||
{ keys: 'Escape', description: 'Cancel processing / close overlay' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Input',
|
||||
shortcuts: [
|
||||
{ keys: 'Enter', description: 'Submit message' },
|
||||
{ keys: 'Shift+Enter', description: 'New line (multi-line input)' },
|
||||
{ keys: 'Up/Down', description: 'Navigate input history' },
|
||||
{ keys: 'Ctrl+R', description: 'Search history (enter search mode)' },
|
||||
{ keys: 'Tab', description: 'Autocomplete command' },
|
||||
{ keys: 'Ctrl+U', description: 'Clear input line' },
|
||||
{ keys: 'Ctrl+W', description: 'Delete word before cursor' },
|
||||
{ keys: 'Ctrl+A', description: 'Move cursor to start' },
|
||||
{ keys: 'Ctrl+E', description: 'Move cursor to end' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'History Search (after Ctrl+R)',
|
||||
shortcuts: [
|
||||
{ keys: 'Ctrl+R', description: 'Next older match' },
|
||||
{ keys: 'Ctrl+E', description: 'Next newer match' },
|
||||
{ keys: 'Enter', description: 'Accept and exit search' },
|
||||
{ keys: 'Escape', description: 'Cancel search' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Autocomplete & Selectors',
|
||||
shortcuts: [
|
||||
{ keys: 'Up/Down', description: 'Navigate options' },
|
||||
{ keys: 'Enter', description: 'Select / execute' },
|
||||
{ keys: 'Tab', description: 'Load command into input' },
|
||||
{ keys: 'Escape', description: 'Close overlay' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Tool Approval',
|
||||
shortcuts: [
|
||||
{ keys: 'y', description: 'Allow once' },
|
||||
{ keys: 'a', description: 'Allow for session' },
|
||||
{ keys: 'n', description: 'Deny' },
|
||||
{ keys: 'Escape', description: 'Cancel' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Build fallback text
|
||||
const fallbackLines: string[] = ['Keyboard Shortcuts:'];
|
||||
for (const category of styledData.categories) {
|
||||
fallbackLines.push(`\n${category.name}:`);
|
||||
for (const shortcut of category.shortcuts) {
|
||||
fallbackLines.push(` ${shortcut.keys.padEnd(14)} ${shortcut.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
return CommandOutputHelper.styled('shortcuts', styledData, fallbackLines.join('\n'));
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* MCP Commands Module
|
||||
*
|
||||
* In interactive CLI, /mcp always shows the interactive MCP server list overlay.
|
||||
* This command definition exists for autocomplete and help display.
|
||||
*/
|
||||
|
||||
import type { CommandDefinition } from '../command-parser.js';
|
||||
import { overlayOnlyHandler } from '../command-parser.js';
|
||||
|
||||
/**
|
||||
* MCP management command definition.
|
||||
* Handler is never called - mcp is in ALWAYS_OVERLAY and handled by McpServerList overlay.
|
||||
*/
|
||||
export const mcpCommands: CommandDefinition = {
|
||||
name: 'mcp',
|
||||
description: 'Manage MCP servers (interactive)',
|
||||
usage: '/mcp',
|
||||
category: 'MCP Management',
|
||||
handler: overlayOnlyHandler,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Model Commands Module
|
||||
*
|
||||
* In interactive CLI, /model always shows the interactive model selector overlay.
|
||||
* This command definition exists for autocomplete and help display.
|
||||
*/
|
||||
|
||||
import type { CommandDefinition } from '../command-parser.js';
|
||||
import { overlayOnlyHandler } from '../command-parser.js';
|
||||
|
||||
/**
|
||||
* Model management command definition.
|
||||
* Handler is never called - model is in ALWAYS_OVERLAY and handled by ModelSelector overlay.
|
||||
*/
|
||||
export const modelCommands: CommandDefinition = {
|
||||
name: 'model',
|
||||
description: 'Switch AI model (interactive selector)',
|
||||
usage: '/model',
|
||||
category: 'General',
|
||||
aliases: ['m'],
|
||||
handler: overlayOnlyHandler,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Plugin Commands Module
|
||||
*
|
||||
* In interactive CLI, /plugin always shows the interactive plugin manager overlay.
|
||||
* This command definition exists for autocomplete and help display.
|
||||
*/
|
||||
|
||||
import type { CommandDefinition } from '../command-parser.js';
|
||||
import { overlayOnlyHandler } from '../command-parser.js';
|
||||
|
||||
/**
|
||||
* Plugin management command definition.
|
||||
* Handler is never called - plugin is in ALWAYS_OVERLAY and handled by PluginManager overlay.
|
||||
*/
|
||||
export const pluginCommands: CommandDefinition = {
|
||||
name: 'plugin',
|
||||
description: 'Manage plugins (interactive)',
|
||||
usage: '/plugin',
|
||||
category: 'Plugin Management',
|
||||
handler: overlayOnlyHandler,
|
||||
};
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Prompt Commands Module
|
||||
*
|
||||
* This module defines prompt management slash commands for the Dexto CLI interface.
|
||||
* These commands provide functionality for viewing and managing system prompts.
|
||||
*
|
||||
* Available Prompt Commands:
|
||||
* - /sysprompt - Display the current system prompt
|
||||
* - /prompts - List all available prompts (MCP + internal)
|
||||
* - /<prompt-name> [args] - Direct prompt execution (auto-generated for each prompt)
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import type { DextoAgent, PromptInfo } from '@dexto/core';
|
||||
import type { CommandDefinition, CommandContext, CommandHandlerResult } from './command-parser.js';
|
||||
import { formatForInkCli } from './utils/format-output.js';
|
||||
import { createSendMessageMarker, type StyledOutput } from '../../ink-cli/services/index.js';
|
||||
// Avoid depending on core types to keep CLI typecheck independent of build
|
||||
|
||||
/**
|
||||
* Prompt management commands
|
||||
*/
|
||||
export const promptCommands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'sysprompt',
|
||||
description: 'Display the current system prompt',
|
||||
usage: '/sysprompt',
|
||||
category: 'Prompt Management',
|
||||
handler: async (
|
||||
_args: string[],
|
||||
agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<CommandHandlerResult> => {
|
||||
try {
|
||||
const systemPrompt = await agent.getSystemPrompt();
|
||||
|
||||
// Return styled output for ink-cli
|
||||
const styledOutput: StyledOutput = {
|
||||
styledType: 'sysprompt',
|
||||
styledData: { content: systemPrompt },
|
||||
fallbackText: `System Prompt:\n${systemPrompt}`,
|
||||
};
|
||||
|
||||
return styledOutput;
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to get system prompt: ${error instanceof Error ? error.message : String(error)}`;
|
||||
agent.logger.error(errorMsg);
|
||||
return formatForInkCli(`❌ ${errorMsg}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'prompts',
|
||||
description: 'Browse, add, and delete prompts',
|
||||
usage: '/prompts',
|
||||
category: 'Prompt Management',
|
||||
handler: async (
|
||||
_args: string[],
|
||||
agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
try {
|
||||
const prompts = await agent.listPrompts();
|
||||
const promptNames = Object.keys(prompts || {});
|
||||
|
||||
if (promptNames.length === 0) {
|
||||
const output = '\n⚠️ No prompts available';
|
||||
console.log(chalk.rgb(255, 165, 0)(output));
|
||||
return formatForInkCli(output);
|
||||
}
|
||||
|
||||
// Build output string
|
||||
const outputLines: string[] = ['\n📝 Available Prompts:\n'];
|
||||
|
||||
// Group by source
|
||||
const mcpPrompts: string[] = [];
|
||||
const configPrompts: string[] = [];
|
||||
const customPrompts: string[] = [];
|
||||
|
||||
for (const [name, info] of Object.entries(prompts)) {
|
||||
if (info.source === 'mcp') {
|
||||
mcpPrompts.push(name);
|
||||
} else if (info.source === 'config') {
|
||||
configPrompts.push(name);
|
||||
} else if (info.source === 'custom') {
|
||||
customPrompts.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (mcpPrompts.length > 0) {
|
||||
outputLines.push('🔗 MCP Prompts:');
|
||||
mcpPrompts.forEach((name) => {
|
||||
const info = prompts[name];
|
||||
if (info) {
|
||||
const displayName = info.displayName || name;
|
||||
const title =
|
||||
info.title && info.title !== displayName ? ` (${info.title})` : '';
|
||||
const desc = info.description ? ` - ${info.description}` : '';
|
||||
const args =
|
||||
info.arguments && info.arguments.length > 0
|
||||
? ` [args: ${info.arguments
|
||||
.map((a) => `${a.name}${a.required ? '*' : ''}`)
|
||||
.join(', ')}]`
|
||||
: '';
|
||||
outputLines.push(` ${displayName}${title}${desc}${args}`);
|
||||
}
|
||||
});
|
||||
outputLines.push('');
|
||||
}
|
||||
|
||||
if (configPrompts.length > 0) {
|
||||
outputLines.push('📋 Config Prompts:');
|
||||
configPrompts.forEach((name) => {
|
||||
const info = prompts[name];
|
||||
if (info) {
|
||||
const displayName = info.displayName || name;
|
||||
const title =
|
||||
info.title && info.title !== displayName ? ` (${info.title})` : '';
|
||||
const desc = info.description ? ` - ${info.description}` : '';
|
||||
outputLines.push(` ${displayName}${title}${desc}`);
|
||||
}
|
||||
});
|
||||
outputLines.push('');
|
||||
}
|
||||
|
||||
if (customPrompts.length > 0) {
|
||||
outputLines.push('✨ Custom Prompts:');
|
||||
customPrompts.forEach((name) => {
|
||||
const info = prompts[name];
|
||||
if (info) {
|
||||
const displayName = info.displayName || name;
|
||||
const title =
|
||||
info.title && info.title !== displayName ? ` (${info.title})` : '';
|
||||
const desc = info.description ? ` - ${info.description}` : '';
|
||||
outputLines.push(` ${displayName}${title}${desc}`);
|
||||
}
|
||||
});
|
||||
outputLines.push('');
|
||||
}
|
||||
|
||||
outputLines.push(`Total: ${promptNames.length} prompts`);
|
||||
const output = outputLines.join('\n');
|
||||
|
||||
// Log for regular CLI (with chalk formatting)
|
||||
console.log(chalk.bold.green('\n📝 Available Prompts:\n'));
|
||||
if (mcpPrompts.length > 0) {
|
||||
console.log(chalk.cyan('🔗 MCP Prompts:'));
|
||||
mcpPrompts.forEach((name) => {
|
||||
const info = prompts[name];
|
||||
if (info) {
|
||||
const displayName = info.displayName || name;
|
||||
const title =
|
||||
info.title && info.title !== displayName ? ` (${info.title})` : '';
|
||||
const desc = info.description ? ` - ${info.description}` : '';
|
||||
const args =
|
||||
info.arguments && info.arguments.length > 0
|
||||
? ` [args: ${info.arguments
|
||||
.map((a) => `${a.name}${a.required ? '*' : ''}`)
|
||||
.join(', ')}]`
|
||||
: '';
|
||||
console.log(
|
||||
` ${chalk.blue(displayName)}${chalk.rgb(255, 165, 0)(title)}${chalk.gray(desc)}${chalk.gray(args)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
console.log();
|
||||
}
|
||||
if (configPrompts.length > 0) {
|
||||
console.log(chalk.cyan('📋 Config Prompts:'));
|
||||
configPrompts.forEach((name) => {
|
||||
const info = prompts[name];
|
||||
if (info) {
|
||||
const displayName = info.displayName || name;
|
||||
const title =
|
||||
info.title && info.title !== displayName ? ` (${info.title})` : '';
|
||||
const desc = info.description ? ` - ${info.description}` : '';
|
||||
console.log(
|
||||
` ${chalk.blue(displayName)}${chalk.rgb(255, 165, 0)(title)}${chalk.gray(desc)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
console.log();
|
||||
}
|
||||
if (customPrompts.length > 0) {
|
||||
console.log(chalk.greenBright('✨ Custom Prompts:'));
|
||||
customPrompts.forEach((name) => {
|
||||
const info = prompts[name];
|
||||
if (info) {
|
||||
const displayName = info.displayName || name;
|
||||
const title =
|
||||
info.title && info.title !== displayName ? ` (${info.title})` : '';
|
||||
const desc = info.description ? ` - ${info.description}` : '';
|
||||
console.log(
|
||||
` ${chalk.blue(displayName)}${chalk.rgb(255, 165, 0)(title)}${chalk.gray(desc)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
console.log();
|
||||
}
|
||||
console.log(chalk.gray(`Total: ${promptNames.length} prompts`));
|
||||
console.log(chalk.gray('💡 Use /<prompt-name> to execute a prompt directly\n'));
|
||||
|
||||
return formatForInkCli(output);
|
||||
} catch (error) {
|
||||
const errorMsg = `Error listing prompts: ${error instanceof Error ? error.message : String(error)}`;
|
||||
console.error(chalk.red(`❌ ${errorMsg}`));
|
||||
return formatForInkCli(`❌ ${errorMsg}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
// Note: /use command removed - use /<prompt-name> directly instead
|
||||
// Prompts are automatically registered as slash commands (see getDynamicPromptCommands)
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a dynamic command definition from a prompt
|
||||
* @param promptInfo The prompt metadata with pre-computed commandName
|
||||
*/
|
||||
function createPromptCommand(promptInfo: PromptInfo): CommandDefinition {
|
||||
// Use pre-computed commandName (collision-resolved by PromptManager)
|
||||
// Fall back to displayName or name for backwards compatibility
|
||||
const commandName = promptInfo.commandName || promptInfo.displayName || promptInfo.name;
|
||||
// Keep internal name for prompt resolution (e.g., "config:review" or "mcp:server1:review")
|
||||
const internalName = promptInfo.name;
|
||||
// Base name for display purposes (without source prefix)
|
||||
const baseName = promptInfo.displayName || promptInfo.name;
|
||||
|
||||
return {
|
||||
name: commandName,
|
||||
description: promptInfo.description || `Execute ${baseName} prompt`,
|
||||
usage: `/${commandName} [context]`,
|
||||
category: 'Dynamic Prompts',
|
||||
handler: async (
|
||||
args: string[],
|
||||
agent: DextoAgent,
|
||||
ctx: CommandContext
|
||||
): Promise<CommandHandlerResult> => {
|
||||
try {
|
||||
const { argMap, context: contextString } = splitPromptArguments(args);
|
||||
|
||||
// Use resolvePrompt instead of getPrompt + flattenPromptResult (matches WebUI approach)
|
||||
const resolveOptions: {
|
||||
args?: Record<string, unknown>;
|
||||
context?: string;
|
||||
} = {};
|
||||
if (Object.keys(argMap).length > 0) {
|
||||
resolveOptions.args = argMap;
|
||||
}
|
||||
if (contextString) {
|
||||
resolveOptions.context = contextString;
|
||||
}
|
||||
// Use internal name for resolution (includes prefix like "config:")
|
||||
const result = await agent.resolvePrompt(internalName, resolveOptions);
|
||||
|
||||
// Apply per-prompt overrides (Phase 2 Claude Code compatibility)
|
||||
// These overrides persist for the session until explicitly cleared
|
||||
if (ctx.sessionId) {
|
||||
// Apply model override if specified
|
||||
if (result.model) {
|
||||
console.log(
|
||||
chalk.gray(`🔄 Switching model to '${result.model}' for this prompt`)
|
||||
);
|
||||
try {
|
||||
await agent.switchLLM({ model: result.model }, ctx.sessionId);
|
||||
} catch (modelError) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ Failed to switch model: ${modelError instanceof Error ? modelError.message : String(modelError)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply auto-approve tools if specified
|
||||
// These tools will skip confirmation prompts during skill execution
|
||||
if (result.allowedTools && result.allowedTools.length > 0) {
|
||||
console.log(
|
||||
chalk.gray(`🔓 Auto-approving tools: ${result.allowedTools.join(', ')}`)
|
||||
);
|
||||
try {
|
||||
agent.toolManager.setSessionAutoApproveTools(
|
||||
ctx.sessionId,
|
||||
result.allowedTools
|
||||
);
|
||||
} catch (toolError) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ Failed to set auto-approve tools: ${toolError instanceof Error ? toolError.message : String(toolError)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fork skills: route through LLM to call invoke_skill
|
||||
// This ensures approval flow and context management work naturally through
|
||||
// processStream, rather than bypassing it with direct tool execution.
|
||||
if (result.context === 'fork') {
|
||||
const skillName = internalName;
|
||||
const taskContext = contextString || '';
|
||||
|
||||
// Build instruction message for the LLM
|
||||
// The <skill-invocation> tags help the LLM recognize this is a structured request
|
||||
const instructionText = `<skill-invocation>
|
||||
Execute the fork skill: ${commandName}
|
||||
${taskContext ? `Task context: ${taskContext}` : ''}
|
||||
|
||||
Call the internal--invoke_skill tool immediately with:
|
||||
- skill: "${skillName}"
|
||||
${taskContext ? `- taskContext: "${taskContext}"` : ''}
|
||||
</skill-invocation>`;
|
||||
|
||||
return createSendMessageMarker(instructionText);
|
||||
}
|
||||
|
||||
// Inline skills: wrap content in <skill-invocation> for clean history display
|
||||
// Convert resource URIs to @resource mentions so agent.run() can expand them
|
||||
let finalText = result.text;
|
||||
if (result.resources.length > 0) {
|
||||
// Append resource references as @<uri> format
|
||||
const resourceRefs = result.resources.map((uri) => `@<${uri}>`).join(' ');
|
||||
finalText = finalText ? `${finalText}\n\n${resourceRefs}` : resourceRefs;
|
||||
}
|
||||
|
||||
if (finalText.trim()) {
|
||||
// Wrap in <skill-invocation> tags for clean display in history
|
||||
// The tags help formatSkillInvocationMessage() detect and format these
|
||||
const taskContext = contextString || '';
|
||||
const wrappedText = `<skill-invocation>
|
||||
Execute the inline skill: ${commandName}
|
||||
${taskContext ? `Task context: ${taskContext}` : ''}
|
||||
|
||||
skill: "${internalName}"
|
||||
</skill-invocation>
|
||||
|
||||
${finalText.trim()}`;
|
||||
|
||||
return createSendMessageMarker(wrappedText);
|
||||
} else {
|
||||
const warningMsg = `⚠️ Prompt '${commandName}' returned no content`;
|
||||
console.log(chalk.rgb(255, 165, 0)(warningMsg));
|
||||
return formatForInkCli(warningMsg);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
agent.logger.error(
|
||||
`Failed to execute prompt command '${commandName}': ${errorMessage}`
|
||||
);
|
||||
|
||||
const errorMsg = `❌ Error executing prompt '${commandName}': ${errorMessage}`;
|
||||
return formatForInkCli(errorMsg);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dynamic prompt commands based on available prompts.
|
||||
* Uses pre-computed commandName from PromptManager for collision handling.
|
||||
* Filters out prompts with `userInvocable: false` as these are not intended
|
||||
* for direct user invocation via slash commands.
|
||||
*/
|
||||
export async function getDynamicPromptCommands(agent: DextoAgent): Promise<CommandDefinition[]> {
|
||||
try {
|
||||
const prompts = await agent.listPrompts();
|
||||
// Filter out prompts that are not user-invocable (userInvocable: false)
|
||||
// These prompts are intended for LLM auto-invocation only, not CLI slash commands
|
||||
const promptEntries = Object.entries(prompts).filter(
|
||||
([, info]) => info.userInvocable !== false
|
||||
);
|
||||
|
||||
// Create commands using pre-computed commandName (collision-resolved by PromptManager)
|
||||
return promptEntries.map(([, info]) => createPromptCommand(info));
|
||||
} catch (error) {
|
||||
agent.logger.error(
|
||||
`Failed to get dynamic prompt commands: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function splitPromptArguments(args: string[]): {
|
||||
argMap: Record<string, string>;
|
||||
context?: string | undefined;
|
||||
} {
|
||||
const map: Record<string, string> = {};
|
||||
const contextParts: string[] = [];
|
||||
|
||||
for (const arg of args) {
|
||||
const equalsIndex = arg.indexOf('=');
|
||||
if (equalsIndex > 0) {
|
||||
const key = arg.slice(0, equalsIndex).trim();
|
||||
const value = arg.slice(equalsIndex + 1);
|
||||
if (key.length > 0) {
|
||||
map[key] = value;
|
||||
}
|
||||
} else if (arg.trim().length > 0) {
|
||||
contextParts.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const context = contextParts.length > 0 ? contextParts.join(' ') : undefined;
|
||||
return { argMap: map, context };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Session Commands Module (Interactive CLI)
|
||||
*
|
||||
* This module provides session-related commands for the interactive CLI.
|
||||
* These commands use interactive overlays/selectors rather than subcommands.
|
||||
*
|
||||
* Exports:
|
||||
* - searchCommand: Opens interactive search overlay
|
||||
* - resumeCommand: Shows interactive session selector
|
||||
* - renameCommand: Rename the current session
|
||||
*
|
||||
* Note: For headless CLI session management, see src/cli/commands/session-commands.ts
|
||||
*/
|
||||
|
||||
export { searchCommand, resumeCommand, renameCommand } from './session-commands.js';
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Session Management Commands (Interactive CLI)
|
||||
*
|
||||
* This module contains session-related commands for the interactive CLI.
|
||||
* Session management in interactive mode uses overlays/selectors rather than subcommands.
|
||||
*
|
||||
* Commands:
|
||||
* - resume: Shows interactive session selector
|
||||
* - search: Opens interactive search overlay
|
||||
* - rename: Rename the current session
|
||||
*
|
||||
* Note: For headless CLI session management (list, history, delete),
|
||||
* see src/cli/commands/session-commands.ts
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import type { DextoAgent } from '@dexto/core';
|
||||
import type { CommandDefinition, CommandContext } from '../command-parser.js';
|
||||
|
||||
/**
|
||||
* Resume command - shows interactive session selector
|
||||
* Note: In interactive CLI, this always shows the selector (args ignored)
|
||||
* For headless CLI, use `dexto -r <sessionId>` instead
|
||||
*/
|
||||
export const resumeCommand: CommandDefinition = {
|
||||
name: 'resume',
|
||||
description: 'Switch to a different session (interactive selector)',
|
||||
usage: '/resume',
|
||||
category: 'General',
|
||||
aliases: ['r'],
|
||||
handler: async (
|
||||
_args: string[],
|
||||
_agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
// In interactive CLI, /resume always triggers the interactive selector
|
||||
// The selector is shown via detectInteractiveSelector in inputParsing.ts
|
||||
// This handler should not be called in ink-cli (selector shows instead)
|
||||
const helpText = [
|
||||
'📋 Resume Session',
|
||||
'\nType /resume to show the session selector\n',
|
||||
].join('\n');
|
||||
|
||||
console.log(chalk.blue(helpText));
|
||||
return helpText;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Standalone search command - opens interactive search overlay
|
||||
*/
|
||||
export const searchCommand: CommandDefinition = {
|
||||
name: 'search',
|
||||
description: 'Search messages across all sessions',
|
||||
usage: '/search',
|
||||
category: 'General',
|
||||
aliases: ['find'],
|
||||
handler: async (
|
||||
_args: string[],
|
||||
_agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<boolean> => {
|
||||
// Interactive overlay handles everything - just return success
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Rename command - rename the current session
|
||||
* In interactive CLI, this shows the rename overlay.
|
||||
* The overlay is triggered via commandOverlays.ts registry.
|
||||
*/
|
||||
export const renameCommand: CommandDefinition = {
|
||||
name: 'rename',
|
||||
description: 'Rename the current session',
|
||||
usage: '/rename',
|
||||
category: 'General',
|
||||
handler: async (
|
||||
_args: string[],
|
||||
_agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<boolean> => {
|
||||
// Interactive overlay handles everything - just return success
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* System Commands Module Index
|
||||
*
|
||||
* This module exports all system-level CLI commands for the Dexto interactive interface.
|
||||
* System commands provide functionality for configuration, logging, and statistics.
|
||||
*
|
||||
* Available commands:
|
||||
* - /log - Set or view log level
|
||||
* - /config - Show current configuration
|
||||
* - /stats - Show system statistics
|
||||
*/
|
||||
|
||||
export { systemCommands } from './system-commands.js';
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* System Commands Module
|
||||
*
|
||||
* This module defines system-level slash commands for the Dexto CLI interface.
|
||||
* These commands provide system configuration, logging, and statistics functionality.
|
||||
*
|
||||
* Available System Commands:
|
||||
* - /log [level] - Set or view log level
|
||||
* - /config - Show current configuration
|
||||
* - /stats - Show system statistics
|
||||
* - /stream - Toggle streaming mode for LLM responses
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { logger, type DextoAgent } from '@dexto/core';
|
||||
import type { CommandDefinition, CommandHandlerResult, CommandContext } from '../command-parser.js';
|
||||
import { formatForInkCli } from '../utils/format-output.js';
|
||||
import { CommandOutputHelper } from '../utils/command-output.js';
|
||||
import type { ConfigStyledData, StatsStyledData } from '../../../ink-cli/state/types.js';
|
||||
|
||||
/**
|
||||
* System commands for configuration and monitoring
|
||||
*/
|
||||
export const systemCommands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'log',
|
||||
description: 'View or change log level interactively',
|
||||
usage: '/log [level]',
|
||||
category: 'System',
|
||||
aliases: [],
|
||||
handler: async (
|
||||
args: string[],
|
||||
_agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
const validLevels = ['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly'];
|
||||
const level = args[0];
|
||||
|
||||
if (!level) {
|
||||
// Interactive view: show current level and options
|
||||
const currentLevel = logger.getLevel();
|
||||
const logFilePath = logger.getLogFilePath();
|
||||
|
||||
console.log(chalk.bold.blue('\n📊 Logging Configuration:\n'));
|
||||
console.log(` Current level: ${chalk.green.bold(currentLevel)}`);
|
||||
if (logFilePath) {
|
||||
console.log(` Log file: ${chalk.cyan(logFilePath)}`);
|
||||
}
|
||||
console.log(chalk.gray('\n Available levels (from least to most verbose):'));
|
||||
validLevels.forEach((lvl) => {
|
||||
const isCurrent = lvl === currentLevel;
|
||||
const marker = isCurrent ? chalk.green('▶') : ' ';
|
||||
const levelText = isCurrent ? chalk.green.bold(lvl) : chalk.gray(lvl);
|
||||
console.log(` ${marker} ${levelText}`);
|
||||
});
|
||||
console.log(
|
||||
chalk.gray('\n 💡 Use /log <level> to change level (e.g., /log debug)\n')
|
||||
);
|
||||
|
||||
const output = [
|
||||
'\n📊 Logging Configuration:',
|
||||
`Current level: ${currentLevel}`,
|
||||
logFilePath ? `Log file: ${logFilePath}` : '',
|
||||
'\nAvailable levels: error, warn, info, http, verbose, debug, silly',
|
||||
'💡 Use /log <level> to change level',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return formatForInkCli(output);
|
||||
}
|
||||
|
||||
if (validLevels.includes(level)) {
|
||||
logger.setLevel(level);
|
||||
logger.info(`Log level set to ${level}`, null, 'green');
|
||||
console.log(chalk.green(`✅ Log level changed to: ${chalk.bold(level)}`));
|
||||
const output = `✅ Log level set to ${level}`;
|
||||
return formatForInkCli(output);
|
||||
} else {
|
||||
const errorMsg = `❌ Invalid log level: ${level}\nValid levels: ${validLevels.join(', ')}`;
|
||||
console.log(chalk.red(`❌ Invalid log level: ${chalk.bold(level)}`));
|
||||
console.log(chalk.gray(`Valid levels: ${validLevels.join(', ')}`));
|
||||
return formatForInkCli(errorMsg);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'config',
|
||||
description: 'Show current configuration',
|
||||
usage: '/config',
|
||||
category: 'System',
|
||||
handler: async (
|
||||
_args: string[],
|
||||
agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<CommandHandlerResult> => {
|
||||
try {
|
||||
const config = agent.getEffectiveConfig();
|
||||
const servers = Object.keys(config.mcpServers || {});
|
||||
|
||||
// Get config file path (may not exist for programmatic agents)
|
||||
let configFilePath: string | null = null;
|
||||
try {
|
||||
configFilePath = agent.getAgentFilePath();
|
||||
} catch {
|
||||
// No config file path available
|
||||
}
|
||||
|
||||
// Get enabled plugins
|
||||
const pluginsEnabled: string[] = [];
|
||||
if (config.plugins) {
|
||||
// Check built-in plugins
|
||||
if (config.plugins.contentPolicy?.enabled) {
|
||||
pluginsEnabled.push('contentPolicy');
|
||||
}
|
||||
if (config.plugins.responseSanitizer?.enabled) {
|
||||
pluginsEnabled.push('responseSanitizer');
|
||||
}
|
||||
// Check custom plugins
|
||||
for (const plugin of config.plugins.custom || []) {
|
||||
if (plugin.enabled) {
|
||||
pluginsEnabled.push(plugin.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build styled data
|
||||
const styledData: ConfigStyledData = {
|
||||
configFilePath,
|
||||
provider: config.llm.provider,
|
||||
model: config.llm.model,
|
||||
maxTokens: config.llm.maxOutputTokens ?? null,
|
||||
temperature: config.llm.temperature ?? null,
|
||||
toolConfirmationMode: config.toolConfirmation?.mode || 'auto',
|
||||
maxSessions: config.sessions?.maxSessions?.toString() || 'Default',
|
||||
sessionTTL: config.sessions?.sessionTTL
|
||||
? `${config.sessions.sessionTTL / 1000}s`
|
||||
: 'Default',
|
||||
mcpServers: servers,
|
||||
promptsCount: config.prompts?.length || 0,
|
||||
pluginsEnabled,
|
||||
};
|
||||
|
||||
// Build fallback text (no console.log - interferes with Ink rendering)
|
||||
const fallbackLines: string[] = [
|
||||
'Configuration:',
|
||||
configFilePath ? ` Config: ${configFilePath}` : '',
|
||||
` LLM: ${config.llm.provider} / ${config.llm.model}`,
|
||||
` Tool Confirmation: ${styledData.toolConfirmationMode}`,
|
||||
` Sessions: max=${styledData.maxSessions}, ttl=${styledData.sessionTTL}`,
|
||||
` MCP Servers: ${servers.length > 0 ? servers.join(', ') : 'none'}`,
|
||||
` Prompts: ${styledData.promptsCount}`,
|
||||
].filter(Boolean);
|
||||
|
||||
return CommandOutputHelper.styled('config', styledData, fallbackLines.join('\n'));
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to get configuration: ${error instanceof Error ? error.message : String(error)}`;
|
||||
agent.logger.error(errorMsg);
|
||||
return formatForInkCli(`❌ ${errorMsg}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stats',
|
||||
description: 'Show system statistics',
|
||||
usage: '/stats',
|
||||
category: 'System',
|
||||
handler: async (
|
||||
_args: string[],
|
||||
agent: DextoAgent,
|
||||
ctx: CommandContext
|
||||
): Promise<CommandHandlerResult> => {
|
||||
try {
|
||||
// Session stats
|
||||
const sessionStats = await agent.sessionManager.getSessionStats();
|
||||
|
||||
// MCP stats
|
||||
const connectedServers = agent.getMcpClients().size;
|
||||
const failedConnections = Object.keys(agent.getMcpFailedConnections()).length;
|
||||
|
||||
// Tools
|
||||
let toolCount = 0;
|
||||
try {
|
||||
const tools = await agent.getAllMcpTools();
|
||||
toolCount = Object.keys(tools).length;
|
||||
} catch {
|
||||
// Ignore - toolCount stays 0
|
||||
}
|
||||
|
||||
// Get token usage from current session metadata
|
||||
let tokenUsage: StatsStyledData['tokenUsage'];
|
||||
let estimatedCost: number | undefined;
|
||||
if (ctx.sessionId) {
|
||||
const sessionMetadata = await agent.sessionManager.getSessionMetadata(
|
||||
ctx.sessionId
|
||||
);
|
||||
if (sessionMetadata?.tokenUsage) {
|
||||
tokenUsage = sessionMetadata.tokenUsage;
|
||||
}
|
||||
estimatedCost = sessionMetadata?.estimatedCost;
|
||||
}
|
||||
|
||||
// Build styled data
|
||||
const styledData: StatsStyledData = {
|
||||
sessions: {
|
||||
total: sessionStats.totalSessions,
|
||||
inMemory: sessionStats.inMemorySessions,
|
||||
maxAllowed: sessionStats.maxSessions,
|
||||
},
|
||||
mcp: {
|
||||
connected: connectedServers,
|
||||
failed: failedConnections,
|
||||
toolCount,
|
||||
},
|
||||
...(tokenUsage && { tokenUsage }),
|
||||
...(estimatedCost !== undefined && { estimatedCost }),
|
||||
};
|
||||
|
||||
// Build fallback text
|
||||
const fallbackLines: string[] = [
|
||||
'System Statistics:',
|
||||
` Sessions: ${sessionStats.totalSessions} total, ${sessionStats.inMemorySessions} in memory`,
|
||||
` MCP: ${connectedServers} connected, ${toolCount} tools`,
|
||||
];
|
||||
if (failedConnections > 0) {
|
||||
fallbackLines.push(` Failed connections: ${failedConnections}`);
|
||||
}
|
||||
if (tokenUsage) {
|
||||
fallbackLines.push(
|
||||
` Tokens: ${tokenUsage.totalTokens} total (${tokenUsage.inputTokens} in, ${tokenUsage.outputTokens} out)`
|
||||
);
|
||||
}
|
||||
|
||||
return CommandOutputHelper.styled('stats', styledData, fallbackLines.join('\n'));
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to get statistics: ${error instanceof Error ? error.message : String(error)}`;
|
||||
agent.logger.error(errorMsg);
|
||||
return formatForInkCli(`❌ ${errorMsg}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stream',
|
||||
description: 'Toggle streaming mode for LLM responses',
|
||||
usage: '/stream',
|
||||
category: 'System',
|
||||
handler: async (
|
||||
_args: string[],
|
||||
_agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
// Overlay is handled via commandOverlays.ts mapping
|
||||
return true;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Tool Commands Module
|
||||
*
|
||||
* This module defines tool management slash commands for the Dexto CLI interface.
|
||||
* These commands provide functionality for listing and managing MCP tools.
|
||||
*
|
||||
* Available Tool Commands:
|
||||
* - /tools - Interactive tool browser
|
||||
*/
|
||||
|
||||
import type { DextoAgent } from '@dexto/core';
|
||||
import type { CommandDefinition, CommandContext } from './command-parser.js';
|
||||
|
||||
/**
|
||||
* Tool management commands
|
||||
*/
|
||||
export const toolCommands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'tools',
|
||||
description: 'Browse available tools interactively',
|
||||
usage: '/tools',
|
||||
category: 'Tool Management',
|
||||
handler: async (
|
||||
_args: string[],
|
||||
_agent: DextoAgent,
|
||||
_ctx: CommandContext
|
||||
): Promise<boolean | string> => {
|
||||
// Overlay is handled via commandOverlays.ts mapping
|
||||
return true;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Argument Parsing Utilities
|
||||
*
|
||||
* This module provides utilities for parsing command-line arguments in the
|
||||
* interactive CLI commands. These utilities are designed to be reusable
|
||||
* across different command modules.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Result of parsing command-line arguments
|
||||
*/
|
||||
export interface ParsedArguments {
|
||||
/** Regular arguments (non-option arguments) */
|
||||
parsedArgs: string[];
|
||||
/** Options in --key=value format */
|
||||
options: Record<string, string>;
|
||||
/** Flags in --flag format (boolean options) */
|
||||
flags: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse command-line arguments supporting --key=value options and --flag style flags.
|
||||
*
|
||||
* This function separates regular arguments from options and flags:
|
||||
* - Regular args: any argument that doesn't start with --
|
||||
* - Options: --key=value format, parsed into options object
|
||||
* - Flags: --flag format (no value), parsed into flags set
|
||||
*
|
||||
* @param args Array of command-line arguments to parse
|
||||
* @returns Object containing parsed arguments, options, and flags
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = parseOptions(['server', 'cmd', '--timeout=5000', '--verbose']);
|
||||
* // result.parsedArgs: ['server', 'cmd']
|
||||
* // result.options: { timeout: '5000' }
|
||||
* // result.flags: Set(['verbose'])
|
||||
* ```
|
||||
*/
|
||||
export function parseOptions(args: string[]): ParsedArguments {
|
||||
const parsedArgs: string[] = [];
|
||||
const options: Record<string, string> = {};
|
||||
const flags: Set<string> = new Set();
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith('--')) {
|
||||
if (arg.includes('=')) {
|
||||
// Handle --key=value format
|
||||
const [key, ...valueParts] = arg.slice(2).split('=');
|
||||
if (key) {
|
||||
// Rejoin value parts in case the value contained '=' characters
|
||||
options[key] = valueParts.join('=');
|
||||
}
|
||||
} else {
|
||||
// Handle --flag format (boolean flags)
|
||||
flags.add(arg.slice(2));
|
||||
}
|
||||
} else {
|
||||
// Regular argument (not an option)
|
||||
parsedArgs.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return { parsedArgs, options, flags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed options back to command-line argument format.
|
||||
* Useful for debugging or reconstructing command lines.
|
||||
*
|
||||
* @param parsed The parsed arguments object
|
||||
* @returns Array of command-line arguments
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const args = reconstructArgs({
|
||||
* parsedArgs: ['server', 'cmd'],
|
||||
* options: { timeout: '5000' },
|
||||
* flags: new Set(['verbose'])
|
||||
* });
|
||||
* // Result: ['server', 'cmd', '--timeout=5000', '--verbose']
|
||||
* ```
|
||||
*/
|
||||
export function reconstructArgs(parsed: ParsedArguments): string[] {
|
||||
const result: string[] = [...parsed.parsedArgs];
|
||||
|
||||
// Add options in --key=value format
|
||||
for (const [key, value] of Object.entries(parsed.options)) {
|
||||
result.push(`--${key}=${value}`);
|
||||
}
|
||||
|
||||
// Add flags in --flag format
|
||||
for (const flag of parsed.flags) {
|
||||
result.push(`--${flag}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Command Output Helper
|
||||
* Utilities for consistent command output handling across all slash commands
|
||||
*/
|
||||
|
||||
import { formatForInkCli } from './format-output.js';
|
||||
import type { StyledOutput } from '../../../ink-cli/services/CommandService.js';
|
||||
import type { StyledMessageType, StyledData } from '../../../ink-cli/state/types.js';
|
||||
|
||||
/**
|
||||
* Command output helper for consistent display and error handling
|
||||
* Returns formatted strings for ink-cli to render (no direct console output)
|
||||
*/
|
||||
export class CommandOutputHelper {
|
||||
/**
|
||||
* Format success message for ink-cli to display
|
||||
*/
|
||||
static success(message: string): string {
|
||||
return formatForInkCli(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format info message for ink-cli to display
|
||||
*/
|
||||
static info(message: string): string {
|
||||
return formatForInkCli(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format warning message for ink-cli to display
|
||||
*/
|
||||
static warning(message: string): string {
|
||||
return formatForInkCli(`⚠️ ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error message for ink-cli to display
|
||||
*/
|
||||
static error(error: unknown, context?: string): string {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const fullMessage = context ? `❌ ${context}: ${errorMessage}` : `❌ ${errorMessage}`;
|
||||
return formatForInkCli(fullMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format multi-line output for ink-cli to display
|
||||
*/
|
||||
static output(lines: string[]): string {
|
||||
return formatForInkCli(lines.join('\n'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create styled output for rich rendering in ink-cli
|
||||
* @param styledType - The type of styled rendering
|
||||
* @param styledData - The structured data for rendering
|
||||
* @param fallbackText - Plain text fallback for logging/non-ink environments
|
||||
*/
|
||||
static styled(
|
||||
styledType: StyledMessageType,
|
||||
styledData: StyledData,
|
||||
fallbackText: string
|
||||
): StyledOutput {
|
||||
return {
|
||||
styledType,
|
||||
styledData,
|
||||
fallbackText: formatForInkCli(fallbackText),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Utility functions for formatting command output for ink-cli
|
||||
* Strips chalk formatting and provides plain text versions
|
||||
*/
|
||||
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
|
||||
/**
|
||||
* Strip ANSI color codes from a string
|
||||
*/
|
||||
export function stripAnsi(str: string): string {
|
||||
return stripVTControlCharacters(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format output for ink-cli (removes chalk formatting)
|
||||
* This allows commands to use chalk for regular CLI while returning plain text for ink-cli
|
||||
*/
|
||||
export function formatForInkCli(output: string): string {
|
||||
return stripAnsi(output);
|
||||
}
|
||||
288
dexto/packages/cli/src/cli/commands/list-agents.ts
Normal file
288
dexto/packages/cli/src/cli/commands/list-agents.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
// packages/cli/src/cli/commands/list-agents.ts
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
getDextoGlobalPath,
|
||||
globalPreferencesExist,
|
||||
loadGlobalPreferences,
|
||||
loadBundledRegistryAgents,
|
||||
} from '@dexto/agent-management';
|
||||
|
||||
// Zod schema for list-agents command validation
|
||||
const ListAgentsCommandSchema = z
|
||||
.object({
|
||||
verbose: z.boolean().default(false),
|
||||
installed: z.boolean().default(false),
|
||||
available: z.boolean().default(false),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ListAgentsCommandOptions = z.output<typeof ListAgentsCommandSchema>;
|
||||
export type ListAgentsCommandOptionsInput = z.input<typeof ListAgentsCommandSchema>;
|
||||
|
||||
/**
|
||||
* Information about an installed agent
|
||||
*/
|
||||
interface InstalledAgentInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
llmProvider?: string;
|
||||
llmModel?: string;
|
||||
installedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about an available agent from registry
|
||||
*/
|
||||
interface AvailableAgentInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
type: 'builtin' | 'custom';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about installed agents
|
||||
*/
|
||||
async function getInstalledAgents(): Promise<InstalledAgentInfo[]> {
|
||||
const globalAgentsDir = getDextoGlobalPath('agents');
|
||||
|
||||
if (!existsSync(globalAgentsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const bundledRegistry = loadBundledRegistryAgents();
|
||||
const installedAgents: InstalledAgentInfo[] = [];
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(globalAgentsDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip registry.json and temp files
|
||||
if (entry.name === 'registry.json' || entry.name.includes('.tmp.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const agentName = entry.name;
|
||||
const agentPath = path.join(globalAgentsDir, entry.name);
|
||||
|
||||
try {
|
||||
// For directory agents, try to find main config
|
||||
// Check bundled registry for main field, default to agent.yml
|
||||
const bundledEntry = bundledRegistry[agentName];
|
||||
const mainFile = bundledEntry?.main || 'agent.yml';
|
||||
const mainConfigPath = path.join(agentPath, mainFile);
|
||||
|
||||
// If main config doesn't exist, skip
|
||||
if (!existsSync(mainConfigPath)) {
|
||||
console.warn(
|
||||
`Warning: Could not find main config for agent '${agentName}' at ${mainConfigPath}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get install date from directory stats
|
||||
const stats = await fs.stat(agentPath);
|
||||
|
||||
// Try to extract LLM info from config
|
||||
let llmProvider: string | undefined;
|
||||
let llmModel: string | undefined;
|
||||
|
||||
try {
|
||||
const configContent = await fs.readFile(mainConfigPath, 'utf-8');
|
||||
const configMatch = configContent.match(/provider:\s*([^\n\r]+)/);
|
||||
const modelMatch = configContent.match(/model:\s*([^\n\r]+)/);
|
||||
|
||||
llmProvider = configMatch?.[1]?.trim();
|
||||
llmModel = modelMatch?.[1]?.trim();
|
||||
} catch (_error) {
|
||||
// Ignore config parsing errors
|
||||
}
|
||||
|
||||
// Get description from bundled registry if available
|
||||
const description = bundledEntry?.description || 'Custom agent';
|
||||
|
||||
const agentInfo: InstalledAgentInfo = {
|
||||
name: agentName,
|
||||
description,
|
||||
path: mainConfigPath,
|
||||
installedAt: stats.birthtime || stats.mtime,
|
||||
};
|
||||
|
||||
if (llmProvider) agentInfo.llmProvider = llmProvider;
|
||||
if (llmModel) agentInfo.llmModel = llmModel;
|
||||
|
||||
installedAgents.push(agentInfo);
|
||||
} catch (error) {
|
||||
// Skip agents that can't be processed
|
||||
console.warn(`Warning: Could not process agent '${agentName}': ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Return empty array if we can't read the directory
|
||||
return [];
|
||||
}
|
||||
|
||||
return installedAgents.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about available agents from registry
|
||||
*/
|
||||
function getAvailableAgents(): AvailableAgentInfo[] {
|
||||
const bundledRegistry = loadBundledRegistryAgents();
|
||||
|
||||
return Object.entries(bundledRegistry)
|
||||
.map(([name, data]) => ({
|
||||
name,
|
||||
description: data.description || 'No description',
|
||||
author: data.author || 'Unknown',
|
||||
tags: data.tags || [],
|
||||
type: 'builtin' as const,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the list-agents command
|
||||
*/
|
||||
export async function handleListAgentsCommand(
|
||||
options: ListAgentsCommandOptionsInput
|
||||
): Promise<void> {
|
||||
// Validate command with Zod
|
||||
const validated = ListAgentsCommandSchema.parse(options);
|
||||
|
||||
console.log(chalk.cyan('\n📋 Dexto Agents\n'));
|
||||
|
||||
// Get global preferences for LLM info
|
||||
let globalLLM: string | undefined;
|
||||
if (globalPreferencesExist()) {
|
||||
try {
|
||||
const preferences = await loadGlobalPreferences();
|
||||
globalLLM = `${preferences.llm.provider}/${preferences.llm.model}`;
|
||||
} catch {
|
||||
// Ignore preference loading errors
|
||||
}
|
||||
}
|
||||
|
||||
// Get installed and available agents
|
||||
const installedAgents = await getInstalledAgents();
|
||||
const availableAgents = getAvailableAgents();
|
||||
|
||||
// Filter based on options
|
||||
const showInstalled = !validated.available || validated.installed;
|
||||
const showAvailable = !validated.installed || validated.available;
|
||||
|
||||
// Show installed agents
|
||||
if (showInstalled && installedAgents.length > 0) {
|
||||
console.log(chalk.green('✅ Installed Agents:'));
|
||||
|
||||
for (const agent of installedAgents) {
|
||||
const llmInfo =
|
||||
agent.llmProvider && agent.llmModel
|
||||
? `${agent.llmProvider}/${agent.llmModel}`
|
||||
: globalLLM || 'Unknown LLM';
|
||||
|
||||
const llmDisplay = chalk.gray(`(${llmInfo})`);
|
||||
|
||||
if (validated.verbose) {
|
||||
console.log(` ${chalk.bold(agent.name)} ${llmDisplay}`);
|
||||
console.log(` ${chalk.gray(agent.description)}`);
|
||||
console.log(` ${chalk.gray('Path:')} ${agent.path}`);
|
||||
if (agent.installedAt) {
|
||||
console.log(
|
||||
` ${chalk.gray('Installed:')} ${agent.installedAt.toLocaleDateString()}`
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
} else {
|
||||
console.log(` • ${chalk.bold(agent.name)} ${llmDisplay} - ${agent.description}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
} else if (showInstalled) {
|
||||
console.log(chalk.rgb(255, 165, 0)('📦 No agents installed yet.'));
|
||||
console.log(
|
||||
chalk.gray(' Use `dexto install <agent-name>` to install agents from the registry.\n')
|
||||
);
|
||||
}
|
||||
|
||||
// Show available agents (not installed)
|
||||
if (showAvailable) {
|
||||
const availableNotInstalled = availableAgents.filter(
|
||||
(available) => !installedAgents.some((installed) => installed.name === available.name)
|
||||
);
|
||||
|
||||
const builtinAgents = availableNotInstalled.filter((a) => a.type === 'builtin');
|
||||
const customAgents = availableNotInstalled.filter((a) => a.type === 'custom');
|
||||
|
||||
if (builtinAgents.length > 0) {
|
||||
console.log(chalk.blue('📋 Builtin Agents Available to Install:'));
|
||||
|
||||
for (const agent of builtinAgents) {
|
||||
if (validated.verbose) {
|
||||
console.log(` ${chalk.bold(agent.name)}`);
|
||||
console.log(` ${chalk.gray(agent.description)}`);
|
||||
console.log(` ${chalk.gray('Author:')} ${agent.author}`);
|
||||
console.log(` ${chalk.gray('Tags:')} ${agent.tags.join(', ')}`);
|
||||
console.log();
|
||||
} else {
|
||||
console.log(` • ${chalk.bold(agent.name)} - ${agent.description}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (customAgents.length > 0) {
|
||||
console.log(chalk.cyan('🔧 Custom Agents Available:'));
|
||||
|
||||
for (const agent of customAgents) {
|
||||
if (validated.verbose) {
|
||||
console.log(` ${chalk.bold(agent.name)}`);
|
||||
console.log(` ${chalk.gray(agent.description)}`);
|
||||
console.log(` ${chalk.gray('Author:')} ${agent.author}`);
|
||||
console.log(` ${chalk.gray('Tags:')} ${agent.tags.join(', ')}`);
|
||||
console.log();
|
||||
} else {
|
||||
console.log(` • ${chalk.bold(agent.name)} - ${agent.description}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
// Show summary
|
||||
const totalInstalled = installedAgents.length;
|
||||
const availableToInstall = availableAgents.filter(
|
||||
(a) => !installedAgents.some((i) => i.name === a.name)
|
||||
).length;
|
||||
|
||||
if (!validated.verbose) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`📊 Summary: ${totalInstalled} installed, ${availableToInstall} available to install`
|
||||
)
|
||||
);
|
||||
|
||||
if (availableToInstall > 0) {
|
||||
console.log(
|
||||
chalk.gray(` Use \`dexto install <agent-name>\` to install more agents.`)
|
||||
);
|
||||
}
|
||||
|
||||
console.log(chalk.gray(` Use \`dexto list-agents --verbose\` for detailed information.`));
|
||||
console.log(
|
||||
chalk.gray(` After installing an agent, use \`dexto -a <agent-name>\` to run it.`)
|
||||
);
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
518
dexto/packages/cli/src/cli/commands/plugin.ts
Normal file
518
dexto/packages/cli/src/cli/commands/plugin.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* Plugin CLI Command Handlers
|
||||
*
|
||||
* Handles CLI commands for plugin management:
|
||||
* - dexto plugin list
|
||||
* - dexto plugin install --path <path>
|
||||
* - dexto plugin uninstall <name>
|
||||
* - dexto plugin validate [path]
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
listInstalledPlugins,
|
||||
installPluginFromPath,
|
||||
uninstallPlugin,
|
||||
validatePluginDirectory,
|
||||
type PluginInstallScope,
|
||||
// Marketplace
|
||||
addMarketplace,
|
||||
removeMarketplace,
|
||||
updateMarketplace,
|
||||
listMarketplaces,
|
||||
listAllMarketplacePlugins,
|
||||
installPluginFromMarketplace,
|
||||
} from '@dexto/agent-management';
|
||||
|
||||
// === Schema Definitions ===
|
||||
|
||||
const PluginListCommandSchema = z
|
||||
.object({
|
||||
verbose: z.boolean().default(false).describe('Show detailed plugin information'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PluginInstallCommandSchema = z
|
||||
.object({
|
||||
path: z.string().min(1).describe('Path to the plugin directory'),
|
||||
scope: z.enum(['user', 'project', 'local']).default('user').describe('Installation scope'),
|
||||
force: z.boolean().default(false).describe('Force overwrite if already installed'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PluginUninstallCommandSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).describe('Name of the plugin to uninstall'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PluginValidateCommandSchema = z
|
||||
.object({
|
||||
path: z.string().default('.').describe('Path to the plugin directory to validate'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// === Marketplace Command Schemas ===
|
||||
|
||||
const MarketplaceAddCommandSchema = z
|
||||
.object({
|
||||
source: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('Marketplace source (owner/repo, git URL, or local path)'),
|
||||
name: z.string().optional().describe('Custom name for the marketplace'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MarketplaceRemoveCommandSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).describe('Name of the marketplace to remove'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MarketplaceUpdateCommandSchema = z
|
||||
.object({
|
||||
name: z.string().optional().describe('Name of the marketplace to update (all if omitted)'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MarketplaceListCommandSchema = z
|
||||
.object({
|
||||
verbose: z.boolean().default(false).describe('Show detailed marketplace information'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MarketplaceInstallCommandSchema = z
|
||||
.object({
|
||||
plugin: z.string().min(1).describe('Plugin spec: name or name@marketplace'),
|
||||
scope: z.enum(['user', 'project', 'local']).default('user').describe('Installation scope'),
|
||||
force: z.boolean().default(false).describe('Force reinstall if already exists'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// === Type Exports ===
|
||||
|
||||
export type PluginListCommandOptions = z.output<typeof PluginListCommandSchema>;
|
||||
export type PluginListCommandOptionsInput = z.input<typeof PluginListCommandSchema>;
|
||||
|
||||
export type PluginInstallCommandOptions = z.output<typeof PluginInstallCommandSchema>;
|
||||
export type PluginInstallCommandOptionsInput = z.input<typeof PluginInstallCommandSchema>;
|
||||
|
||||
export type PluginUninstallCommandOptions = z.output<typeof PluginUninstallCommandSchema>;
|
||||
export type PluginUninstallCommandOptionsInput = z.input<typeof PluginUninstallCommandSchema>;
|
||||
|
||||
export type PluginValidateCommandOptions = z.output<typeof PluginValidateCommandSchema>;
|
||||
export type PluginValidateCommandOptionsInput = z.input<typeof PluginValidateCommandSchema>;
|
||||
|
||||
// Marketplace command types
|
||||
export type MarketplaceAddCommandOptions = z.output<typeof MarketplaceAddCommandSchema>;
|
||||
export type MarketplaceAddCommandOptionsInput = z.input<typeof MarketplaceAddCommandSchema>;
|
||||
|
||||
export type MarketplaceRemoveCommandOptions = z.output<typeof MarketplaceRemoveCommandSchema>;
|
||||
export type MarketplaceRemoveCommandOptionsInput = z.input<typeof MarketplaceRemoveCommandSchema>;
|
||||
|
||||
export type MarketplaceUpdateCommandOptions = z.output<typeof MarketplaceUpdateCommandSchema>;
|
||||
export type MarketplaceUpdateCommandOptionsInput = z.input<typeof MarketplaceUpdateCommandSchema>;
|
||||
|
||||
export type MarketplaceListCommandOptions = z.output<typeof MarketplaceListCommandSchema>;
|
||||
export type MarketplaceListCommandOptionsInput = z.input<typeof MarketplaceListCommandSchema>;
|
||||
|
||||
export type MarketplaceInstallCommandOptions = z.output<typeof MarketplaceInstallCommandSchema>;
|
||||
export type MarketplaceInstallCommandOptionsInput = z.input<typeof MarketplaceInstallCommandSchema>;
|
||||
|
||||
// === Command Handlers ===
|
||||
|
||||
/**
|
||||
* Handles the `dexto plugin list` command.
|
||||
* Lists all installed plugins managed by Dexto.
|
||||
*/
|
||||
export async function handlePluginListCommand(
|
||||
options: PluginListCommandOptionsInput
|
||||
): Promise<void> {
|
||||
const validated = PluginListCommandSchema.parse(options);
|
||||
const plugins = listInstalledPlugins();
|
||||
|
||||
if (plugins.length === 0) {
|
||||
console.log(chalk.yellow('No plugins installed.'));
|
||||
console.log('');
|
||||
console.log('Install a plugin with:');
|
||||
console.log(chalk.cyan(' dexto plugin install --path <path-to-plugin>'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`Installed Plugins (${plugins.length}):`));
|
||||
console.log('');
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const sourceLabel = getSourceLabel(plugin.source);
|
||||
const scopeLabel = plugin.scope ? ` [${plugin.scope}]` : '';
|
||||
|
||||
console.log(
|
||||
` ${chalk.green(plugin.name)}${chalk.dim('@' + (plugin.version || 'unknown'))} ${sourceLabel}${scopeLabel}`
|
||||
);
|
||||
|
||||
if (validated.verbose) {
|
||||
if (plugin.description) {
|
||||
console.log(chalk.dim(` ${plugin.description}`));
|
||||
}
|
||||
console.log(chalk.dim(` Path: ${plugin.path}`));
|
||||
if (plugin.installedAt) {
|
||||
const date = new Date(plugin.installedAt).toLocaleDateString();
|
||||
console.log(chalk.dim(` Installed: ${date}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `dexto plugin install --path <path>` command.
|
||||
* Installs a plugin from a local directory.
|
||||
*/
|
||||
export async function handlePluginInstallCommand(
|
||||
options: PluginInstallCommandOptionsInput
|
||||
): Promise<void> {
|
||||
const validated = PluginInstallCommandSchema.parse(options);
|
||||
|
||||
console.log(chalk.cyan(`Installing plugin from ${validated.path}...`));
|
||||
console.log('');
|
||||
|
||||
const result = await installPluginFromPath(validated.path, {
|
||||
scope: validated.scope as PluginInstallScope,
|
||||
force: validated.force,
|
||||
});
|
||||
|
||||
// Show warnings
|
||||
if (result.warnings.length > 0) {
|
||||
console.log(chalk.yellow('Warnings:'));
|
||||
for (const warning of result.warnings) {
|
||||
console.log(chalk.yellow(` - ${warning}`));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(chalk.green(`Successfully installed plugin '${result.pluginName}'`));
|
||||
console.log(chalk.dim(` Path: ${result.installPath}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `dexto plugin uninstall <name>` command.
|
||||
* Uninstalls a plugin by name.
|
||||
*/
|
||||
export async function handlePluginUninstallCommand(
|
||||
options: PluginUninstallCommandOptionsInput
|
||||
): Promise<void> {
|
||||
const validated = PluginUninstallCommandSchema.parse(options);
|
||||
|
||||
console.log(chalk.cyan(`Uninstalling plugin '${validated.name}'...`));
|
||||
|
||||
const result = await uninstallPlugin(validated.name);
|
||||
|
||||
console.log(chalk.green(`Successfully uninstalled plugin '${validated.name}'`));
|
||||
if (result.removedPath) {
|
||||
console.log(chalk.dim(` Removed: ${result.removedPath}`));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `dexto plugin validate [path]` command.
|
||||
* Validates a plugin directory structure and manifest.
|
||||
*/
|
||||
export async function handlePluginValidateCommand(
|
||||
options: PluginValidateCommandOptionsInput
|
||||
): Promise<void> {
|
||||
const validated = PluginValidateCommandSchema.parse(options);
|
||||
|
||||
console.log(chalk.cyan(`Validating plugin at ${validated.path}...`));
|
||||
console.log('');
|
||||
|
||||
const result = validatePluginDirectory(validated.path);
|
||||
|
||||
if (result.valid) {
|
||||
console.log(chalk.green('Plugin is valid!'));
|
||||
console.log('');
|
||||
|
||||
if (result.manifest) {
|
||||
console.log(chalk.bold('Manifest:'));
|
||||
console.log(` Name: ${chalk.green(result.manifest.name)}`);
|
||||
if (result.manifest.description) {
|
||||
console.log(` Description: ${result.manifest.description}`);
|
||||
}
|
||||
if (result.manifest.version) {
|
||||
console.log(` Version: ${result.manifest.version}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.red('Plugin validation failed!'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Show errors
|
||||
if (result.errors.length > 0) {
|
||||
console.log(chalk.red('Errors:'));
|
||||
for (const error of result.errors) {
|
||||
console.log(chalk.red(` - ${error}`));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Show warnings
|
||||
if (result.warnings.length > 0) {
|
||||
console.log(chalk.yellow('Warnings:'));
|
||||
for (const warning of result.warnings) {
|
||||
console.log(chalk.yellow(` - ${warning}`));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Exit with error code if invalid
|
||||
if (!result.valid) {
|
||||
const errorDetails = result.errors.length > 0 ? `: ${result.errors.join(', ')}` : '';
|
||||
throw new Error(`Plugin validation failed${errorDetails}`);
|
||||
}
|
||||
}
|
||||
|
||||
// === Marketplace Command Handlers ===
|
||||
|
||||
/**
|
||||
* Handles the `dexto plugin marketplace add <source>` command.
|
||||
* Adds a new marketplace from GitHub, git URL, or local path.
|
||||
*/
|
||||
export async function handleMarketplaceAddCommand(
|
||||
options: MarketplaceAddCommandOptionsInput
|
||||
): Promise<void> {
|
||||
const validated = MarketplaceAddCommandSchema.parse(options);
|
||||
|
||||
console.log(chalk.cyan(`Adding marketplace from ${validated.source}...`));
|
||||
console.log('');
|
||||
|
||||
const result = await addMarketplace(validated.source, {
|
||||
name: validated.name,
|
||||
});
|
||||
|
||||
// Show warnings
|
||||
if (result.warnings.length > 0) {
|
||||
console.log(chalk.yellow('Warnings:'));
|
||||
for (const warning of result.warnings) {
|
||||
console.log(chalk.yellow(` - ${warning}`));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(chalk.green(`Successfully added marketplace '${result.name}'`));
|
||||
console.log(chalk.dim(` Plugins found: ${result.pluginCount}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `dexto plugin marketplace remove <name>` command.
|
||||
* Removes a registered marketplace.
|
||||
*/
|
||||
export async function handleMarketplaceRemoveCommand(
|
||||
options: MarketplaceRemoveCommandOptionsInput
|
||||
): Promise<void> {
|
||||
const validated = MarketplaceRemoveCommandSchema.parse(options);
|
||||
|
||||
console.log(chalk.cyan(`Removing marketplace '${validated.name}'...`));
|
||||
|
||||
await removeMarketplace(validated.name);
|
||||
|
||||
console.log(chalk.green(`Successfully removed marketplace '${validated.name}'`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `dexto plugin marketplace update [name]` command.
|
||||
* Updates marketplace(s) by pulling latest from git.
|
||||
*/
|
||||
export async function handleMarketplaceUpdateCommand(
|
||||
options: MarketplaceUpdateCommandOptionsInput
|
||||
): Promise<void> {
|
||||
const validated = MarketplaceUpdateCommandSchema.parse(options);
|
||||
|
||||
if (validated.name) {
|
||||
console.log(chalk.cyan(`Updating marketplace '${validated.name}'...`));
|
||||
} else {
|
||||
console.log(chalk.cyan('Updating all marketplaces...'));
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const results = await updateMarketplace(validated.name);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.hasChanges) {
|
||||
console.log(chalk.green(`✓ ${result.name}: Updated`));
|
||||
if (result.previousSha && result.newSha) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` ${result.previousSha.substring(0, 8)} → ${result.newSha.substring(0, 8)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.dim(`○ ${result.name}: Already up to date`));
|
||||
}
|
||||
|
||||
// Show warnings
|
||||
if (result.warnings.length > 0) {
|
||||
for (const warning of result.warnings) {
|
||||
console.log(chalk.yellow(` - ${warning}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `dexto plugin marketplace list` command.
|
||||
* Lists all registered marketplaces.
|
||||
*/
|
||||
export async function handleMarketplaceListCommand(
|
||||
options: MarketplaceListCommandOptionsInput
|
||||
): Promise<void> {
|
||||
const validated = MarketplaceListCommandSchema.parse(options);
|
||||
const marketplaces = listMarketplaces();
|
||||
|
||||
if (marketplaces.length === 0) {
|
||||
console.log(chalk.yellow('No marketplaces registered.'));
|
||||
console.log('');
|
||||
console.log('Add a marketplace with:');
|
||||
console.log(chalk.cyan(' dexto plugin marketplace add <owner/repo>'));
|
||||
console.log('');
|
||||
console.log('Examples:');
|
||||
console.log(chalk.dim(' dexto plugin marketplace add anthropics/claude-plugins-official'));
|
||||
console.log(
|
||||
chalk.dim(' dexto plugin marketplace add https://github.com/user/plugins.git')
|
||||
);
|
||||
console.log(chalk.dim(' dexto plugin marketplace add ~/my-local-plugins'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`Registered Marketplaces (${marketplaces.length}):`));
|
||||
console.log('');
|
||||
|
||||
for (const marketplace of marketplaces) {
|
||||
const sourceType = chalk.dim(`[${marketplace.source.type}]`);
|
||||
console.log(` ${chalk.green(marketplace.name)} ${sourceType}`);
|
||||
|
||||
if (validated.verbose) {
|
||||
console.log(chalk.dim(` Source: ${marketplace.source.value}`));
|
||||
console.log(chalk.dim(` Path: ${marketplace.installLocation}`));
|
||||
if (marketplace.lastUpdated) {
|
||||
const date = new Date(marketplace.lastUpdated).toLocaleDateString();
|
||||
console.log(chalk.dim(` Updated: ${date}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `dexto plugin marketplace plugins [name]` command.
|
||||
* Lists plugins available in marketplaces.
|
||||
*/
|
||||
export async function handleMarketplacePluginsCommand(options: {
|
||||
marketplace?: string | undefined;
|
||||
verbose?: boolean | undefined;
|
||||
}): Promise<void> {
|
||||
const plugins = listAllMarketplacePlugins();
|
||||
|
||||
// Filter by marketplace if specified
|
||||
const filtered = options.marketplace
|
||||
? plugins.filter(
|
||||
(plugin) => plugin.marketplace.toLowerCase() === options.marketplace?.toLowerCase()
|
||||
)
|
||||
: plugins;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
if (options.marketplace) {
|
||||
console.log(chalk.yellow(`No plugins found in marketplace '${options.marketplace}'.`));
|
||||
} else {
|
||||
console.log(chalk.yellow('No plugins found in any marketplace.'));
|
||||
console.log('');
|
||||
console.log('Make sure you have marketplaces registered:');
|
||||
console.log(chalk.cyan(' dexto plugin marketplace list'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`Available Plugins (${filtered.length}):`));
|
||||
console.log('');
|
||||
|
||||
// Group by marketplace
|
||||
const byMarketplace = new Map<string, typeof filtered>();
|
||||
for (const plugin of filtered) {
|
||||
const list = byMarketplace.get(plugin.marketplace) || [];
|
||||
list.push(plugin);
|
||||
byMarketplace.set(plugin.marketplace, list);
|
||||
}
|
||||
|
||||
for (const [marketplace, marketplacePlugins] of byMarketplace) {
|
||||
console.log(chalk.cyan(` ${marketplace}:`));
|
||||
for (const plugin of marketplacePlugins) {
|
||||
const version = plugin.version ? chalk.dim(`@${plugin.version}`) : '';
|
||||
const category = plugin.category ? chalk.dim(` [${plugin.category}]`) : '';
|
||||
console.log(` ${chalk.green(plugin.name)}${version}${category}`);
|
||||
|
||||
if (options.verbose && plugin.description) {
|
||||
console.log(chalk.dim(` ${plugin.description}`));
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log('Install a plugin with:');
|
||||
console.log(chalk.cyan(' dexto plugin marketplace install <name>@<marketplace>'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `dexto plugin marketplace install <plugin>` command.
|
||||
* Installs a plugin from a registered marketplace.
|
||||
*/
|
||||
export async function handleMarketplaceInstallCommand(
|
||||
options: MarketplaceInstallCommandOptionsInput
|
||||
): Promise<void> {
|
||||
const validated = MarketplaceInstallCommandSchema.parse(options);
|
||||
|
||||
console.log(chalk.cyan(`Installing plugin '${validated.plugin}' from marketplace...`));
|
||||
console.log('');
|
||||
|
||||
const result = await installPluginFromMarketplace(validated.plugin, {
|
||||
scope: validated.scope as PluginInstallScope,
|
||||
force: validated.force,
|
||||
});
|
||||
|
||||
// Show warnings
|
||||
if (result.warnings.length > 0) {
|
||||
console.log(chalk.yellow('Warnings:'));
|
||||
for (const warning of result.warnings) {
|
||||
console.log(chalk.yellow(` - ${warning}`));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(chalk.green(`Successfully installed plugin '${result.pluginName}'`));
|
||||
console.log(chalk.dim(` Marketplace: ${result.marketplace}`));
|
||||
console.log(chalk.dim(` Path: ${result.installPath}`));
|
||||
if (result.gitCommitSha) {
|
||||
console.log(chalk.dim(` Version: ${result.gitCommitSha.substring(0, 8)}`));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
/**
|
||||
* Gets a display label for the plugin source.
|
||||
*/
|
||||
function getSourceLabel(_source: 'dexto'): string {
|
||||
return chalk.blue('(dexto)');
|
||||
}
|
||||
285
dexto/packages/cli/src/cli/commands/session-commands.ts
Normal file
285
dexto/packages/cli/src/cli/commands/session-commands.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Non-Interactive Session Management Commands
|
||||
*
|
||||
* This module provides CLI commands for managing sessions outside of interactive mode.
|
||||
* These commands allow users to manage sessions via direct CLI invocation.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { logger, DextoAgent, type SessionMetadata } from '@dexto/core';
|
||||
import { formatSessionInfo, formatHistoryMessage } from './helpers/formatters.js';
|
||||
|
||||
/**
|
||||
* Escape special regex characters in a string to prevent ReDoS attacks
|
||||
*/
|
||||
function escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get most recent session info (for non-interactive CLI)
|
||||
* Non-interactive commands don't have a "current session" concept,
|
||||
* so we use the most recently active session as a sensible default.
|
||||
*/
|
||||
async function getMostRecentSessionInfo(
|
||||
agent: DextoAgent
|
||||
): Promise<{ id: string; metadata: SessionMetadata | undefined } | null> {
|
||||
const sessionIds = await agent.listSessions();
|
||||
if (sessionIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find most recently active session
|
||||
let mostRecentId: string | null = null;
|
||||
let mostRecentActivity = 0;
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
const metadata = await agent.getSessionMetadata(sessionId);
|
||||
if (metadata && metadata.lastActivity > mostRecentActivity) {
|
||||
mostRecentActivity = metadata.lastActivity;
|
||||
mostRecentId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mostRecentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = await agent.getSessionMetadata(mostRecentId);
|
||||
return { id: mostRecentId, metadata };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to display session history with consistent formatting
|
||||
*/
|
||||
async function displaySessionHistory(sessionId: string, agent: DextoAgent): Promise<void> {
|
||||
console.log(chalk.blue(`\n💬 Session History for: ${chalk.bold(sessionId)}\n`));
|
||||
|
||||
const history = await agent.getSessionHistory(sessionId);
|
||||
|
||||
if (history.length === 0) {
|
||||
console.log(chalk.gray(' No messages in this session yet.\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Display each message with formatting
|
||||
history.forEach((message, index) => {
|
||||
console.log(formatHistoryMessage(message, index));
|
||||
});
|
||||
|
||||
console.log(chalk.gray(`\n Total: ${history.length} messages`));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available sessions
|
||||
*/
|
||||
export async function handleSessionListCommand(agent: DextoAgent): Promise<void> {
|
||||
try {
|
||||
console.log(chalk.bold.blue('\n📋 Sessions:\n'));
|
||||
|
||||
const sessionIds = await agent.listSessions();
|
||||
const mostRecent = await getMostRecentSessionInfo(agent);
|
||||
|
||||
if (sessionIds.length === 0) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
' No sessions found. Run `dexto` to start a new session, or use `dexto -c`/`dexto -r <id>`.\n'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch metadata concurrently; errors do not abort listing
|
||||
const entries = await Promise.all(
|
||||
sessionIds.map(async (id) => {
|
||||
try {
|
||||
const metadata = await agent.getSessionMetadata(id);
|
||||
return { id, metadata };
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to fetch metadata for session ${id}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
null,
|
||||
'red'
|
||||
);
|
||||
return { id, metadata: undefined as SessionMetadata | undefined };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
let displayed = 0;
|
||||
for (const { id, metadata } of entries) {
|
||||
if (!metadata) continue;
|
||||
// Mark most recent session with indicator (instead of "current")
|
||||
const isMostRecent = mostRecent ? id === mostRecent.id : false;
|
||||
console.log(` ${formatSessionInfo(id, metadata, isMostRecent)}`);
|
||||
displayed++;
|
||||
}
|
||||
|
||||
console.log(chalk.gray(`\n Total: ${displayed} of ${sessionIds.length} sessions`));
|
||||
console.log(chalk.gray(' 💡 Use `dexto -r <id>` to resume a session\n'));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to list sessions: ${error instanceof Error ? error.message : String(error)}`,
|
||||
null,
|
||||
'red'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show session history
|
||||
*/
|
||||
export async function handleSessionHistoryCommand(
|
||||
agent: DextoAgent,
|
||||
sessionId?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Use provided session ID or most recent session
|
||||
let targetSessionId = sessionId;
|
||||
if (!targetSessionId) {
|
||||
const recentSession = await getMostRecentSessionInfo(agent);
|
||||
if (!recentSession) {
|
||||
console.log(chalk.red('❌ No sessions found'));
|
||||
console.log(chalk.gray(' Create a session first by running: dexto'));
|
||||
throw new Error('No sessions found');
|
||||
}
|
||||
targetSessionId = recentSession.id;
|
||||
console.log(chalk.gray(`Using most recent session: ${targetSessionId}\n`));
|
||||
}
|
||||
|
||||
await displaySessionHistory(targetSessionId, agent);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
console.log(chalk.red(`❌ Session not found: ${sessionId || 'current'}`));
|
||||
console.log(chalk.gray(' Use `dexto session list` to see available sessions'));
|
||||
} else if (error instanceof Error && error.message !== 'No sessions found') {
|
||||
logger.error(`Failed to get session history: ${error.message}`, null, 'red');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session
|
||||
*/
|
||||
export async function handleSessionDeleteCommand(
|
||||
agent: DextoAgent,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Note: For non-interactive CLI, there's no concept of "current session"
|
||||
// We just delete the requested session without restrictions
|
||||
await agent.deleteSession(sessionId);
|
||||
console.log(chalk.green(`✅ Deleted session: ${chalk.bold(sessionId)}`));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to delete session: ${error instanceof Error ? error.message : String(error)}`,
|
||||
null,
|
||||
'red'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search session history
|
||||
*/
|
||||
export async function handleSessionSearchCommand(
|
||||
agent: DextoAgent,
|
||||
query: string,
|
||||
options: {
|
||||
sessionId?: string;
|
||||
role?: 'user' | 'assistant' | 'system' | 'tool';
|
||||
limit?: number;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const searchOptions: {
|
||||
limit: number;
|
||||
sessionId?: string;
|
||||
role?: 'user' | 'assistant' | 'system' | 'tool';
|
||||
} = {
|
||||
limit: options.limit || 10,
|
||||
};
|
||||
|
||||
if (options.sessionId) {
|
||||
searchOptions.sessionId = options.sessionId;
|
||||
}
|
||||
if (options.role) {
|
||||
const allowed = new Set(['user', 'assistant', 'system', 'tool']);
|
||||
if (!allowed.has(options.role)) {
|
||||
console.log(
|
||||
chalk.red(
|
||||
`❌ Invalid role: ${options.role}. Use one of: user, assistant, system, tool`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
searchOptions.role = options.role;
|
||||
}
|
||||
|
||||
console.log(chalk.blue(`🔍 Searching for: "${query}"`));
|
||||
if (searchOptions.sessionId) {
|
||||
console.log(chalk.gray(` Session: ${searchOptions.sessionId}`));
|
||||
}
|
||||
if (searchOptions.role) {
|
||||
console.log(chalk.gray(` Role: ${searchOptions.role}`));
|
||||
}
|
||||
console.log(chalk.gray(` Limit: ${searchOptions.limit}`));
|
||||
console.log();
|
||||
|
||||
const results = await agent.searchMessages(query, searchOptions);
|
||||
|
||||
if (results.results.length === 0) {
|
||||
console.log(chalk.rgb(255, 165, 0)('📭 No messages found matching your search'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(`✅ Found ${results.total} result${results.total === 1 ? '' : 's'}`)
|
||||
);
|
||||
if (results.hasMore) {
|
||||
console.log(chalk.gray(` Showing first ${results.results.length} results`));
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Precompile safe regex for highlighting (only if query is not empty)
|
||||
const highlightRegex = query.trim()
|
||||
? new RegExp(`(${escapeRegExp(query.trim().slice(0, 256))})`, 'gi')
|
||||
: null;
|
||||
|
||||
// Display results
|
||||
results.results.forEach((result, index) => {
|
||||
const roleColor =
|
||||
result.message.role === 'user'
|
||||
? chalk.blue
|
||||
: result.message.role === 'assistant'
|
||||
? chalk.green
|
||||
: chalk.rgb(255, 165, 0);
|
||||
|
||||
console.log(
|
||||
`${chalk.gray(`${index + 1}.`)} ${chalk.cyan(result.sessionId)} ${roleColor(`[${result.message.role}]`)}`
|
||||
);
|
||||
|
||||
// Safe highlighting - only if we have a valid regex
|
||||
const highlightedContext = highlightRegex
|
||||
? result.context.replace(highlightRegex, chalk.inverse('$1'))
|
||||
: result.context;
|
||||
|
||||
console.log(` ${highlightedContext}`);
|
||||
console.log();
|
||||
});
|
||||
|
||||
if (results.hasMore) {
|
||||
console.log(chalk.gray('💡 Use --limit to see more results'));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Search failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
null,
|
||||
'red'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
754
dexto/packages/cli/src/cli/commands/setup.test.ts
Normal file
754
dexto/packages/cli/src/cli/commands/setup.test.ts
Normal file
@@ -0,0 +1,754 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { handleSetupCommand, type CLISetupOptionsInput } from './setup.js';
|
||||
|
||||
// Mock only external dependencies that can't be tested directly
|
||||
vi.mock('@dexto/core', async () => {
|
||||
const actual = await vi.importActual<typeof import('@dexto/core')>('@dexto/core');
|
||||
return {
|
||||
...actual,
|
||||
resolveApiKeyForProvider: vi.fn(),
|
||||
requiresApiKey: vi.fn(() => true), // Most providers need API keys
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@dexto/agent-management', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@dexto/agent-management')>('@dexto/agent-management');
|
||||
return {
|
||||
...actual,
|
||||
createInitialPreferences: vi.fn((...args: any[]) => {
|
||||
const options = args[0];
|
||||
// Handle new options object signature
|
||||
if (typeof options === 'object' && 'provider' in options) {
|
||||
const llmConfig: any = { provider: options.provider, model: options.model };
|
||||
if (options.apiKeyVar) {
|
||||
llmConfig.apiKey = `$${options.apiKeyVar}`;
|
||||
}
|
||||
if (options.baseURL) {
|
||||
llmConfig.baseURL = options.baseURL;
|
||||
}
|
||||
return {
|
||||
llm: llmConfig,
|
||||
defaults: {
|
||||
defaultAgent: options.defaultAgent || 'coding-agent',
|
||||
defaultMode: options.defaultMode || 'web',
|
||||
},
|
||||
setup: { completed: options.setupCompleted ?? true },
|
||||
};
|
||||
}
|
||||
// Legacy signature (provider, model, apiKeyVar, defaultAgent)
|
||||
return {
|
||||
llm: { provider: options, model: args[1], apiKey: `$${args[2]}` },
|
||||
defaults: { defaultAgent: args[3] || 'coding-agent' },
|
||||
setup: { completed: true },
|
||||
};
|
||||
}),
|
||||
saveGlobalPreferences: vi.fn().mockResolvedValue(undefined),
|
||||
loadGlobalPreferences: vi.fn().mockResolvedValue(null),
|
||||
getGlobalPreferencesPath: vi.fn(() => '/tmp/preferences.yml'),
|
||||
updateGlobalPreferences: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/api-key-setup.js', () => ({
|
||||
interactiveApiKeySetup: vi.fn().mockResolvedValue({ success: true }),
|
||||
hasApiKeyConfigured: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/provider-setup.js', () => ({
|
||||
selectProvider: vi.fn(),
|
||||
getProviderDisplayName: vi.fn((provider: string) => provider),
|
||||
getProviderEnvVar: vi.fn((provider: string) => `${provider.toUpperCase()}_API_KEY`),
|
||||
getProviderInfo: vi.fn(() => ({ apiKeyUrl: 'https://example.com' })),
|
||||
providerRequiresBaseURL: vi.fn(() => false),
|
||||
getDefaultModel: vi.fn(() => 'test-model'),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/setup-utils.js', () => ({
|
||||
requiresSetup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@clack/prompts', () => ({
|
||||
intro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
isCancel: vi.fn(),
|
||||
select: vi.fn().mockResolvedValue('exit'),
|
||||
text: vi.fn().mockResolvedValue('test'),
|
||||
password: vi.fn().mockResolvedValue('test-key'),
|
||||
spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
|
||||
log: { warn: vi.fn(), success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('Setup Command', () => {
|
||||
let tempDir: string;
|
||||
let mockCreateInitialPreferences: any;
|
||||
let mockSaveGlobalPreferences: any;
|
||||
let mockInteractiveApiKeySetup: any;
|
||||
let mockHasApiKeyConfigured: any;
|
||||
let mockSelectProvider: any;
|
||||
let mockRequiresSetup: any;
|
||||
let mockResolveApiKeyForProvider: any;
|
||||
let mockPrompts: any;
|
||||
let consoleSpy: any;
|
||||
let consoleErrorSpy: any;
|
||||
let processExitSpy: any;
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(tmpdir(), 'setup-test-'));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
tempDir = createTempDir();
|
||||
|
||||
// Get mock functions
|
||||
const prefLoader = await import('@dexto/agent-management');
|
||||
const apiKeySetup = await import('../utils/api-key-setup.js');
|
||||
const apiKeyResolver = await import('@dexto/core');
|
||||
const providerSetup = await import('../utils/provider-setup.js');
|
||||
const setupUtils = await import('../utils/setup-utils.js');
|
||||
const prompts = await import('@clack/prompts');
|
||||
|
||||
mockCreateInitialPreferences = vi.mocked(prefLoader.createInitialPreferences);
|
||||
mockSaveGlobalPreferences = vi.mocked(prefLoader.saveGlobalPreferences);
|
||||
mockInteractiveApiKeySetup = vi.mocked(apiKeySetup.interactiveApiKeySetup);
|
||||
mockHasApiKeyConfigured = vi.mocked(apiKeySetup.hasApiKeyConfigured);
|
||||
mockResolveApiKeyForProvider = vi.mocked(apiKeyResolver.resolveApiKeyForProvider);
|
||||
mockSelectProvider = vi.mocked(providerSetup.selectProvider);
|
||||
mockRequiresSetup = vi.mocked(setupUtils.requiresSetup);
|
||||
mockPrompts = {
|
||||
intro: vi.mocked(prompts.intro),
|
||||
note: vi.mocked(prompts.note),
|
||||
confirm: vi.mocked(prompts.confirm),
|
||||
cancel: vi.mocked(prompts.cancel),
|
||||
isCancel: vi.mocked(prompts.isCancel),
|
||||
select: vi.mocked(prompts.select),
|
||||
log: {
|
||||
warn: vi.mocked(prompts.log.warn),
|
||||
success: vi.mocked(prompts.log.success),
|
||||
info: vi.mocked(prompts.log.info),
|
||||
},
|
||||
};
|
||||
|
||||
// Reset mocks to default behavior - use new options object signature
|
||||
mockCreateInitialPreferences.mockImplementation((...args: any[]) => {
|
||||
const options = args[0];
|
||||
if (typeof options === 'object' && 'provider' in options) {
|
||||
const llmConfig: any = { provider: options.provider, model: options.model };
|
||||
if (options.apiKeyVar) {
|
||||
llmConfig.apiKey = `$${options.apiKeyVar}`;
|
||||
}
|
||||
return {
|
||||
llm: llmConfig,
|
||||
defaults: {
|
||||
defaultAgent: options.defaultAgent || 'coding-agent',
|
||||
defaultMode: options.defaultMode || 'web',
|
||||
},
|
||||
setup: { completed: options.setupCompleted ?? true },
|
||||
};
|
||||
}
|
||||
return {
|
||||
llm: { provider: options, model: args[1], apiKey: `$${args[2]}` },
|
||||
defaults: { defaultAgent: args[3] || 'coding-agent' },
|
||||
setup: { completed: true },
|
||||
};
|
||||
});
|
||||
mockSaveGlobalPreferences.mockResolvedValue(undefined);
|
||||
mockInteractiveApiKeySetup.mockResolvedValue({ success: true });
|
||||
mockHasApiKeyConfigured.mockReturnValue(true); // Default: API key exists
|
||||
mockResolveApiKeyForProvider.mockReturnValue(undefined); // Default: no API key exists (for analytics)
|
||||
mockSelectProvider.mockResolvedValue(null);
|
||||
mockRequiresSetup.mockResolvedValue(true); // Default: setup is required
|
||||
mockPrompts.isCancel.mockReturnValue(false);
|
||||
mockPrompts.select.mockResolvedValue('exit'); // Default: exit settings menu
|
||||
|
||||
// Mock console to prevent test output noise
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code?: string | number | null | undefined) => {
|
||||
throw new Error(`Process exit called with code ${code}`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('Non-interactive setup', () => {
|
||||
it('creates preferences with provided options using new object signature', async () => {
|
||||
const options: CLISetupOptionsInput = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-5',
|
||||
defaultAgent: 'my-agent',
|
||||
interactive: false,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
expect(mockCreateInitialPreferences).toHaveBeenCalledWith({
|
||||
provider: 'openai',
|
||||
model: 'gpt-5',
|
||||
apiKeyVar: 'OPENAI_API_KEY',
|
||||
defaultAgent: 'my-agent',
|
||||
setupCompleted: true,
|
||||
});
|
||||
expect(mockSaveGlobalPreferences).toHaveBeenCalled();
|
||||
expect(mockInteractiveApiKeySetup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses default model when not specified', async () => {
|
||||
const options = {
|
||||
provider: 'anthropic' as const,
|
||||
interactive: false,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
expect(mockCreateInitialPreferences).toHaveBeenCalledWith({
|
||||
provider: 'anthropic',
|
||||
model: 'test-model', // From mocked getDefaultModel
|
||||
apiKeyVar: 'ANTHROPIC_API_KEY',
|
||||
defaultAgent: 'coding-agent',
|
||||
setupCompleted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws error when provider missing in non-interactive mode', async () => {
|
||||
const options = {
|
||||
interactive: false,
|
||||
};
|
||||
|
||||
await expect(handleSetupCommand(options)).rejects.toThrow(
|
||||
'Provider required in non-interactive mode. Use --provider or --quick-start option.'
|
||||
);
|
||||
});
|
||||
|
||||
it('exits with error when model required but not provided', async () => {
|
||||
// Mock getDefaultModel to return empty string for this provider (simulating no default)
|
||||
const providerSetup = await import('../utils/provider-setup.js');
|
||||
vi.mocked(providerSetup.getDefaultModel).mockReturnValueOnce('');
|
||||
|
||||
const options = {
|
||||
provider: 'openai-compatible' as const, // Provider with no default model
|
||||
interactive: false,
|
||||
};
|
||||
|
||||
await expect(handleSetupCommand(options)).rejects.toThrow(
|
||||
'Process exit called with code 1'
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Model is required for provider 'openai-compatible'")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interactive setup', () => {
|
||||
it('shows setup type selection when interactive without provider', async () => {
|
||||
// User selects 'custom' setup, then provider selection happens
|
||||
// Wizard uses selectProvider for provider selection
|
||||
mockPrompts.select.mockResolvedValueOnce('custom'); // Setup type
|
||||
mockSelectProvider.mockResolvedValueOnce('anthropic'); // Provider (via selectProvider)
|
||||
mockPrompts.select.mockResolvedValueOnce('claude-haiku-4-5-20251001'); // Model
|
||||
mockPrompts.select.mockResolvedValueOnce('web'); // Default mode
|
||||
|
||||
const options = {
|
||||
interactive: true,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
expect(mockCreateInitialPreferences).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'anthropic',
|
||||
defaultMode: 'web',
|
||||
setupCompleted: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles quick start selection in interactive mode', async () => {
|
||||
// User selects 'quick' setup
|
||||
mockPrompts.select.mockResolvedValueOnce('quick'); // Setup type -> quick start
|
||||
mockPrompts.select.mockResolvedValueOnce('google'); // Provider picker -> google
|
||||
mockPrompts.confirm.mockResolvedValueOnce(true); // CLI mode confirmation -> yes
|
||||
mockHasApiKeyConfigured.mockReturnValue(true); // API key already configured
|
||||
|
||||
const options = {
|
||||
interactive: true,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
// Quick start uses Google provider with CLI mode
|
||||
expect(mockCreateInitialPreferences).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'google',
|
||||
defaultMode: 'cli',
|
||||
setupCompleted: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('runs interactive API key setup when no API key exists', async () => {
|
||||
// New wizard flow uses p.select for setup type, selectProvider for provider
|
||||
mockPrompts.select.mockResolvedValueOnce('custom'); // Setup type
|
||||
mockSelectProvider.mockResolvedValueOnce('openai'); // Provider (via selectProvider)
|
||||
mockPrompts.select.mockResolvedValueOnce('gpt-4o'); // Model (must be valid OpenAI model from registry)
|
||||
mockPrompts.select.mockResolvedValueOnce('web'); // Default mode
|
||||
mockHasApiKeyConfigured.mockReturnValue(false); // No API key exists
|
||||
|
||||
const options = {
|
||||
interactive: true,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
// API key setup is called with provider and model
|
||||
expect(mockInteractiveApiKeySetup).toHaveBeenCalledWith(
|
||||
'openai',
|
||||
expect.objectContaining({
|
||||
exitOnCancel: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips interactive API key setup when API key already exists', async () => {
|
||||
mockPrompts.select.mockResolvedValueOnce('custom'); // Setup type
|
||||
mockSelectProvider.mockResolvedValueOnce('openai'); // Provider (via selectProvider)
|
||||
mockPrompts.select.mockResolvedValueOnce('gpt-4'); // Model
|
||||
mockPrompts.select.mockResolvedValueOnce('web'); // Default mode
|
||||
mockHasApiKeyConfigured.mockReturnValue(true); // API key exists
|
||||
|
||||
const options = {
|
||||
interactive: true,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
expect(mockInteractiveApiKeySetup).not.toHaveBeenCalled();
|
||||
expect(mockPrompts.log.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels setup when user cancels setup type selection', async () => {
|
||||
mockPrompts.select.mockResolvedValueOnce(Symbol.for('cancel')); // Cancel
|
||||
mockPrompts.isCancel.mockReturnValue(true);
|
||||
|
||||
const options = {
|
||||
interactive: true,
|
||||
};
|
||||
|
||||
await expect(handleSetupCommand(options)).rejects.toThrow(
|
||||
'Process exit called with code 0'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
it('validates schema correctly with defaults and uses new options signature', async () => {
|
||||
// Interactive mode with provider - goes through full setup flow
|
||||
mockPrompts.select.mockResolvedValueOnce('custom'); // Setup type
|
||||
mockSelectProvider.mockResolvedValueOnce('google'); // Provider (via selectProvider)
|
||||
mockPrompts.select.mockResolvedValueOnce('gemini-2.5-pro'); // Model
|
||||
mockPrompts.select.mockResolvedValueOnce('web'); // Default mode
|
||||
|
||||
const options = {
|
||||
provider: 'google' as const,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
// Should apply defaults: interactive=true, defaultAgent='coding-agent'
|
||||
expect(mockCreateInitialPreferences).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'google',
|
||||
setupCompleted: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws ZodError for invalid provider', async () => {
|
||||
const options = {
|
||||
provider: 'invalid-provider',
|
||||
interactive: false,
|
||||
} as any;
|
||||
|
||||
await expect(handleSetupCommand(options)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws validation error for empty model name', async () => {
|
||||
const options = {
|
||||
provider: 'openai',
|
||||
model: '',
|
||||
interactive: false,
|
||||
} as any;
|
||||
|
||||
await expect(handleSetupCommand(options)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws validation error for empty default agent', async () => {
|
||||
const options = {
|
||||
provider: 'openai',
|
||||
defaultAgent: '',
|
||||
interactive: false,
|
||||
} as any;
|
||||
|
||||
await expect(handleSetupCommand(options)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('handles strict mode validation correctly', async () => {
|
||||
const options = {
|
||||
provider: 'openai',
|
||||
unknownField: 'should-cause-error',
|
||||
interactive: false,
|
||||
} as any;
|
||||
|
||||
await expect(handleSetupCommand(options)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('propagates errors from createInitialPreferences', async () => {
|
||||
mockCreateInitialPreferences.mockImplementation(() => {
|
||||
throw new Error('Failed to create preferences');
|
||||
});
|
||||
|
||||
const options = {
|
||||
provider: 'openai' as const,
|
||||
interactive: false,
|
||||
};
|
||||
|
||||
await expect(handleSetupCommand(options)).rejects.toThrow(
|
||||
'Failed to create preferences'
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates errors from saveGlobalPreferences', async () => {
|
||||
// Reset createInitialPreferences to new options signature
|
||||
mockCreateInitialPreferences.mockImplementation((options: any) => ({
|
||||
llm: {
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
apiKey: `$${options.apiKeyVar}`,
|
||||
},
|
||||
defaults: {
|
||||
defaultAgent: options.defaultAgent || 'coding-agent',
|
||||
defaultMode: 'web',
|
||||
},
|
||||
setup: { completed: true },
|
||||
}));
|
||||
mockSaveGlobalPreferences.mockRejectedValue(new Error('Failed to save preferences'));
|
||||
|
||||
const options = {
|
||||
provider: 'openai' as const,
|
||||
interactive: false,
|
||||
};
|
||||
|
||||
await expect(handleSetupCommand(options)).rejects.toThrow('Failed to save preferences');
|
||||
});
|
||||
|
||||
it('propagates errors from interactiveApiKeySetup', async () => {
|
||||
// Reset to new options signature
|
||||
mockCreateInitialPreferences.mockImplementation((options: any) => ({
|
||||
llm: {
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
apiKey: `$${options.apiKeyVar}`,
|
||||
},
|
||||
defaults: {
|
||||
defaultAgent: options.defaultAgent || 'coding-agent',
|
||||
defaultMode: 'web',
|
||||
},
|
||||
setup: { completed: true },
|
||||
}));
|
||||
mockSaveGlobalPreferences.mockResolvedValue(undefined);
|
||||
mockHasApiKeyConfigured.mockReturnValue(false); // No API key exists
|
||||
// Simulate a thrown error (not just a failed result)
|
||||
mockInteractiveApiKeySetup.mockRejectedValue(new Error('API key setup failed'));
|
||||
|
||||
// Setup mocks for interactive flow
|
||||
mockPrompts.select.mockResolvedValueOnce('custom'); // Setup type
|
||||
mockSelectProvider.mockResolvedValueOnce('openai'); // Provider (via selectProvider)
|
||||
mockPrompts.select.mockResolvedValueOnce('gpt-4'); // Model
|
||||
mockPrompts.select.mockResolvedValueOnce('web'); // Mode (won't be reached due to error)
|
||||
|
||||
const options = {
|
||||
interactive: true,
|
||||
};
|
||||
|
||||
await expect(handleSetupCommand(options)).rejects.toThrow('API key setup failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('works correctly with multiple providers in non-interactive mode', async () => {
|
||||
const testCases = [
|
||||
{
|
||||
provider: 'openai',
|
||||
expectedKey: 'OPENAI_API_KEY',
|
||||
},
|
||||
{
|
||||
provider: 'anthropic',
|
||||
expectedKey: 'ANTHROPIC_API_KEY',
|
||||
},
|
||||
{
|
||||
provider: 'google',
|
||||
expectedKey: 'GOOGLE_API_KEY',
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of testCases) {
|
||||
// Reset mocks for each test case
|
||||
mockCreateInitialPreferences.mockClear();
|
||||
mockSaveGlobalPreferences.mockClear();
|
||||
mockInteractiveApiKeySetup.mockClear();
|
||||
|
||||
const options = {
|
||||
provider: testCase.provider,
|
||||
interactive: false,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
expect(mockCreateInitialPreferences).toHaveBeenCalledWith({
|
||||
provider: testCase.provider,
|
||||
model: 'test-model', // From mocked getDefaultModel
|
||||
apiKeyVar: testCase.expectedKey,
|
||||
defaultAgent: 'coding-agent',
|
||||
setupCompleted: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves user-provided model over default', async () => {
|
||||
// Reset to new options signature
|
||||
mockCreateInitialPreferences.mockImplementation((options: any) => ({
|
||||
llm: {
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
apiKey: `$${options.apiKeyVar}`,
|
||||
},
|
||||
defaults: {
|
||||
defaultAgent: options.defaultAgent || 'coding-agent',
|
||||
defaultMode: 'web',
|
||||
},
|
||||
setup: { completed: true },
|
||||
}));
|
||||
|
||||
const options = {
|
||||
provider: 'openai' as const,
|
||||
model: 'gpt-5-mini',
|
||||
interactive: false,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
expect(mockCreateInitialPreferences).toHaveBeenCalledWith({
|
||||
provider: 'openai',
|
||||
model: 'gpt-5-mini', // User-specified model, not default
|
||||
apiKeyVar: 'OPENAI_API_KEY',
|
||||
defaultAgent: 'coding-agent',
|
||||
setupCompleted: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Re-setup scenarios', () => {
|
||||
beforeEach(() => {
|
||||
// Setup is already complete for these tests
|
||||
mockRequiresSetup.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe('Non-interactive re-setup', () => {
|
||||
it('errors without --force flag when setup is already complete', async () => {
|
||||
const options = {
|
||||
provider: 'openai' as const,
|
||||
interactive: false,
|
||||
force: false,
|
||||
};
|
||||
|
||||
await expect(handleSetupCommand(options)).rejects.toThrow(
|
||||
'Process exit called with code 1'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Setup is already complete')
|
||||
);
|
||||
expect(mockCreateInitialPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proceeds with --force flag when setup is already complete', async () => {
|
||||
const options = {
|
||||
provider: 'openai' as const,
|
||||
interactive: false,
|
||||
force: true,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
expect(mockCreateInitialPreferences).toHaveBeenCalledWith({
|
||||
provider: 'openai',
|
||||
model: 'test-model', // From mocked getDefaultModel
|
||||
apiKeyVar: 'OPENAI_API_KEY',
|
||||
defaultAgent: 'coding-agent',
|
||||
setupCompleted: true,
|
||||
});
|
||||
expect(mockSaveGlobalPreferences).toHaveBeenCalled();
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interactive re-setup (Settings Menu)', () => {
|
||||
it('shows settings menu when setup is already complete', async () => {
|
||||
// User selects 'exit' from settings menu
|
||||
mockPrompts.select.mockResolvedValueOnce('exit');
|
||||
|
||||
const options = {
|
||||
interactive: true,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
// Should show settings menu intro
|
||||
expect(mockPrompts.intro).toHaveBeenCalledWith(expect.stringContaining('Settings'));
|
||||
// Should not try to create new preferences when exiting
|
||||
expect(mockCreateInitialPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits gracefully when user cancels from settings menu', async () => {
|
||||
mockPrompts.select.mockResolvedValueOnce(Symbol.for('cancel'));
|
||||
mockPrompts.isCancel.mockReturnValue(true);
|
||||
|
||||
const options = {
|
||||
interactive: true,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
// Should not throw, just exit gracefully
|
||||
expect(mockCreateInitialPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('proceeds normally when setup is required despite preferences existing', async () => {
|
||||
// Edge case: preferences exist but are incomplete/corrupted
|
||||
mockRequiresSetup.mockResolvedValue(true);
|
||||
|
||||
const options = {
|
||||
provider: 'openai' as const,
|
||||
interactive: false,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
expect(mockCreateInitialPreferences).toHaveBeenCalled();
|
||||
expect(mockSaveGlobalPreferences).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quick start flow', () => {
|
||||
it('handles --quick-start flag in non-interactive mode', async () => {
|
||||
mockPrompts.select.mockResolvedValueOnce('google'); // Provider picker
|
||||
mockPrompts.confirm.mockResolvedValueOnce(true); // CLI mode confirmation
|
||||
mockHasApiKeyConfigured.mockReturnValue(true);
|
||||
|
||||
const options = {
|
||||
quickStart: true,
|
||||
interactive: false,
|
||||
};
|
||||
|
||||
// Note: quickStart triggers the quick start flow even in non-interactive
|
||||
await handleSetupCommand(options);
|
||||
|
||||
expect(mockCreateInitialPreferences).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'google',
|
||||
defaultMode: 'cli',
|
||||
setupCompleted: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for API key if not configured during quick start', async () => {
|
||||
mockPrompts.select.mockResolvedValueOnce('google'); // Provider picker
|
||||
mockPrompts.confirm.mockResolvedValueOnce(true); // CLI mode confirmation
|
||||
mockHasApiKeyConfigured.mockReturnValue(false);
|
||||
mockInteractiveApiKeySetup.mockResolvedValue({ success: true });
|
||||
|
||||
const options = {
|
||||
quickStart: true,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
expect(mockInteractiveApiKeySetup).toHaveBeenCalledWith(
|
||||
'google',
|
||||
expect.objectContaining({
|
||||
exitOnCancel: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles API key skip during quick start', async () => {
|
||||
mockPrompts.select.mockResolvedValueOnce('google'); // Provider picker
|
||||
mockPrompts.confirm.mockResolvedValueOnce(true); // CLI mode confirmation
|
||||
mockHasApiKeyConfigured.mockReturnValue(false);
|
||||
mockInteractiveApiKeySetup.mockResolvedValue({ success: true, skipped: true });
|
||||
|
||||
const options = {
|
||||
quickStart: true,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
// Should save preferences with apiKeyPending flag set to true
|
||||
expect(mockCreateInitialPreferences).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'google',
|
||||
apiKeyPending: true,
|
||||
setupCompleted: true,
|
||||
})
|
||||
);
|
||||
expect(mockSaveGlobalPreferences).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets apiKeyPending to false when API key is provided', async () => {
|
||||
// Reset mocks to ensure clean state
|
||||
mockPrompts.select.mockReset();
|
||||
mockPrompts.confirm.mockReset();
|
||||
mockPrompts.select.mockResolvedValueOnce('google'); // Provider picker
|
||||
mockPrompts.confirm.mockResolvedValueOnce(true); // CLI mode confirmation
|
||||
|
||||
mockHasApiKeyConfigured.mockReturnValue(false);
|
||||
// interactiveApiKeySetup returns success without skipped flag - API key was provided
|
||||
mockInteractiveApiKeySetup.mockResolvedValue({ success: true, apiKey: 'test-key' });
|
||||
|
||||
const options = {
|
||||
quickStart: true,
|
||||
};
|
||||
|
||||
await handleSetupCommand(options);
|
||||
|
||||
// Should save preferences with apiKeyPending false
|
||||
expect(mockCreateInitialPreferences).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'google',
|
||||
apiKeyPending: false,
|
||||
setupCompleted: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
1632
dexto/packages/cli/src/cli/commands/setup.ts
Normal file
1632
dexto/packages/cli/src/cli/commands/setup.ts
Normal file
File diff suppressed because it is too large
Load Diff
212
dexto/packages/cli/src/cli/commands/sync-agents.test.ts
Normal file
212
dexto/packages/cli/src/cli/commands/sync-agents.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import { shouldPromptForSync, markSyncDismissed, clearSyncDismissed } from './sync-agents.js';
|
||||
|
||||
// Mock fs module
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual('fs');
|
||||
return {
|
||||
...actual,
|
||||
promises: {
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
access: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock agent-management
|
||||
const mockLoadBundledRegistryAgents = vi.fn();
|
||||
const mockResolveBundledScript = vi.fn();
|
||||
|
||||
vi.mock('@dexto/agent-management', () => ({
|
||||
getDextoGlobalPath: vi.fn((type: string, filename?: string) => {
|
||||
if (type === 'agents') return '/mock/.dexto/agents';
|
||||
if (type === 'cache')
|
||||
return filename ? `/mock/.dexto/cache/${filename}` : '/mock/.dexto/cache';
|
||||
return '/mock/.dexto';
|
||||
}),
|
||||
loadBundledRegistryAgents: () => mockLoadBundledRegistryAgents(),
|
||||
resolveBundledScript: (path: string) => mockResolveBundledScript(path),
|
||||
copyDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('sync-agents', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('shouldPromptForSync', () => {
|
||||
it('returns false when sync was dismissed for current version', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ version: '1.0.0' }));
|
||||
|
||||
const result = await shouldPromptForSync('1.0.0');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('checks for updates when not dismissed', async () => {
|
||||
// Dismissed file doesn't exist
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
// No installed agents
|
||||
vi.mocked(fs.readdir).mockResolvedValue([]);
|
||||
|
||||
mockLoadBundledRegistryAgents.mockReturnValue({
|
||||
'test-agent': { id: 'test-agent', name: 'Test Agent', source: 'test-agent/' },
|
||||
});
|
||||
|
||||
const result = await shouldPromptForSync('1.0.0');
|
||||
|
||||
expect(result).toBe(false); // No installed agents = nothing to sync
|
||||
});
|
||||
|
||||
it('returns true when installed agent differs from bundled', async () => {
|
||||
// Not dismissed
|
||||
vi.mocked(fs.readFile).mockImplementation(async (path) => {
|
||||
if (String(path).includes('sync-dismissed')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
// Return different content for bundled vs installed to simulate hash mismatch
|
||||
if (String(path).includes('bundled')) {
|
||||
return Buffer.from('bundled content v2');
|
||||
}
|
||||
return Buffer.from('installed content v1');
|
||||
});
|
||||
|
||||
// One installed agent
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'test-agent', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
// Mock stat to return file (not directory) for simpler hash
|
||||
vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => false } as any);
|
||||
|
||||
mockLoadBundledRegistryAgents.mockReturnValue({
|
||||
'test-agent': {
|
||||
id: 'test-agent',
|
||||
name: 'Test Agent',
|
||||
source: 'test-agent.yml',
|
||||
description: 'A test agent',
|
||||
author: 'Test',
|
||||
tags: [],
|
||||
},
|
||||
});
|
||||
|
||||
mockResolveBundledScript.mockReturnValue('/bundled/agents/test-agent.yml');
|
||||
|
||||
const result = await shouldPromptForSync('1.0.0');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when all installed agents match bundled', async () => {
|
||||
// Not dismissed
|
||||
vi.mocked(fs.readFile).mockImplementation(async (path) => {
|
||||
if (String(path).includes('sync-dismissed')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
// Return same content for both
|
||||
return Buffer.from('same content');
|
||||
});
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'test-agent', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => false } as any);
|
||||
|
||||
mockLoadBundledRegistryAgents.mockReturnValue({
|
||||
'test-agent': {
|
||||
id: 'test-agent',
|
||||
name: 'Test Agent',
|
||||
source: 'test-agent.yml',
|
||||
description: 'A test agent',
|
||||
author: 'Test',
|
||||
tags: [],
|
||||
},
|
||||
});
|
||||
|
||||
mockResolveBundledScript.mockReturnValue('/bundled/agents/test-agent.yml');
|
||||
|
||||
const result = await shouldPromptForSync('1.0.0');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('skips custom agents not in bundled registry', async () => {
|
||||
// Not dismissed
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
// Custom agent installed but not in bundled registry
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'my-custom-agent', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
mockLoadBundledRegistryAgents.mockReturnValue({
|
||||
'test-agent': { id: 'test-agent', name: 'Test Agent', source: 'test-agent/' },
|
||||
});
|
||||
|
||||
const result = await shouldPromptForSync('1.0.0');
|
||||
|
||||
expect(result).toBe(false); // Custom agent is skipped
|
||||
});
|
||||
|
||||
it('returns false on error', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('Some error'));
|
||||
mockLoadBundledRegistryAgents.mockImplementation(() => {
|
||||
throw new Error('Registry error');
|
||||
});
|
||||
|
||||
const result = await shouldPromptForSync('1.0.0');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markSyncDismissed', () => {
|
||||
it('writes dismissed state to file', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue();
|
||||
|
||||
await markSyncDismissed('1.5.0');
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('sync-dismissed.json'),
|
||||
JSON.stringify({ version: '1.5.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw on error', async () => {
|
||||
vi.mocked(fs.mkdir).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
await expect(markSyncDismissed('1.5.0')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSyncDismissed', () => {
|
||||
it('removes dismissed state file', async () => {
|
||||
vi.mocked(fs.unlink).mockResolvedValue();
|
||||
|
||||
await clearSyncDismissed();
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('sync-dismissed.json'));
|
||||
});
|
||||
|
||||
it('does not throw when file does not exist', async () => {
|
||||
const error = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.unlink).mockRejectedValue(error);
|
||||
|
||||
await expect(clearSyncDismissed()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
594
dexto/packages/cli/src/cli/commands/sync-agents.ts
Normal file
594
dexto/packages/cli/src/cli/commands/sync-agents.ts
Normal file
@@ -0,0 +1,594 @@
|
||||
// packages/cli/src/cli/commands/sync-agents.ts
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import * as p from '@clack/prompts';
|
||||
import { logger } from '@dexto/core';
|
||||
import {
|
||||
getDextoGlobalPath,
|
||||
resolveBundledScript,
|
||||
copyDirectory,
|
||||
loadBundledRegistryAgents,
|
||||
type AgentRegistryEntry,
|
||||
} from '@dexto/agent-management';
|
||||
|
||||
/**
|
||||
* Options for the sync-agents command
|
||||
*/
|
||||
export interface SyncAgentsCommandOptions {
|
||||
/** Just list status without updating */
|
||||
list?: boolean;
|
||||
/** Update all without prompting */
|
||||
force?: boolean;
|
||||
/** Minimal output - used when called from startup prompt */
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent sync status
|
||||
*/
|
||||
type AgentStatus =
|
||||
| 'up_to_date'
|
||||
| 'changes_available'
|
||||
| 'not_installed'
|
||||
| 'custom' // User-installed, not in bundled registry
|
||||
| 'error';
|
||||
|
||||
interface AgentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | undefined;
|
||||
status: AgentStatus;
|
||||
error?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA256 hash of a file
|
||||
*/
|
||||
async function hashFile(filePath: string): Promise<string> {
|
||||
const content = await fs.readFile(filePath);
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate combined hash for a directory
|
||||
* Hashes all files recursively and combines them
|
||||
*/
|
||||
async function hashDirectory(dirPath: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
const files: string[] = [];
|
||||
|
||||
async function collectFiles(dir: string): Promise<void> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await collectFiles(fullPath);
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await collectFiles(dirPath);
|
||||
|
||||
// Sort for consistent ordering
|
||||
files.sort();
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = path.relative(dirPath, file);
|
||||
const content = await fs.readFile(file);
|
||||
hash.update(relativePath);
|
||||
hash.update(content);
|
||||
}
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash of bundled agent
|
||||
*/
|
||||
async function getBundledAgentHash(agentEntry: AgentRegistryEntry): Promise<string | null> {
|
||||
try {
|
||||
const sourcePath = resolveBundledScript(`agents/${agentEntry.source}`);
|
||||
const stat = await fs.stat(sourcePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return await hashDirectory(sourcePath);
|
||||
} else {
|
||||
return await hashFile(sourcePath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to hash bundled agent: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash of installed agent
|
||||
*/
|
||||
async function getInstalledAgentHash(agentId: string): Promise<string | null> {
|
||||
try {
|
||||
const installedPath = path.join(getDextoGlobalPath('agents'), agentId);
|
||||
const stat = await fs.stat(installedPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return await hashDirectory(installedPath);
|
||||
} else {
|
||||
return await hashFile(installedPath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to hash installed agent: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an agent is installed
|
||||
*/
|
||||
async function isAgentInstalled(agentId: string): Promise<boolean> {
|
||||
try {
|
||||
const installedPath = path.join(getDextoGlobalPath('agents'), agentId);
|
||||
await fs.access(installedPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all installed agent directories
|
||||
*/
|
||||
async function getInstalledAgentIds(): Promise<string[]> {
|
||||
try {
|
||||
const agentsDir = getDextoGlobalPath('agents');
|
||||
const entries = await fs.readdir(agentsDir, { withFileTypes: true });
|
||||
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to list installed agents: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent status by comparing bundled vs installed
|
||||
*/
|
||||
async function getAgentStatus(agentId: string, agentEntry: AgentRegistryEntry): Promise<AgentInfo> {
|
||||
const installed = await isAgentInstalled(agentId);
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
id: agentId,
|
||||
name: agentEntry.name,
|
||||
description: agentEntry.description,
|
||||
status: 'not_installed',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const bundledHash = await getBundledAgentHash(agentEntry);
|
||||
const installedHash = await getInstalledAgentHash(agentId);
|
||||
|
||||
if (!bundledHash || !installedHash) {
|
||||
return {
|
||||
id: agentId,
|
||||
name: agentEntry.name,
|
||||
description: agentEntry.description,
|
||||
status: 'error',
|
||||
error: 'Could not compute hash',
|
||||
};
|
||||
}
|
||||
|
||||
if (bundledHash === installedHash) {
|
||||
return {
|
||||
id: agentId,
|
||||
name: agentEntry.name,
|
||||
description: agentEntry.description,
|
||||
status: 'up_to_date',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: agentId,
|
||||
name: agentEntry.name,
|
||||
description: agentEntry.description,
|
||||
status: 'changes_available',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
id: agentId,
|
||||
name: agentEntry.name,
|
||||
description: agentEntry.description,
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Store sync dismissed state in cache directory
|
||||
const SYNC_DISMISSED_PATH = getDextoGlobalPath('cache', 'sync-dismissed.json');
|
||||
|
||||
/**
|
||||
* Check if sync was dismissed for current version
|
||||
*/
|
||||
async function wasSyncDismissed(currentVersion: string): Promise<boolean> {
|
||||
try {
|
||||
const content = await fs.readFile(SYNC_DISMISSED_PATH, 'utf-8');
|
||||
const data = JSON.parse(content) as { version: string };
|
||||
return data.version === currentVersion;
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Could not read sync dismissed state: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark sync as dismissed for current version
|
||||
*/
|
||||
export async function markSyncDismissed(currentVersion: string): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(SYNC_DISMISSED_PATH), { recursive: true });
|
||||
await fs.writeFile(SYNC_DISMISSED_PATH, JSON.stringify({ version: currentVersion }));
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Could not save sync dismissed state: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear sync dismissed state (called after successful sync)
|
||||
*/
|
||||
export async function clearSyncDismissed(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(SYNC_DISMISSED_PATH);
|
||||
} catch (error) {
|
||||
// File might not exist - only log if it's a different error
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.debug(
|
||||
`Could not clear sync dismissed state: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check if any installed agents have updates available
|
||||
*
|
||||
* Used at CLI startup to prompt for sync without full command output.
|
||||
* Returns true if at least one installed agent differs from bundled
|
||||
* AND the user hasn't dismissed the prompt for this version.
|
||||
*
|
||||
* @param currentVersion Current CLI version to check dismissal state
|
||||
* @returns true if should prompt for sync
|
||||
*/
|
||||
export async function shouldPromptForSync(currentVersion: string): Promise<boolean> {
|
||||
try {
|
||||
// Check if user already dismissed for this version
|
||||
if (await wasSyncDismissed(currentVersion)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bundledAgents = loadBundledRegistryAgents();
|
||||
const installedAgentIds = await getInstalledAgentIds();
|
||||
|
||||
for (const agentId of installedAgentIds) {
|
||||
const agentEntry = bundledAgents[agentId];
|
||||
// Skip custom agents (not in bundled registry)
|
||||
if (!agentEntry) continue;
|
||||
|
||||
const bundledHash = await getBundledAgentHash(agentEntry);
|
||||
const installedHash = await getInstalledAgentHash(agentId);
|
||||
|
||||
if (bundledHash && installedHash && bundledHash !== installedHash) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`shouldPromptForSync check failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an agent from bundled to installed
|
||||
*/
|
||||
async function updateAgent(agentId: string, agentEntry: AgentRegistryEntry): Promise<void> {
|
||||
const agentsDir = getDextoGlobalPath('agents');
|
||||
const targetDir = path.join(agentsDir, agentId);
|
||||
const sourcePath = resolveBundledScript(`agents/${agentEntry.source}`);
|
||||
|
||||
// Ensure agents directory exists
|
||||
await fs.mkdir(agentsDir, { recursive: true });
|
||||
|
||||
// Remove old installation
|
||||
try {
|
||||
await fs.rm(targetDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore if doesn't exist
|
||||
}
|
||||
|
||||
// Copy from bundled source
|
||||
const stat = await fs.stat(sourcePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await copyDirectory(sourcePath, targetDir);
|
||||
} else {
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
const targetFile = path.join(targetDir, path.basename(sourcePath));
|
||||
await fs.copyFile(sourcePath, targetFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display agent status with appropriate colors
|
||||
*/
|
||||
function formatStatus(status: AgentStatus): string {
|
||||
switch (status) {
|
||||
case 'up_to_date':
|
||||
return chalk.green('Up to date');
|
||||
case 'changes_available':
|
||||
return chalk.yellow('Changes available');
|
||||
case 'not_installed':
|
||||
return chalk.gray('Not installed');
|
||||
case 'custom':
|
||||
return chalk.blue('Custom (user-installed)');
|
||||
case 'error':
|
||||
return chalk.red('Error');
|
||||
default:
|
||||
return chalk.gray('Unknown');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main handler for the sync-agents command
|
||||
*
|
||||
* @param options Command options
|
||||
*
|
||||
* @example
|
||||
* ```bash
|
||||
* dexto sync-agents # Interactive - prompt for each
|
||||
* dexto sync-agents --list # Show what would be updated
|
||||
* dexto sync-agents --force # Update all without prompting
|
||||
* ```
|
||||
*/
|
||||
export async function handleSyncAgentsCommand(options: SyncAgentsCommandOptions): Promise<void> {
|
||||
const { list = false, force = false, quiet = false } = options;
|
||||
|
||||
if (!quiet) {
|
||||
p.intro(chalk.cyan('Agent Sync'));
|
||||
}
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Checking agent configs...');
|
||||
|
||||
try {
|
||||
// Load bundled registry (uses existing function from agent-management)
|
||||
const bundledAgents = loadBundledRegistryAgents();
|
||||
const bundledAgentIds = Object.keys(bundledAgents);
|
||||
|
||||
// Get installed agents
|
||||
const installedAgentIds = await getInstalledAgentIds();
|
||||
|
||||
// Find custom agents (installed but not in bundled registry)
|
||||
const customAgentIds = installedAgentIds.filter((id) => !bundledAgents[id]);
|
||||
|
||||
// Check status of all bundled agents
|
||||
const agentInfos: AgentInfo[] = [];
|
||||
|
||||
for (const agentId of bundledAgentIds) {
|
||||
const entry = bundledAgents[agentId];
|
||||
if (entry) {
|
||||
const info = await getAgentStatus(agentId, entry);
|
||||
agentInfos.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom agents
|
||||
for (const agentId of customAgentIds) {
|
||||
agentInfos.push({
|
||||
id: agentId,
|
||||
name: agentId,
|
||||
status: 'custom',
|
||||
});
|
||||
}
|
||||
|
||||
const updatableAgents = agentInfos.filter((a) => a.status === 'changes_available');
|
||||
const upToDateAgents = agentInfos.filter((a) => a.status === 'up_to_date');
|
||||
const notInstalledAgents = agentInfos.filter((a) => a.status === 'not_installed');
|
||||
const customAgents = agentInfos.filter((a) => a.status === 'custom');
|
||||
const errorAgents = agentInfos.filter((a) => a.status === 'error');
|
||||
|
||||
spinner.stop('Agent check complete');
|
||||
|
||||
// Quiet mode with force - minimal output for startup prompt
|
||||
if (quiet && force) {
|
||||
if (updatableAgents.length === 0) {
|
||||
p.log.success('All agents up to date');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedNames: string[] = [];
|
||||
const failedNames: string[] = [];
|
||||
|
||||
for (const agent of updatableAgents) {
|
||||
const entry = bundledAgents[agent.id];
|
||||
if (entry) {
|
||||
try {
|
||||
await updateAgent(agent.id, entry);
|
||||
updatedNames.push(agent.id);
|
||||
} catch (error) {
|
||||
failedNames.push(agent.id);
|
||||
logger.debug(
|
||||
`Failed to update ${agent.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedNames.length > 0) {
|
||||
p.log.success(`Updated: ${updatedNames.join(', ')}`);
|
||||
}
|
||||
if (failedNames.length > 0) {
|
||||
p.log.warn(`Failed to update: ${failedNames.join(', ')}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Display full status (non-quiet mode)
|
||||
console.log('');
|
||||
console.log(chalk.bold('Agent Status:'));
|
||||
console.log('');
|
||||
|
||||
// Show updatable first
|
||||
for (const agent of updatableAgents) {
|
||||
console.log(` ${chalk.cyan(agent.id)}:`);
|
||||
console.log(` Status: ${formatStatus(agent.status)}`);
|
||||
if (agent.description) {
|
||||
console.log(` ${chalk.gray(agent.description)}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Show up-to-date
|
||||
for (const agent of upToDateAgents) {
|
||||
console.log(` ${chalk.green(agent.id)}: ${formatStatus(agent.status)}`);
|
||||
}
|
||||
|
||||
// Show not installed (summarized)
|
||||
if (notInstalledAgents.length > 0) {
|
||||
console.log('');
|
||||
console.log(
|
||||
chalk.gray(
|
||||
` ${notInstalledAgents.length} agents not installed: ${notInstalledAgents.map((a) => a.id).join(', ')}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Show custom
|
||||
if (customAgents.length > 0) {
|
||||
console.log('');
|
||||
for (const agent of customAgents) {
|
||||
console.log(` ${chalk.blue(agent.id)}: ${formatStatus(agent.status)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show errors
|
||||
for (const agent of errorAgents) {
|
||||
console.log(` ${chalk.red(agent.id)}: ${formatStatus(agent.status)}`);
|
||||
if (agent.error) {
|
||||
console.log(` ${chalk.red(agent.error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// Summary
|
||||
console.log(chalk.bold('Summary:'));
|
||||
console.log(` Up to date: ${chalk.green(upToDateAgents.length.toString())}`);
|
||||
console.log(` Changes available: ${chalk.yellow(updatableAgents.length.toString())}`);
|
||||
console.log(` Not installed: ${chalk.gray(notInstalledAgents.length.toString())}`);
|
||||
if (customAgents.length > 0) {
|
||||
console.log(` Custom: ${chalk.blue(customAgents.length.toString())}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// If list mode, stop here
|
||||
if (list) {
|
||||
p.outro('Use `dexto sync-agents` to update agents');
|
||||
return;
|
||||
}
|
||||
|
||||
// No updates needed
|
||||
if (updatableAgents.length === 0) {
|
||||
p.outro(chalk.green('All installed agents are up to date!'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Force mode - update all without prompting
|
||||
if (force) {
|
||||
const updateSpinner = p.spinner();
|
||||
updateSpinner.start(`Updating ${updatableAgents.length} agents...`);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const agent of updatableAgents) {
|
||||
const entry = bundledAgents[agent.id];
|
||||
if (entry) {
|
||||
try {
|
||||
await updateAgent(agent.id, entry);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
logger.error(
|
||||
`Failed to update ${agent.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSpinner.stop(`Updated ${successCount} agents`);
|
||||
|
||||
if (failCount > 0) {
|
||||
p.log.warn(`${failCount} agents failed to update`);
|
||||
}
|
||||
|
||||
p.outro(chalk.green('Sync complete!'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Interactive mode - prompt for each
|
||||
for (const agent of updatableAgents) {
|
||||
const shouldUpdate = await p.confirm({
|
||||
message: `Update ${chalk.cyan(agent.name)} (${agent.id})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (p.isCancel(shouldUpdate)) {
|
||||
p.cancel('Sync cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
const entry = bundledAgents[agent.id];
|
||||
if (entry) {
|
||||
try {
|
||||
const updateSpinner = p.spinner();
|
||||
updateSpinner.start(`Updating ${agent.id}...`);
|
||||
await updateAgent(agent.id, entry);
|
||||
updateSpinner.stop(`Updated ${agent.id}`);
|
||||
} catch (error) {
|
||||
p.log.error(
|
||||
`Failed to update ${agent.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
p.log.info(`Skipped ${agent.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
p.outro(chalk.green('Sync complete!'));
|
||||
} catch (error) {
|
||||
spinner.stop('Error');
|
||||
p.log.error(
|
||||
`Failed to check agents: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
191
dexto/packages/cli/src/cli/commands/uninstall.test.ts
Normal file
191
dexto/packages/cli/src/cli/commands/uninstall.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Mock the agent-helpers module
|
||||
vi.mock('../../utils/agent-helpers.js', () => ({
|
||||
uninstallAgent: vi.fn(),
|
||||
listInstalledAgents: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock analytics
|
||||
vi.mock('../../analytics/index.js', () => ({
|
||||
capture: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import SUT after mocks
|
||||
import { handleUninstallCommand } from './uninstall.js';
|
||||
import { uninstallAgent, listInstalledAgents } from '../../utils/agent-helpers.js';
|
||||
|
||||
describe('Uninstall Command', () => {
|
||||
let consoleSpy: any;
|
||||
let consoleErrorSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock console
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('Command validation', () => {
|
||||
it('rejects when no agents specified and all flag is false', async () => {
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue(['some-agent']);
|
||||
|
||||
await expect(handleUninstallCommand([], {})).rejects.toThrow(/No agents specified/);
|
||||
});
|
||||
|
||||
it('rejects when no agents are installed', async () => {
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue([]);
|
||||
|
||||
await expect(handleUninstallCommand(['any-agent'], {})).rejects.toThrow(
|
||||
/No agents are currently installed/
|
||||
);
|
||||
expect(uninstallAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects uninstalling agents that are not installed', async () => {
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue(['real-agent']);
|
||||
|
||||
await expect(handleUninstallCommand(['fake-agent'], {})).rejects.toThrow(
|
||||
/not installed/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Single agent uninstall', () => {
|
||||
it('successfully uninstalls existing agent', async () => {
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue(['test-agent']);
|
||||
vi.mocked(uninstallAgent).mockResolvedValue(undefined);
|
||||
|
||||
await expect(handleUninstallCommand(['test-agent'], {})).resolves.not.toThrow();
|
||||
|
||||
expect(uninstallAgent).toHaveBeenCalledWith('test-agent');
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uninstalls agent without force flag', async () => {
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue(['default-agent']);
|
||||
vi.mocked(uninstallAgent).mockResolvedValue(undefined);
|
||||
|
||||
await handleUninstallCommand(['default-agent'], {});
|
||||
|
||||
expect(uninstallAgent).toHaveBeenCalledWith('default-agent');
|
||||
expect(uninstallAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bulk uninstall', () => {
|
||||
it('uninstalls all agents when --all flag is used', async () => {
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue(['agent1', 'agent2', 'agent3']);
|
||||
vi.mocked(uninstallAgent).mockResolvedValue(undefined);
|
||||
|
||||
await expect(handleUninstallCommand([], { all: true })).resolves.not.toThrow();
|
||||
|
||||
expect(uninstallAgent).toHaveBeenCalledTimes(3);
|
||||
expect(uninstallAgent).toHaveBeenCalledWith('agent1');
|
||||
expect(uninstallAgent).toHaveBeenCalledWith('agent2');
|
||||
expect(uninstallAgent).toHaveBeenCalledWith('agent3');
|
||||
// Multiple agents show summary
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('📊 Uninstallation Summary')
|
||||
);
|
||||
});
|
||||
|
||||
it('uninstalls multiple specified agents', async () => {
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue(['agent1', 'agent2', 'agent3']);
|
||||
vi.mocked(uninstallAgent).mockResolvedValue(undefined);
|
||||
|
||||
await handleUninstallCommand(['agent1', 'agent2'], {});
|
||||
|
||||
expect(uninstallAgent).toHaveBeenCalledTimes(2);
|
||||
expect(uninstallAgent).toHaveBeenCalledWith('agent1');
|
||||
expect(uninstallAgent).toHaveBeenCalledWith('agent2');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('📊 Uninstallation Summary')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('continues with other agents when one fails', async () => {
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue(['good-agent', 'bad-agent']);
|
||||
vi.mocked(uninstallAgent).mockImplementation((agent: string) => {
|
||||
if (agent === 'bad-agent') {
|
||||
throw new Error('Cannot remove protected agent');
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleUninstallCommand(['good-agent', 'bad-agent'], {})
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(uninstallAgent).toHaveBeenCalledTimes(2);
|
||||
// Should show summary for multiple agents
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('📊 Uninstallation Summary')
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to uninstall bad-agent')
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when single agent uninstall fails', async () => {
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue(['bad-agent']);
|
||||
vi.mocked(uninstallAgent).mockRejectedValue(new Error('Protection error'));
|
||||
|
||||
// Single agent failure should propagate the error directly
|
||||
await expect(handleUninstallCommand(['bad-agent'], {})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('shows error in summary when all agents fail in bulk operation', async () => {
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue(['agent1', 'agent2']);
|
||||
vi.mocked(uninstallAgent).mockRejectedValue(new Error('Protection error'));
|
||||
|
||||
await expect(handleUninstallCommand(['agent1', 'agent2'], {})).rejects.toThrow(
|
||||
/All uninstallations failed/
|
||||
);
|
||||
|
||||
expect(uninstallAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows partial success when some agents fail in bulk operation', async () => {
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue(['agent1', 'agent2', 'agent3']);
|
||||
vi.mocked(uninstallAgent).mockImplementation((agent: string) => {
|
||||
if (agent === 'agent2') {
|
||||
throw new Error('Failed to uninstall');
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleUninstallCommand(['agent1', 'agent2', 'agent3'], {})
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(uninstallAgent).toHaveBeenCalledTimes(3);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('📊 Uninstallation Summary')
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('✅ Successfully'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('❌ Failed'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Force flag handling', () => {
|
||||
it('accepts force flag in options', async () => {
|
||||
vi.mocked(listInstalledAgents).mockResolvedValue(['test-agent']);
|
||||
vi.mocked(uninstallAgent).mockResolvedValue(undefined);
|
||||
|
||||
// Force flag is in the options but doesn't affect uninstallAgent call
|
||||
await handleUninstallCommand(['test-agent'], { force: true });
|
||||
|
||||
// uninstallAgent only takes agentId, no force parameter
|
||||
expect(uninstallAgent).toHaveBeenCalledWith('test-agent');
|
||||
});
|
||||
});
|
||||
});
|
||||
160
dexto/packages/cli/src/cli/commands/uninstall.ts
Normal file
160
dexto/packages/cli/src/cli/commands/uninstall.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// packages/cli/src/cli/commands/uninstall.ts
|
||||
|
||||
import { z } from 'zod';
|
||||
import { uninstallAgent, listInstalledAgents } from '../../utils/agent-helpers.js';
|
||||
import { capture } from '../../analytics/index.js';
|
||||
|
||||
// Zod schema for uninstall command validation
|
||||
const UninstallCommandSchema = z
|
||||
.object({
|
||||
agents: z.array(z.string().min(1, 'Agent name cannot be empty')),
|
||||
all: z.boolean().default(false),
|
||||
force: z.boolean().default(false),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type UninstallCommandOptions = z.output<typeof UninstallCommandSchema>;
|
||||
|
||||
/**
|
||||
* Validate uninstall command arguments
|
||||
*/
|
||||
async function validateUninstallCommand(
|
||||
agents: string[],
|
||||
options: Partial<UninstallCommandOptions>
|
||||
): Promise<UninstallCommandOptions> {
|
||||
// Basic structure validation
|
||||
const validated = UninstallCommandSchema.parse({
|
||||
...options,
|
||||
agents,
|
||||
});
|
||||
|
||||
// Business logic validation
|
||||
const installedAgents = await listInstalledAgents();
|
||||
|
||||
if (installedAgents.length === 0) {
|
||||
throw new Error('No agents are currently installed.');
|
||||
}
|
||||
|
||||
if (!validated.all && validated.agents.length === 0) {
|
||||
throw new Error(
|
||||
`No agents specified. Use agent names or --all flag. Installed agents: ${installedAgents.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
export async function handleUninstallCommand(
|
||||
agents: string[],
|
||||
options: Partial<UninstallCommandOptions>
|
||||
): Promise<void> {
|
||||
// Validate command with Zod
|
||||
const validated = await validateUninstallCommand(agents, options);
|
||||
const installedAgents = await listInstalledAgents();
|
||||
|
||||
if (installedAgents.length === 0) {
|
||||
console.log('📋 No agents are currently installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which agents to uninstall
|
||||
let agentsToUninstall: string[];
|
||||
if (validated.all) {
|
||||
agentsToUninstall = installedAgents;
|
||||
console.log(`📋 Uninstalling all ${agentsToUninstall.length} installed agents...`);
|
||||
} else {
|
||||
agentsToUninstall = validated.agents;
|
||||
|
||||
// Validate all specified agents are actually installed
|
||||
const notInstalled = agentsToUninstall.filter((agent) => !installedAgents.includes(agent));
|
||||
if (notInstalled.length > 0) {
|
||||
throw new Error(
|
||||
`Agents not installed: ${notInstalled.join(', ')}. ` +
|
||||
`Installed agents: ${installedAgents.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🗑️ Uninstalling ${agentsToUninstall.length} agents...`);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const errors: string[] = [];
|
||||
const uninstalled: string[] = [];
|
||||
const failed: string[] = [];
|
||||
|
||||
// Uninstall each agent
|
||||
for (const agentName of agentsToUninstall) {
|
||||
try {
|
||||
console.log(`\n🗑️ Uninstalling ${agentName}...`);
|
||||
await uninstallAgent(agentName);
|
||||
successCount++;
|
||||
console.log(`✅ ${agentName} uninstalled successfully`);
|
||||
uninstalled.push(agentName);
|
||||
// Per-agent analytics for successful uninstall
|
||||
try {
|
||||
capture('dexto_uninstall_agent', {
|
||||
agent: agentName,
|
||||
status: 'uninstalled',
|
||||
force: validated.force,
|
||||
});
|
||||
} catch {
|
||||
// Analytics failures should not block CLI execution.
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
const errorMsg = `Failed to uninstall ${agentName}: ${error instanceof Error ? error.message : String(error)}`;
|
||||
errors.push(errorMsg);
|
||||
failed.push(agentName);
|
||||
console.error(`❌ ${errorMsg}`);
|
||||
// Per-agent analytics for failed uninstall
|
||||
try {
|
||||
capture('dexto_uninstall_agent', {
|
||||
agent: agentName,
|
||||
status: 'failed',
|
||||
error_message: error instanceof Error ? error.message : String(error),
|
||||
force: validated.force,
|
||||
});
|
||||
} catch {
|
||||
// Analytics failures should not block CLI execution.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit analytics for both single- and multi-agent cases
|
||||
try {
|
||||
capture('dexto_uninstall', {
|
||||
requested: agentsToUninstall,
|
||||
uninstalled,
|
||||
failed,
|
||||
successCount,
|
||||
errorCount,
|
||||
});
|
||||
} catch {
|
||||
// Analytics failures should not block CLI execution.
|
||||
}
|
||||
|
||||
// For single agent operations, throw error if it failed (after emitting analytics)
|
||||
if (agentsToUninstall.length === 1) {
|
||||
if (errorCount > 0) {
|
||||
throw new Error(errors[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show summary if more than 1 agent uninstalled
|
||||
console.log(`\n📊 Uninstallation Summary:`);
|
||||
console.log(`✅ Successfully uninstalled: ${successCount}`);
|
||||
if (errorCount > 0) {
|
||||
console.log(`❌ Failed to uninstall: ${errorCount}`);
|
||||
errors.forEach((error) => console.log(` • ${error}`));
|
||||
}
|
||||
|
||||
if (errorCount > 0 && successCount === 0) {
|
||||
throw new Error('All uninstallations failed');
|
||||
} else if (errorCount > 0) {
|
||||
console.log(`⚠️ Some uninstallations failed, but ${successCount} succeeded.`);
|
||||
} else {
|
||||
console.log(`🎉 All agents uninstalled successfully!`);
|
||||
}
|
||||
}
|
||||
50
dexto/packages/cli/src/cli/commands/which.ts
Normal file
50
dexto/packages/cli/src/cli/commands/which.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// packages/cli/src/cli/commands/which.ts
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import chalk from 'chalk';
|
||||
import { z } from 'zod';
|
||||
import { resolveAgentPath, resolveBundledScript } from '@dexto/agent-management';
|
||||
|
||||
// Zod schema for which command validation
|
||||
const WhichCommandSchema = z
|
||||
.object({
|
||||
agentName: z.string().min(1, 'Agent name cannot be empty'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type WhichCommandOptions = z.output<typeof WhichCommandSchema>;
|
||||
|
||||
/**
|
||||
* Load available agent names from bundled registry
|
||||
*/
|
||||
function getAvailableAgentNames(): string[] {
|
||||
try {
|
||||
const registryPath = resolveBundledScript('agents/agent-registry.json');
|
||||
const content = readFileSync(registryPath, 'utf-8');
|
||||
const registry = JSON.parse(content);
|
||||
return Object.keys(registry.agents || {});
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the which command
|
||||
*/
|
||||
export async function handleWhichCommand(agentName: string): Promise<void> {
|
||||
// Validate command with Zod
|
||||
const validated = WhichCommandSchema.parse({ agentName });
|
||||
const availableAgents = getAvailableAgentNames();
|
||||
|
||||
try {
|
||||
const resolvedPath = await resolveAgentPath(validated.agentName, false); // Don't auto-install
|
||||
console.log(resolvedPath);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`❌ dexto which command failed: ${error instanceof Error ? error.message : String(error)}. Available agents: ${availableAgents.join(', ')}`
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
182
dexto/packages/cli/src/cli/ink-cli/AGENTS.md
Normal file
182
dexto/packages/cli/src/cli/ink-cli/AGENTS.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Ink-CLI Architecture
|
||||
|
||||
React-based terminal UI built with [Ink](https://github.com/vadimdemedes/ink).
|
||||
|
||||
## Entry Point
|
||||
|
||||
`InkCLIRefactored.tsx` → `startInkCliRefactored()`
|
||||
|
||||
Two rendering modes (controlled by `USE_ALTERNATE_BUFFER` constant):
|
||||
- **StaticCLI** (default): Uses Ink's `<Static>` for copy-friendly terminal scrollback
|
||||
- **AlternateBufferCLI**: Fullscreen with VirtualizedList and mouse support
|
||||
|
||||
## State Management
|
||||
|
||||
State is managed via **multiple useState hooks** in `useCLIState.ts`.
|
||||
|
||||
### Message State (separate arrays for render ordering)
|
||||
|
||||
```typescript
|
||||
messages: Message[] // Finalized → rendered in <Static>
|
||||
pendingMessages: Message[] // Streaming → rendered dynamically
|
||||
dequeuedBuffer: Message[] // User messages after pending (ordering fix)
|
||||
queuedMessages: QueuedMessage[] // Waiting to be processed
|
||||
```
|
||||
|
||||
### CLIState
|
||||
|
||||
```typescript
|
||||
interface CLIState {
|
||||
input: InputState // value, history, images, pastedBlocks
|
||||
ui: UIState // isProcessing, activeOverlay, exitWarning
|
||||
session: SessionState // id, modelName
|
||||
approval: ApprovalRequest | null
|
||||
approvalQueue: ApprovalRequest[]
|
||||
}
|
||||
```
|
||||
|
||||
### Message Interface
|
||||
|
||||
```typescript
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||
content: string
|
||||
timestamp: Date
|
||||
isStreaming?: boolean
|
||||
toolResult?: string
|
||||
toolStatus?: 'running' | 'finished'
|
||||
isError?: boolean // Tool execution failed
|
||||
styledType?: StyledMessageType // config, stats, help, session-list, etc.
|
||||
styledData?: StyledData
|
||||
isContinuation?: boolean // Split message continuation
|
||||
}
|
||||
```
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```text
|
||||
InkCLIRefactored
|
||||
├── KeypressProvider
|
||||
├── MouseProvider (alternate buffer only)
|
||||
├── ScrollProvider (alternate buffer only)
|
||||
└── StaticCLI / AlternateBufferCLI
|
||||
├── Header
|
||||
├── Messages (Static or VirtualizedList)
|
||||
├── PendingMessages (dynamic, outside Static)
|
||||
├── StatusBar
|
||||
├── OverlayContainer
|
||||
├── InputContainer → TextBufferInput
|
||||
└── Footer
|
||||
```
|
||||
|
||||
## Input Architecture
|
||||
|
||||
### Keyboard Flow
|
||||
|
||||
1. `KeypressContext` captures raw stdin
|
||||
2. `useInputOrchestrator` routes to handlers based on focus
|
||||
3. Main text input uses its own `useKeypress()` in `TextBufferInput`
|
||||
|
||||
### Focus Priority
|
||||
|
||||
1. **Approval prompt** (if visible)
|
||||
2. **Overlay** (if active)
|
||||
3. **Global shortcuts** (Ctrl+C, Escape)
|
||||
4. **Main input** (TextBufferInput)
|
||||
|
||||
### Global Shortcuts
|
||||
|
||||
- **Ctrl+C**: Cancel processing → clear input → exit warning → exit
|
||||
- **Escape**: Clear exit warning → cancel processing → close overlay
|
||||
- **Ctrl+S**: Toggle copy mode (alternate buffer only)
|
||||
|
||||
## Overlay System
|
||||
|
||||
### All Overlay Types
|
||||
|
||||
```typescript
|
||||
type OverlayType =
|
||||
| 'none'
|
||||
| 'slash-autocomplete' // /command
|
||||
| 'resource-autocomplete' // @resource
|
||||
| 'model-selector' // /model
|
||||
| 'session-selector' // /session, /resume
|
||||
| 'session-subcommand-selector'
|
||||
| 'mcp-selector'
|
||||
| 'mcp-add-selector'
|
||||
| 'mcp-remove-selector'
|
||||
| 'mcp-custom-type-selector'
|
||||
| 'mcp-custom-wizard'
|
||||
| 'log-level-selector'
|
||||
| 'approval'
|
||||
```
|
||||
|
||||
### Detection
|
||||
|
||||
Overlays detected by input content (debounced 50ms):
|
||||
- `/` prefix → slash-autocomplete (or specific selector)
|
||||
- `@` anywhere → resource-autocomplete
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `InkCLIRefactored.tsx` | Entry point, provider setup |
|
||||
| `hooks/useCLIState.ts` | All state management |
|
||||
| `hooks/useInputOrchestrator.ts` | Keyboard routing |
|
||||
| `hooks/useAgentEvents.ts` | Event subscriptions |
|
||||
| `services/processStream.ts` | Streaming handler |
|
||||
| `services/CommandService.ts` | Command execution |
|
||||
| `containers/OverlayContainer.tsx` | Overlay management |
|
||||
| `components/shared/text-buffer.ts` | Input text buffer |
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Use `TextBuffer` as source of truth for input
|
||||
- Pass explicit `sessionId` to all agent calls
|
||||
- Check `key.escape` BEFORE checking item count in selectors
|
||||
- Return `boolean` from handlers (`true` = consumed)
|
||||
- Use refs + `useImperativeHandle` for component coordination
|
||||
|
||||
### Don't
|
||||
|
||||
```typescript
|
||||
// Adding useInput directly to components
|
||||
useInput((input, key) => { ... }); // Don't do this
|
||||
|
||||
// Checking item count before escape
|
||||
if (items.length === 0) return false;
|
||||
if (key.escape) { onClose(); return true; } // Never reached!
|
||||
|
||||
// Using agent.getCurrentSessionId()
|
||||
const sessionId = agent.getCurrentSessionId(); // Stale!
|
||||
// Use state.session.id instead
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Add New Overlay
|
||||
|
||||
1. Add to `OverlayType` in `state/types.ts`
|
||||
2. Add detection in `useCLIState.ts`
|
||||
3. Create component in `components/overlays/`
|
||||
4. Register in `OverlayContainer.tsx`
|
||||
5. **If overlay has its own text input** (wizard, search, etc.): Add to `overlaysWithOwnInput` array in `InputContainer.tsx` (~line 659) to disable main input while overlay is active
|
||||
|
||||
### Add New Slash Command
|
||||
|
||||
1. Add to `commands/interactive-commands/commands.ts`
|
||||
2. Add handler in `CommandService.ts` if needed
|
||||
3. Appears in autocomplete automatically
|
||||
|
||||
## Streaming Architecture
|
||||
|
||||
`processStream.ts` handles the async iterator from `agent.stream()`:
|
||||
|
||||
- Streaming content → `pendingMessages` (redrawn each frame)
|
||||
- Finalized content → `messages` (in `<Static>`, rendered once)
|
||||
- Large content split at markdown boundaries to reduce flickering
|
||||
- Uses `localPending` mirror to avoid React batching race conditions
|
||||
1
dexto/packages/cli/src/cli/ink-cli/CLAUDE.md
Symbolic link
1
dexto/packages/cli/src/cli/ink-cli/CLAUDE.md
Symbolic link
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
1
dexto/packages/cli/src/cli/ink-cli/GEMINI.md
Symbolic link
1
dexto/packages/cli/src/cli/ink-cli/GEMINI.md
Symbolic link
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
213
dexto/packages/cli/src/cli/ink-cli/InkCLIRefactored.tsx
Normal file
213
dexto/packages/cli/src/cli/ink-cli/InkCLIRefactored.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* InkCLI Component (Refactored)
|
||||
*
|
||||
* Entry point for the Ink-based CLI. Selects between two rendering modes:
|
||||
* - AlternateBufferCLI: VirtualizedList with mouse scroll, keyboard scroll, copy mode
|
||||
* - StaticCLI: Static pattern with native terminal scrollback and selection
|
||||
*
|
||||
* The mode is selected via USE_ALTERNATE_BUFFER constant.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { render } from 'ink';
|
||||
import type { DextoAgent } from '@dexto/core';
|
||||
import { registerGracefulShutdown } from '../../utils/graceful-shutdown.js';
|
||||
import { enableBracketedPaste, disableBracketedPaste } from './utils/bracketedPaste.js';
|
||||
|
||||
// Types
|
||||
import type { StartupInfo } from './state/types.js';
|
||||
|
||||
// Contexts (keyboard/mouse providers)
|
||||
import {
|
||||
KeypressProvider,
|
||||
MouseProvider,
|
||||
ScrollProvider,
|
||||
SoundProvider,
|
||||
} from './contexts/index.js';
|
||||
|
||||
// Sound notification
|
||||
import type { SoundNotificationService } from './utils/soundNotification.js';
|
||||
|
||||
// Components
|
||||
import { ErrorBoundary } from './components/ErrorBoundary.js';
|
||||
import { AlternateBufferCLI, StaticCLI } from './components/modes/index.js';
|
||||
|
||||
// Hooks
|
||||
import { useStreaming } from './hooks/useStreaming.js';
|
||||
|
||||
// Utils
|
||||
import { getStartupInfo } from './utils/messageFormatting.js';
|
||||
|
||||
// Rendering mode: true = alternate buffer with VirtualizedList, false = Static pattern
|
||||
// Toggle this to switch between modes for testing
|
||||
//const USE_ALTERNATE_BUFFER = true;
|
||||
const USE_ALTERNATE_BUFFER = false;
|
||||
|
||||
interface InkCLIProps {
|
||||
agent: DextoAgent;
|
||||
initialSessionId: string | null;
|
||||
startupInfo: StartupInfo;
|
||||
soundService: SoundNotificationService | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component that wraps the mode-specific component with providers
|
||||
*/
|
||||
function InkCLIInner({ agent, initialSessionId, startupInfo, soundService }: InkCLIProps) {
|
||||
// Selection hint callback for alternate buffer mode
|
||||
const [, setSelectionHintShown] = useState(false);
|
||||
|
||||
// Streaming mode - can be toggled via /stream command
|
||||
const { streaming } = useStreaming();
|
||||
|
||||
const handleSelectionAttempt = useCallback(() => {
|
||||
setSelectionHintShown(true);
|
||||
}, []);
|
||||
|
||||
if (USE_ALTERNATE_BUFFER) {
|
||||
return (
|
||||
<SoundProvider soundService={soundService}>
|
||||
<ScrollProvider onSelectionAttempt={handleSelectionAttempt}>
|
||||
<AlternateBufferCLI
|
||||
agent={agent}
|
||||
initialSessionId={initialSessionId}
|
||||
startupInfo={startupInfo}
|
||||
onSelectionAttempt={handleSelectionAttempt}
|
||||
useStreaming={streaming}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
</SoundProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Static mode - no ScrollProvider needed
|
||||
return (
|
||||
<SoundProvider soundService={soundService}>
|
||||
<StaticCLI
|
||||
agent={agent}
|
||||
initialSessionId={initialSessionId}
|
||||
startupInfo={startupInfo}
|
||||
useStreaming={streaming}
|
||||
/>
|
||||
</SoundProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern CLI interface using React Ink
|
||||
*
|
||||
* Wraps the CLI with:
|
||||
* - ErrorBoundary for graceful error handling
|
||||
* - KeypressProvider for unified keyboard input
|
||||
* - MouseProvider (only in alternate buffer mode)
|
||||
*/
|
||||
export function InkCLIRefactored({
|
||||
agent,
|
||||
initialSessionId,
|
||||
startupInfo,
|
||||
soundService,
|
||||
}: InkCLIProps) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<KeypressProvider>
|
||||
{/* Mouse events only in alternate buffer mode - Static mode uses native terminal selection */}
|
||||
<MouseProvider mouseEventsEnabled={USE_ALTERNATE_BUFFER}>
|
||||
<InkCLIInner
|
||||
agent={agent}
|
||||
initialSessionId={initialSessionId}
|
||||
startupInfo={startupInfo}
|
||||
soundService={soundService}
|
||||
/>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for starting the Ink CLI
|
||||
*/
|
||||
export interface InkCLIOptions {
|
||||
/** Update info if a newer version is available */
|
||||
updateInfo?: { current: string; latest: string; updateCommand: string } | undefined;
|
||||
/** True if installed agents differ from bundled and user should sync */
|
||||
needsAgentSync?: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the modern Ink-based CLI
|
||||
*/
|
||||
export async function startInkCliRefactored(
|
||||
agent: DextoAgent,
|
||||
initialSessionId: string | null,
|
||||
options: InkCLIOptions = {}
|
||||
): Promise<void> {
|
||||
registerGracefulShutdown(() => agent, { inkMode: true });
|
||||
|
||||
// Enable bracketed paste mode so we can detect pasted text
|
||||
// This wraps pastes with escape sequences that our KeypressContext handles
|
||||
enableBracketedPaste();
|
||||
|
||||
const baseStartupInfo = await getStartupInfo(agent);
|
||||
const startupInfo = {
|
||||
...baseStartupInfo,
|
||||
updateInfo: options.updateInfo,
|
||||
needsAgentSync: options.needsAgentSync,
|
||||
};
|
||||
|
||||
// Initialize sound service from preferences
|
||||
const { SoundNotificationService } = await import('./utils/soundNotification.js');
|
||||
const { globalPreferencesExist, loadGlobalPreferences } = await import(
|
||||
'@dexto/agent-management'
|
||||
);
|
||||
|
||||
let soundService: SoundNotificationService | null = null;
|
||||
// Initialize sound config with defaults (enabled by default even without preferences file)
|
||||
let soundConfig = {
|
||||
enabled: true,
|
||||
onApprovalRequired: true,
|
||||
onTaskComplete: true,
|
||||
};
|
||||
// Override with user preferences if they exist
|
||||
if (globalPreferencesExist()) {
|
||||
try {
|
||||
const preferences = await loadGlobalPreferences();
|
||||
soundConfig = {
|
||||
enabled: preferences.sounds?.enabled ?? soundConfig.enabled,
|
||||
onApprovalRequired:
|
||||
preferences.sounds?.onApprovalRequired ?? soundConfig.onApprovalRequired,
|
||||
onTaskComplete: preferences.sounds?.onTaskComplete ?? soundConfig.onTaskComplete,
|
||||
};
|
||||
} catch (error) {
|
||||
// Log at debug level to help troubleshoot sound configuration issues
|
||||
// Continue with default sounds - this is non-critical functionality
|
||||
agent.logger.debug(
|
||||
`Sound preferences could not be loaded: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (soundConfig.enabled) {
|
||||
soundService = new SoundNotificationService(soundConfig);
|
||||
}
|
||||
|
||||
const inkApp = render(
|
||||
<InkCLIRefactored
|
||||
agent={agent}
|
||||
initialSessionId={initialSessionId}
|
||||
startupInfo={startupInfo}
|
||||
soundService={soundService}
|
||||
/>,
|
||||
{
|
||||
exitOnCtrlC: false,
|
||||
alternateBuffer: USE_ALTERNATE_BUFFER,
|
||||
// Incremental rendering works better with VirtualizedList
|
||||
// Static pattern doesn't need it (and may work better without)
|
||||
incrementalRendering: USE_ALTERNATE_BUFFER,
|
||||
}
|
||||
);
|
||||
|
||||
await inkApp.waitUntilExit();
|
||||
|
||||
// Disable bracketed paste mode to restore normal terminal behavior
|
||||
disableBracketedPaste();
|
||||
}
|
||||
462
dexto/packages/cli/src/cli/ink-cli/components/ApprovalPrompt.tsx
Normal file
462
dexto/packages/cli/src/cli/ink-cli/components/ApprovalPrompt.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
useState,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ToolDisplayData, ElicitationMetadata } from '@dexto/core';
|
||||
import type { Key } from '../hooks/useInputOrchestrator.js';
|
||||
import { ElicitationForm, type ElicitationFormHandle } from './ElicitationForm.js';
|
||||
import { DiffPreview, CreateFilePreview } from './renderers/index.js';
|
||||
import { isEditWriteTool } from '../utils/toolUtils.js';
|
||||
import { formatToolHeader } from '../utils/messageFormatting.js';
|
||||
|
||||
export interface ApprovalRequest {
|
||||
approvalId: string;
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
timeout?: number;
|
||||
timestamp: Date;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ApprovalPromptHandle {
|
||||
handleInput: (input: string, key: Key) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed when approving a request
|
||||
*/
|
||||
export interface ApprovalOptions {
|
||||
/** Remember this tool for the entire session (approves ALL uses) */
|
||||
rememberChoice?: boolean;
|
||||
/** Remember a specific command pattern for bash (e.g., "git *") */
|
||||
rememberPattern?: string;
|
||||
/** Form data for elicitation requests */
|
||||
formData?: Record<string, unknown>;
|
||||
/** Enable "accept all edits" mode (auto-approve future edit_file/write_file) */
|
||||
enableAcceptEditsMode?: boolean;
|
||||
/** Remember directory access for the session */
|
||||
rememberDirectory?: boolean;
|
||||
}
|
||||
|
||||
interface ApprovalPromptProps {
|
||||
approval: ApprovalRequest;
|
||||
onApprove: (options: ApprovalOptions) => void;
|
||||
onDeny: (feedback?: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection option type - supports both simple yes/no and pattern-based options
|
||||
*/
|
||||
type SelectionOption =
|
||||
| 'yes'
|
||||
| 'yes-session'
|
||||
| 'yes-accept-edits'
|
||||
| 'no'
|
||||
| `pattern-${number}`
|
||||
// Plan review specific options
|
||||
| 'plan-approve'
|
||||
| 'plan-approve-accept-edits'
|
||||
| 'plan-reject'; // Single reject option with feedback input
|
||||
|
||||
/**
|
||||
* Compact approval prompt component that displays above the input area
|
||||
* Shows options based on approval type:
|
||||
* - Tool confirmation: Yes, Yes (Session), No
|
||||
* - Bash with patterns: Yes (once), pattern options, Yes (all bash), No
|
||||
* - Elicitation: Form with input fields
|
||||
*/
|
||||
export const ApprovalPrompt = forwardRef<ApprovalPromptHandle, ApprovalPromptProps>(
|
||||
({ approval, onApprove, onDeny, onCancel }, ref) => {
|
||||
const isCommandConfirmation = approval.type === 'command_confirmation';
|
||||
const isElicitation = approval.type === 'elicitation';
|
||||
const isDirectoryAccess = approval.type === 'directory_access';
|
||||
|
||||
// Extract tool metadata
|
||||
const toolName = approval.metadata.toolName as string | undefined;
|
||||
const toolArgs = (approval.metadata.args as Record<string, unknown>) || {};
|
||||
|
||||
// Check if this is a plan_review tool (shows custom approval options)
|
||||
const isPlanReview =
|
||||
toolName === 'custom--plan_review' ||
|
||||
toolName === 'internal--plan_review' ||
|
||||
toolName === 'plan_review';
|
||||
|
||||
// Extract suggested patterns for bash tools
|
||||
const suggestedPatterns =
|
||||
(approval.metadata.suggestedPatterns as string[] | undefined) ?? [];
|
||||
const hasBashPatterns = suggestedPatterns.length > 0;
|
||||
|
||||
// Check if this is an edit/write file tool
|
||||
const isEditOrWriteTool = isEditWriteTool(toolName);
|
||||
|
||||
// Format tool header using shared utility (same format as tool messages)
|
||||
const formattedTool = useMemo(() => {
|
||||
if (!toolName) return null;
|
||||
return formatToolHeader(toolName, toolArgs);
|
||||
}, [toolName, toolArgs]);
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// State for plan review feedback input
|
||||
const [showFeedbackInput, setShowFeedbackInput] = useState(false);
|
||||
const [feedbackText, setFeedbackText] = useState('');
|
||||
|
||||
// Ref for elicitation form
|
||||
const elicitationFormRef = useRef<ElicitationFormHandle>(null);
|
||||
|
||||
// Use ref to avoid stale closure issues in handleInput
|
||||
const selectedIndexRef = useRef(0);
|
||||
|
||||
// Build the list of options based on approval type
|
||||
const options: Array<{ id: SelectionOption; label: string }> = [];
|
||||
|
||||
if (isPlanReview) {
|
||||
// Plan review - show plan-specific options (2 options + feedback input)
|
||||
options.push({ id: 'plan-approve', label: 'Approve' });
|
||||
options.push({ id: 'plan-approve-accept-edits', label: 'Approve + Accept All Edits' });
|
||||
// Third "option" is the feedback input (handled specially in render)
|
||||
} else if (hasBashPatterns) {
|
||||
// Bash tool with pattern suggestions
|
||||
options.push({ id: 'yes', label: 'Yes (once)' });
|
||||
suggestedPatterns.forEach((pattern, i) => {
|
||||
options.push({
|
||||
id: `pattern-${i}` as SelectionOption,
|
||||
label: `Yes, allow "${pattern}"`,
|
||||
});
|
||||
});
|
||||
options.push({ id: 'yes-session', label: 'Yes, allow all bash' });
|
||||
options.push({ id: 'no', label: 'No' });
|
||||
} else if (isCommandConfirmation) {
|
||||
// Command confirmation (no session option)
|
||||
options.push({ id: 'yes', label: 'Yes' });
|
||||
options.push({ id: 'no', label: 'No' });
|
||||
} else if (isDirectoryAccess) {
|
||||
// Directory access - offer session-scoped access
|
||||
const parentDir = approval.metadata.parentDir as string | undefined;
|
||||
const dirLabel = parentDir ? ` "${parentDir}"` : '';
|
||||
options.push({ id: 'yes', label: 'Yes (once)' });
|
||||
options.push({ id: 'yes-session', label: `Yes, allow${dirLabel} (session)` });
|
||||
options.push({ id: 'no', label: 'No' });
|
||||
} else if (isEditOrWriteTool) {
|
||||
// Edit/write file tools - offer "accept all edits" mode instead of session
|
||||
options.push({ id: 'yes', label: 'Yes' });
|
||||
options.push({ id: 'yes-accept-edits', label: 'Yes, and accept all edits' });
|
||||
options.push({ id: 'no', label: 'No' });
|
||||
} else {
|
||||
// Standard tool confirmation
|
||||
options.push({ id: 'yes', label: 'Yes' });
|
||||
options.push({ id: 'yes-session', label: 'Yes (Session)' });
|
||||
options.push({ id: 'no', label: 'No' });
|
||||
}
|
||||
|
||||
// Keep ref in sync with state
|
||||
useEffect(() => {
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Helper to get the option at current index
|
||||
const getCurrentOption = () => options[selectedIndexRef.current];
|
||||
|
||||
// Expose handleInput method via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
handleInput: (input: string, key: Key) => {
|
||||
// For elicitation, delegate to the form
|
||||
if (isElicitation && elicitationFormRef.current) {
|
||||
return elicitationFormRef.current.handleInput(input, key);
|
||||
}
|
||||
|
||||
// For plan review, calculate total options including feedback input
|
||||
const totalOptions = isPlanReview ? options.length + 1 : options.length;
|
||||
const isFeedbackSelected =
|
||||
isPlanReview && selectedIndexRef.current === options.length;
|
||||
|
||||
// Handle typing when feedback input is selected
|
||||
if (isFeedbackSelected) {
|
||||
if (key.return) {
|
||||
// Submit rejection with feedback
|
||||
onDeny(feedbackText || undefined);
|
||||
return true;
|
||||
} else if (key.backspace || key.delete) {
|
||||
setFeedbackText((prev) => prev.slice(0, -1));
|
||||
return true;
|
||||
} else if (key.upArrow) {
|
||||
// Navigate up from feedback input
|
||||
setSelectedIndex(options.length - 1);
|
||||
return true;
|
||||
} else if (key.downArrow) {
|
||||
// Wrap to first option
|
||||
setSelectedIndex(0);
|
||||
return true;
|
||||
} else if (key.escape) {
|
||||
onCancel();
|
||||
return true;
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Add typed character to feedback
|
||||
setFeedbackText((prev) => prev + input);
|
||||
return true;
|
||||
}
|
||||
return true; // Consume all input when feedback is selected
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((current) =>
|
||||
current === 0 ? totalOptions - 1 : current - 1
|
||||
);
|
||||
return true;
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((current) =>
|
||||
current === totalOptions - 1 ? 0 : current + 1
|
||||
);
|
||||
return true;
|
||||
} else if (key.return) {
|
||||
const option = getCurrentOption();
|
||||
if (!option) return false;
|
||||
|
||||
// Plan review options
|
||||
if (option.id === 'plan-approve') {
|
||||
onApprove({});
|
||||
} else if (option.id === 'plan-approve-accept-edits') {
|
||||
onApprove({ enableAcceptEditsMode: true });
|
||||
} else if (option.id === 'yes') {
|
||||
onApprove({});
|
||||
} else if (option.id === 'yes-session') {
|
||||
// For directory access, remember the directory; otherwise remember the tool
|
||||
if (isDirectoryAccess) {
|
||||
onApprove({ rememberDirectory: true });
|
||||
} else {
|
||||
onApprove({ rememberChoice: true });
|
||||
}
|
||||
} else if (option.id === 'yes-accept-edits') {
|
||||
// Approve and enable "accept all edits" mode
|
||||
onApprove({ enableAcceptEditsMode: true });
|
||||
} else if (option.id === 'no') {
|
||||
onDeny();
|
||||
} else if (option.id.startsWith('pattern-')) {
|
||||
// Extract pattern index and get the pattern string
|
||||
const patternIndex = parseInt(option.id.replace('pattern-', ''), 10);
|
||||
const pattern = suggestedPatterns[patternIndex];
|
||||
if (pattern) {
|
||||
onApprove({ rememberPattern: pattern });
|
||||
} else {
|
||||
onApprove({});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} else if (key.shift && key.tab && isEditOrWriteTool) {
|
||||
// Shift+Tab on edit/write tool: approve and enable "accept all edits" mode
|
||||
onApprove({ enableAcceptEditsMode: true });
|
||||
return true;
|
||||
} else if (key.escape) {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
[
|
||||
isElicitation,
|
||||
isEditOrWriteTool,
|
||||
isDirectoryAccess,
|
||||
isPlanReview,
|
||||
options,
|
||||
suggestedPatterns,
|
||||
onApprove,
|
||||
onDeny,
|
||||
onCancel,
|
||||
feedbackText,
|
||||
]
|
||||
);
|
||||
|
||||
// For elicitation, render the form
|
||||
if (isElicitation) {
|
||||
const metadata = approval.metadata as unknown as ElicitationMetadata;
|
||||
return (
|
||||
<ElicitationForm
|
||||
ref={elicitationFormRef}
|
||||
metadata={metadata}
|
||||
onSubmit={(formData) => onApprove({ formData })}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Extract information from metadata based on approval type
|
||||
const command = approval.metadata.command as string | undefined;
|
||||
const displayPreview = approval.metadata.displayPreview as ToolDisplayData | undefined;
|
||||
|
||||
// Render preview based on display type
|
||||
const renderPreview = () => {
|
||||
if (!displayPreview) return null;
|
||||
|
||||
switch (displayPreview.type) {
|
||||
case 'diff': {
|
||||
const isOverwrite =
|
||||
toolName === 'custom--write_file' ||
|
||||
toolName === 'internal--write_file' ||
|
||||
toolName === 'write_file';
|
||||
return (
|
||||
<DiffPreview
|
||||
data={displayPreview}
|
||||
headerType={isOverwrite ? 'overwrite' : 'edit'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'shell':
|
||||
// For shell preview, just show the command (no output yet)
|
||||
return (
|
||||
<Box marginBottom={1} flexDirection="row">
|
||||
<Text color="gray">$ </Text>
|
||||
<Text color="yellowBright">{displayPreview.command}</Text>
|
||||
{displayPreview.isBackground && <Text color="gray"> (background)</Text>}
|
||||
</Box>
|
||||
);
|
||||
case 'file':
|
||||
// Use enhanced file preview with full content for file creation
|
||||
if (displayPreview.operation === 'create' && displayPreview.content) {
|
||||
return <CreateFilePreview data={displayPreview} />;
|
||||
}
|
||||
// For plan_review (read operation with content), show full content for review
|
||||
if (
|
||||
displayPreview.operation === 'read' &&
|
||||
displayPreview.content &&
|
||||
isPlanReview
|
||||
) {
|
||||
return <CreateFilePreview data={displayPreview} header="Review plan" />;
|
||||
}
|
||||
// Fallback for other file operations
|
||||
return (
|
||||
<Box marginBottom={1}>
|
||||
<Text color="gray">
|
||||
{displayPreview.operation === 'read' &&
|
||||
`Read ${displayPreview.lineCount ?? 'file'} lines`}
|
||||
{displayPreview.operation === 'write' &&
|
||||
`Write to ${displayPreview.path}`}
|
||||
{displayPreview.operation === 'delete' &&
|
||||
`Delete ${displayPreview.path}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract directory access metadata
|
||||
const directoryPath = approval.metadata.path as string | undefined;
|
||||
const parentDir = approval.metadata.parentDir as string | undefined;
|
||||
const operation = approval.metadata.operation as string | undefined;
|
||||
|
||||
return (
|
||||
<Box paddingX={0} paddingY={0} flexDirection="column">
|
||||
{/* Compact header with context */}
|
||||
<Box flexDirection="column" marginBottom={0}>
|
||||
{isDirectoryAccess ? (
|
||||
<>
|
||||
<Box flexDirection="row">
|
||||
<Text color="yellowBright" bold>
|
||||
🔐 Directory Access:{' '}
|
||||
</Text>
|
||||
<Text color="cyan">{parentDir || directoryPath}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" marginTop={0}>
|
||||
<Text color="gray">{' '}</Text>
|
||||
<Text color="gray">
|
||||
{formattedTool ? `"${formattedTool.displayName}"` : 'Tool'}{' '}
|
||||
wants to {operation || 'access'} files outside working directory
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box flexDirection="row">
|
||||
<Text color="yellowBright" bold>
|
||||
🔐 Approval:{' '}
|
||||
</Text>
|
||||
{formattedTool && <Text color="cyan">{formattedTool.header}</Text>}
|
||||
</Box>
|
||||
{isCommandConfirmation && command && (
|
||||
<Box flexDirection="row" marginTop={0}>
|
||||
<Text color="gray">{' Command: '}</Text>
|
||||
<Text color="red">{command}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Preview section - shown BEFORE approval options */}
|
||||
{renderPreview()}
|
||||
|
||||
{/* Vertical selection options */}
|
||||
<Box flexDirection="column" marginTop={0}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const isNo = option.id === 'no';
|
||||
|
||||
return (
|
||||
<Box key={option.id}>
|
||||
{isSelected ? (
|
||||
<Text color={isNo ? 'red' : 'green'} bold>
|
||||
{' ▶ '}
|
||||
{option.label}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color="gray">
|
||||
{' '}
|
||||
{option.label}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Feedback input as third option for plan review */}
|
||||
{isPlanReview && (
|
||||
<Box>
|
||||
{selectedIndex === options.length ? (
|
||||
// Selected - show editable input
|
||||
<Box flexDirection="row">
|
||||
<Text color="red" bold>
|
||||
{' ▶ '}
|
||||
</Text>
|
||||
{feedbackText ? (
|
||||
<Text color="white">
|
||||
{feedbackText}
|
||||
<Text color="cyan">▋</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color="gray">
|
||||
What changes would you like?
|
||||
<Text color="cyan">▋</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
// Not selected - show placeholder
|
||||
<Text color="gray">
|
||||
{' '}
|
||||
{feedbackText || 'What changes would you like?'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Compact instructions */}
|
||||
<Box marginTop={0}>
|
||||
<Text color="gray">{' '}↑↓ to select • Enter to confirm • Esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ApprovalPrompt.displayName = 'ApprovalPrompt';
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useInput, Text } from 'ink';
|
||||
|
||||
interface CustomInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
placeholder?: string;
|
||||
isProcessing?: boolean;
|
||||
onWordDelete?: () => void;
|
||||
onLineDelete?: () => void;
|
||||
onToggleMultiLine?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom input component that handles keyboard shortcuts
|
||||
* Fully custom implementation without TextInput to properly handle shortcuts
|
||||
*/
|
||||
export default function CustomInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
isProcessing = false,
|
||||
onWordDelete,
|
||||
onLineDelete,
|
||||
onToggleMultiLine,
|
||||
}: CustomInputProps) {
|
||||
// Handle all keyboard input directly
|
||||
useInput(
|
||||
(inputChar, key) => {
|
||||
if (isProcessing) return;
|
||||
|
||||
// Shift+Enter = toggle multi-line mode
|
||||
if (key.return && key.shift) {
|
||||
onToggleMultiLine?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter = submit
|
||||
if (key.return) {
|
||||
onSubmit(value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+U = line delete (Unix standard, also what Cmd+Backspace becomes)
|
||||
if (key.ctrl && inputChar === 'u') {
|
||||
onLineDelete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+W = word delete (Unix standard, also what Option+Backspace becomes)
|
||||
if (key.ctrl && inputChar === 'w') {
|
||||
onWordDelete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular backspace/delete
|
||||
if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular character input
|
||||
if (inputChar && !key.ctrl && !key.meta) {
|
||||
onChange(value + inputChar);
|
||||
}
|
||||
},
|
||||
{ isActive: true }
|
||||
);
|
||||
|
||||
// Render with block cursor highlighting the character at cursor position
|
||||
if (!value && placeholder) {
|
||||
// Empty input - highlight first character of placeholder
|
||||
const firstChar = placeholder[0] || ' ';
|
||||
const rest = placeholder.slice(1);
|
||||
return (
|
||||
<Text>
|
||||
<Text color="black" backgroundColor="green">
|
||||
{firstChar}
|
||||
</Text>
|
||||
<Text color="gray">{rest}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Has value - highlight character after end (space)
|
||||
return (
|
||||
<Text>
|
||||
{value}
|
||||
<Text color="black" backgroundColor="green">
|
||||
{' '}
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
interface CustomTextInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
placeholder?: string;
|
||||
onWordDelete?: () => void;
|
||||
onLineDelete?: () => void;
|
||||
onNewline?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom TextInput wrapper that handles keyboard shortcuts
|
||||
* before TextInput consumes them
|
||||
*/
|
||||
export default function CustomTextInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
onWordDelete,
|
||||
onLineDelete,
|
||||
onNewline,
|
||||
}: CustomTextInputProps) {
|
||||
// Use useInput to intercept keyboard shortcuts
|
||||
// This needs to run with isActive: true to intercept before TextInput
|
||||
useInput(
|
||||
(inputChar, key) => {
|
||||
// Handle Shift+Enter or Ctrl+E to toggle multi-line mode
|
||||
// Note: Shift+Enter may not work in all terminals, Ctrl+E is more reliable
|
||||
if ((key.return && key.shift) || (key.ctrl && inputChar === 'e')) {
|
||||
onNewline?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle word deletion (Cmd+Delete or Cmd+Backspace on Mac, Ctrl+Delete or Ctrl+Backspace on Windows/Linux)
|
||||
// Note: On Mac, Cmd+Backspace is the standard for word deletion
|
||||
if ((key.delete || key.backspace) && (key.meta || key.ctrl)) {
|
||||
onWordDelete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle line deletion (Cmd+Shift+Delete or Ctrl+U)
|
||||
if ((key.delete && key.meta && key.shift) || (key.ctrl && inputChar === 'u')) {
|
||||
onLineDelete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Ctrl+Shift+Delete as additional word deletion shortcut (Windows/Linux)
|
||||
if (key.delete && key.ctrl && key.shift) {
|
||||
onWordDelete?.();
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive: true }
|
||||
);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
{...(placeholder ? { placeholder } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Editable multi-line input component
|
||||
* Simple, reliable multi-line editor without complex box layouts
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
|
||||
interface EditableMultiLineInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
placeholder?: string;
|
||||
isProcessing?: boolean;
|
||||
onToggleSingleLine?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-line input with cursor navigation
|
||||
* Uses simple text rendering without nested boxes for reliable terminal display
|
||||
*/
|
||||
export default function EditableMultiLineInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
isProcessing = false,
|
||||
onToggleSingleLine,
|
||||
}: EditableMultiLineInputProps) {
|
||||
const [cursorPos, setCursorPos] = useState(value.length);
|
||||
|
||||
// Keep cursor valid when value changes externally
|
||||
useEffect(() => {
|
||||
if (cursorPos > value.length) {
|
||||
setCursorPos(value.length);
|
||||
}
|
||||
}, [value, cursorPos]);
|
||||
|
||||
// Calculate line info from cursor position
|
||||
const { lines, currentLine, currentCol, lineStartIndices } = useMemo(() => {
|
||||
const lines = value.split('\n');
|
||||
const lineStartIndices: number[] = [];
|
||||
let pos = 0;
|
||||
for (const line of lines) {
|
||||
lineStartIndices.push(pos);
|
||||
pos += line.length + 1;
|
||||
}
|
||||
|
||||
let currentLine = 0;
|
||||
for (let i = 0; i < lineStartIndices.length; i++) {
|
||||
const lineEnd =
|
||||
i < lineStartIndices.length - 1 ? lineStartIndices[i + 1]! - 1 : value.length;
|
||||
if (cursorPos <= lineEnd || i === lineStartIndices.length - 1) {
|
||||
currentLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const currentCol = cursorPos - (lineStartIndices[currentLine] ?? 0);
|
||||
return { lines, currentLine, currentCol, lineStartIndices };
|
||||
}, [value, cursorPos]);
|
||||
|
||||
useInput(
|
||||
(inputChar, key) => {
|
||||
if (isProcessing) return;
|
||||
|
||||
// Cmd/Ctrl+Enter = submit
|
||||
if (key.return && (key.meta || key.ctrl)) {
|
||||
onSubmit(value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+Enter = toggle back to single-line
|
||||
// Note: Ctrl+E is reserved for standard "move to end of line" behavior
|
||||
if (key.return && key.shift) {
|
||||
onToggleSingleLine?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter = newline
|
||||
if (key.return) {
|
||||
const newValue = value.slice(0, cursorPos) + '\n' + value.slice(cursorPos);
|
||||
onChange(newValue);
|
||||
setCursorPos(cursorPos + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace
|
||||
if (key.backspace && cursorPos > 0) {
|
||||
onChange(value.slice(0, cursorPos - 1) + value.slice(cursorPos));
|
||||
setCursorPos(cursorPos - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete
|
||||
if (key.delete && cursorPos < value.length) {
|
||||
onChange(value.slice(0, cursorPos) + value.slice(cursorPos + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow navigation
|
||||
if (key.leftArrow) {
|
||||
setCursorPos(Math.max(0, cursorPos - 1));
|
||||
return;
|
||||
}
|
||||
if (key.rightArrow) {
|
||||
setCursorPos(Math.min(value.length, cursorPos + 1));
|
||||
return;
|
||||
}
|
||||
if (key.upArrow && currentLine > 0) {
|
||||
const prevLineStart = lineStartIndices[currentLine - 1]!;
|
||||
const prevLineLen = lines[currentLine - 1]!.length;
|
||||
setCursorPos(prevLineStart + Math.min(currentCol, prevLineLen));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow && currentLine < lines.length - 1) {
|
||||
const nextLineStart = lineStartIndices[currentLine + 1]!;
|
||||
const nextLineLen = lines[currentLine + 1]!.length;
|
||||
setCursorPos(nextLineStart + Math.min(currentCol, nextLineLen));
|
||||
return;
|
||||
}
|
||||
|
||||
// Character input
|
||||
if (inputChar && !key.ctrl && !key.meta) {
|
||||
onChange(value.slice(0, cursorPos) + inputChar + value.slice(cursorPos));
|
||||
setCursorPos(cursorPos + inputChar.length);
|
||||
}
|
||||
},
|
||||
{ isActive: true }
|
||||
);
|
||||
|
||||
// Render each line with cursor
|
||||
const renderLine = (line: string, lineIdx: number) => {
|
||||
const lineStart = lineStartIndices[lineIdx]!;
|
||||
const isCursorLine = lineIdx === currentLine;
|
||||
const cursorCol = isCursorLine ? cursorPos - lineStart : -1;
|
||||
|
||||
const prefix = lineIdx === 0 ? '» ' : ' ';
|
||||
|
||||
if (cursorCol < 0) {
|
||||
// No cursor on this line
|
||||
return (
|
||||
<Text key={lineIdx}>
|
||||
<Text color="cyan">{prefix}</Text>
|
||||
<Text>{line || ' '}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Cursor on this line - highlight character at cursor
|
||||
const before = line.slice(0, cursorCol);
|
||||
const atCursor = line.charAt(cursorCol) || ' ';
|
||||
const after = line.slice(cursorCol + 1);
|
||||
|
||||
return (
|
||||
<Text key={lineIdx}>
|
||||
<Text color="cyan">{prefix}</Text>
|
||||
<Text>{before}</Text>
|
||||
<Text backgroundColor="green" color="black">
|
||||
{atCursor}
|
||||
</Text>
|
||||
<Text>{after}</Text>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
// Show placeholder if empty
|
||||
if (!value && placeholder) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text color="cyan">{'» '}</Text>
|
||||
<Text backgroundColor="green" color="black">
|
||||
{' '}
|
||||
</Text>
|
||||
<Text color="gray"> {placeholder}</Text>
|
||||
</Text>
|
||||
<Text color="gray">
|
||||
Multi-line mode • Cmd/Ctrl+Enter to submit • Shift+Enter for single-line
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{lines.map((line, idx) => renderLine(line, idx))}
|
||||
<Text color="gray">
|
||||
Multi-line mode • Cmd/Ctrl+Enter to submit • Shift+Enter for single-line
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* ElicitationForm Component
|
||||
* Renders a form for ask_user/elicitation requests in the CLI
|
||||
* Supports string, number, boolean, and enum field types
|
||||
*/
|
||||
|
||||
import React, { useState, forwardRef, useImperativeHandle, useCallback, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { Key } from '../hooks/useInputOrchestrator.js';
|
||||
import type { ElicitationMetadata } from '@dexto/core';
|
||||
|
||||
export interface ElicitationFormHandle {
|
||||
handleInput: (input: string, key: Key) => boolean;
|
||||
}
|
||||
|
||||
interface ElicitationFormProps {
|
||||
metadata: ElicitationMetadata;
|
||||
onSubmit: (formData: Record<string, unknown>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface FormField {
|
||||
name: string;
|
||||
label: string; // title if available, otherwise name
|
||||
type: 'string' | 'number' | 'boolean' | 'enum' | 'array-enum';
|
||||
description: string | undefined;
|
||||
required: boolean;
|
||||
enumValues: unknown[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form component for elicitation/ask_user requests
|
||||
*/
|
||||
export const ElicitationForm = forwardRef<ElicitationFormHandle, ElicitationFormProps>(
|
||||
({ metadata, onSubmit, onCancel }, ref) => {
|
||||
// Parse schema into form fields
|
||||
const fields = useMemo((): FormField[] => {
|
||||
const schema = metadata.schema;
|
||||
if (!schema?.properties) return [];
|
||||
|
||||
const required = schema.required || [];
|
||||
return Object.entries(schema.properties)
|
||||
.filter(
|
||||
(entry): entry is [string, Exclude<(typeof entry)[1], boolean>] =>
|
||||
typeof entry[1] !== 'boolean'
|
||||
)
|
||||
.map(([name, prop]) => {
|
||||
let type: FormField['type'] = 'string';
|
||||
let enumValues: unknown[] | undefined;
|
||||
|
||||
if (prop.type === 'boolean') {
|
||||
type = 'boolean';
|
||||
} else if (prop.type === 'number' || prop.type === 'integer') {
|
||||
type = 'number';
|
||||
} else if (prop.enum && Array.isArray(prop.enum)) {
|
||||
type = 'enum';
|
||||
enumValues = prop.enum;
|
||||
} else if (
|
||||
prop.type === 'array' &&
|
||||
typeof prop.items === 'object' &&
|
||||
prop.items &&
|
||||
'enum' in prop.items
|
||||
) {
|
||||
type = 'array-enum';
|
||||
enumValues = prop.items.enum as unknown[];
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
label: prop.title || name,
|
||||
type,
|
||||
description: prop.description,
|
||||
required: required.includes(name),
|
||||
enumValues,
|
||||
};
|
||||
});
|
||||
}, [metadata.schema]);
|
||||
|
||||
// Form state
|
||||
const [activeFieldIndex, setActiveFieldIndex] = useState(0);
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
const [currentInput, setCurrentInput] = useState('');
|
||||
const [enumIndex, setEnumIndex] = useState(0); // For enum selection
|
||||
const [arraySelections, setArraySelections] = useState<Set<number>>(new Set()); // For array-enum
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isReviewing, setIsReviewing] = useState(false); // Confirmation step before submit
|
||||
|
||||
const activeField = fields[activeFieldIndex];
|
||||
|
||||
// Update a field value
|
||||
const updateField = useCallback((name: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Validate and enter review mode (or submit if already reviewing)
|
||||
// Accepts optional currentFieldValue to handle async state update timing
|
||||
const handleSubmit = useCallback(
|
||||
(currentFieldValue?: { name: string; value: unknown }) => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
// Merge current field value since React state update is async
|
||||
const finalFormData = currentFieldValue
|
||||
? { ...formData, [currentFieldValue.name]: currentFieldValue.value }
|
||||
: formData;
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.required) {
|
||||
const value = finalFormData[field.name];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
newErrors[field.name] = 'Required';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
// Focus first error field
|
||||
const firstErrorField = fields.findIndex((f) => newErrors[f.name]);
|
||||
if (firstErrorField >= 0) {
|
||||
setActiveFieldIndex(firstErrorField);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update formData with final value and enter review mode
|
||||
if (currentFieldValue) {
|
||||
setFormData(finalFormData);
|
||||
}
|
||||
setIsReviewing(true);
|
||||
},
|
||||
[fields, formData]
|
||||
);
|
||||
|
||||
// Final submission after review
|
||||
const confirmSubmit = useCallback(() => {
|
||||
onSubmit(formData);
|
||||
}, [formData, onSubmit]);
|
||||
|
||||
// Navigate to next/previous field
|
||||
const nextField = useCallback(() => {
|
||||
if (activeFieldIndex < fields.length - 1) {
|
||||
// Save current input for string/number fields
|
||||
if (activeField?.type === 'string' || activeField?.type === 'number') {
|
||||
if (currentInput.trim()) {
|
||||
const value =
|
||||
activeField.type === 'number' ? Number(currentInput) : currentInput;
|
||||
updateField(activeField.name, value);
|
||||
}
|
||||
}
|
||||
setActiveFieldIndex((prev) => prev + 1);
|
||||
setCurrentInput('');
|
||||
setEnumIndex(0);
|
||||
setArraySelections(new Set());
|
||||
}
|
||||
}, [activeFieldIndex, fields.length, activeField, currentInput, updateField]);
|
||||
|
||||
const prevField = useCallback(() => {
|
||||
if (activeFieldIndex > 0) {
|
||||
setActiveFieldIndex((prev) => prev - 1);
|
||||
setCurrentInput('');
|
||||
setEnumIndex(0);
|
||||
setArraySelections(new Set());
|
||||
}
|
||||
}, [activeFieldIndex]);
|
||||
|
||||
// Handle keyboard input
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
handleInput: (input: string, key: Key): boolean => {
|
||||
// Review mode handling
|
||||
if (isReviewing) {
|
||||
if (key.return) {
|
||||
confirmSubmit();
|
||||
return true;
|
||||
}
|
||||
// Backspace to go back to editing
|
||||
if (key.backspace || key.delete) {
|
||||
setIsReviewing(false);
|
||||
return true;
|
||||
}
|
||||
// Esc to cancel entirely
|
||||
if (key.escape) {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Escape to cancel
|
||||
if (key.escape) {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!activeField) return false;
|
||||
|
||||
// Shift+Tab or Up to previous field (check BEFORE plain Tab)
|
||||
if (
|
||||
(key.tab && key.shift) ||
|
||||
(key.upArrow &&
|
||||
activeField.type !== 'enum' &&
|
||||
activeField.type !== 'array-enum')
|
||||
) {
|
||||
prevField();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tab (without Shift) or Down to next field
|
||||
if (
|
||||
(key.tab && !key.shift) ||
|
||||
(key.downArrow &&
|
||||
activeField.type !== 'enum' &&
|
||||
activeField.type !== 'array-enum')
|
||||
) {
|
||||
nextField();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Field-specific handling
|
||||
switch (activeField.type) {
|
||||
case 'boolean': {
|
||||
// Space or Enter to toggle
|
||||
if (input === ' ' || key.return) {
|
||||
const current = formData[activeField.name] === true;
|
||||
const newValue = !current;
|
||||
updateField(activeField.name, newValue);
|
||||
if (key.return) {
|
||||
if (activeFieldIndex === fields.length - 1) {
|
||||
handleSubmit({ name: activeField.name, value: newValue });
|
||||
} else {
|
||||
nextField();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Left/Right to toggle
|
||||
if (key.leftArrow || key.rightArrow) {
|
||||
const current = formData[activeField.name] === true;
|
||||
updateField(activeField.name, !current);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'enum': {
|
||||
const values = activeField.enumValues || [];
|
||||
// Up/Down to navigate enum
|
||||
if (key.upArrow) {
|
||||
setEnumIndex((prev) => (prev > 0 ? prev - 1 : values.length - 1));
|
||||
return true;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setEnumIndex((prev) => (prev < values.length - 1 ? prev + 1 : 0));
|
||||
return true;
|
||||
}
|
||||
// Enter to select and move to next (or submit if last)
|
||||
if (key.return) {
|
||||
const selectedValue = values[enumIndex];
|
||||
updateField(activeField.name, selectedValue);
|
||||
if (activeFieldIndex === fields.length - 1) {
|
||||
handleSubmit({ name: activeField.name, value: selectedValue });
|
||||
} else {
|
||||
nextField();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'array-enum': {
|
||||
const values = activeField.enumValues || [];
|
||||
// Up/Down to navigate
|
||||
if (key.upArrow) {
|
||||
setEnumIndex((prev) => (prev > 0 ? prev - 1 : values.length - 1));
|
||||
return true;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setEnumIndex((prev) => (prev < values.length - 1 ? prev + 1 : 0));
|
||||
return true;
|
||||
}
|
||||
// Space to toggle selection
|
||||
if (input === ' ') {
|
||||
setArraySelections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(enumIndex)) {
|
||||
next.delete(enumIndex);
|
||||
} else {
|
||||
next.add(enumIndex);
|
||||
}
|
||||
// Update form data
|
||||
const selected = Array.from(next).map((i) => values[i]);
|
||||
updateField(activeField.name, selected);
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
// Enter to confirm and move to next (or submit if last)
|
||||
if (key.return) {
|
||||
// Get current selections for submit
|
||||
const selected = Array.from(arraySelections).map((i) => values[i]);
|
||||
if (activeFieldIndex === fields.length - 1) {
|
||||
handleSubmit({ name: activeField.name, value: selected });
|
||||
} else {
|
||||
nextField();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'string':
|
||||
case 'number': {
|
||||
// Enter to confirm field and move to next (or submit if last)
|
||||
if (key.return) {
|
||||
const value = currentInput.trim()
|
||||
? activeField.type === 'number'
|
||||
? Number(currentInput)
|
||||
: currentInput
|
||||
: formData[activeField.name]; // Use existing value if no new input
|
||||
if (currentInput.trim()) {
|
||||
updateField(activeField.name, value);
|
||||
}
|
||||
if (activeFieldIndex === fields.length - 1) {
|
||||
// Last field - submit with current value
|
||||
handleSubmit(
|
||||
value !== undefined
|
||||
? { name: activeField.name, value }
|
||||
: undefined
|
||||
);
|
||||
} else {
|
||||
nextField();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Backspace
|
||||
if (key.backspace || key.delete) {
|
||||
setCurrentInput((prev) => prev.slice(0, -1));
|
||||
return true;
|
||||
}
|
||||
// Regular character input
|
||||
if (input && !key.ctrl && !key.meta) {
|
||||
// For number type, only allow digits and decimal
|
||||
if (activeField.type === 'number') {
|
||||
if (/^[\d.-]$/.test(input)) {
|
||||
setCurrentInput((prev) => prev + input);
|
||||
}
|
||||
} else {
|
||||
setCurrentInput((prev) => prev + input);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
[
|
||||
activeField,
|
||||
activeFieldIndex,
|
||||
arraySelections,
|
||||
confirmSubmit,
|
||||
currentInput,
|
||||
enumIndex,
|
||||
fields.length,
|
||||
formData,
|
||||
handleSubmit,
|
||||
isReviewing,
|
||||
nextField,
|
||||
onCancel,
|
||||
prevField,
|
||||
updateField,
|
||||
]
|
||||
);
|
||||
|
||||
if (fields.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color="red">Invalid form schema</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const prompt = metadata.prompt;
|
||||
|
||||
// Review mode - show summary of choices
|
||||
if (isReviewing) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={0} paddingY={0}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color="green" bold>
|
||||
✓ Review your answers:
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{fields.map((field) => {
|
||||
const value = formData[field.name];
|
||||
const displayValue = Array.isArray(value)
|
||||
? value.join(', ')
|
||||
: value === true
|
||||
? 'Yes'
|
||||
: value === false
|
||||
? 'No'
|
||||
: String(value ?? '');
|
||||
return (
|
||||
<Box key={field.name} marginBottom={0}>
|
||||
<Text>
|
||||
<Text color="cyan">{field.label}</Text>
|
||||
<Text>: </Text>
|
||||
<Text color="green">{displayValue}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">
|
||||
Enter to submit • Backspace to edit • Esc to cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={0} paddingY={0}>
|
||||
{/* Header */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color="yellowBright" bold>
|
||||
📝 {prompt}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Form fields */}
|
||||
{fields.map((field, index) => {
|
||||
const isActive = index === activeFieldIndex;
|
||||
const value = formData[field.name];
|
||||
const error = errors[field.name];
|
||||
|
||||
return (
|
||||
<Box key={field.name} flexDirection="column" marginBottom={1}>
|
||||
{/* Field label */}
|
||||
<Box>
|
||||
<Text color={isActive ? 'cyan' : 'white'} bold={isActive}>
|
||||
{isActive ? '▶ ' : ' '}
|
||||
{field.label}
|
||||
{field.required && <Text color="red">*</Text>}
|
||||
{': '}
|
||||
</Text>
|
||||
|
||||
{/* Field value display */}
|
||||
{field.type === 'boolean' && (
|
||||
<Text color={value === true ? 'green' : 'gray'}>
|
||||
{value === true ? '[✓] Yes' : '[ ] No'}
|
||||
{isActive && <Text color="gray"> (Space to toggle)</Text>}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{field.type === 'string' && !isActive && value !== undefined && (
|
||||
<Text color="green">{String(value)}</Text>
|
||||
)}
|
||||
|
||||
{field.type === 'number' && !isActive && value !== undefined && (
|
||||
<Text color="green">{String(value)}</Text>
|
||||
)}
|
||||
|
||||
{field.type === 'enum' && !isActive && value !== undefined && (
|
||||
<Text color="green">{String(value)}</Text>
|
||||
)}
|
||||
|
||||
{field.type === 'array-enum' &&
|
||||
!isActive &&
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 && (
|
||||
<Text color="green">{value.join(', ')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Active field input */}
|
||||
{isActive && (field.type === 'string' || field.type === 'number') && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color="cyan">> </Text>
|
||||
<Text>{currentInput}</Text>
|
||||
<Text color="cyan">_</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Enum selection */}
|
||||
{isActive && field.type === 'enum' && field.enumValues && (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
{field.enumValues.map((opt, i) => (
|
||||
<Box key={String(opt)}>
|
||||
<Text color={i === enumIndex ? 'green' : 'gray'}>
|
||||
{i === enumIndex ? ' ▶ ' : ' '}
|
||||
{String(opt)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Array-enum multi-select */}
|
||||
{isActive && field.type === 'array-enum' && field.enumValues && (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
<Text color="gray"> (Space to select, Enter to confirm)</Text>
|
||||
{field.enumValues.map((opt, i) => {
|
||||
const isSelected = arraySelections.has(i);
|
||||
return (
|
||||
<Box key={String(opt)}>
|
||||
<Text color={i === enumIndex ? 'cyan' : 'gray'}>
|
||||
{i === enumIndex ? ' ▶ ' : ' '}
|
||||
<Text color={isSelected ? 'green' : 'gray'}>
|
||||
{isSelected ? '[✓]' : '[ ]'}
|
||||
</Text>{' '}
|
||||
{String(opt)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Field description */}
|
||||
{isActive && field.description && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color="gray">{field.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">
|
||||
Tab/↓ next field • Shift+Tab/↑ prev • Enter to confirm • Esc to cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ElicitationForm.displayName = 'ElicitationForm';
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Error Boundary Component
|
||||
* Catches and displays errors in the component tree
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { logger } from '@dexto/core';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
logger.error(`Error in ErrorBoundary: ${error.message}`, {
|
||||
error,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
override render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1} borderStyle="round" borderColor="red">
|
||||
<Text color="red" bold>
|
||||
❌ CLI Error
|
||||
</Text>
|
||||
<Text color="red">{this.state.error?.message || 'Unknown error'}</Text>
|
||||
<Text color="yellowBright">Press Ctrl+C to exit</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
152
dexto/packages/cli/src/cli/ink-cli/components/Footer.tsx
Normal file
152
dexto/packages/cli/src/cli/ink-cli/components/Footer.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Footer Component
|
||||
* Status line at the bottom showing CWD, branch, and model info.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import path from 'node:path';
|
||||
import { Box, Text } from 'ink';
|
||||
import { getModelDisplayName, type DextoAgent } from '@dexto/core';
|
||||
|
||||
interface FooterProps {
|
||||
agent: DextoAgent;
|
||||
sessionId: string | null;
|
||||
modelName: string;
|
||||
cwd?: string;
|
||||
branchName?: string;
|
||||
autoApproveEdits?: boolean;
|
||||
planModeActive?: boolean;
|
||||
/** Whether user is in shell command mode (input starts with !) */
|
||||
isShellMode?: boolean;
|
||||
}
|
||||
|
||||
function getDirectoryName(cwd: string): string {
|
||||
const base = path.basename(cwd);
|
||||
return base || cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure presentational component for footer status line
|
||||
*/
|
||||
export function Footer({
|
||||
agent,
|
||||
sessionId,
|
||||
modelName,
|
||||
cwd,
|
||||
branchName,
|
||||
autoApproveEdits,
|
||||
planModeActive,
|
||||
isShellMode,
|
||||
}: FooterProps) {
|
||||
const displayPath = cwd ? getDirectoryName(cwd) : '';
|
||||
const displayModelName = getModelDisplayName(modelName);
|
||||
const [contextLeft, setContextLeft] = useState<{
|
||||
percentLeft: number;
|
||||
} | null>(null);
|
||||
|
||||
// Check if Dexto is actually the active provider (not just if API key exists)
|
||||
const viaDexto = agent.getCurrentLLMConfig().provider === 'dexto';
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setContextLeft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let refreshId = 0;
|
||||
|
||||
const refreshContext = async () => {
|
||||
const requestId = ++refreshId;
|
||||
try {
|
||||
const stats = await agent.getContextStats(sessionId);
|
||||
if (cancelled || requestId !== refreshId) return;
|
||||
const percentLeft = Math.max(0, Math.min(100, 100 - stats.usagePercent));
|
||||
setContextLeft({
|
||||
percentLeft,
|
||||
});
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setContextLeft(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
refreshContext();
|
||||
|
||||
const bus = agent.agentEventBus;
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const sessionEvents = [
|
||||
'llm:response',
|
||||
'context:compacted',
|
||||
'context:pruned',
|
||||
'context:cleared',
|
||||
'message:dequeued',
|
||||
'session:reset',
|
||||
] as const;
|
||||
|
||||
const handleEvent = (payload: { sessionId?: string }) => {
|
||||
if (payload.sessionId && payload.sessionId !== sessionId) return;
|
||||
refreshContext();
|
||||
};
|
||||
|
||||
for (const eventName of sessionEvents) {
|
||||
bus.on(eventName, handleEvent, { signal });
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [agent, sessionId]);
|
||||
|
||||
// Shell mode changes the path color to yellow as indicator
|
||||
const pathColor = isShellMode ? 'yellow' : 'blue';
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{/* Line 1: CWD (left) | Model name (right) */}
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box>
|
||||
<Text color={pathColor}>{displayPath}</Text>
|
||||
{branchName && <Text color="gray"> ({branchName})</Text>}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="cyan">{displayModelName}</Text>
|
||||
{viaDexto && <Text color="gray"> via Dexto</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 2: Context left */}
|
||||
{contextLeft && (
|
||||
<Box>
|
||||
<Text color="gray">{contextLeft.percentLeft}% context left</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Line 3: Mode indicators (left) */}
|
||||
{/* Shift+Tab cycles: Normal → Plan Mode → Accept All Edits → Normal */}
|
||||
{isShellMode && (
|
||||
<Box>
|
||||
<Text color="yellow" bold>
|
||||
!
|
||||
</Text>
|
||||
<Text color="gray"> for shell mode</Text>
|
||||
</Box>
|
||||
)}
|
||||
{planModeActive && !isShellMode && (
|
||||
<Box>
|
||||
<Text color="magentaBright">plan mode</Text>
|
||||
<Text color="gray"> (shift + tab to cycle)</Text>
|
||||
</Box>
|
||||
)}
|
||||
{autoApproveEdits && !planModeActive && !isShellMode && (
|
||||
<Box>
|
||||
<Text color="yellowBright">accept edits</Text>
|
||||
<Text color="gray"> (shift + tab to cycle)</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* HistorySearchBar - UI for Ctrl+R reverse history search
|
||||
*
|
||||
* Displayed at the very bottom when search mode is active.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
interface HistorySearchBarProps {
|
||||
/** Current search query */
|
||||
query: string;
|
||||
/** Whether there's a match for the current query */
|
||||
hasMatch: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search bar displayed during history search mode
|
||||
*/
|
||||
export function HistorySearchBar({ query, hasMatch }: HistorySearchBarProps) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{/* Hints on separate line above */}
|
||||
<Text color="gray">Ctrl+R: older, Ctrl+E: newer, Enter: accept, Esc: cancel</Text>
|
||||
{/* Search query line */}
|
||||
<Box>
|
||||
<Text color="green">search history: </Text>
|
||||
<Text color="cyan">{query}</Text>
|
||||
<Text color="gray">_</Text>
|
||||
{query && !hasMatch && <Text color="red"> (no match)</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
interface MultiLineInputProps {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom multi-line input display component
|
||||
* Calculates height based on content and displays text with proper line breaks
|
||||
*/
|
||||
export default function MultiLineInput({ value, placeholder, prompt = '> ' }: MultiLineInputProps) {
|
||||
// Calculate number of lines (split by newlines and count)
|
||||
const lines = useMemo(() => {
|
||||
if (!value) return [];
|
||||
return value.split('\n');
|
||||
}, [value]);
|
||||
|
||||
const lineCount = lines.length;
|
||||
|
||||
// If empty, show placeholder
|
||||
if (!value && placeholder) {
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text color="green" bold>
|
||||
{prompt}
|
||||
</Text>
|
||||
<Text color="gray">{placeholder}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Display multi-line text - show last N lines if too many
|
||||
const visibleLines = lineCount > 10 ? lines.slice(-10) : lines;
|
||||
const startOffset = lineCount > 10 ? lineCount - 10 : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{startOffset > 0 && (
|
||||
<Box>
|
||||
<Text color="gray">... ({startOffset} more lines above)</Text>
|
||||
</Box>
|
||||
)}
|
||||
{visibleLines.map((line, index) => {
|
||||
const actualIndex = startOffset + index;
|
||||
return (
|
||||
<Box key={actualIndex} flexDirection="row">
|
||||
{index === 0 && (
|
||||
<Text color="green" bold>
|
||||
{prompt}
|
||||
</Text>
|
||||
)}
|
||||
{index > 0 && <Text color="green">{' '.repeat(prompt.length)}</Text>}
|
||||
<Text wrap="wrap">{line || ' '}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { Key } from '../hooks/useInputOrchestrator.js';
|
||||
import type { ResourceMetadata } from '@dexto/core';
|
||||
import type { DextoAgent } from '@dexto/core';
|
||||
|
||||
export interface ResourceAutocompleteHandle {
|
||||
handleInput: (input: string, key: Key) => boolean;
|
||||
}
|
||||
|
||||
interface ResourceAutocompleteProps {
|
||||
isVisible: boolean;
|
||||
searchQuery: string;
|
||||
onSelectResource: (resource: ResourceMetadata) => void;
|
||||
onLoadIntoInput?: (text: string) => void; // New prop for Tab key
|
||||
onClose: () => void;
|
||||
agent: DextoAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get match score for resource: 0 = no match, 1 = description/URI match, 2 = name includes, 3 = name starts with
|
||||
* Prioritizes name matches over description/URI matches
|
||||
*/
|
||||
function getResourceMatchScore(resource: ResourceMetadata, query: string): number {
|
||||
if (!query) return 3; // Show all when no query
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const name = (resource.name || '').toLowerCase();
|
||||
const uri = resource.uri.toLowerCase();
|
||||
const uriFilename = uri.split('/').pop()?.toLowerCase() || '';
|
||||
const description = (resource.description || '').toLowerCase();
|
||||
|
||||
// Highest priority: name starts with query
|
||||
if (name.startsWith(lowerQuery)) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
// Second priority: name includes query
|
||||
if (name.includes(lowerQuery)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
// Third priority: URI filename starts with query
|
||||
if (uriFilename.startsWith(lowerQuery)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Fourth priority: URI filename includes query
|
||||
if (uriFilename.includes(lowerQuery)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Fifth priority: URI includes query
|
||||
if (uri.includes(lowerQuery)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Lowest priority: description includes query
|
||||
if (description.includes(lowerQuery)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0; // No match
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if resource matches query (for filtering)
|
||||
*/
|
||||
function matchesQuery(resource: ResourceMetadata, query: string): boolean {
|
||||
return getResourceMatchScore(resource, query) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort resources by match score (highest first), then alphabetically
|
||||
*/
|
||||
function sortResources(resources: ResourceMetadata[], query: string): ResourceMetadata[] {
|
||||
if (!query) return resources;
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return [...resources].sort((a, b) => {
|
||||
const scoreA = getResourceMatchScore(a, lowerQuery);
|
||||
const scoreB = getResourceMatchScore(b, lowerQuery);
|
||||
|
||||
// Sort by score first (higher score first)
|
||||
if (scoreA !== scoreB) {
|
||||
return scoreB - scoreA;
|
||||
}
|
||||
|
||||
// If scores are equal, sort alphabetically by name
|
||||
const aName = (a.name || '').toLowerCase();
|
||||
const bName = (b.name || '').toLowerCase();
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component - wrapped with React.memo below
|
||||
*/
|
||||
const ResourceAutocompleteInner = forwardRef<ResourceAutocompleteHandle, ResourceAutocompleteProps>(
|
||||
function ResourceAutocomplete(
|
||||
{ isVisible, searchQuery, onSelectResource, onLoadIntoInput, onClose, agent },
|
||||
ref
|
||||
) {
|
||||
const [resources, setResources] = useState<ResourceMetadata[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// Combined state to guarantee single render on navigation
|
||||
const [selection, setSelection] = useState({ index: 0, offset: 0 });
|
||||
const selectedIndexRef = useRef(0);
|
||||
const MAX_VISIBLE_ITEMS = 5;
|
||||
|
||||
// Update selection AND scroll offset in a single state update
|
||||
// This guarantees exactly one render per navigation action
|
||||
const updateSelection = useCallback(
|
||||
(indexUpdater: number | ((prev: number) => number)) => {
|
||||
setSelection((prev) => {
|
||||
const newIndex =
|
||||
typeof indexUpdater === 'function'
|
||||
? indexUpdater(prev.index)
|
||||
: indexUpdater;
|
||||
selectedIndexRef.current = newIndex;
|
||||
|
||||
// Calculate new scroll offset
|
||||
let newOffset = prev.offset;
|
||||
if (newIndex < prev.offset) {
|
||||
newOffset = newIndex;
|
||||
} else if (newIndex >= prev.offset + MAX_VISIBLE_ITEMS) {
|
||||
newOffset = Math.max(0, newIndex - MAX_VISIBLE_ITEMS + 1);
|
||||
}
|
||||
|
||||
return { index: newIndex, offset: newOffset };
|
||||
});
|
||||
},
|
||||
[MAX_VISIBLE_ITEMS]
|
||||
);
|
||||
|
||||
// Fetch resources from agent
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
const fetchResources = async () => {
|
||||
try {
|
||||
const resourceSet = await agent.listResources();
|
||||
const resourceList: ResourceMetadata[] = Object.values(resourceSet);
|
||||
if (!cancelled) {
|
||||
setResources(resourceList);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
// Silently fail - don't use console.error as it interferes with Ink rendering
|
||||
setResources([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void fetchResources();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVisible, agent]);
|
||||
|
||||
// NOTE: Auto-close logic is handled synchronously in TextBufferInput.tsx
|
||||
// (on backspace deleting @ and on space after @). We don't use useEffect here
|
||||
// because React batches state updates, causing race conditions where isVisible
|
||||
// and searchQuery update at different times.
|
||||
|
||||
// Extract query from @mention (everything after @)
|
||||
const mentionQuery = useMemo(() => {
|
||||
// Find the last @ that's at start or after space
|
||||
const atIndex = searchQuery.lastIndexOf('@');
|
||||
if (atIndex === -1) return '';
|
||||
|
||||
// Check if @ is at start or preceded by space
|
||||
const prevChar = searchQuery[atIndex - 1];
|
||||
if (atIndex === 0 || (prevChar && /\s/.test(prevChar))) {
|
||||
return searchQuery.slice(atIndex + 1).trim();
|
||||
}
|
||||
return '';
|
||||
}, [searchQuery]);
|
||||
|
||||
// Filter and sort resources (no limit - scrolling handles it)
|
||||
const filteredResources = useMemo(() => {
|
||||
const matched = resources.filter((r) => matchesQuery(r, mentionQuery));
|
||||
return sortResources(matched, mentionQuery);
|
||||
}, [resources, mentionQuery]);
|
||||
|
||||
// Track items length for reset detection
|
||||
const prevItemsLengthRef = useRef(filteredResources.length);
|
||||
const itemsChanged = filteredResources.length !== prevItemsLengthRef.current;
|
||||
|
||||
// Derive clamped selection values during render (always valid, no setState needed)
|
||||
// This prevents the double-render that was causing flickering
|
||||
const selectedIndex = itemsChanged
|
||||
? 0
|
||||
: Math.min(selection.index, Math.max(0, filteredResources.length - 1));
|
||||
const scrollOffset = itemsChanged
|
||||
? 0
|
||||
: Math.min(selection.offset, Math.max(0, filteredResources.length - MAX_VISIBLE_ITEMS));
|
||||
|
||||
// Sync state only when items actually changed AND state differs
|
||||
// This effect runs AFTER render, updating state for next user interaction
|
||||
useEffect(() => {
|
||||
if (itemsChanged) {
|
||||
prevItemsLengthRef.current = filteredResources.length;
|
||||
// Only setState if values actually differ (prevents unnecessary re-render)
|
||||
if (selection.index !== 0 || selection.offset !== 0) {
|
||||
selectedIndexRef.current = 0;
|
||||
setSelection({ index: 0, offset: 0 });
|
||||
} else {
|
||||
selectedIndexRef.current = 0;
|
||||
}
|
||||
}
|
||||
}, [itemsChanged, filteredResources.length, selection.index, selection.offset]);
|
||||
|
||||
// Calculate visible items based on scroll offset
|
||||
const visibleResources = useMemo(() => {
|
||||
return filteredResources.slice(scrollOffset, scrollOffset + MAX_VISIBLE_ITEMS);
|
||||
}, [filteredResources, scrollOffset, MAX_VISIBLE_ITEMS]);
|
||||
|
||||
// Expose handleInput method via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
handleInput: (_input: string, key: Key): boolean => {
|
||||
if (!isVisible) return false;
|
||||
|
||||
// Escape always closes, regardless of item count
|
||||
if (key.escape) {
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
const itemsLength = filteredResources.length;
|
||||
if (itemsLength === 0) return false;
|
||||
|
||||
if (key.upArrow) {
|
||||
updateSelection((prev) => (prev - 1 + itemsLength) % itemsLength);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
updateSelection((prev) => (prev + 1) % itemsLength);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tab to load into input (for editing before selection)
|
||||
if (key.tab) {
|
||||
const resource = filteredResources[selectedIndexRef.current];
|
||||
if (!resource) return false;
|
||||
|
||||
// Get the @ position and construct the text to load
|
||||
const atIndex = searchQuery.lastIndexOf('@');
|
||||
if (atIndex >= 0) {
|
||||
const before = searchQuery.slice(0, atIndex + 1);
|
||||
const uriParts = resource.uri.split('/');
|
||||
const reference =
|
||||
resource.name || uriParts[uriParts.length - 1] || resource.uri;
|
||||
onLoadIntoInput?.(`${before}${reference}`);
|
||||
} else {
|
||||
// Fallback: just append @resource
|
||||
const uriParts = resource.uri.split('/');
|
||||
const reference =
|
||||
resource.name || uriParts[uriParts.length - 1] || resource.uri;
|
||||
onLoadIntoInput?.(`${searchQuery}@${reference}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Enter to select
|
||||
if (key.return) {
|
||||
const resource = filteredResources[selectedIndexRef.current];
|
||||
if (resource) {
|
||||
onSelectResource(resource);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't consume other keys (typing, backspace, etc.)
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
[
|
||||
isVisible,
|
||||
filteredResources,
|
||||
selectedIndexRef,
|
||||
searchQuery,
|
||||
onClose,
|
||||
onLoadIntoInput,
|
||||
onSelectResource,
|
||||
updateSelection,
|
||||
]
|
||||
);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box paddingX={0} paddingY={0}>
|
||||
<Text color="gray">Loading resources...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredResources.length === 0) {
|
||||
return (
|
||||
<Box paddingX={0} paddingY={0}>
|
||||
<Text color="gray">
|
||||
{mentionQuery
|
||||
? `No resources match "${mentionQuery}"`
|
||||
: 'No resources available. Connect an MCP server or enable internal resources.'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const totalItems = filteredResources.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={0} paddingY={0}>
|
||||
<Text color="yellowBright" bold>
|
||||
Resources ({selectedIndex + 1}/{totalItems}) - ↑↓ navigate, Tab load, Enter
|
||||
select, Esc close
|
||||
</Text>
|
||||
</Box>
|
||||
{visibleResources.map((resource, visibleIndex) => {
|
||||
const actualIndex = scrollOffset + visibleIndex;
|
||||
const isSelected = actualIndex === selectedIndex;
|
||||
const uriParts = resource.uri.split('/');
|
||||
const displayName =
|
||||
resource.name || uriParts[uriParts.length - 1] || resource.uri;
|
||||
const isImage = (resource.mimeType || '').startsWith('image/');
|
||||
|
||||
// Truncate URI for display (show last 40 chars with ellipsis)
|
||||
const truncatedUri =
|
||||
resource.uri.length > 50 ? '…' + resource.uri.slice(-49) : resource.uri;
|
||||
|
||||
return (
|
||||
<Box key={resource.uri}>
|
||||
{isImage && <Text color={isSelected ? 'cyan' : 'gray'}>🖼️ </Text>}
|
||||
<Text color={isSelected ? 'cyan' : 'white'} bold={isSelected}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{resource.serverName && (
|
||||
<Text color="gray"> [{resource.serverName}]</Text>
|
||||
)}
|
||||
<Text color="gray"> {truncatedUri}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Export with React.memo to prevent unnecessary re-renders from parent
|
||||
* Only re-renders when props actually change (shallow comparison)
|
||||
*/
|
||||
const ResourceAutocomplete = React.memo(
|
||||
ResourceAutocompleteInner
|
||||
) as typeof ResourceAutocompleteInner;
|
||||
|
||||
export default ResourceAutocomplete;
|
||||
@@ -0,0 +1,589 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import type { Key } from '../hooks/useInputOrchestrator.js';
|
||||
import type { PromptInfo } from '@dexto/core';
|
||||
import type { DextoAgent } from '@dexto/core';
|
||||
import { getAllCommands } from '../../commands/interactive-commands/commands.js';
|
||||
import type { CommandDefinition } from '../../commands/interactive-commands/command-parser.js';
|
||||
|
||||
export interface SlashCommandAutocompleteHandle {
|
||||
handleInput: (input: string, key: Key) => boolean;
|
||||
}
|
||||
|
||||
interface SlashCommandAutocompleteProps {
|
||||
isVisible: boolean;
|
||||
searchQuery: string;
|
||||
onSelectPrompt: (prompt: PromptInfo) => void;
|
||||
onSelectSystemCommand?: (command: string) => void;
|
||||
onLoadIntoInput?: (command: string) => void; // For Tab - loads command into input
|
||||
onSubmitRaw?: ((text: string) => Promise<void> | void) | undefined; // For Enter with no matches - submit raw text
|
||||
onClose: () => void;
|
||||
agent: DextoAgent;
|
||||
}
|
||||
|
||||
interface PromptItem extends PromptInfo {
|
||||
kind: 'prompt';
|
||||
}
|
||||
|
||||
interface SystemCommandItem {
|
||||
kind: 'system';
|
||||
name: string;
|
||||
description: string;
|
||||
category?: string;
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get match score for prompt: 0 = no match, 1 = description/title match, 2 = name includes, 3 = name starts with
|
||||
*/
|
||||
function getPromptMatchScore(prompt: PromptInfo, query: string): number {
|
||||
if (!query) return 3; // Show all when no query
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const name = prompt.name.toLowerCase();
|
||||
const description = (prompt.description || '').toLowerCase();
|
||||
const title = (prompt.title || '').toLowerCase();
|
||||
|
||||
// Highest priority: name starts with query
|
||||
if (name.startsWith(lowerQuery)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
// Second priority: name includes query
|
||||
if (name.includes(lowerQuery)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Lowest priority: description or title includes query
|
||||
if (description.includes(lowerQuery) || title.includes(lowerQuery)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0; // No match
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if prompt matches query (for filtering)
|
||||
*/
|
||||
function matchesPromptQuery(prompt: PromptInfo, query: string): boolean {
|
||||
return getPromptMatchScore(prompt, query) > 0;
|
||||
}
|
||||
|
||||
type CommandMatchCandidate = Pick<CommandDefinition, 'name' | 'description' | 'aliases'>;
|
||||
|
||||
/**
|
||||
* Simple fuzzy match - checks if query matches system command name or description
|
||||
* Returns a score: 0 = no match, 1 = description match, 2 = alias match, 3 = name includes, 4 = name starts with
|
||||
*/
|
||||
function getSystemCommandMatchScore(cmd: CommandMatchCandidate, query: string): number {
|
||||
if (!query) return 4; // Show all when no query
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const name = cmd.name.toLowerCase();
|
||||
const description = (cmd.description || '').toLowerCase();
|
||||
|
||||
// Highest priority: name starts with query
|
||||
if (name.startsWith(lowerQuery)) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
// Second priority: name includes query
|
||||
if (name.includes(lowerQuery)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
// Third priority: aliases match
|
||||
if (cmd.aliases) {
|
||||
for (const alias of cmd.aliases) {
|
||||
const lowerAlias = alias.toLowerCase();
|
||||
if (lowerAlias.startsWith(lowerQuery)) {
|
||||
return 2;
|
||||
}
|
||||
if (lowerAlias.includes(lowerQuery)) {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lowest priority: description includes query
|
||||
if (description.includes(lowerQuery)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0; // No match
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if command matches query (for filtering)
|
||||
*/
|
||||
function matchesSystemCommandQuery(cmd: CommandMatchCandidate, query: string): boolean {
|
||||
return getSystemCommandMatchScore(cmd, query) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to fit within maxLength, adding ellipsis if truncated
|
||||
*/
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
if (maxLength <= 3) return text.slice(0, maxLength);
|
||||
return text.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component - wrapped with React.memo below
|
||||
*/
|
||||
const SlashCommandAutocompleteInner = forwardRef<
|
||||
SlashCommandAutocompleteHandle,
|
||||
SlashCommandAutocompleteProps
|
||||
>(function SlashCommandAutocomplete(
|
||||
{
|
||||
isVisible,
|
||||
searchQuery,
|
||||
onSelectPrompt,
|
||||
onSelectSystemCommand,
|
||||
onLoadIntoInput,
|
||||
onSubmitRaw,
|
||||
onClose,
|
||||
agent,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const [prompts, setPrompts] = useState<PromptItem[]>([]);
|
||||
const [systemCommands, setSystemCommands] = useState<SystemCommandItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// Combined state to guarantee single render on navigation
|
||||
const [selection, setSelection] = useState({ index: 0, offset: 0 });
|
||||
const selectedIndexRef = useRef(0);
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = stdout?.columns || 80;
|
||||
const MAX_VISIBLE_ITEMS = 8;
|
||||
|
||||
// Update selection AND scroll offset in a single state update
|
||||
// This guarantees exactly one render per navigation action
|
||||
const updateSelection = useCallback(
|
||||
(indexUpdater: number | ((prev: number) => number)) => {
|
||||
setSelection((prev) => {
|
||||
const newIndex =
|
||||
typeof indexUpdater === 'function' ? indexUpdater(prev.index) : indexUpdater;
|
||||
selectedIndexRef.current = newIndex;
|
||||
|
||||
// Calculate new scroll offset
|
||||
let newOffset = prev.offset;
|
||||
if (newIndex < prev.offset) {
|
||||
newOffset = newIndex;
|
||||
} else if (newIndex >= prev.offset + MAX_VISIBLE_ITEMS) {
|
||||
newOffset = Math.max(0, newIndex - MAX_VISIBLE_ITEMS + 1);
|
||||
}
|
||||
|
||||
return { index: newIndex, offset: newOffset };
|
||||
});
|
||||
},
|
||||
[MAX_VISIBLE_ITEMS]
|
||||
);
|
||||
|
||||
// Fetch prompts and system commands from agent
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
const fetchCommands = async () => {
|
||||
try {
|
||||
// Fetch prompts
|
||||
const promptSet = await agent.listPrompts();
|
||||
const promptList: PromptItem[] = Object.values(promptSet).map((p) => ({
|
||||
...p,
|
||||
kind: 'prompt' as const,
|
||||
}));
|
||||
|
||||
// Fetch system commands
|
||||
const allCommands = getAllCommands();
|
||||
const commandList: SystemCommandItem[] = allCommands.map((cmd) => ({
|
||||
kind: 'system' as const,
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
...(cmd.category && { category: cmd.category }),
|
||||
...(cmd.aliases && { aliases: cmd.aliases }),
|
||||
}));
|
||||
|
||||
if (!cancelled) {
|
||||
setPrompts(promptList);
|
||||
setSystemCommands(commandList);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
// Silently fail - don't use console.error as it interferes with Ink rendering
|
||||
setPrompts([]);
|
||||
setSystemCommands([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void fetchCommands();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVisible, agent]);
|
||||
|
||||
// Extract command name from search query (only the first word after /)
|
||||
const commandQuery = useMemo(() => {
|
||||
if (!searchQuery.startsWith('/')) return '';
|
||||
const afterSlash = searchQuery.slice(1).trim();
|
||||
// Only take the first word (command name), not the arguments
|
||||
const spaceIndex = afterSlash.indexOf(' ');
|
||||
return spaceIndex > 0 ? afterSlash.slice(0, spaceIndex) : afterSlash;
|
||||
}, [searchQuery]);
|
||||
|
||||
// Filter prompts and system commands based on query
|
||||
const filteredPrompts = useMemo(() => {
|
||||
if (!commandQuery) {
|
||||
return prompts;
|
||||
}
|
||||
// Filter and sort by match score (highest first)
|
||||
return prompts
|
||||
.filter((p) => matchesPromptQuery(p, commandQuery))
|
||||
.sort((a, b) => {
|
||||
const scoreA = getPromptMatchScore(a, commandQuery);
|
||||
const scoreB = getPromptMatchScore(b, commandQuery);
|
||||
return scoreB - scoreA; // Higher score first
|
||||
});
|
||||
}, [prompts, commandQuery]);
|
||||
|
||||
const filteredSystemCommands = useMemo(() => {
|
||||
if (!commandQuery) {
|
||||
return systemCommands;
|
||||
}
|
||||
// Filter and sort by match score (highest first)
|
||||
return systemCommands
|
||||
.filter((cmd) => matchesSystemCommandQuery(cmd, commandQuery))
|
||||
.sort((a, b) => {
|
||||
const scoreA = getSystemCommandMatchScore(a, commandQuery);
|
||||
const scoreB = getSystemCommandMatchScore(b, commandQuery);
|
||||
return scoreB - scoreA; // Higher score first
|
||||
});
|
||||
}, [systemCommands, commandQuery]);
|
||||
|
||||
// Check if user has started typing arguments (hide autocomplete)
|
||||
const hasArguments = useMemo(() => {
|
||||
if (!searchQuery.startsWith('/')) return false;
|
||||
const afterSlash = searchQuery.slice(1).trim();
|
||||
return afterSlash.includes(' ');
|
||||
}, [searchQuery]);
|
||||
|
||||
// Combine items: system commands first, then prompts
|
||||
// When typing arguments, show only exact matches (for argument hints)
|
||||
const combinedItems = useMemo(() => {
|
||||
const items: Array<
|
||||
{ kind: 'system'; command: SystemCommandItem } | { kind: 'prompt'; prompt: PromptItem }
|
||||
> = [];
|
||||
|
||||
if (hasArguments) {
|
||||
// When typing arguments, only show exact match for the command
|
||||
const exactSystemCmd = systemCommands.find(
|
||||
(cmd) => cmd.name.toLowerCase() === commandQuery.toLowerCase()
|
||||
);
|
||||
if (exactSystemCmd) {
|
||||
items.push({ kind: 'system', command: exactSystemCmd });
|
||||
}
|
||||
const exactPrompt = prompts.find(
|
||||
(p) =>
|
||||
p.name.toLowerCase() === commandQuery.toLowerCase() ||
|
||||
(p.displayName && p.displayName.toLowerCase() === commandQuery.toLowerCase())
|
||||
);
|
||||
if (exactPrompt) {
|
||||
items.push({ kind: 'prompt', prompt: exactPrompt });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// System commands first (they're more commonly used)
|
||||
filteredSystemCommands.forEach((cmd) => items.push({ kind: 'system', command: cmd }));
|
||||
|
||||
// Then prompts
|
||||
filteredPrompts.forEach((prompt) => items.push({ kind: 'prompt', prompt }));
|
||||
|
||||
return items;
|
||||
}, [
|
||||
hasArguments,
|
||||
filteredPrompts,
|
||||
filteredSystemCommands,
|
||||
systemCommands,
|
||||
prompts,
|
||||
commandQuery,
|
||||
]);
|
||||
|
||||
// Get stable identity for first item (used to detect content changes)
|
||||
const getFirstItemId = (): string | null => {
|
||||
const first = combinedItems[0];
|
||||
if (!first) return null;
|
||||
if (first.kind === 'system') return `sys:${first.command.name}`;
|
||||
return `prompt:${first.prompt.name}`;
|
||||
};
|
||||
|
||||
// Track items for reset detection (length + first item identity)
|
||||
const currentFirstId = getFirstItemId();
|
||||
const prevItemsRef = useRef({ length: combinedItems.length, firstId: currentFirstId });
|
||||
const itemsChanged =
|
||||
combinedItems.length !== prevItemsRef.current.length ||
|
||||
currentFirstId !== prevItemsRef.current.firstId;
|
||||
|
||||
// Derive clamped selection values during render (always valid, no setState needed)
|
||||
// This prevents the double-render that was causing flickering
|
||||
const selectedIndex = itemsChanged
|
||||
? 0
|
||||
: Math.min(selection.index, Math.max(0, combinedItems.length - 1));
|
||||
const scrollOffset = itemsChanged
|
||||
? 0
|
||||
: Math.min(selection.offset, Math.max(0, combinedItems.length - MAX_VISIBLE_ITEMS));
|
||||
|
||||
// Sync state only when items actually changed AND state differs
|
||||
// This effect runs AFTER render, updating state for next user interaction
|
||||
useEffect(() => {
|
||||
if (itemsChanged) {
|
||||
prevItemsRef.current = { length: combinedItems.length, firstId: currentFirstId };
|
||||
// Only setState if values actually differ (prevents unnecessary re-render)
|
||||
if (selection.index !== 0 || selection.offset !== 0) {
|
||||
selectedIndexRef.current = 0;
|
||||
setSelection({ index: 0, offset: 0 });
|
||||
} else {
|
||||
selectedIndexRef.current = 0;
|
||||
}
|
||||
}
|
||||
}, [itemsChanged, combinedItems.length, currentFirstId, selection.index, selection.offset]);
|
||||
|
||||
// Calculate visible items based on scroll offset
|
||||
const visibleItems = useMemo(() => {
|
||||
return combinedItems.slice(scrollOffset, scrollOffset + MAX_VISIBLE_ITEMS);
|
||||
}, [combinedItems, scrollOffset, MAX_VISIBLE_ITEMS]);
|
||||
|
||||
// Expose handleInput method via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
handleInput: (input: string, key: Key): boolean => {
|
||||
if (!isVisible) return false;
|
||||
|
||||
// Escape always closes, regardless of item count
|
||||
if (key.escape) {
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
const itemsLength = combinedItems.length;
|
||||
|
||||
// Handle Enter when no matches or typing arguments
|
||||
// Submit raw text directly (main input won't handle it since overlay is active)
|
||||
if (itemsLength === 0 || hasArguments) {
|
||||
if (key.return) {
|
||||
void Promise.resolve(onSubmitRaw?.(searchQuery)).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
agent.logger.error(
|
||||
`SlashCommandAutocomplete: Failed to submit raw command: ${message}`
|
||||
);
|
||||
});
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
// Let other keys (typing, backspace) fall through
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
updateSelection((prev) => (prev - 1 + itemsLength) % itemsLength);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
updateSelection((prev) => (prev + 1) % itemsLength);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tab: For interactive commands (model, resume, switch), execute them like Enter
|
||||
// For other commands, load into input for editing
|
||||
if (key.tab) {
|
||||
const item = combinedItems[selectedIndexRef.current];
|
||||
if (!item) return false;
|
||||
|
||||
// Check if this is an interactive command that should be executed
|
||||
const interactiveCommands = ['model', 'resume', 'switch'];
|
||||
const isInteractiveCommand =
|
||||
item.kind === 'system' && interactiveCommands.includes(item.command.name);
|
||||
|
||||
if (isInteractiveCommand && item.kind === 'system') {
|
||||
// Execute interactive command (same as Enter)
|
||||
onSelectSystemCommand?.(item.command.name);
|
||||
} else if (item.kind === 'system') {
|
||||
// Load system command into input
|
||||
onLoadIntoInput?.(`/${item.command.name}`);
|
||||
} else {
|
||||
// Load prompt command into input using pre-computed commandName
|
||||
// commandName is collision-resolved by PromptManager (e.g., "plan" or "config:plan")
|
||||
const cmdName =
|
||||
item.prompt.commandName || item.prompt.displayName || item.prompt.name;
|
||||
const argsString =
|
||||
item.prompt.arguments && item.prompt.arguments.length > 0
|
||||
? ' ' +
|
||||
item.prompt.arguments
|
||||
.map((arg) => `<${arg.name}${arg.required ? '' : '?'}>`)
|
||||
.join(' ')
|
||||
: '';
|
||||
onLoadIntoInput?.(`/${cmdName}${argsString}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Enter: Execute the highlighted command/prompt and close overlay
|
||||
if (key.return) {
|
||||
const item = combinedItems[selectedIndexRef.current];
|
||||
if (!item) return false;
|
||||
if (item.kind === 'system') {
|
||||
onSelectSystemCommand?.(item.command.name);
|
||||
} else {
|
||||
onSelectPrompt(item.prompt);
|
||||
}
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Don't consume other keys (typing, backspace, etc.)
|
||||
// Let them fall through to the input handler
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
[
|
||||
isVisible,
|
||||
combinedItems,
|
||||
hasArguments,
|
||||
selectedIndexRef,
|
||||
searchQuery,
|
||||
onClose,
|
||||
onLoadIntoInput,
|
||||
onSubmitRaw,
|
||||
onSelectPrompt,
|
||||
onSelectSystemCommand,
|
||||
updateSelection,
|
||||
agent,
|
||||
]
|
||||
);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
// Show loading state while fetching commands
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box width={terminalWidth} paddingX={0} paddingY={0}>
|
||||
<Text color="gray">Loading commands...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// If no items after loading, don't render
|
||||
if (combinedItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalItems = combinedItems.length;
|
||||
|
||||
// Show simplified header when user is typing arguments
|
||||
const headerText = hasArguments
|
||||
? 'Press Enter to execute'
|
||||
: `Commands (${selectedIndex + 1}/${totalItems}) - ↑↓ navigate, Tab load, Enter execute, Esc close`;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={terminalWidth}>
|
||||
<Box paddingX={0} paddingY={0}>
|
||||
<Text color="purple" bold>
|
||||
{headerText}
|
||||
</Text>
|
||||
</Box>
|
||||
{visibleItems.map((item, visibleIndex) => {
|
||||
const actualIndex = scrollOffset + visibleIndex;
|
||||
const isSelected = actualIndex === selectedIndex;
|
||||
|
||||
if (item.kind === 'system') {
|
||||
const cmd = item.command;
|
||||
const nameText = `/${cmd.name}`;
|
||||
const categoryText = cmd.category ? ` (${cmd.category})` : '';
|
||||
const descText = cmd.description || '';
|
||||
|
||||
// Two-line layout:
|
||||
// Line 1: /command-name
|
||||
// Line 2: Description text (category)
|
||||
return (
|
||||
<Box key={`system-${cmd.name}`} flexDirection="column" paddingX={0}>
|
||||
<Text color={isSelected ? 'cyan' : 'gray'} bold={isSelected}>
|
||||
{nameText}
|
||||
</Text>
|
||||
<Text color={isSelected ? 'white' : 'gray'}>
|
||||
{' '}
|
||||
{descText}
|
||||
{categoryText}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Prompt command (MCP prompts)
|
||||
const prompt = item.prompt;
|
||||
// Use displayName for user-friendly display, fall back to full name
|
||||
const displayName = prompt.displayName || prompt.name;
|
||||
// Check if there's a collision (commandName includes source prefix)
|
||||
const hasCollision = prompt.commandName && prompt.commandName !== displayName;
|
||||
const nameText = `/${displayName}`;
|
||||
const argsString =
|
||||
prompt.arguments && prompt.arguments.length > 0
|
||||
? ' ' +
|
||||
prompt.arguments
|
||||
.map((arg) => `<${arg.name}${arg.required ? '' : '?'}>`)
|
||||
.join(' ')
|
||||
: '';
|
||||
const description = prompt.title || prompt.description || '';
|
||||
|
||||
// Two-line layout:
|
||||
// Line 1: /command-name <args>
|
||||
// Line 2: Description text (source)
|
||||
const commandText = nameText + argsString;
|
||||
// Show source as label, with collision indicator if needed
|
||||
// For plugin skills, show namespace (plugin name) instead of "config"
|
||||
const metadata = prompt.metadata as Record<string, unknown> | undefined;
|
||||
const displaySource = metadata?.namespace
|
||||
? String(metadata.namespace)
|
||||
: prompt.source || 'prompt';
|
||||
const sourceLabel = hasCollision
|
||||
? `${displaySource} - use /${prompt.commandName}`
|
||||
: displaySource;
|
||||
|
||||
return (
|
||||
<Box key={`prompt-${prompt.name}`} flexDirection="column" paddingX={0}>
|
||||
<Text color={isSelected ? 'cyan' : 'gray'} bold={isSelected}>
|
||||
{commandText}
|
||||
</Text>
|
||||
<Text color={isSelected ? 'white' : 'gray'}>
|
||||
{' '}
|
||||
{description}
|
||||
{` (${sourceLabel})`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Export with React.memo to prevent unnecessary re-renders from parent
|
||||
* Only re-renders when props actually change (shallow comparison)
|
||||
*/
|
||||
export const SlashCommandAutocomplete = React.memo(
|
||||
SlashCommandAutocompleteInner
|
||||
) as typeof SlashCommandAutocompleteInner;
|
||||
176
dexto/packages/cli/src/cli/ink-cli/components/StatusBar.tsx
Normal file
176
dexto/packages/cli/src/cli/ink-cli/components/StatusBar.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* StatusBar Component
|
||||
* Displays processing status and controls above the input area
|
||||
*
|
||||
* Layout:
|
||||
* - Line 1: Spinner + phrase (+ queue count if any)
|
||||
* - Line 2: Meta info (time, tokens, cancel hint)
|
||||
* This 2-line layout prevents truncation on any terminal width.
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import type { DextoAgent } from '@dexto/core';
|
||||
import { usePhraseCycler } from '../hooks/usePhraseCycler.js';
|
||||
import { useElapsedTime } from '../hooks/useElapsedTime.js';
|
||||
import { useTokenCounter } from '../hooks/useTokenCounter.js';
|
||||
|
||||
interface StatusBarProps {
|
||||
agent: DextoAgent;
|
||||
isProcessing: boolean;
|
||||
isThinking: boolean;
|
||||
isCompacting: boolean;
|
||||
approvalQueueCount: number;
|
||||
copyModeEnabled?: boolean;
|
||||
/** Whether an approval prompt is currently shown */
|
||||
isAwaitingApproval?: boolean;
|
||||
/** Whether the todo list is expanded */
|
||||
todoExpanded?: boolean;
|
||||
/** Whether there are todos to display */
|
||||
hasTodos?: boolean;
|
||||
/** Whether plan mode is active */
|
||||
planModeActive?: boolean;
|
||||
/** Whether accept all edits mode is active */
|
||||
autoApproveEdits?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status bar that shows processing state above input area
|
||||
* Provides clear feedback on whether the agent is running or idle
|
||||
*
|
||||
* Design decisions:
|
||||
* - Hide spinner during approval wait (user is reviewing, not waiting)
|
||||
* - Only show elapsed time after 30s (avoid visual noise for fast operations)
|
||||
*/
|
||||
export function StatusBar({
|
||||
agent,
|
||||
isProcessing,
|
||||
isThinking,
|
||||
isCompacting,
|
||||
approvalQueueCount,
|
||||
copyModeEnabled = false,
|
||||
isAwaitingApproval = false,
|
||||
todoExpanded = true,
|
||||
hasTodos = false,
|
||||
planModeActive = false,
|
||||
autoApproveEdits = false,
|
||||
}: StatusBarProps) {
|
||||
// Cycle through witty phrases while processing (not during compacting)
|
||||
const { phrase } = usePhraseCycler({ isActive: isProcessing && !isCompacting });
|
||||
// Track elapsed time during processing
|
||||
const { formatted: elapsedTime, elapsedMs } = useElapsedTime({ isActive: isProcessing });
|
||||
// Track token usage during processing
|
||||
const { formatted: tokenCount } = useTokenCounter({ agent, isActive: isProcessing });
|
||||
// Only show time after 30 seconds
|
||||
const showTime = elapsedMs >= 30000;
|
||||
|
||||
// Show copy mode warning (highest priority)
|
||||
if (copyModeEnabled) {
|
||||
return (
|
||||
<Box paddingX={1} marginBottom={0}>
|
||||
<Text color="yellowBright" bold>
|
||||
📋 Copy Mode - Select text with mouse. Press any key to exit.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isProcessing) {
|
||||
// Mode indicators (plan mode, accept edits) are shown in Footer, not here
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hide status bar during approval wait - user is reviewing, not waiting
|
||||
if (isAwaitingApproval) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build the task toggle hint based on state
|
||||
const todoHint = hasTodos
|
||||
? todoExpanded
|
||||
? 'ctrl+t to hide tasks'
|
||||
: 'ctrl+t to show tasks'
|
||||
: null;
|
||||
|
||||
// Show compacting state - yellow/orange color to indicate context management
|
||||
if (isCompacting) {
|
||||
const metaParts: string[] = [];
|
||||
if (showTime) metaParts.push(`(${elapsedTime})`);
|
||||
metaParts.push('Esc to cancel');
|
||||
if (todoHint) metaParts.push(todoHint);
|
||||
const metaContent = metaParts.join(' • ');
|
||||
|
||||
return (
|
||||
<Box paddingX={1} marginTop={1} flexDirection="column">
|
||||
{/* Line 1: spinner + compacting message */}
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
<Text color="yellow">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="yellow"> 📦 Compacting context...</Text>
|
||||
</Box>
|
||||
{/* Line 2: meta info */}
|
||||
<Box marginLeft={2}>
|
||||
<Text color="gray">{metaContent}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show initial processing state (before streaming starts) - green/teal color
|
||||
// TODO: Rename this event/state to "reasoning" and associate it with actual reasoning tokens
|
||||
// Currently "thinking" event fires before any response, not during reasoning token generation
|
||||
if (isThinking) {
|
||||
const metaParts: string[] = [];
|
||||
if (showTime) metaParts.push(`(${elapsedTime})`);
|
||||
if (tokenCount) metaParts.push(tokenCount);
|
||||
metaParts.push('Esc to cancel');
|
||||
if (todoHint) metaParts.push(todoHint);
|
||||
const metaContent = metaParts.join(' • ');
|
||||
|
||||
return (
|
||||
<Box paddingX={1} marginTop={1} flexDirection="column">
|
||||
{/* Line 1: spinner + phrase */}
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
<Text color="green">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="green"> {phrase}</Text>
|
||||
</Box>
|
||||
{/* Line 2: meta info */}
|
||||
<Box marginLeft={2}>
|
||||
<Text color="gray">{metaContent}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show active streaming state - green/teal color
|
||||
// Always use 2-line layout: phrase on first line, meta on second
|
||||
// This prevents truncation and messy wrapping on any terminal width
|
||||
const metaParts: string[] = [];
|
||||
if (showTime) metaParts.push(`(${elapsedTime})`);
|
||||
if (tokenCount) metaParts.push(tokenCount);
|
||||
metaParts.push('Esc to cancel');
|
||||
if (todoHint) metaParts.push(todoHint);
|
||||
const metaContent = metaParts.join(' • ');
|
||||
|
||||
return (
|
||||
<Box paddingX={1} marginTop={1} flexDirection="column">
|
||||
{/* Line 1: spinner + phrase + queue count */}
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
<Text color="green">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="green"> {phrase}</Text>
|
||||
{approvalQueueCount > 0 && (
|
||||
<Text color="yellowBright"> • {approvalQueueCount} queued</Text>
|
||||
)}
|
||||
</Box>
|
||||
{/* Line 2: meta info (time, tokens, cancel hint) */}
|
||||
<Box marginLeft={2}>
|
||||
<Text color="gray">{metaContent}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
/**
|
||||
* TextBufferInput Component
|
||||
*
|
||||
* Buffer is passed as prop from parent.
|
||||
* Uses direct useKeypress for input handling (no ref chain).
|
||||
* Parent owns the buffer and can read values directly.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useEffect } from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import type { PendingImage, PastedBlock } from '../state/types.js';
|
||||
import { readClipboardImage } from '../utils/clipboardUtils.js';
|
||||
|
||||
/** Overlay trigger types for event-driven overlay detection */
|
||||
export type OverlayTrigger = 'slash-autocomplete' | 'resource-autocomplete' | 'close';
|
||||
|
||||
/** Threshold for collapsing pasted content */
|
||||
const PASTE_COLLAPSE_LINE_THRESHOLD = 3;
|
||||
const PASTE_COLLAPSE_CHAR_THRESHOLD = 150;
|
||||
|
||||
/** Platform-aware keyboard shortcut labels */
|
||||
const isMac = process.platform === 'darwin';
|
||||
const KEY_LABELS = {
|
||||
ctrlT: isMac ? '⌃T' : 'Ctrl+T',
|
||||
altUp: isMac ? '⌥↑' : 'Alt+Up',
|
||||
altDown: isMac ? '⌥↓' : 'Alt+Down',
|
||||
};
|
||||
|
||||
interface TextBufferInputProps {
|
||||
/** Text buffer (owned by parent) */
|
||||
buffer: TextBuffer;
|
||||
/** Called when user presses Enter to submit */
|
||||
onSubmit: (value: string) => void;
|
||||
/** Placeholder text when empty */
|
||||
placeholder?: string | undefined;
|
||||
/** Whether input handling is disabled (e.g., during processing) */
|
||||
isDisabled?: boolean | undefined;
|
||||
/** Called for history navigation (up/down at boundaries) */
|
||||
onHistoryNavigate?: ((direction: 'up' | 'down') => void) | undefined;
|
||||
/** Called to trigger overlay (slash command, @mention) */
|
||||
onTriggerOverlay?: ((trigger: OverlayTrigger) => void) | undefined;
|
||||
/** Maximum lines to show in viewport */
|
||||
maxViewportLines?: number | undefined;
|
||||
/** Whether this input should handle keypresses */
|
||||
isActive: boolean;
|
||||
/** Optional handler for keyboard scroll (PageUp/PageDown, Shift+arrows) */
|
||||
onKeyboardScroll?: ((direction: 'up' | 'down') => void) | undefined;
|
||||
/** Current number of attached images (for placeholder numbering) */
|
||||
imageCount?: number | undefined;
|
||||
/** Called when image is pasted from clipboard */
|
||||
onImagePaste?: ((image: PendingImage) => void) | undefined;
|
||||
/** Current pending images (for placeholder removal detection) */
|
||||
images?: PendingImage[] | undefined;
|
||||
/** Called when an image placeholder is removed from text */
|
||||
onImageRemove?: ((imageId: string) => void) | undefined;
|
||||
/** Current pasted blocks for collapse/expand feature */
|
||||
pastedBlocks?: PastedBlock[] | undefined;
|
||||
/** Called when a large paste is detected and should be collapsed */
|
||||
onPasteBlock?: ((block: PastedBlock) => void) | undefined;
|
||||
/** Called to update a pasted block (e.g., toggle collapse) */
|
||||
onPasteBlockUpdate?: ((blockId: string, updates: Partial<PastedBlock>) => void) | undefined;
|
||||
/** Called when a paste block placeholder is removed from text */
|
||||
onPasteBlockRemove?: ((blockId: string) => void) | undefined;
|
||||
/** Query to highlight in input text (for history search) */
|
||||
highlightQuery?: string | undefined;
|
||||
}
|
||||
|
||||
function isBackspaceKey(key: Key): boolean {
|
||||
return key.name === 'backspace' || key.sequence === '\x7f' || key.sequence === '\x08';
|
||||
}
|
||||
|
||||
function isForwardDeleteKey(key: Key): boolean {
|
||||
return key.name === 'delete';
|
||||
}
|
||||
|
||||
/** Renders text with optional query highlighting in green */
|
||||
function HighlightedText({ text, query }: { text: string; query: string | undefined }) {
|
||||
if (!query || !text) {
|
||||
return <Text>{text}</Text>;
|
||||
}
|
||||
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const matchIndex = lowerText.indexOf(lowerQuery);
|
||||
|
||||
if (matchIndex === -1) {
|
||||
return <Text>{text}</Text>;
|
||||
}
|
||||
|
||||
const before = text.slice(0, matchIndex);
|
||||
const match = text.slice(matchIndex, matchIndex + query.length);
|
||||
const after = text.slice(matchIndex + query.length);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{before}
|
||||
<Text color="green" bold>
|
||||
{match}
|
||||
</Text>
|
||||
{after}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextBufferInput({
|
||||
buffer,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
isDisabled = false,
|
||||
onHistoryNavigate,
|
||||
onTriggerOverlay,
|
||||
maxViewportLines = 10,
|
||||
isActive,
|
||||
onKeyboardScroll,
|
||||
imageCount = 0,
|
||||
onImagePaste,
|
||||
images = [],
|
||||
onImageRemove,
|
||||
pastedBlocks = [],
|
||||
onPasteBlock,
|
||||
onPasteBlockUpdate,
|
||||
onPasteBlockRemove,
|
||||
highlightQuery,
|
||||
}: TextBufferInputProps) {
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = stdout?.columns || 80;
|
||||
|
||||
// Use ref to track imageCount to avoid stale closure in async paste handler
|
||||
const imageCountRef = useRef(imageCount);
|
||||
useEffect(() => {
|
||||
imageCountRef.current = imageCount;
|
||||
}, [imageCount]);
|
||||
|
||||
// Use ref to track paste number for generating sequential IDs
|
||||
const pasteCounterRef = useRef(pastedBlocks.length);
|
||||
useEffect(() => {
|
||||
// Update counter to be at least the current number of blocks
|
||||
pasteCounterRef.current = Math.max(pasteCounterRef.current, pastedBlocks.length);
|
||||
}, [pastedBlocks.length]);
|
||||
|
||||
// Check for removed image placeholders after text changes
|
||||
const checkRemovedImages = useCallback(() => {
|
||||
if (!onImageRemove || images.length === 0) return;
|
||||
const currentText = buffer.text;
|
||||
for (const img of images) {
|
||||
if (!currentText.includes(img.placeholder)) {
|
||||
onImageRemove(img.id);
|
||||
}
|
||||
}
|
||||
}, [buffer, images, onImageRemove]);
|
||||
|
||||
// Check for removed paste block placeholders after text changes
|
||||
const checkRemovedPasteBlocks = useCallback(() => {
|
||||
if (!onPasteBlockRemove || pastedBlocks.length === 0) return;
|
||||
const currentText = buffer.text;
|
||||
for (const block of pastedBlocks) {
|
||||
// Check if either the placeholder or the full text (when expanded) is present
|
||||
const textToFind = block.isCollapsed ? block.placeholder : block.fullText;
|
||||
if (!currentText.includes(textToFind)) {
|
||||
onPasteBlockRemove(block.id);
|
||||
}
|
||||
}
|
||||
}, [buffer, pastedBlocks, onPasteBlockRemove]);
|
||||
|
||||
// Find the currently expanded paste block (only one can be expanded at a time)
|
||||
const findExpandedBlock = useCallback((): PastedBlock | null => {
|
||||
return pastedBlocks.find((block) => !block.isCollapsed) || null;
|
||||
}, [pastedBlocks]);
|
||||
|
||||
// Find which collapsed paste block the cursor is on (by placeholder)
|
||||
const findCollapsedBlockAtCursor = useCallback((): PastedBlock | null => {
|
||||
if (pastedBlocks.length === 0) return null;
|
||||
const currentText = buffer.text;
|
||||
const [cursorRow, cursorCol] = buffer.cursor;
|
||||
const cursorOffset = getCursorPosition(buffer.lines, cursorRow, cursorCol);
|
||||
|
||||
for (const block of pastedBlocks) {
|
||||
if (!block.isCollapsed) continue; // Skip expanded blocks
|
||||
const startIdx = currentText.indexOf(block.placeholder);
|
||||
if (startIdx === -1) continue;
|
||||
const endIdx = startIdx + block.placeholder.length;
|
||||
if (cursorOffset >= startIdx && cursorOffset <= endIdx) {
|
||||
return block;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [buffer, pastedBlocks]);
|
||||
|
||||
// Handle Ctrl+T toggle:
|
||||
// - If something is expanded: collapse it
|
||||
// - If cursor is on a collapsed paste: expand it
|
||||
const handlePasteToggle = useCallback(() => {
|
||||
if (!onPasteBlockUpdate) return;
|
||||
|
||||
const expandedBlock = findExpandedBlock();
|
||||
const currentText = buffer.text;
|
||||
|
||||
// If something is expanded, collapse it
|
||||
if (expandedBlock) {
|
||||
// Normalize for comparison (buffer might have different line endings)
|
||||
const normalizedCurrent = currentText.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const normalizedFullText = expandedBlock.fullText
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n');
|
||||
|
||||
const startIdx = normalizedCurrent.indexOf(normalizedFullText);
|
||||
if (startIdx === -1) {
|
||||
// Fallback: just mark as collapsed without text replacement
|
||||
// This handles edge cases where text was modified
|
||||
onPasteBlockUpdate(expandedBlock.id, { isCollapsed: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace full text with placeholder
|
||||
const before = currentText.slice(0, startIdx);
|
||||
const after = currentText.slice(startIdx + normalizedFullText.length);
|
||||
const newText = before + expandedBlock.placeholder + after;
|
||||
|
||||
// Adjust cursor
|
||||
const [cursorRow, cursorCol] = buffer.cursor;
|
||||
const cursorOffset = getCursorPosition(buffer.lines, cursorRow, cursorCol);
|
||||
let newCursorOffset = cursorOffset;
|
||||
if (cursorOffset > startIdx) {
|
||||
// Cursor is after the start of expanded block - adjust
|
||||
const lengthDiff = expandedBlock.placeholder.length - normalizedFullText.length;
|
||||
newCursorOffset = Math.max(startIdx, cursorOffset + lengthDiff);
|
||||
}
|
||||
|
||||
buffer.setText(newText);
|
||||
buffer.moveToOffset(Math.min(newCursorOffset, newText.length));
|
||||
onPasteBlockUpdate(expandedBlock.id, { isCollapsed: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, check if cursor is on a collapsed paste to expand
|
||||
const collapsedBlock = findCollapsedBlockAtCursor();
|
||||
if (collapsedBlock) {
|
||||
const startIdx = currentText.indexOf(collapsedBlock.placeholder);
|
||||
if (startIdx === -1) return;
|
||||
|
||||
// Replace placeholder with full text
|
||||
const before = currentText.slice(0, startIdx);
|
||||
const after = currentText.slice(startIdx + collapsedBlock.placeholder.length);
|
||||
const newText = before + collapsedBlock.fullText + after;
|
||||
|
||||
buffer.setText(newText);
|
||||
// Move cursor to start of expanded content
|
||||
buffer.moveToOffset(startIdx);
|
||||
onPasteBlockUpdate(collapsedBlock.id, { isCollapsed: false });
|
||||
}
|
||||
}, [buffer, findExpandedBlock, findCollapsedBlockAtCursor, onPasteBlockUpdate]);
|
||||
|
||||
// Handle keyboard input directly - reads buffer state fresh each time
|
||||
const handleKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
if (isDisabled) return;
|
||||
|
||||
// Read buffer state directly - always fresh, no stale closures
|
||||
const currentText = buffer.text;
|
||||
const cursorVisualRow = buffer.visualCursor[0];
|
||||
const visualLines = buffer.allVisualLines;
|
||||
|
||||
// === KEYBOARD SCROLL (PageUp/PageDown, Shift+arrows) ===
|
||||
if (onKeyboardScroll) {
|
||||
if (key.name === 'pageup' || (key.shift && key.name === 'up')) {
|
||||
onKeyboardScroll('up');
|
||||
return;
|
||||
}
|
||||
if (key.name === 'pagedown' || (key.shift && key.name === 'down')) {
|
||||
onKeyboardScroll('down');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// === IMAGE PASTE (Ctrl+V) ===
|
||||
// Check clipboard for image before letting normal paste through
|
||||
if (key.ctrl && key.name === 'v' && onImagePaste) {
|
||||
// Async clipboard check - fire and forget, don't block input
|
||||
void (async () => {
|
||||
try {
|
||||
const clipboardImage = await readClipboardImage();
|
||||
if (clipboardImage) {
|
||||
// Use ref to get current count (avoids stale closure issue)
|
||||
const currentCount = imageCountRef.current;
|
||||
const imageNumber = currentCount + 1;
|
||||
// Immediately increment ref to handle rapid pastes
|
||||
imageCountRef.current = imageNumber;
|
||||
|
||||
const placeholder = `[Image ${imageNumber}]`;
|
||||
const pendingImage: PendingImage = {
|
||||
id: `img-${Date.now()}-${imageNumber}`,
|
||||
data: clipboardImage.data,
|
||||
mimeType: clipboardImage.mimeType,
|
||||
placeholder,
|
||||
};
|
||||
onImagePaste(pendingImage);
|
||||
buffer.insert(placeholder);
|
||||
}
|
||||
} catch {
|
||||
// Clipboard read failed, ignore
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
// === NEWLINE DETECTION ===
|
||||
const isCtrlJ = key.sequence === '\n';
|
||||
const isShiftEnter =
|
||||
key.sequence === '\\\r' ||
|
||||
(key.name === 'return' && key.shift) ||
|
||||
key.sequence === '\x1b[13;2u' ||
|
||||
key.sequence === '\x1bOM';
|
||||
const isPasteReturn = key.name === 'return' && key.paste;
|
||||
const wantsNewline =
|
||||
isCtrlJ || isShiftEnter || (key.name === 'return' && key.meta) || isPasteReturn;
|
||||
|
||||
if (wantsNewline) {
|
||||
buffer.newline();
|
||||
return;
|
||||
}
|
||||
|
||||
// === SUBMIT (Enter) ===
|
||||
if (key.name === 'return' && !key.paste) {
|
||||
if (currentText.trim()) {
|
||||
onSubmit(currentText);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// === UNDO/REDO ===
|
||||
if (key.ctrl && key.name === 'z' && !key.shift) {
|
||||
buffer.undo();
|
||||
return;
|
||||
}
|
||||
if ((key.ctrl && key.name === 'y') || (key.ctrl && key.shift && key.name === 'z')) {
|
||||
buffer.redo();
|
||||
return;
|
||||
}
|
||||
|
||||
// === PASTE BLOCK TOGGLE (Ctrl+T) ===
|
||||
if (key.ctrl && key.name === 't') {
|
||||
handlePasteToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
// === BACKSPACE ===
|
||||
if (isBackspaceKey(key) && !key.meta) {
|
||||
const prevText = buffer.text;
|
||||
const [cursorRow, cursorCol] = buffer.cursor;
|
||||
const cursorPos = getCursorPosition(buffer.lines, cursorRow, cursorCol);
|
||||
|
||||
buffer.backspace();
|
||||
checkRemovedImages();
|
||||
checkRemovedPasteBlocks();
|
||||
|
||||
// Check if we should close overlay after backspace
|
||||
// NOTE: buffer.text is memoized and won't update until next render,
|
||||
// so we calculate the expected new text ourselves
|
||||
if (onTriggerOverlay && cursorPos > 0) {
|
||||
const deletedChar = prevText[cursorPos - 1];
|
||||
// Calculate what the text will be after backspace
|
||||
const expectedNewText =
|
||||
prevText.slice(0, cursorPos - 1) + prevText.slice(cursorPos);
|
||||
|
||||
if (deletedChar === '/' && cursorPos === 1) {
|
||||
onTriggerOverlay('close');
|
||||
} else if (deletedChar === '@') {
|
||||
// Close if no valid @ mention remains
|
||||
// A valid @ is at start of text or after whitespace
|
||||
const hasValidAt = /(^|[\s])@/.test(expectedNewText);
|
||||
if (!hasValidAt) {
|
||||
onTriggerOverlay('close');
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// === FORWARD DELETE ===
|
||||
if (isForwardDeleteKey(key)) {
|
||||
buffer.del();
|
||||
checkRemovedImages();
|
||||
checkRemovedPasteBlocks();
|
||||
return;
|
||||
}
|
||||
|
||||
// === WORD DELETE ===
|
||||
if (key.ctrl && key.name === 'w') {
|
||||
buffer.deleteWordLeft();
|
||||
checkRemovedImages();
|
||||
checkRemovedPasteBlocks();
|
||||
return;
|
||||
}
|
||||
if (key.meta && isBackspaceKey(key)) {
|
||||
buffer.deleteWordLeft();
|
||||
checkRemovedImages();
|
||||
checkRemovedPasteBlocks();
|
||||
return;
|
||||
}
|
||||
|
||||
// === ARROW NAVIGATION ===
|
||||
if (key.name === 'left') {
|
||||
buffer.move(key.meta || key.ctrl ? 'wordLeft' : 'left');
|
||||
return;
|
||||
}
|
||||
if (key.name === 'right') {
|
||||
buffer.move(key.meta || key.ctrl ? 'wordRight' : 'right');
|
||||
return;
|
||||
}
|
||||
// Cmd+Up: Move to start of input
|
||||
if (key.meta && key.name === 'up') {
|
||||
buffer.moveToOffset(0);
|
||||
return;
|
||||
}
|
||||
// Cmd+Down: Move to end of input
|
||||
if (key.meta && key.name === 'down') {
|
||||
buffer.moveToOffset(currentText.length);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'up') {
|
||||
// Only trigger history navigation when at top visual line
|
||||
if (cursorVisualRow === 0 && onHistoryNavigate) {
|
||||
onHistoryNavigate('up');
|
||||
} else {
|
||||
buffer.move('up');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
// Only trigger history navigation when at bottom visual line
|
||||
if (cursorVisualRow >= visualLines.length - 1 && onHistoryNavigate) {
|
||||
onHistoryNavigate('down');
|
||||
} else {
|
||||
buffer.move('down');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// === LINE NAVIGATION ===
|
||||
if (key.ctrl && key.name === 'a') {
|
||||
buffer.move('home');
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.name === 'e') {
|
||||
buffer.move('end');
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.name === 'k') {
|
||||
buffer.killLineRight();
|
||||
checkRemovedImages();
|
||||
checkRemovedPasteBlocks();
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.name === 'u') {
|
||||
buffer.killLineLeft();
|
||||
checkRemovedImages();
|
||||
checkRemovedPasteBlocks();
|
||||
return;
|
||||
}
|
||||
|
||||
// === WORD NAVIGATION ===
|
||||
if (key.meta && key.name === 'b') {
|
||||
buffer.move('wordLeft');
|
||||
return;
|
||||
}
|
||||
if (key.meta && key.name === 'f') {
|
||||
buffer.move('wordRight');
|
||||
return;
|
||||
}
|
||||
|
||||
// === CHARACTER INPUT ===
|
||||
if (key.insertable && !key.ctrl && !key.meta) {
|
||||
const [cursorRow, cursorCol] = buffer.cursor;
|
||||
const cursorPos = getCursorPosition(buffer.lines, cursorRow, cursorCol);
|
||||
|
||||
// Check if this is a large paste that should be collapsed
|
||||
if (key.paste && onPasteBlock) {
|
||||
// Normalize line endings to \n for consistent handling
|
||||
const pastedText = key.sequence.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const lineCount = (pastedText.match(/\n/g)?.length ?? 0) + 1;
|
||||
|
||||
if (
|
||||
lineCount >= PASTE_COLLAPSE_LINE_THRESHOLD ||
|
||||
pastedText.length > PASTE_COLLAPSE_CHAR_THRESHOLD
|
||||
) {
|
||||
// Create collapsed paste block
|
||||
pasteCounterRef.current += 1;
|
||||
const pasteNumber = pasteCounterRef.current;
|
||||
const placeholder = `[Paste ${pasteNumber}: ~${lineCount} lines]`;
|
||||
|
||||
const pasteBlock: PastedBlock = {
|
||||
id: `paste-${Date.now()}-${pasteNumber}`,
|
||||
number: pasteNumber,
|
||||
fullText: pastedText,
|
||||
lineCount,
|
||||
isCollapsed: true,
|
||||
placeholder,
|
||||
};
|
||||
|
||||
// Insert placeholder instead of full text
|
||||
buffer.insert(placeholder);
|
||||
onPasteBlock(pasteBlock);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
buffer.insert(key.sequence, { paste: key.paste });
|
||||
|
||||
if (onTriggerOverlay) {
|
||||
if (key.sequence === '/' && cursorPos === 0) {
|
||||
onTriggerOverlay('slash-autocomplete');
|
||||
} else if (key.sequence === '@') {
|
||||
onTriggerOverlay('resource-autocomplete');
|
||||
} else if (/\s/.test(key.sequence)) {
|
||||
// Close resource autocomplete when user types whitespace
|
||||
// Whitespace means user is done with the mention (either selected or abandoned)
|
||||
onTriggerOverlay('close');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
buffer,
|
||||
isDisabled,
|
||||
onSubmit,
|
||||
onHistoryNavigate,
|
||||
onTriggerOverlay,
|
||||
onKeyboardScroll,
|
||||
// imageCount intentionally omitted - callback uses imageCountRef which is synced via useEffect
|
||||
onImagePaste,
|
||||
checkRemovedImages,
|
||||
checkRemovedPasteBlocks,
|
||||
handlePasteToggle,
|
||||
onPasteBlock,
|
||||
]
|
||||
);
|
||||
|
||||
// Subscribe to keypress events when active
|
||||
useKeypress(handleKeypress, { isActive: isActive && !isDisabled });
|
||||
|
||||
// === RENDERING ===
|
||||
// Read buffer state for rendering
|
||||
const bufferText = buffer.text;
|
||||
const visualCursor = buffer.visualCursor;
|
||||
const visualLines = buffer.allVisualLines;
|
||||
const cursorVisualRow = visualCursor[0];
|
||||
const cursorVisualCol = visualCursor[1];
|
||||
|
||||
const separator = '─'.repeat(terminalWidth);
|
||||
const totalLines = visualLines.length;
|
||||
|
||||
// Detect shell command mode (input starts with "!")
|
||||
const isShellMode = bufferText.startsWith('!');
|
||||
const promptPrefix = isShellMode ? '$ ' : '> ';
|
||||
const promptColor = isShellMode ? 'yellow' : 'green';
|
||||
const separatorColor = isShellMode ? 'yellow' : 'gray';
|
||||
|
||||
// Calculate visible window
|
||||
let startLine = 0;
|
||||
let endLine = totalLines;
|
||||
if (totalLines > maxViewportLines) {
|
||||
const halfViewport = Math.floor(maxViewportLines / 2);
|
||||
startLine = Math.max(0, cursorVisualRow - halfViewport);
|
||||
endLine = Math.min(totalLines, startLine + maxViewportLines);
|
||||
if (endLine === totalLines) {
|
||||
startLine = Math.max(0, totalLines - maxViewportLines);
|
||||
}
|
||||
}
|
||||
|
||||
const visibleLines = visualLines.slice(startLine, endLine);
|
||||
|
||||
// Empty state
|
||||
if (bufferText === '') {
|
||||
return (
|
||||
<Box flexDirection="column" width={terminalWidth}>
|
||||
<Text color="gray">{separator}</Text>
|
||||
<Box width={terminalWidth}>
|
||||
<Text color="green" bold>
|
||||
{'> '}
|
||||
</Text>
|
||||
<Text inverse> </Text>
|
||||
{placeholder && <Text color="gray">{placeholder}</Text>}
|
||||
<Text>
|
||||
{' '.repeat(Math.max(0, terminalWidth - 3 - (placeholder?.length || 0)))}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray">{separator}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={terminalWidth}>
|
||||
<Text color={separatorColor}>{separator}</Text>
|
||||
{startLine > 0 && (
|
||||
<Text color="gray">
|
||||
{' '}↑ {startLine} more line{startLine > 1 ? 's' : ''} above (
|
||||
{KEY_LABELS.altUp} to jump)
|
||||
</Text>
|
||||
)}
|
||||
{visibleLines.map((line: string, idx: number) => {
|
||||
const absoluteRow = startLine + idx;
|
||||
const isFirst = absoluteRow === 0;
|
||||
const prefix = isFirst ? promptPrefix : ' ';
|
||||
const isCursorLine = absoluteRow === cursorVisualRow;
|
||||
|
||||
if (!isCursorLine) {
|
||||
return (
|
||||
<Box key={absoluteRow} width={terminalWidth}>
|
||||
<Text color={promptColor} bold={isFirst}>
|
||||
{prefix}
|
||||
</Text>
|
||||
<HighlightedText text={line} query={highlightQuery} />
|
||||
<Text>
|
||||
{' '.repeat(
|
||||
Math.max(0, terminalWidth - prefix.length - line.length)
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const before = line.slice(0, cursorVisualCol);
|
||||
const atCursor = line.charAt(cursorVisualCol) || ' ';
|
||||
const after = line.slice(cursorVisualCol + 1);
|
||||
|
||||
return (
|
||||
<Box key={absoluteRow} width={terminalWidth}>
|
||||
<Text color={promptColor} bold={isFirst}>
|
||||
{prefix}
|
||||
</Text>
|
||||
<HighlightedText text={before} query={highlightQuery} />
|
||||
<Text inverse>{atCursor}</Text>
|
||||
<HighlightedText text={after} query={highlightQuery} />
|
||||
<Text>
|
||||
{' '.repeat(
|
||||
Math.max(
|
||||
0,
|
||||
terminalWidth - prefix.length - before.length - 1 - after.length
|
||||
)
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{endLine < totalLines && (
|
||||
<Text color="gray">
|
||||
{' '}↓ {totalLines - endLine} more line{totalLines - endLine > 1 ? 's' : ''}{' '}
|
||||
below ({KEY_LABELS.altDown} to jump)
|
||||
</Text>
|
||||
)}
|
||||
{/* Paste block hints */}
|
||||
{pastedBlocks.length > 0 && (
|
||||
<PasteBlockHint
|
||||
pastedBlocks={pastedBlocks}
|
||||
expandedBlock={findExpandedBlock()}
|
||||
cursorOnCollapsed={findCollapsedBlockAtCursor()}
|
||||
/>
|
||||
)}
|
||||
<Text color={separatorColor}>{separator}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hint component for paste blocks */
|
||||
function PasteBlockHint({
|
||||
pastedBlocks,
|
||||
expandedBlock,
|
||||
cursorOnCollapsed,
|
||||
}: {
|
||||
pastedBlocks: PastedBlock[];
|
||||
expandedBlock: PastedBlock | null;
|
||||
cursorOnCollapsed: PastedBlock | null;
|
||||
}) {
|
||||
const collapsedCount = pastedBlocks.filter((b) => b.isCollapsed).length;
|
||||
|
||||
// If something is expanded, always show collapse hint
|
||||
if (expandedBlock) {
|
||||
return (
|
||||
<Text color="cyan">
|
||||
{' '}
|
||||
{KEY_LABELS.ctrlT} to collapse expanded paste
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// If cursor is on a collapsed paste, show expand hint
|
||||
if (cursorOnCollapsed) {
|
||||
return (
|
||||
<Text color="cyan">
|
||||
{' '}
|
||||
{KEY_LABELS.ctrlT} to expand paste
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise show count of collapsed pastes
|
||||
if (collapsedCount > 0) {
|
||||
return (
|
||||
<Text color="gray">
|
||||
{' '}
|
||||
{collapsedCount} collapsed paste{collapsedCount > 1 ? 's' : ''} ({KEY_LABELS.ctrlT}{' '}
|
||||
on placeholder to expand)
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCursorPosition(lines: string[], cursorRow: number, cursorCol: number): number {
|
||||
let pos = 0;
|
||||
for (let i = 0; i < cursorRow; i++) {
|
||||
pos += (lines[i]?.length ?? 0) + 1;
|
||||
}
|
||||
return pos + cursorCol;
|
||||
}
|
||||
158
dexto/packages/cli/src/cli/ink-cli/components/TodoPanel.tsx
Normal file
158
dexto/packages/cli/src/cli/ink-cli/components/TodoPanel.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* TodoPanel Component
|
||||
*
|
||||
* Displays the current todo list for workflow tracking.
|
||||
* Shows todos with their status indicators (pending, in progress, completed).
|
||||
*
|
||||
* Display modes:
|
||||
* - Processing + Collapsed: Shows "Next:" with the next pending/in-progress task
|
||||
* - Processing + Expanded: Shows simple checklist with ☐/☑ indicators below status bar
|
||||
* - Idle + Expanded: Shows boxed format with header
|
||||
* - Idle + Collapsed: Hidden
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { TodoItem, TodoStatus } from '../state/types.js';
|
||||
|
||||
interface TodoPanelProps {
|
||||
todos: TodoItem[];
|
||||
/** Whether to show the full list or just the next task */
|
||||
isExpanded: boolean;
|
||||
/** Whether the agent is currently processing (affects display style) */
|
||||
isProcessing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status indicator for a todo item (used in boxed mode)
|
||||
*/
|
||||
function getStatusIndicator(status: TodoStatus): { icon: string; color: string } {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: '✓', color: 'green' };
|
||||
case 'in_progress':
|
||||
return { icon: '●', color: 'yellow' };
|
||||
case 'pending':
|
||||
default:
|
||||
return { icon: '○', color: 'gray' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TodoPanel - Shows current todos for workflow tracking
|
||||
*/
|
||||
export function TodoPanel({ todos, isExpanded, isProcessing = false }: TodoPanelProps) {
|
||||
if (todos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort todos by position
|
||||
const sortedTodos = [...todos].sort((a, b) => a.position - b.position);
|
||||
|
||||
// Find the next task to work on (in_progress first, then first pending)
|
||||
const currentTask = sortedTodos.find((t) => t.status === 'in_progress');
|
||||
const nextPendingTask = sortedTodos.find((t) => t.status === 'pending');
|
||||
const nextTask = currentTask || nextPendingTask;
|
||||
|
||||
// When idle (not processing)
|
||||
if (!isProcessing) {
|
||||
// Collapsed + idle = hidden
|
||||
if (!isExpanded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Expanded + idle = boxed format
|
||||
const completedCount = todos.filter((t) => t.status === 'completed').length;
|
||||
const totalCount = todos.length;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
marginX={1}
|
||||
marginBottom={1}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
📋 Tasks{' '}
|
||||
</Text>
|
||||
<Text color="gray">
|
||||
({completedCount}/{totalCount})
|
||||
</Text>
|
||||
<Text color="gray" dimColor>
|
||||
{' '}
|
||||
· ctrl+t to hide tasks
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Todo items */}
|
||||
<Box flexDirection="column">
|
||||
{sortedTodos.map((todo) => {
|
||||
const { icon, color } = getStatusIndicator(todo.status);
|
||||
const isCompleted = todo.status === 'completed';
|
||||
const isInProgress = todo.status === 'in_progress';
|
||||
|
||||
return (
|
||||
<Box key={todo.id}>
|
||||
<Text color={color}>{icon} </Text>
|
||||
<Text
|
||||
color={isCompleted ? 'gray' : isInProgress ? 'white' : 'gray'}
|
||||
strikethrough={isCompleted}
|
||||
dimColor={!isInProgress && !isCompleted}
|
||||
>
|
||||
{isInProgress ? todo.activeForm : todo.content}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// When processing - use minimal style
|
||||
|
||||
// Collapsed: show current task being worked on
|
||||
if (!isExpanded) {
|
||||
if (!currentTask) {
|
||||
return null; // No active task
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingX={1} marginBottom={1}>
|
||||
<Box marginLeft={2}>
|
||||
<Text color="gray">⎿ </Text>
|
||||
<Text color="gray">{currentTask.activeForm}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded: show simple checklist
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} marginBottom={1}>
|
||||
{sortedTodos.map((todo, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isCompleted = todo.status === 'completed';
|
||||
const isInProgress = todo.status === 'in_progress';
|
||||
const checkbox = isCompleted ? '☑' : '☐';
|
||||
|
||||
return (
|
||||
<Box key={todo.id} marginLeft={2}>
|
||||
{/* Tree connector for first item, space for others */}
|
||||
<Text color="gray">{isFirst ? '⎿ ' : ' '}</Text>
|
||||
<Text color={isCompleted ? 'green' : isInProgress ? 'yellow' : 'white'}>
|
||||
{checkbox}{' '}
|
||||
</Text>
|
||||
<Text color={isCompleted ? 'gray' : 'white'} dimColor={isCompleted}>
|
||||
{todo.content}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Base Autocomplete Component
|
||||
* Reusable autocomplete with filtering, scoring, and keyboard navigation
|
||||
* Used by SlashCommandAutocomplete and ResourceAutocomplete
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
|
||||
export interface BaseAutocompleteProps<T> {
|
||||
items: T[];
|
||||
query: string;
|
||||
isVisible: boolean;
|
||||
isLoading?: boolean;
|
||||
onSelect: (item: T) => void;
|
||||
onLoadIntoInput?: (text: string) => void;
|
||||
onClose: () => void;
|
||||
filterFn: (item: T, query: string) => boolean;
|
||||
scoreFn: (item: T, query: string) => number;
|
||||
formatItem: (item: T, isSelected: boolean) => ReactNode;
|
||||
formatLoadText?: (item: T) => string;
|
||||
title: string;
|
||||
maxVisibleItems?: number;
|
||||
loadingMessage?: string;
|
||||
emptyMessage?: string;
|
||||
borderColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic autocomplete component with filtering, scoring, and keyboard navigation
|
||||
*/
|
||||
export function BaseAutocomplete<T>({
|
||||
items,
|
||||
query,
|
||||
isVisible,
|
||||
isLoading = false,
|
||||
onSelect,
|
||||
onLoadIntoInput,
|
||||
onClose,
|
||||
filterFn,
|
||||
scoreFn,
|
||||
formatItem,
|
||||
formatLoadText,
|
||||
title,
|
||||
maxVisibleItems = 10,
|
||||
loadingMessage = 'Loading...',
|
||||
emptyMessage = 'No matches found',
|
||||
borderColor = 'cyan',
|
||||
}: BaseAutocompleteProps<T>) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const selectedIndexRef = useRef(0);
|
||||
|
||||
// Keep ref in sync
|
||||
useEffect(() => {
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Filter and sort items
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!query) return items;
|
||||
return items
|
||||
.filter((item) => filterFn(item, query))
|
||||
.sort((a, b) => {
|
||||
const scoreA = scoreFn(a, query);
|
||||
const scoreB = scoreFn(b, query);
|
||||
return scoreB - scoreA; // Higher score first
|
||||
});
|
||||
}, [items, query, filterFn, scoreFn]);
|
||||
|
||||
// Reset selection when items change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setScrollOffset(0);
|
||||
}, [filteredItems.length]);
|
||||
|
||||
// Auto-scroll to keep selected item visible
|
||||
useEffect(() => {
|
||||
if (selectedIndex < scrollOffset) {
|
||||
setScrollOffset(selectedIndex);
|
||||
} else if (selectedIndex >= scrollOffset + maxVisibleItems) {
|
||||
setScrollOffset(Math.max(0, selectedIndex - maxVisibleItems + 1));
|
||||
}
|
||||
}, [selectedIndex, scrollOffset, maxVisibleItems]);
|
||||
|
||||
// Calculate visible items
|
||||
const visibleItems = useMemo(() => {
|
||||
return filteredItems.slice(scrollOffset, scrollOffset + maxVisibleItems);
|
||||
}, [filteredItems, scrollOffset, maxVisibleItems]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const itemsLength = filteredItems.length;
|
||||
if (itemsLength === 0) return;
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev - 1 + itemsLength) % itemsLength);
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev + 1) % itemsLength);
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Tab: Load into input for editing
|
||||
if (key.tab && onLoadIntoInput && formatLoadText && itemsLength > 0) {
|
||||
const item = filteredItems[selectedIndexRef.current];
|
||||
if (item) {
|
||||
onLoadIntoInput(formatLoadText(item));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter: Select item
|
||||
if (key.return && itemsLength > 0) {
|
||||
const item = filteredItems[selectedIndexRef.current];
|
||||
if (item) {
|
||||
onSelect(item);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isVisible }
|
||||
);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1} paddingY={1}>
|
||||
<Text color="gray">{loadingMessage}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredItems.length === 0) {
|
||||
return (
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1} paddingY={1}>
|
||||
<Text color="gray">{emptyMessage}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const hasMoreAbove = scrollOffset > 0;
|
||||
const hasMoreBelow = scrollOffset + maxVisibleItems < filteredItems.length;
|
||||
const totalItems = filteredItems.length;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={borderColor}
|
||||
flexDirection="column"
|
||||
height={Math.min(maxVisibleItems + 3, totalItems + 3)}
|
||||
>
|
||||
<Box paddingX={1} paddingY={0}>
|
||||
<Text color="gray">
|
||||
{title} ({selectedIndex + 1}/{totalItems}) - ↑↓ navigate
|
||||
{onLoadIntoInput && ', Tab load'}
|
||||
{', Enter select, Esc close'}
|
||||
</Text>
|
||||
</Box>
|
||||
{hasMoreAbove && (
|
||||
<Box paddingX={1} paddingY={0}>
|
||||
<Text color="gray">... ↑ ({scrollOffset} more above)</Text>
|
||||
</Box>
|
||||
)}
|
||||
{visibleItems.map((item, visibleIndex) => {
|
||||
const actualIndex = scrollOffset + visibleIndex;
|
||||
const isSelected = actualIndex === selectedIndex;
|
||||
|
||||
return (
|
||||
<Box key={actualIndex} paddingX={1} paddingY={0}>
|
||||
{formatItem(item, isSelected)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{hasMoreBelow && (
|
||||
<Box paddingX={1} paddingY={0}>
|
||||
<Text color="gray">
|
||||
... ↓ ({totalItems - scrollOffset - maxVisibleItems} more below)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Base Selector Component
|
||||
* Reusable selector with keyboard navigation for lists
|
||||
* Used by ModelSelector and SessionSelector
|
||||
*/
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { Key } from '../../hooks/useInputOrchestrator.js';
|
||||
|
||||
export interface BaseSelectorProps<T> {
|
||||
items: T[];
|
||||
isVisible: boolean;
|
||||
isLoading?: boolean;
|
||||
selectedIndex: number;
|
||||
onSelectIndex: (index: number) => void;
|
||||
onSelect: (item: T) => void;
|
||||
onClose: () => void;
|
||||
formatItem: (item: T, isSelected: boolean) => ReactNode;
|
||||
title: string;
|
||||
maxVisibleItems?: number;
|
||||
loadingMessage?: string;
|
||||
emptyMessage?: string;
|
||||
borderColor?: string;
|
||||
onTab?: (item: T) => void; // Optional Tab key handler
|
||||
supportsTab?: boolean; // Whether to show Tab in instructions
|
||||
}
|
||||
|
||||
export interface BaseSelectorHandle {
|
||||
handleInput: (input: string, key: Key) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic selector component with keyboard navigation and scrolling
|
||||
*/
|
||||
function BaseSelectorInner<T>(
|
||||
{
|
||||
items,
|
||||
isVisible,
|
||||
isLoading = false,
|
||||
selectedIndex,
|
||||
onSelectIndex,
|
||||
onSelect,
|
||||
onClose,
|
||||
formatItem,
|
||||
title,
|
||||
maxVisibleItems = 8,
|
||||
loadingMessage = 'Loading...',
|
||||
emptyMessage = 'No items found',
|
||||
borderColor = 'cyan',
|
||||
onTab,
|
||||
supportsTab = false,
|
||||
}: BaseSelectorProps<T>,
|
||||
ref: React.Ref<BaseSelectorHandle>
|
||||
) {
|
||||
// Track scroll offset as state, but derive during render when needed
|
||||
const [scrollOffsetState, setScrollOffset] = useState(0);
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
const prevItemsLengthRef = useRef(items.length);
|
||||
|
||||
// Keep ref in sync
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
// Derive the correct scroll offset during render (no second render needed)
|
||||
// This handles both selectedIndex changes from parent AND items length changes
|
||||
const scrollOffset = useMemo(() => {
|
||||
const itemsChanged = items.length !== prevItemsLengthRef.current;
|
||||
|
||||
// Reset scroll if items changed significantly
|
||||
if (itemsChanged && items.length <= maxVisibleItems) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let offset = scrollOffsetState;
|
||||
|
||||
// Adjust offset to keep selectedIndex visible
|
||||
if (selectedIndex < offset) {
|
||||
offset = selectedIndex;
|
||||
} else if (selectedIndex >= offset + maxVisibleItems) {
|
||||
offset = Math.max(0, selectedIndex - maxVisibleItems + 1);
|
||||
}
|
||||
|
||||
// Clamp to valid range
|
||||
const maxOffset = Math.max(0, items.length - maxVisibleItems);
|
||||
return Math.min(maxOffset, Math.max(0, offset));
|
||||
}, [selectedIndex, items.length, maxVisibleItems, scrollOffsetState]);
|
||||
|
||||
// Update refs after render (not during useMemo which can run multiple times)
|
||||
useEffect(() => {
|
||||
prevItemsLengthRef.current = items.length;
|
||||
}, [items.length]);
|
||||
|
||||
// Sync scroll offset state after render if it changed
|
||||
// This ensures the stored state is correct for next navigation
|
||||
useEffect(() => {
|
||||
if (scrollOffset !== scrollOffsetState) {
|
||||
setScrollOffset(scrollOffset);
|
||||
}
|
||||
}, [scrollOffset, scrollOffsetState]);
|
||||
|
||||
// Handle selection change - only updates parent state
|
||||
const handleSelectIndex = useCallback(
|
||||
(newIndex: number) => {
|
||||
selectedIndexRef.current = newIndex;
|
||||
onSelectIndex(newIndex);
|
||||
},
|
||||
[onSelectIndex]
|
||||
);
|
||||
|
||||
// Calculate visible items
|
||||
const visibleItems = useMemo(() => {
|
||||
return items.slice(scrollOffset, scrollOffset + maxVisibleItems);
|
||||
}, [items, scrollOffset, maxVisibleItems]);
|
||||
|
||||
// Expose handleInput method via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
handleInput: (_input: string, key: Key): boolean => {
|
||||
if (!isVisible) return false;
|
||||
|
||||
// Escape always works, regardless of item count
|
||||
if (key.escape) {
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
const itemsLength = items.length;
|
||||
if (itemsLength === 0) return false;
|
||||
|
||||
if (key.upArrow) {
|
||||
const nextIndex = (selectedIndexRef.current - 1 + itemsLength) % itemsLength;
|
||||
handleSelectIndex(nextIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
const nextIndex = (selectedIndexRef.current + 1) % itemsLength;
|
||||
handleSelectIndex(nextIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.tab && onTab) {
|
||||
const item = items[selectedIndexRef.current];
|
||||
if (item !== undefined) {
|
||||
onTab(item);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.return && itemsLength > 0) {
|
||||
const item = items[selectedIndexRef.current];
|
||||
if (item !== undefined) {
|
||||
onSelect(item);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
[isVisible, items, handleSelectIndex, onClose, onSelect, onTab]
|
||||
);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box paddingX={0} paddingY={0}>
|
||||
<Text color="gray">{loadingMessage}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Box paddingX={0} paddingY={0}>
|
||||
<Text color="gray">{emptyMessage}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Build instruction text based on features
|
||||
const instructions = supportsTab
|
||||
? '↑↓ navigate, Tab load, Enter select, Esc close'
|
||||
: '↑↓ navigate, Enter select, Esc close';
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={0} paddingY={0}>
|
||||
<Text color={borderColor} bold>
|
||||
{title} ({selectedIndex + 1}/{items.length}) - {instructions}
|
||||
</Text>
|
||||
</Box>
|
||||
{visibleItems.map((item, visibleIndex) => {
|
||||
const actualIndex = scrollOffset + visibleIndex;
|
||||
const isSelected = actualIndex === selectedIndex;
|
||||
|
||||
return (
|
||||
<Box key={actualIndex} paddingX={0} paddingY={0}>
|
||||
{formatItem(item, isSelected)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Export with proper generic type support
|
||||
export const BaseSelector = forwardRef(BaseSelectorInner) as <T>(
|
||||
props: BaseSelectorProps<T> & { ref?: React.Ref<BaseSelectorHandle> }
|
||||
) => ReturnType<typeof BaseSelectorInner>;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Base components module exports
|
||||
*/
|
||||
|
||||
export { BaseSelector, type BaseSelectorProps } from './BaseSelector.js';
|
||||
export { BaseAutocomplete, type BaseAutocompleteProps } from './BaseAutocomplete.js';
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* ChatView Component
|
||||
* Main chat display area combining header and messages
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { Header } from './Header.js';
|
||||
import { MessageList } from './MessageList.js';
|
||||
import type { Message, StartupInfo } from '../../state/types.js';
|
||||
|
||||
interface ChatViewProps {
|
||||
messages: Message[];
|
||||
modelName: string;
|
||||
sessionId?: string | undefined;
|
||||
hasActiveSession: boolean;
|
||||
startupInfo: StartupInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure presentational component for chat area
|
||||
* Combines header and message list
|
||||
*/
|
||||
export function ChatView({
|
||||
messages,
|
||||
modelName,
|
||||
sessionId,
|
||||
hasActiveSession,
|
||||
startupInfo,
|
||||
}: ChatViewProps) {
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Header
|
||||
modelName={modelName}
|
||||
sessionId={sessionId}
|
||||
hasActiveSession={hasActiveSession}
|
||||
startupInfo={startupInfo}
|
||||
/>
|
||||
<MessageList messages={messages} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Footer Component
|
||||
* Displays keyboard shortcuts and help information
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
/**
|
||||
* Pure presentational component for CLI footer
|
||||
*/
|
||||
export function Footer() {
|
||||
return (
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text color="gray">
|
||||
Shift+Enter/Ctrl+J: newline • Ctrl+W: del word • Ctrl+U: del line • Ctrl+C: exit
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
106
dexto/packages/cli/src/cli/ink-cli/components/chat/Header.tsx
Normal file
106
dexto/packages/cli/src/cli/ink-cli/components/chat/Header.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Header Component
|
||||
* Displays CLI branding and session information
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { StartupInfo } from '../../state/types.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
|
||||
interface HeaderProps {
|
||||
modelName: string;
|
||||
sessionId?: string | undefined;
|
||||
hasActiveSession: boolean;
|
||||
startupInfo: StartupInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure presentational component for CLI header
|
||||
* Automatically adjusts width to terminal size
|
||||
*/
|
||||
export function Header({ modelName, sessionId, hasActiveSession, startupInfo }: HeaderProps) {
|
||||
const { columns } = useTerminalSize();
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
width={columns}
|
||||
>
|
||||
<Box marginTop={1}>
|
||||
<Text color="greenBright">
|
||||
{`██████╗ ███████╗██╗ ██╗████████╗ ██████╗
|
||||
██╔══██╗██╔════╝╚██╗██╔╝╚══██╔══╝██╔═══██╗
|
||||
██║ ██║█████╗ ╚███╔╝ ██║ ██║ ██║
|
||||
██║ ██║██╔══╝ ██╔██╗ ██║ ██║ ██║
|
||||
██████╔╝███████╗██╔╝ ██╗ ██║ ╚██████╔╝
|
||||
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Model and Session */}
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Text color="gray">Model: </Text>
|
||||
<Text color="white">{modelName}</Text>
|
||||
{hasActiveSession && sessionId && (
|
||||
<>
|
||||
<Text color="gray"> • Session: </Text>
|
||||
<Text color="white">{sessionId.slice(0, 8)}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* MCP Servers and Tools */}
|
||||
<Box flexDirection="row">
|
||||
<Text color="gray">Servers: </Text>
|
||||
<Text color="white">{startupInfo.connectedServers.count}</Text>
|
||||
<Text color="gray"> • Tools: </Text>
|
||||
<Text color="white">{startupInfo.toolCount}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Failed connections warning */}
|
||||
{startupInfo.failedConnections.length > 0 && (
|
||||
<Box flexDirection="row">
|
||||
<Text color="yellowBright">
|
||||
⚠️ Failed: {startupInfo.failedConnections.join(', ')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Log file (only shown in dev mode) */}
|
||||
{startupInfo.logFile && process.env.DEXTO_DEV_MODE === 'true' && (
|
||||
<Box flexDirection="row">
|
||||
<Text color="gray">Logs: {startupInfo.logFile}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Update available notification */}
|
||||
{startupInfo.updateInfo && (
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Text color="yellow">
|
||||
⬆️ Update available: {startupInfo.updateInfo.current} →{' '}
|
||||
{startupInfo.updateInfo.latest}
|
||||
</Text>
|
||||
<Text color="gray"> • Run: </Text>
|
||||
<Text color="cyan">{startupInfo.updateInfo.updateCommand}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Agent sync notification */}
|
||||
{startupInfo.needsAgentSync && (
|
||||
<Box marginTop={startupInfo.updateInfo ? 0 : 1} flexDirection="row">
|
||||
<Text color="yellow">🔄 Agent configs have updates available. Run: </Text>
|
||||
<Text color="cyan">dexto sync-agents</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* MessageItem Component
|
||||
* Displays a single message with visual hierarchy
|
||||
* Uses colors and spacing instead of explicit labels
|
||||
*/
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import wrapAnsi from 'wrap-ansi';
|
||||
import type {
|
||||
Message,
|
||||
ConfigStyledData,
|
||||
StatsStyledData,
|
||||
HelpStyledData,
|
||||
SessionListStyledData,
|
||||
SessionHistoryStyledData,
|
||||
LogConfigStyledData,
|
||||
RunSummaryStyledData,
|
||||
ShortcutsStyledData,
|
||||
SysPromptStyledData,
|
||||
} from '../../state/types.js';
|
||||
import {
|
||||
ConfigBox,
|
||||
StatsBox,
|
||||
HelpBox,
|
||||
SessionListBox,
|
||||
SessionHistoryBox,
|
||||
LogConfigBox,
|
||||
ShortcutsBox,
|
||||
SyspromptBox,
|
||||
} from './styled-boxes/index.js';
|
||||
import { ToolResultRenderer } from '../renderers/index.js';
|
||||
import { MarkdownText } from '../shared/MarkdownText.js';
|
||||
import { ToolIcon } from './ToolIcon.js';
|
||||
|
||||
/**
|
||||
* Strip <plan-mode>...</plan-mode> tags from content.
|
||||
* Plan mode instructions are injected for the LLM but should not be shown in the UI.
|
||||
* Only trims when a tag was actually removed to preserve user-intended formatting.
|
||||
*/
|
||||
function stripPlanModeTags(content: string): string {
|
||||
// Remove <plan-mode>...</plan-mode> including any trailing whitespace
|
||||
const stripped = content.replace(/<plan-mode>[\s\S]*?<\/plan-mode>\s*/g, '');
|
||||
// Only trim if a tag was actually removed
|
||||
return stripped === content ? content : stripped.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds into a compact human-readable string
|
||||
* Examples: "1.2s", "1m 23s", "1h 2m"
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const tenths = Math.floor((ms % 1000) / 100);
|
||||
|
||||
if (seconds < 60) {
|
||||
return `${seconds}.${tenths}s`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message;
|
||||
/** Terminal width for proper text wrapping calculations */
|
||||
terminalWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure presentational component for a single message
|
||||
* Visual hierarchy through colors and spacing only (no borders for easy text copying)
|
||||
*
|
||||
* Memoization with custom comparator prevents re-renders when message array changes
|
||||
* but individual message content hasn't changed.
|
||||
*/
|
||||
export const MessageItem = memo(
|
||||
({ message, terminalWidth = 80 }: MessageItemProps) => {
|
||||
// Check for styled message first
|
||||
if (message.styledType && message.styledData) {
|
||||
switch (message.styledType) {
|
||||
case 'config':
|
||||
return <ConfigBox data={message.styledData as ConfigStyledData} />;
|
||||
case 'stats':
|
||||
return <StatsBox data={message.styledData as StatsStyledData} />;
|
||||
case 'help':
|
||||
return <HelpBox data={message.styledData as HelpStyledData} />;
|
||||
case 'session-list':
|
||||
return <SessionListBox data={message.styledData as SessionListStyledData} />;
|
||||
case 'session-history':
|
||||
return (
|
||||
<SessionHistoryBox data={message.styledData as SessionHistoryStyledData} />
|
||||
);
|
||||
case 'log-config':
|
||||
return <LogConfigBox data={message.styledData as LogConfigStyledData} />;
|
||||
case 'run-summary': {
|
||||
const data = message.styledData as RunSummaryStyledData;
|
||||
const durationStr = formatDuration(data.durationMs);
|
||||
// Only show tokens when >= 1000, using K notation
|
||||
const tokensStr =
|
||||
data.totalTokens >= 1000
|
||||
? `, Used ${(data.totalTokens / 1000).toFixed(1)}K tokens`
|
||||
: '';
|
||||
return (
|
||||
<Box marginTop={1} marginBottom={1} width={terminalWidth}>
|
||||
<Text color="gray">
|
||||
─ Worked for {durationStr}
|
||||
{tokensStr} ─
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
case 'shortcuts':
|
||||
return <ShortcutsBox data={message.styledData as ShortcutsStyledData} />;
|
||||
case 'sysprompt':
|
||||
return <SyspromptBox data={message.styledData as SysPromptStyledData} />;
|
||||
}
|
||||
}
|
||||
|
||||
// User message: '>' prefix with gray background
|
||||
// Strip plan-mode tags before display (plan instructions are for LLM, not user)
|
||||
// Properly wrap text accounting for prefix "> " (2 chars) and paddingX={1} (2 chars total)
|
||||
if (message.role === 'user') {
|
||||
const prefix = '> ';
|
||||
const paddingChars = 2; // paddingX={1} = 1 char on each side
|
||||
const availableWidth = Math.max(20, terminalWidth - prefix.length - paddingChars);
|
||||
const displayContent = stripPlanModeTags(message.content);
|
||||
const wrappedContent = wrapAnsi(displayContent, availableWidth, {
|
||||
hard: true,
|
||||
wordWrap: true,
|
||||
trim: false,
|
||||
});
|
||||
const lines = wrappedContent.split('\n');
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={2} marginBottom={1} width={terminalWidth}>
|
||||
<Box flexDirection="column" paddingX={1} backgroundColor="gray">
|
||||
{lines.map((line, i) => (
|
||||
<Box key={i} flexDirection="row">
|
||||
<Text color="green">{i === 0 ? prefix : ' '}</Text>
|
||||
<Text color="white">{line}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Assistant message: Gray circle indicator (unless continuation)
|
||||
// IMPORTANT: width="100%" is required to prevent Ink layout failures on large content.
|
||||
// Without width constraints, streaming content causes terminal blackout at ~50+ lines.
|
||||
// marginTop={1} for consistent spacing with tool messages
|
||||
if (message.role === 'assistant') {
|
||||
// Continuation messages: no indicator, just content
|
||||
if (message.isContinuation) {
|
||||
return (
|
||||
<Box flexDirection="column" width={terminalWidth}>
|
||||
<MarkdownText>{message.content || ''}</MarkdownText>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular assistant message: bullet prefix inline with first line
|
||||
// Text wraps at terminal width - wrapped lines may start at column 0
|
||||
// This is simpler and avoids mid-word splitting issues with Ink's wrap
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1} width={terminalWidth}>
|
||||
<MarkdownText bulletPrefix="⏺ ">{message.content || ''}</MarkdownText>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Tool message: Animated icon based on status
|
||||
// - Running: green spinner + "Running..."
|
||||
// - Finished (success): green dot
|
||||
// - Finished (error): red dot
|
||||
if (message.role === 'tool') {
|
||||
// Use structured renderers if display data is available
|
||||
const hasStructuredDisplay = message.toolDisplayData && message.toolContent;
|
||||
const isRunning = message.toolStatus === 'running';
|
||||
const isPending =
|
||||
message.toolStatus === 'pending' || message.toolStatus === 'pending_approval';
|
||||
|
||||
// Check for sub-agent progress data
|
||||
const subAgentProgress = message.subAgentProgress;
|
||||
|
||||
// Parse tool name and args for bold formatting: "ToolName(args)" → bold name + normal args
|
||||
const parenIndex = message.content.indexOf('(');
|
||||
const toolName =
|
||||
parenIndex > 0 ? message.content.slice(0, parenIndex) : message.content;
|
||||
const toolArgs = parenIndex > 0 ? message.content.slice(parenIndex) : '';
|
||||
|
||||
// Build the full tool header text for wrapping
|
||||
// Don't include status suffix if we have sub-agent progress (it shows its own status)
|
||||
const statusSuffix = subAgentProgress
|
||||
? ''
|
||||
: isRunning
|
||||
? ' Running...'
|
||||
: isPending
|
||||
? ' Waiting...'
|
||||
: '';
|
||||
const fullToolText = `${toolName}${toolArgs}${statusSuffix}`;
|
||||
|
||||
// ToolIcon takes 2 chars ("● "), so available width is terminalWidth - 2
|
||||
const iconWidth = 2;
|
||||
const availableWidth = Math.max(20, terminalWidth - iconWidth);
|
||||
const wrappedToolText = wrapAnsi(fullToolText, availableWidth, {
|
||||
hard: true,
|
||||
wordWrap: true,
|
||||
trim: false,
|
||||
});
|
||||
const toolLines = wrappedToolText.split('\n');
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1} width={terminalWidth}>
|
||||
{/* Tool header: icon + name + args + status text */}
|
||||
{toolLines.map((line, i) => (
|
||||
<Box key={i} flexDirection="row">
|
||||
{i === 0 ? (
|
||||
<ToolIcon
|
||||
status={message.toolStatus || 'finished'}
|
||||
isError={message.isError ?? false}
|
||||
/>
|
||||
) : (
|
||||
<Text>{' '}</Text>
|
||||
)}
|
||||
<Text>
|
||||
{i === 0 ? (
|
||||
<>
|
||||
<Text bold>{line.slice(0, toolName.length)}</Text>
|
||||
<Text>{line.slice(toolName.length)}</Text>
|
||||
</>
|
||||
) : (
|
||||
line
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{/* Sub-agent progress line - show when we have progress data */}
|
||||
{subAgentProgress && isRunning && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color="gray">
|
||||
└─ {subAgentProgress.toolsCalled} tool
|
||||
{subAgentProgress.toolsCalled !== 1 ? 's' : ''} called | Current:{' '}
|
||||
{subAgentProgress.currentTool}
|
||||
{subAgentProgress.tokenUsage &&
|
||||
subAgentProgress.tokenUsage.total > 0
|
||||
? ` | ${subAgentProgress.tokenUsage.total.toLocaleString()} tokens`
|
||||
: ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{/* Tool result - only show when finished */}
|
||||
{hasStructuredDisplay ? (
|
||||
<ToolResultRenderer
|
||||
display={message.toolDisplayData!}
|
||||
content={message.toolContent!}
|
||||
/>
|
||||
) : (
|
||||
message.toolResult && (
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray"> ⎿ {message.toolResult}</Text>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// System message: Compact gray text
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width={terminalWidth}>
|
||||
<Text color="gray">{message.content}</Text>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
// Custom comparator: only re-render if message content actually changed
|
||||
(prev, next) => {
|
||||
return (
|
||||
prev.message.id === next.message.id &&
|
||||
prev.message.content === next.message.content &&
|
||||
prev.message.role === next.message.role &&
|
||||
prev.message.toolStatus === next.message.toolStatus &&
|
||||
prev.message.toolResult === next.message.toolResult &&
|
||||
prev.message.isStreaming === next.message.isStreaming &&
|
||||
prev.message.styledType === next.message.styledType &&
|
||||
prev.message.styledData === next.message.styledData &&
|
||||
prev.message.isContinuation === next.message.isContinuation &&
|
||||
prev.message.isError === next.message.isError &&
|
||||
prev.message.toolDisplayData === next.message.toolDisplayData &&
|
||||
prev.message.toolContent === next.message.toolContent &&
|
||||
prev.terminalWidth === next.terminalWidth &&
|
||||
prev.message.subAgentProgress?.toolsCalled ===
|
||||
next.message.subAgentProgress?.toolsCalled &&
|
||||
prev.message.subAgentProgress?.currentTool ===
|
||||
next.message.subAgentProgress?.currentTool &&
|
||||
prev.message.subAgentProgress?.tokenUsage?.total ===
|
||||
next.message.subAgentProgress?.tokenUsage?.total
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MessageItem.displayName = 'MessageItem';
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* MessageList Component
|
||||
* Displays a list of messages with optional welcome message
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { MessageItem } from './MessageItem.js';
|
||||
import type { Message } from '../../state/types.js';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure presentational component for message list
|
||||
* Shows only recent messages for performance
|
||||
*/
|
||||
export function MessageList({ messages, maxVisible = 50 }: MessageListProps) {
|
||||
// Only render recent messages for performance
|
||||
const visibleMessages = useMemo(() => {
|
||||
return messages.slice(-maxVisible);
|
||||
}, [messages, maxVisible]);
|
||||
|
||||
const hasMoreMessages = messages.length > maxVisible;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
|
||||
{hasMoreMessages && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color="gray">
|
||||
... ({messages.length - maxVisible} earlier messages hidden)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{visibleMessages.length === 0 && (
|
||||
<Box marginY={2}>
|
||||
<Text color="greenBright">
|
||||
Welcome to Dexto CLI! Type your message below or use /help for commands.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{visibleMessages.map((msg) => (
|
||||
<MessageItem key={msg.id} message={msg} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* QueuedMessagesDisplay - Shows queued messages waiting to be processed
|
||||
*
|
||||
* Similar to webui's QueuedMessagesDisplay.tsx but for Ink/terminal.
|
||||
* Shows:
|
||||
* - Count of queued messages
|
||||
* - Keyboard hint (↑ to edit)
|
||||
* - Truncated preview of each queued message
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { QueuedMessage, ContentPart } from '@dexto/core';
|
||||
|
||||
interface QueuedMessagesDisplayProps {
|
||||
messages: QueuedMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from ContentPart[]
|
||||
*/
|
||||
function getMessageText(content: ContentPart[]): string {
|
||||
const textParts = content
|
||||
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
||||
.map((part) => part.text);
|
||||
return textParts.join(' ') || '[attachment]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to fit terminal width
|
||||
*/
|
||||
function truncateText(text: string, maxLength: number = 60): string {
|
||||
// Replace newlines with spaces for single-line display
|
||||
const singleLine = text.replace(/\n/g, ' ').trim();
|
||||
if (singleLine.length <= maxLength) {
|
||||
return singleLine;
|
||||
}
|
||||
return singleLine.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
export function QueuedMessagesDisplay({ messages }: QueuedMessagesDisplayProps) {
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{/* Header with count and keyboard hint */}
|
||||
<Box>
|
||||
<Text color="gray">
|
||||
{messages.length} message{messages.length !== 1 ? 's' : ''} queued
|
||||
</Text>
|
||||
<Text color="gray"> • </Text>
|
||||
<Text color="gray">↑ to edit</Text>
|
||||
</Box>
|
||||
|
||||
{/* Messages list */}
|
||||
{messages.map((message, index) => (
|
||||
<Box key={message.id} flexDirection="row">
|
||||
{/* Arrow indicator - last message gets special indicator */}
|
||||
<Text color="gray">{index === messages.length - 1 ? '↳ ' : '│ '}</Text>
|
||||
{/* Message preview */}
|
||||
<Text color="gray" italic>
|
||||
{truncateText(getMessageText(message.content))}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* ToolIcon Component
|
||||
* Animated icon for tool calls with status-based visual feedback
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Text } from 'ink';
|
||||
import type { ToolStatus } from '../../state/types.js';
|
||||
|
||||
interface ToolIconProps {
|
||||
status: ToolStatus;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
// Spinner frames for running animation
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
|
||||
/**
|
||||
* Animated tool icon that changes based on execution status
|
||||
* - Running: Animated spinner (green/teal)
|
||||
* - Finished (success): Green dot
|
||||
* - Finished (error): Red dot
|
||||
*/
|
||||
export function ToolIcon({ status, isError }: ToolIconProps) {
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
// Animate spinner only when actually running (not during approval)
|
||||
useEffect(() => {
|
||||
if (status !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setFrame((prev) => (prev + 1) % SPINNER_FRAMES.length);
|
||||
}, 80); // 80ms per frame for smooth animation
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [status]);
|
||||
|
||||
// Pending: static gray dot (tool call received, checking approval)
|
||||
if (status === 'pending') {
|
||||
return <Text color="gray">● </Text>;
|
||||
}
|
||||
|
||||
// Pending approval: static yellowBright dot (waiting for user)
|
||||
if (status === 'pending_approval') {
|
||||
return (
|
||||
<Text color="yellowBright" bold>
|
||||
●{' '}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'finished') {
|
||||
// Error state: red dot
|
||||
if (isError) {
|
||||
return (
|
||||
<Text color="red" bold>
|
||||
●{' '}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
// Success state: green dot
|
||||
return (
|
||||
<Text color="green" bold>
|
||||
●{' '}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Running state with spinner
|
||||
return (
|
||||
<Text color="green" bold>
|
||||
{SPINNER_FRAMES[frame]}{' '}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
10
dexto/packages/cli/src/cli/ink-cli/components/chat/index.ts
Normal file
10
dexto/packages/cli/src/cli/ink-cli/components/chat/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Chat components module exports
|
||||
*/
|
||||
|
||||
export { Header } from './Header.js';
|
||||
export { Footer } from './Footer.js';
|
||||
export { MessageItem } from './MessageItem.js';
|
||||
export { MessageList } from './MessageList.js';
|
||||
export { ChatView } from './ChatView.js';
|
||||
export { ToolIcon } from './ToolIcon.js';
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* ConfigBox - Styled output for /config command
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ConfigStyledData } from '../../../state/types.js';
|
||||
import { StyledBox, StyledSection, StyledRow } from './StyledBox.js';
|
||||
|
||||
interface ConfigBoxProps {
|
||||
data: ConfigStyledData;
|
||||
}
|
||||
|
||||
export function ConfigBox({ data }: ConfigBoxProps) {
|
||||
return (
|
||||
<StyledBox title="Runtime Configuration" titleColor="cyan">
|
||||
{/* Config file path at the top */}
|
||||
{data.configFilePath && (
|
||||
<Box>
|
||||
<Text color="gray">Agent config: </Text>
|
||||
<Text color="blue">{data.configFilePath}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<StyledSection title="LLM">
|
||||
<StyledRow label="Provider" value={data.provider} />
|
||||
<StyledRow label="Model" value={data.model} />
|
||||
{data.maxTokens !== null && (
|
||||
<StyledRow label="Max Tokens" value={data.maxTokens.toString()} />
|
||||
)}
|
||||
{data.temperature !== null && (
|
||||
<StyledRow label="Temperature" value={data.temperature.toString()} />
|
||||
)}
|
||||
</StyledSection>
|
||||
|
||||
<StyledSection title="Tool Confirmation">
|
||||
<StyledRow label="Mode" value={data.toolConfirmationMode} />
|
||||
</StyledSection>
|
||||
|
||||
<StyledSection title="Sessions">
|
||||
<StyledRow label="Max Sessions" value={data.maxSessions} />
|
||||
<StyledRow label="Session TTL" value={data.sessionTTL} />
|
||||
</StyledSection>
|
||||
|
||||
<StyledSection title="MCP Servers">
|
||||
{data.mcpServers.length > 0 ? (
|
||||
data.mcpServers.map((server) => (
|
||||
<Box key={server}>
|
||||
<Text color="cyan">{server}</Text>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray">No MCP servers configured</Text>
|
||||
)}
|
||||
</StyledSection>
|
||||
|
||||
{data.promptsCount > 0 && (
|
||||
<StyledSection title="Prompts">
|
||||
<Text color="gray">{data.promptsCount} prompt(s) configured</Text>
|
||||
</StyledSection>
|
||||
)}
|
||||
|
||||
{data.pluginsEnabled.length > 0 && (
|
||||
<StyledSection title="Plugins">
|
||||
{data.pluginsEnabled.map((plugin) => (
|
||||
<Box key={plugin}>
|
||||
<Text color="green">{plugin}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</StyledSection>
|
||||
)}
|
||||
|
||||
{/* Footer note about CLI-populated fields */}
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" italic>
|
||||
Note: Some fields (logs, database paths) are auto-populated by the CLI.
|
||||
</Text>
|
||||
</Box>
|
||||
</StyledBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* HelpBox - Styled output for /help command
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { HelpStyledData } from '../../../state/types.js';
|
||||
import { StyledBox } from './StyledBox.js';
|
||||
|
||||
interface HelpBoxProps {
|
||||
data: HelpStyledData;
|
||||
}
|
||||
|
||||
export function HelpBox({ data }: HelpBoxProps) {
|
||||
// Group commands by category
|
||||
const categories = data.commands.reduce(
|
||||
(acc, cmd) => {
|
||||
const cat = cmd.category || 'Other';
|
||||
if (!acc[cat]) {
|
||||
acc[cat] = [];
|
||||
}
|
||||
acc[cat].push(cmd);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof data.commands>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledBox title="Available Commands">
|
||||
{Object.entries(categories).map(([category, commands]) => (
|
||||
<Box key={category} flexDirection="column" marginTop={1}>
|
||||
<Text bold color="gray">
|
||||
{category}
|
||||
</Text>
|
||||
{commands.map((cmd) => (
|
||||
<Box key={cmd.name} marginLeft={2}>
|
||||
<Box width={16}>
|
||||
<Text color="cyan">/{cmd.name}</Text>
|
||||
</Box>
|
||||
<Text color="gray">{cmd.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</StyledBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* LogConfigBox - Styled output for /log command (no args)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { LogConfigStyledData } from '../../../state/types.js';
|
||||
import { StyledBox, StyledRow, StyledListItem } from './StyledBox.js';
|
||||
|
||||
interface LogConfigBoxProps {
|
||||
data: LogConfigStyledData;
|
||||
}
|
||||
|
||||
export function LogConfigBox({ data }: LogConfigBoxProps) {
|
||||
return (
|
||||
<StyledBox title="Logging Configuration">
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<StyledRow label="Current level" value={data.currentLevel} valueColor="green" />
|
||||
{data.logFile && process.env.DEXTO_DEV_MODE === 'true' && (
|
||||
<StyledRow label="Log file" value={data.logFile} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color="gray">Available levels (least to most verbose):</Text>
|
||||
{data.availableLevels.map((level) => {
|
||||
const isCurrent = level === data.currentLevel;
|
||||
return (
|
||||
<StyledListItem
|
||||
key={level}
|
||||
icon={isCurrent ? '>' : ' '}
|
||||
text={level}
|
||||
isActive={isCurrent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Use /log <level> to change level</Text>
|
||||
</Box>
|
||||
</StyledBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* SessionHistoryBox - Styled output for /session history command
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { SessionHistoryStyledData } from '../../../state/types.js';
|
||||
import { StyledBox } from './StyledBox.js';
|
||||
|
||||
interface SessionHistoryBoxProps {
|
||||
data: SessionHistoryStyledData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate content to a reasonable preview length
|
||||
*/
|
||||
function truncateContent(content: string, maxLength: number = 100): string {
|
||||
if (content.length <= maxLength) return content;
|
||||
return content.slice(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role color and icon
|
||||
*/
|
||||
function getRoleStyle(role: string): { color: string; icon: string } {
|
||||
switch (role) {
|
||||
case 'user':
|
||||
return { color: 'blue', icon: '>' };
|
||||
case 'assistant':
|
||||
return { color: 'green', icon: '|' };
|
||||
case 'system':
|
||||
return { color: 'orange', icon: '#' };
|
||||
case 'tool':
|
||||
return { color: 'green', icon: '*' };
|
||||
default:
|
||||
return { color: 'white', icon: '-' };
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionHistoryBox({ data }: SessionHistoryBoxProps) {
|
||||
if (data.messages.length === 0) {
|
||||
return (
|
||||
<StyledBox title={`Session History: ${data.sessionId.slice(0, 8)}`}>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">No messages in this session yet.</Text>
|
||||
</Box>
|
||||
</StyledBox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledBox title={`Session History: ${data.sessionId.slice(0, 8)}`}>
|
||||
{data.messages.map((msg, index) => {
|
||||
const style = getRoleStyle(msg.role);
|
||||
return (
|
||||
<Box key={index} flexDirection="column" marginTop={index === 0 ? 1 : 0}>
|
||||
<Box>
|
||||
<Text color={style.color} bold>
|
||||
{style.icon}{' '}
|
||||
</Text>
|
||||
<Text color={style.color}>[{msg.role}]</Text>
|
||||
<Text color="gray"> {msg.timestamp}</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text>{truncateContent(msg.content)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Total: {data.total} messages</Text>
|
||||
</Box>
|
||||
</StyledBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* SessionListBox - Styled output for /session list command
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { SessionListStyledData } from '../../../state/types.js';
|
||||
import { StyledBox } from './StyledBox.js';
|
||||
|
||||
interface SessionListBoxProps {
|
||||
data: SessionListStyledData;
|
||||
}
|
||||
|
||||
export function SessionListBox({ data }: SessionListBoxProps) {
|
||||
if (data.sessions.length === 0) {
|
||||
return (
|
||||
<StyledBox title="Sessions">
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">No sessions found.</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Run `dexto` to start a new session.</Text>
|
||||
</Box>
|
||||
</StyledBox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledBox title="Sessions">
|
||||
{data.sessions.map((session) => (
|
||||
<Box key={session.id} marginTop={1}>
|
||||
<Box width={12}>
|
||||
<Text color={session.isCurrent ? 'green' : 'cyan'} bold={session.isCurrent}>
|
||||
{session.isCurrent ? '>' : ' '} {session.id.slice(0, 8)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={14}>
|
||||
<Text color="gray">{session.messageCount} messages</Text>
|
||||
</Box>
|
||||
<Text color="gray">{session.lastActive}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Total: {data.total} sessions</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="gray">Use /resume to switch sessions</Text>
|
||||
</Box>
|
||||
</StyledBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* ShortcutsBox - Styled output for /shortcuts command
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ShortcutsStyledData } from '../../../state/types.js';
|
||||
import { StyledBox } from './StyledBox.js';
|
||||
|
||||
interface ShortcutsBoxProps {
|
||||
data: ShortcutsStyledData;
|
||||
}
|
||||
|
||||
export function ShortcutsBox({ data }: ShortcutsBoxProps) {
|
||||
return (
|
||||
<StyledBox title="Keyboard Shortcuts">
|
||||
{data.categories.map((category, catIndex) => (
|
||||
<Box key={category.name} flexDirection="column" marginTop={catIndex === 0 ? 0 : 1}>
|
||||
<Text bold color="cyan">
|
||||
{category.name}
|
||||
</Text>
|
||||
{category.shortcuts.map((shortcut) => (
|
||||
<Box key={shortcut.keys} marginLeft={2}>
|
||||
<Box width={16}>
|
||||
<Text color="cyan">{shortcut.keys}</Text>
|
||||
</Box>
|
||||
<Text color="gray">{shortcut.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</StyledBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* StatsBox - Styled output for /stats command
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { StatsStyledData } from '../../../state/types.js';
|
||||
import { StyledBox, StyledSection, StyledRow } from './StyledBox.js';
|
||||
|
||||
interface StatsBoxProps {
|
||||
data: StatsStyledData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number with K/M suffixes for compact display
|
||||
*/
|
||||
function formatTokenCount(count: number): string {
|
||||
if (count >= 1_000_000) {
|
||||
return `${(count / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (count >= 1_000) {
|
||||
return `${(count / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format cost in USD with appropriate precision
|
||||
*/
|
||||
function formatCost(cost: number): string {
|
||||
if (cost < 0.01) {
|
||||
return `$${cost.toFixed(4)}`;
|
||||
}
|
||||
if (cost < 1) {
|
||||
return `$${cost.toFixed(3)}`;
|
||||
}
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function StatsBox({ data }: StatsBoxProps) {
|
||||
return (
|
||||
<StyledBox title="System Statistics">
|
||||
<StyledSection title="Sessions">
|
||||
<StyledRow label="Total Sessions" value={data.sessions.total.toString()} />
|
||||
<StyledRow label="In Memory" value={data.sessions.inMemory.toString()} />
|
||||
<StyledRow label="Max Allowed" value={data.sessions.maxAllowed.toString()} />
|
||||
</StyledSection>
|
||||
|
||||
<StyledSection title="MCP Servers">
|
||||
<StyledRow
|
||||
label="Connected"
|
||||
value={data.mcp.connected.toString()}
|
||||
valueColor="green"
|
||||
/>
|
||||
{data.mcp.failed > 0 && (
|
||||
<StyledRow label="Failed" value={data.mcp.failed.toString()} valueColor="red" />
|
||||
)}
|
||||
<StyledRow label="Available Tools" value={data.mcp.toolCount.toString()} />
|
||||
</StyledSection>
|
||||
|
||||
{data.tokenUsage && (
|
||||
<StyledSection title="Token Usage (This Session)">
|
||||
<StyledRow
|
||||
label="Input"
|
||||
value={formatTokenCount(data.tokenUsage.inputTokens)}
|
||||
/>
|
||||
<StyledRow
|
||||
label="Output"
|
||||
value={formatTokenCount(data.tokenUsage.outputTokens)}
|
||||
/>
|
||||
{data.tokenUsage.reasoningTokens > 0 && (
|
||||
<StyledRow
|
||||
label="Reasoning"
|
||||
value={formatTokenCount(data.tokenUsage.reasoningTokens)}
|
||||
/>
|
||||
)}
|
||||
{data.tokenUsage.cacheReadTokens > 0 && (
|
||||
<StyledRow
|
||||
label="Cache Read"
|
||||
value={formatTokenCount(data.tokenUsage.cacheReadTokens)}
|
||||
valueColor="cyan"
|
||||
/>
|
||||
)}
|
||||
{data.tokenUsage.cacheWriteTokens > 0 && (
|
||||
<StyledRow
|
||||
label="Cache Write"
|
||||
value={formatTokenCount(data.tokenUsage.cacheWriteTokens)}
|
||||
valueColor="orange"
|
||||
/>
|
||||
)}
|
||||
<StyledRow
|
||||
label="Total"
|
||||
value={formatTokenCount(data.tokenUsage.totalTokens)}
|
||||
valueColor="blue"
|
||||
/>
|
||||
{data.estimatedCost !== undefined && (
|
||||
<StyledRow
|
||||
label="Est. Cost"
|
||||
value={formatCost(data.estimatedCost)}
|
||||
valueColor="green"
|
||||
/>
|
||||
)}
|
||||
</StyledSection>
|
||||
)}
|
||||
</StyledBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* StyledBox - Base component for styled command output
|
||||
* Provides consistent box styling for structured output
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useTerminalSize } from '../../../hooks/useTerminalSize.js';
|
||||
|
||||
interface StyledBoxProps {
|
||||
title: string;
|
||||
titleColor?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base styled box component with rounded border and title
|
||||
* Automatically adjusts width to terminal size
|
||||
*/
|
||||
export function StyledBox({ title, titleColor = 'cyan', children }: StyledBoxProps) {
|
||||
const { columns } = useTerminalSize();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width={columns}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box marginBottom={0}>
|
||||
<Text bold color={titleColor}>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface StyledSectionProps {
|
||||
title: string;
|
||||
icon?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section within a styled box
|
||||
*/
|
||||
export function StyledSection({ title, icon, children }: StyledSectionProps) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>
|
||||
{icon && `${icon} `}
|
||||
{title}
|
||||
</Text>
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface StyledRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
valueColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key-value row within a section
|
||||
*/
|
||||
export function StyledRow({ label, value, valueColor = 'cyan' }: StyledRowProps) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">{label}: </Text>
|
||||
<Text color={valueColor}>{value}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface StyledListItemProps {
|
||||
icon?: string;
|
||||
text: string;
|
||||
isActive?: boolean;
|
||||
dimmed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* List item with optional icon and active state
|
||||
*/
|
||||
export function StyledListItem({ icon, text, isActive, dimmed }: StyledListItemProps) {
|
||||
// Build props object conditionally to avoid undefined with exactOptionalPropertyTypes
|
||||
const textProps: Record<string, unknown> = {};
|
||||
if (isActive) {
|
||||
textProps.color = 'green';
|
||||
textProps.bold = true;
|
||||
}
|
||||
if (dimmed) {
|
||||
textProps.color = 'gray';
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{icon && <Text {...textProps}>{icon} </Text>}
|
||||
<Text {...textProps}>{text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* SyspromptBox - Styled output for /sysprompt command
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { SysPromptStyledData } from '../../../state/types.js';
|
||||
import { StyledBox } from './StyledBox.js';
|
||||
|
||||
interface SyspromptBoxProps {
|
||||
data: SysPromptStyledData;
|
||||
}
|
||||
|
||||
export function SyspromptBox({ data }: SyspromptBoxProps) {
|
||||
return (
|
||||
<StyledBox title="System Prompt" titleColor="green">
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text>{data.content}</Text>
|
||||
</Box>
|
||||
</StyledBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Styled boxes for command output
|
||||
*/
|
||||
|
||||
export { StyledBox, StyledSection, StyledRow, StyledListItem } from './StyledBox.js';
|
||||
export { ConfigBox } from './ConfigBox.js';
|
||||
export { StatsBox } from './StatsBox.js';
|
||||
export { HelpBox } from './HelpBox.js';
|
||||
export { SessionListBox } from './SessionListBox.js';
|
||||
export { SessionHistoryBox } from './SessionHistoryBox.js';
|
||||
export { LogConfigBox } from './LogConfigBox.js';
|
||||
export { ShortcutsBox } from './ShortcutsBox.js';
|
||||
export { SyspromptBox } from './SyspromptBox.js';
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* InputArea Component
|
||||
* Wrapper around TextBufferInput - accepts buffer from parent
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { TextBufferInput, type OverlayTrigger } from '../TextBufferInput.js';
|
||||
import type { TextBuffer } from '../shared/text-buffer.js';
|
||||
import type { PendingImage, PastedBlock } from '../../state/types.js';
|
||||
|
||||
export type { OverlayTrigger };
|
||||
|
||||
interface InputAreaProps {
|
||||
/** Text buffer (owned by parent) */
|
||||
buffer: TextBuffer;
|
||||
/** Called when user submits */
|
||||
onSubmit: (value: string) => void;
|
||||
/** Whether input is currently disabled */
|
||||
isDisabled: boolean;
|
||||
/** Whether input should handle keypresses */
|
||||
isActive: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string | undefined;
|
||||
/** History navigation callback */
|
||||
onHistoryNavigate?: ((direction: 'up' | 'down') => void) | undefined;
|
||||
/** Overlay trigger callback */
|
||||
onTriggerOverlay?: ((trigger: OverlayTrigger) => void) | undefined;
|
||||
/** Keyboard scroll callback (for alternate buffer mode) */
|
||||
onKeyboardScroll?: ((direction: 'up' | 'down') => void) | undefined;
|
||||
/** Current number of attached images (for placeholder numbering) */
|
||||
imageCount?: number | undefined;
|
||||
/** Called when image is pasted from clipboard */
|
||||
onImagePaste?: ((image: PendingImage) => void) | undefined;
|
||||
/** Current pending images (for placeholder removal detection) */
|
||||
images?: PendingImage[] | undefined;
|
||||
/** Called when an image placeholder is removed from text */
|
||||
onImageRemove?: ((imageId: string) => void) | undefined;
|
||||
/** Current pasted blocks for collapse/expand feature */
|
||||
pastedBlocks?: PastedBlock[] | undefined;
|
||||
/** Called when a large paste is detected and should be collapsed */
|
||||
onPasteBlock?: ((block: PastedBlock) => void) | undefined;
|
||||
/** Called to update a pasted block (e.g., toggle collapse) */
|
||||
onPasteBlockUpdate?: ((blockId: string, updates: Partial<PastedBlock>) => void) | undefined;
|
||||
/** Called when a paste block placeholder is removed from text */
|
||||
onPasteBlockRemove?: ((blockId: string) => void) | undefined;
|
||||
/** Query to highlight in input text (for history search) */
|
||||
highlightQuery?: string | undefined;
|
||||
}
|
||||
|
||||
export function InputArea({
|
||||
buffer,
|
||||
onSubmit,
|
||||
isDisabled,
|
||||
isActive,
|
||||
placeholder,
|
||||
onHistoryNavigate,
|
||||
onTriggerOverlay,
|
||||
onKeyboardScroll,
|
||||
imageCount,
|
||||
onImagePaste,
|
||||
images,
|
||||
onImageRemove,
|
||||
pastedBlocks,
|
||||
onPasteBlock,
|
||||
onPasteBlockUpdate,
|
||||
onPasteBlockRemove,
|
||||
highlightQuery,
|
||||
}: InputAreaProps) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<TextBufferInput
|
||||
buffer={buffer}
|
||||
onSubmit={onSubmit}
|
||||
placeholder={placeholder}
|
||||
isDisabled={isDisabled}
|
||||
isActive={isActive}
|
||||
onHistoryNavigate={onHistoryNavigate}
|
||||
onTriggerOverlay={onTriggerOverlay}
|
||||
onKeyboardScroll={onKeyboardScroll}
|
||||
imageCount={imageCount}
|
||||
onImagePaste={onImagePaste}
|
||||
images={images}
|
||||
onImageRemove={onImageRemove}
|
||||
pastedBlocks={pastedBlocks}
|
||||
onPasteBlock={onPasteBlock}
|
||||
onPasteBlockUpdate={onPasteBlockUpdate}
|
||||
onPasteBlockRemove={onPasteBlockRemove}
|
||||
highlightQuery={highlightQuery}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user