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:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
# @dexto/agent-management
## 1.5.6
### Patch Changes
- 042f4f0: ### CLI Improvements
- Add `/export` command to export conversations as Markdown or JSON
- Add `Ctrl+T` toggle for task list visibility during processing
- Improve task list UI with collapsible view near the processing message
- Fix race condition causing duplicate rendering (mainly visible with explore tool)
- Don't truncate `pattern` and `question` args in tool output display
### Bug Fixes
- Fix build script to preserve `.dexto` storage (conversations, logs) during clean builds
- Fix `@dexto/tools-todo` versioning - add to fixed version group in changeset config
### Configuration Changes
- Remove approval timeout defaults - now waits indefinitely (better UX for CLI)
- Add package versioning guidelines to AGENTS.md
- Updated dependencies [042f4f0]
- @dexto/core@1.5.6
## 1.5.5
### Patch Changes
- Updated dependencies [63fa083]
- Updated dependencies [6df3ca9]
- @dexto/core@1.5.5
## 1.5.4
### Patch Changes
- aa2c9a0: - new --dev flag for using dev mode with the CLI (for maintainers) (sets DEXTO_DEV_MODE=true and ensures local files are used)
- improved bash tool descriptions
- fixed explore agent task description getting truncated
- fixed some alignment issues
- fix search/find tools not asking approval for working outside directory
- add sound feature (sounds when approval reqd, when loop done)
- configurable in `preferences.yml` (on by default) and in `~/.dexto/sounds`, instructions in comment in `~/.dexto/preferences.yml`
- add new `env` system prompt contributor that includes info about os, working directory, git status. useful for coding agent to get enough context to improve cmd construction without unnecessary directory shifts
- support for loading `.claude/commands` and `.cursor/commands` global and local commands in addition to `.dexto/commands`
- Updated dependencies [0016cd3]
- Updated dependencies [499b890]
- Updated dependencies [aa2c9a0]
- @dexto/core@1.5.4
## 1.5.3
### Patch Changes
- 4f00295: Added spawn-agent tools and explore agent.
- Updated dependencies [4f00295]
- Updated dependencies [69c944c]
- @dexto/core@1.5.3
## 1.5.2
### Patch Changes
- 91acb03: Fix typo in agents.md detection
- Updated dependencies [8a85ea4]
- Updated dependencies [527f3f9]
- @dexto/core@1.5.2
## 1.5.1
### Patch Changes
- a25d3ee: Add shell command execution (`!command` shortcut), token counting display, and auto-discovery of agent instruction files (agent.md, claude.md, gemini.md)
- bfcc7b1: PostgreSQL improvements and privacy mode
**PostgreSQL enhancements:**
- Add connection resilience for serverless databases (Neon, Supabase, etc.) with automatic retry on connection failures
- Support custom PostgreSQL schemas via `options.schema` config
- Add schema name validation to prevent SQL injection
- Improve connection pool error handling to prevent process crashes
**Privacy mode:**
- Add `--privacy-mode` CLI flag to hide file paths from output (useful for screen recording/sharing)
- Can also be enabled via `DEXTO_PRIVACY_MODE=true` environment variable
**Session improvements:**
- Add message deduplication in history provider to handle data corruption gracefully
- Add warning when conversation history hits 10k message limit
- Improve session deletion to ensure messages are always cleaned up
**Other fixes:**
- Sanitize explicit `agentId` for filesystem safety
- Change verbose flush logs to debug level
- Export `BaseTypedEventEmitter` from events module
- 4aabdb7: Fix claude caching, added gpt-5.2 models and reasoning effort options in user flows.
- Updated dependencies [bfcc7b1]
- Updated dependencies [4aabdb7]
- @dexto/core@1.5.1
## 1.5.0
### Minor Changes
- e7722e5: Minor version bump for new release with bundler, custom tool pkgs, etc.
### Patch Changes
- ee12727: Added support for node-llama (llama.cpp) for local GGUF models. Added Ollama as first-class provider. Updated onboarding/setup flow.
- 4c05310: Improve local model/GGUF model support, bash permission fixes in TUI, and add local/ollama switching/deleting support in web UI
- 436a900: Add support for openrouter, bedrock, glama, vertex ai, fix model switching issues and new model experience for each
- Updated dependencies [ee12727]
- Updated dependencies [1e7e974]
- Updated dependencies [4c05310]
- Updated dependencies [5fa79fa]
- Updated dependencies [ef40e60]
- Updated dependencies [e714418]
- Updated dependencies [e7722e5]
- Updated dependencies [7d5ab19]
- Updated dependencies [436a900]
- @dexto/core@1.5.0
## 1.4.0
### Minor Changes
- f73a519: Revamp CLI. Breaking change to DextoAgent.generate() and stream() apis and hono message APIs, so new minor version. Other fixes for logs, web UI related to message streaming/generating
### Patch Changes
- 7a64414: Updated agent-management to use AgentManager instead of AgentOrchestrator.
- 3cdce89: Revamp CLI for coding agent, add new events, improve mcp management, custom models, minor UI changes, prompt management
- d640e40: Remove LLM services, tokenizers, just stick with vercel, remove 'router' from schema and all types and docs
- c54760f: Revamp context management layer - add partial stream cancellation, message queueing, context compression with LLM, MCP UI support and gaming agent. New APIs and UI changes for these things
- Updated dependencies [bd5c097]
- Updated dependencies [3cdce89]
- Updated dependencies [d640e40]
- Updated dependencies [6f5627d]
- Updated dependencies [6e6a3e7]
- Updated dependencies [f73a519]
- Updated dependencies [c54760f]
- Updated dependencies [ab47df8]
- Updated dependencies [3b4b919]
- @dexto/core@1.4.0
## 1.3.0
### Patch Changes
- Updated dependencies [e2f770b]
- Updated dependencies [f843b62]
- Updated dependencies [eb266af]
- @dexto/core@1.3.0
## 1.2.6
### Patch Changes
- Updated dependencies [7feb030]
- @dexto/core@1.2.6
## 1.2.5
### Patch Changes
- c1e814f: ## Logger v2 & Config Enrichment
### New Features
- **Multi-transport logging system**: Configure console, file, and remote logging transports via `logger` field in agent.yml. Supports log levels (error, warn, info, debug, silly) and automatic log rotation for file transports.
- **Per-agent isolation**: CLI automatically creates per-agent log files at `~/.dexto/logs/<agent-id>.log`, database at `~/.dexto/database/<agent-id>.db`, and blob storage at `~/.dexto/blobs/<agent-id>/`
- **Agent ID derivation**: Agent ID is now automatically derived from `agentCard.name` (sanitized) or config filename, enabling proper multi-agent isolation without manual configuration
### Breaking Changes
- **Storage blob default changed**: Default blob storage type changed from `local` to `in-memory`. Existing configs with explicit `blob: { type: 'local' }` are unaffected. CLI enrichment provides automatic paths for SQLite and local blob storage.
### Improvements
- **Config enrichment layer**: New `enrichAgentConfig()` in agent-management package adds per-agent paths before initialization, eliminating path resolution in core services
- **Logger error factory**: Added typed error factory pattern for logger errors following project conventions
- **Removed wildcard exports**: Logger module now uses explicit named exports for better tree-shaking
### Documentation
- Added complete logger configuration section to agent.yml documentation
- Documented agentId field and derivation rules
- Updated storage documentation with CLI auto-configuration notes
- Added logger v2 architecture notes to core README
- a154ae0: UI refactor with TanStack Query, new agent management package, and Hono as default server
**Server:**
- Make Hono the default API server (use `DEXTO_USE_EXPRESS=true` env var to use Express)
- Fix agentId propagation to Hono server for correct agent name display
- Fix circular reference crashes in error logging by using structured logger context
**WebUI:**
- Integrate TanStack Query for server state management with automatic caching and invalidation
- Add centralized query key factory and API client with structured error handling
- Replace manual data fetching with TanStack Query hooks across all components
- Add Zustand for client-side persistent state (recent agents in localStorage)
- Add keyboard shortcuts support with react-hotkeys-hook
- Add optimistic updates for session management via WebSocket events
- Fix Dialog auto-close bug in CreateMemoryModal
- Add defensive null handling in MemoryPanel
- Standardize Prettier formatting (single quotes, 4-space indentation)
**Agent Management:**
- Add `@dexto/agent-management` package for centralized agent configuration management
- Extract agent registry, preferences, and path utilities into dedicated package
**Internal:**
- Improve build orchestration and fix dependency imports
- Add `@dexto/agent-management` to global CLI installation
- ac649fd: Fix error handling and UI bugs, add gpt-5.1, gemini-3
- Updated dependencies [c1e814f]
- Updated dependencies [f9bca72]
- Updated dependencies [c0a10cd]
- Updated dependencies [81598b5]
- Updated dependencies [4c90ffe]
- Updated dependencies [1a20506]
- Updated dependencies [8f373cc]
- Updated dependencies [f28ad7e]
- Updated dependencies [4dd4998]
- Updated dependencies [5e27806]
- Updated dependencies [a35a256]
- Updated dependencies [0fa6ef5]
- Updated dependencies [e2fb5f8]
- Updated dependencies [a154ae0]
- Updated dependencies [ac649fd]
- @dexto/core@1.2.5

View File

@@ -0,0 +1,41 @@
{
"name": "@dexto/agent-management",
"version": "1.5.6",
"private": false,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
"dependencies": {
"@dexto/core": "workspace:*",
"yaml": "^2.7.1",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/node": "^22.13.5"
},
"peerDependencies": {
"zod": "^3.25.0"
},
"scripts": {
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tsup && cross-env NODE_OPTIONS='--max-old-space-size=4096' tsc -p tsconfig.json --emitDeclarationOnly && node scripts/fix-dist-aliases.mjs",
"dev": "tsup --watch",
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
"lint": "eslint . --ext .ts"
},
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
},
"sideEffects": false
}

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
/* eslint-env node */
import console from 'node:console';
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import { dirname, extname, join, relative, resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DIST_DIR = resolve(__dirname, '../dist');
const PROCESS_EXTENSIONS = new Set(['.js', '.cjs', '.mjs', '.ts', '.mts', '.cts']);
const IMPORT_PATTERN = /(['"])@agent-management\/([^'"]+)\1/g;
function collectFiles(root) {
const entries = readdirSync(root);
const files = [];
for (const entry of entries) {
const fullPath = join(root, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
files.push(...collectFiles(fullPath));
} else {
files.push(fullPath);
}
}
return files;
}
function resolveImport(fromFile, subpath) {
const fromExt = extname(fromFile);
const preferredExt = fromExt === '.cjs' ? '.cjs' : '.js';
const candidateBase = subpath.replace(/\.(mjs|cjs|js)$/, '');
const bases = [candidateBase];
if (!candidateBase.endsWith('index')) {
bases.push(join(candidateBase, 'index'));
}
const candidates = [];
for (const base of bases) {
const exts = Array.from(new Set([preferredExt, '.mjs', '.js', '.cjs']));
for (const ext of exts) {
candidates.push(`${base}${ext}`);
}
}
for (const candidate of candidates) {
const absolute = resolve(DIST_DIR, candidate);
if (existsSync(absolute)) {
let relativePath = relative(dirname(fromFile), absolute).replace(/\\/g, '/');
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}
return relativePath;
}
}
return null;
}
function rewriteAliases(filePath) {
const ext = extname(filePath);
if (!PROCESS_EXTENSIONS.has(ext)) {
return false;
}
const original = readFileSync(filePath, 'utf8');
let modified = false;
const updated = original.replace(IMPORT_PATTERN, (match, quote, requested) => {
const resolved = resolveImport(filePath, requested);
if (!resolved) {
console.warn(`⚠️ Unable to resolve alias @agent-management/${requested} in ${filePath}`);
return match;
}
modified = true;
return `${quote}${resolved}${quote}`;
});
if (modified) {
writeFileSync(filePath, updated, 'utf8');
}
return modified;
}
function main() {
if (!existsSync(DIST_DIR)) {
console.error(`❌ dist directory not found at ${DIST_DIR}`);
process.exit(1);
}
const files = collectFiles(DIST_DIR);
let changed = 0;
for (const file of files) {
if (rewriteAliases(file)) {
changed += 1;
}
}
console.log(` Fixed alias imports in ${changed} files.`);
}
main();

View File

@@ -0,0 +1,208 @@
/**
* AgentFactory - Static convenience API for agent operations
*
* USE THIS WHEN: You need a simple, direct way to create agents or manage installations.
* No registry file required for agent creation.
*
* Key methods:
* - `AgentFactory.createAgent(config)` - Create agent from inline config (DB, API, dynamic)
* - `AgentFactory.installAgent(id)` - Install agent from bundled registry
* - `AgentFactory.uninstallAgent(id)` - Remove installed agent
* - `AgentFactory.listAgents()` - List installed/available agents
*
* Examples:
* - SaaS platforms with per-tenant configs from database
* - Dynamically constructed agent configurations
* - Quick scripts and demos
* - Single-agent applications
*
* FOR REGISTRY-BASED MULTI-AGENT SCENARIOS: Use `AgentManager` instead.
* This is better when you have a registry.json with multiple predefined agents
* and need discovery/selection capabilities.
*
* @see AgentManager for registry-based agent management
* @see https://docs.dexto.ai/api/sdk/agent-factory for full documentation
*/
import { promises as fs } from 'fs';
import { DextoAgent, type AgentConfig } from '@dexto/core';
import { getDextoGlobalPath } from './utils/path.js';
import { deriveDisplayName } from './registry/types.js';
import { loadBundledRegistryAgents } from './registry/registry.js';
import { enrichAgentConfig } from './config/index.js';
import {
installBundledAgent,
installCustomAgent,
uninstallAgent,
type InstallOptions,
} from './installation.js';
import type { AgentMetadata } from './AgentManager.js';
/**
* Options for listing agents
*/
export interface ListAgentsOptions {
/** Fallback description when not provided */
descriptionFallback?: string;
/** Fallback description for custom agents */
customAgentDescriptionFallback?: string;
}
/**
* Options for creating an agent from inline config
*/
export interface CreateAgentOptions {
/** Override agent ID (otherwise derived from agentCard.name or defaults to 'inline-agent') */
agentId?: string;
/** Whether this is interactive CLI mode (affects logger defaults) */
isInteractiveCli?: boolean;
}
/**
* Static API for agent management operations
* Provides convenient methods for listing, installing, and uninstalling agents
*/
export const AgentFactory = {
/**
* List all agents (installed and available from bundled registry)
* @param options - Optional fallback values for descriptions
*/
async listAgents(options?: ListAgentsOptions) {
const bundledAgents = loadBundledRegistryAgents();
const installed = await listInstalledAgents();
const descriptionFallback = options?.descriptionFallback ?? '';
const customAgentDescriptionFallback =
options?.customAgentDescriptionFallback ?? descriptionFallback;
// Build installed agent list
const installedAgents = installed.map((id) => {
const bundledEntry = bundledAgents[id];
return {
id,
name: bundledEntry?.name || deriveDisplayName(id),
description:
bundledEntry?.description ||
(bundledEntry ? descriptionFallback : customAgentDescriptionFallback),
author: bundledEntry?.author || '',
tags: bundledEntry?.tags || [],
type: bundledEntry ? ('builtin' as const) : ('custom' as const),
};
});
// Build available agent list (not installed)
const installedSet = new Set(installed);
const availableAgents = Object.entries(bundledAgents)
.filter(([id]) => !installedSet.has(id))
.map(([id, entry]: [string, any]) => ({
id,
name: entry.name,
description: entry.description || descriptionFallback,
author: entry.author || '',
tags: entry.tags || [],
type: 'builtin' as const,
}));
return {
installed: installedAgents,
available: availableAgents,
};
},
/**
* Install an agent from the bundled registry
*/
async installAgent(agentId: string, options?: InstallOptions): Promise<string> {
return installBundledAgent(agentId, options);
},
/**
* Install a custom agent from local path
*/
async installCustomAgent(
agentId: string,
sourcePath: string,
metadata: Pick<AgentMetadata, 'name' | 'description' | 'author' | 'tags'>,
options?: InstallOptions
): Promise<string> {
return installCustomAgent(agentId, sourcePath, metadata, options);
},
/**
* Uninstall an agent
* @param agentId - Agent ID to uninstall
* @param _force - Deprecated: force parameter is kept for backward compatibility but has no effect
*/
async uninstallAgent(agentId: string, _force?: boolean): Promise<void> {
return uninstallAgent(agentId);
},
/**
* Create an agent from an inline configuration object
*
* Use this when you have a config from a database, API, or constructed programmatically
* and don't need a registry file. The agent is returned unstarted.
*
* @param config - Agent configuration object
* @param options - Optional creation options
* @returns Promise resolving to DextoAgent instance (not started)
*
* @example
* ```typescript
* // Create from inline config
* const agent = await AgentFactory.createAgent({
* llm: {
* provider: 'openai',
* model: 'gpt-4o',
* apiKey: process.env.OPENAI_API_KEY
* },
* systemPrompt: 'You are a helpful assistant.'
* });
* await agent.start();
*
* // With custom agent ID (affects log/storage paths)
* const agent = await AgentFactory.createAgent(config, { agentId: 'my-custom-agent' });
*
* // From database
* const configFromDb = await db.getAgentConfig(userId);
* const agent = await AgentFactory.createAgent(configFromDb, { agentId: `user-${userId}` });
* ```
*/
async createAgent(config: AgentConfig, options?: CreateAgentOptions): Promise<DextoAgent> {
// If agentId provided, inject it into config's agentCard.name for enrichment
// This affects path derivation in enrichAgentConfig
let configToEnrich = config;
if (options?.agentId) {
configToEnrich = {
...config,
agentCard: {
...(config.agentCard || {}),
name: options.agentId,
},
} as AgentConfig;
}
// Enrich with runtime paths (logs, database, blob storage)
const enrichedConfig = enrichAgentConfig(
configToEnrich,
undefined, // No config path for inline configs
options?.isInteractiveCli ?? false
);
// Create and return unstarted agent
return new DextoAgent(enrichedConfig);
},
};
// Helper functions
async function listInstalledAgents(): Promise<string[]> {
const agentsDir = getDextoGlobalPath('agents');
try {
const entries = await fs.readdir(agentsDir, { withFileTypes: true });
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
}

View File

@@ -0,0 +1,269 @@
/**
* AgentManager - Registry-based agent lifecycle management
*
* USE THIS WHEN: You have a registry.json file with multiple predefined agents
* and need discovery/selection capabilities (listAgents, hasAgent, createAgent by ID).
*
* Examples:
* - CLI tools with multiple agent options
* - Projects with several predefined agents users can choose from
*
* FOR INLINE/DYNAMIC CONFIGS: Use `AgentFactory.createAgent(config)` instead.
* This is better when configs come from a database, API, or are constructed
* programmatically without a registry file.
*
* @see AgentFactory.createAgent() for inline config creation
* @see https://docs.dexto.ai/api/sdk/agent-manager for full documentation
*/
import { promises as fs } from 'fs';
import path from 'path';
import { logger, DextoAgent, DextoValidationError, zodToIssues } from '@dexto/core';
import { loadAgentConfig, enrichAgentConfig } from './config/index.js';
import { RegistryError } from './registry/errors.js';
import { z, ZodError } from 'zod';
/**
* Agent metadata - describes an agent in the registry
*/
export interface AgentMetadata {
id: string;
name: string;
description: string;
author?: string | undefined;
tags?: string[] | undefined;
}
/**
* Registry file schema
*/
const RegistrySchema = z
.object({
agents: z.array(
z
.object({
id: z.string(),
name: z.string(),
description: z.string(),
configPath: z.string(),
author: z.string().optional(),
tags: z.array(z.string()).optional(),
})
.strict()
),
})
.strict();
type Registry = z.output<typeof RegistrySchema>;
/**
* AgentManager - Simple registry-based agent lifecycle management
*
* Provides a clean API for loading agent configurations from a registry file
* and creating agent instances. The registry is a JSON file that lists available
* agents and their config file paths.
*
* @example
* ```typescript
* // Point to your registry
* const manager = new AgentManager('./agents/registry.json');
*
* // List available agents
* const agents = manager.listAgents();
* console.log(agents); // [{ id: 'coding-agent', name: '...', ... }]
*
* // Load an agent instance
* const agent = await manager.loadAgent('coding-agent');
* await agent.start();
* ```
*/
export class AgentManager {
private registry: Registry | null = null;
private registryPath: string;
private basePath: string;
/**
* Create a new AgentManager
*
* @param registryPath Absolute or relative path to registry.json file
*
* @example
* ```typescript
* // Project-local registry
* const manager = new AgentManager('./agents/registry.json');
*
* // Absolute path
* const manager = new AgentManager('/path/to/registry.json');
* ```
*/
constructor(registryPath: string) {
this.registryPath = path.resolve(registryPath);
this.basePath = path.dirname(this.registryPath);
}
/**
* Load registry from file (lazy loaded, cached)
*
* Call this before using sync methods like `listAgents()` or `hasAgent()`.
* Alternatively, calling `loadAgent()` will automatically load the registry.
*
* @returns The loaded registry
*
* @example
* ```typescript
* const manager = new AgentManager('./registry.json');
* await manager.loadRegistry();
*
* // Now sync methods work
* const agents = manager.listAgents();
* ```
*/
async loadRegistry(): Promise<Registry> {
if (this.registry) {
return this.registry;
}
try {
const content = await fs.readFile(this.registryPath, 'utf-8');
const parsed = JSON.parse(content);
this.registry = RegistrySchema.parse(parsed);
logger.debug(
`Loaded registry from ${this.registryPath}: ${this.registry.agents.length} agents`
);
return this.registry;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw RegistryError.registryNotFound(this.registryPath, 'File does not exist');
}
if (error instanceof ZodError) {
throw RegistryError.registryParseError(
this.registryPath,
`Invalid registry schema: ${error.errors.map((e) => e.message).join(', ')}`
);
}
throw RegistryError.registryParseError(
this.registryPath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* List all agents in the registry
*
* @returns Array of agent metadata
*
* @example
* ```typescript
* const manager = new AgentManager('./registry.json');
* const agents = manager.listAgents();
* console.log(agents);
* // [
* // { id: 'coding-agent', name: 'Coding Assistant', description: '...', tags: ['coding'] },
* // { id: 'support-agent', name: 'Support Assistant', description: '...', tags: ['support'] }
* // ]
* ```
*/
listAgents(): AgentMetadata[] {
if (!this.registry) {
throw RegistryError.registryNotFound(
this.registryPath,
'Registry not loaded. Call loadRegistry() first or use async methods.'
);
}
return this.registry.agents.map((entry) => ({
id: entry.id,
name: entry.name,
description: entry.description,
author: entry.author,
tags: entry.tags,
}));
}
/**
* Load a DextoAgent instance from registry
*
* @param id Agent ID from registry
* @returns Promise resolving to DextoAgent instance (not started)
*
* @throws {DextoRuntimeError} If agent not found or config loading fails
* @throws {DextoValidationError} If agent config validation fails
*
* @example
* ```typescript
* const manager = new AgentManager('./registry.json');
* const agent = await manager.loadAgent('coding-agent');
* await agent.start();
*
* // Use the agent
* const session = await agent.createSession();
* const response = await agent.generate('Write a function', session.id);
* ```
*/
async loadAgent(id: string): Promise<DextoAgent> {
const registry = await this.loadRegistry();
// Find agent in registry
const entry = registry.agents.find((a) => a.id === id);
if (!entry) {
const available = registry.agents.map((a) => a.id);
throw RegistryError.agentNotFound(id, available);
}
// Resolve config path relative to registry location
const configPath = path.resolve(this.basePath, entry.configPath);
try {
// Load and enrich agent config
const config = await loadAgentConfig(configPath);
const enrichedConfig = enrichAgentConfig(config, configPath);
// Load agent instance
logger.debug(`Loading agent: ${id} from ${configPath}`);
return new DextoAgent(enrichedConfig, configPath);
} catch (error) {
// Convert ZodError to DextoValidationError for better error messages
if (error instanceof ZodError) {
const issues = zodToIssues(error, 'error');
throw new DextoValidationError(issues);
}
// Re-throw DextoRuntimeError and DextoValidationError as-is
if (error instanceof Error && error.name.startsWith('Dexto')) {
throw error;
}
// Wrap other errors
throw RegistryError.installationFailed(
id,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Check if an agent exists in the registry
*
* @param id Agent ID to check
* @returns True if agent exists
*
* @example
* ```typescript
* const manager = new AgentManager('./registry.json');
* await manager.loadRegistry();
*
* if (manager.hasAgent('coding-agent')) {
* const agent = await manager.loadAgent('coding-agent');
* }
* ```
*/
hasAgent(id: string): boolean {
if (!this.registry) {
throw RegistryError.registryNotFound(
this.registryPath,
'Registry not loaded. Call loadRegistry() first or use async methods.'
);
}
return this.registry.agents.some((a) => a.id === id);
}
}

View File

@@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { AgentConfig } from '@dexto/core';
// Mock the discover-prompts module (separate file, so mock works!)
vi.mock('./discover-prompts.js', () => ({
discoverCommandPrompts: vi.fn(() => []),
discoverAgentInstructionFile: vi.fn(() => null),
}));
// Mock the plugins module to prevent real filesystem discovery
vi.mock('../plugins/index.js', () => ({
discoverClaudeCodePlugins: vi.fn(() => []),
loadClaudeCodePlugin: vi.fn(() => ({ manifest: {}, commands: [], warnings: [] })),
discoverStandaloneSkills: vi.fn(() => []),
}));
// Import after mock is set up
import { enrichAgentConfig } from './config-enrichment.js';
import { discoverCommandPrompts } from './discover-prompts.js';
// TODO: Add more comprehensive tests for config-enrichment:
// - Test path resolution for per-agent logs, database, blobs
// - Test execution context detection (dexto-source, dexto-project, global-cli)
describe('enrichAgentConfig', () => {
beforeEach(() => {
vi.mocked(discoverCommandPrompts).mockReset();
vi.mocked(discoverCommandPrompts).mockReturnValue([]);
});
describe('prompt deduplication', () => {
it('should deduplicate when same file path exists in config and discovered prompts', () => {
// Setup: discovered prompts include a file that's also in config
const sharedFilePath = '/projects/myapp/commands/shared-prompt.md';
const discoveredOnlyPath = '/projects/myapp/commands/discovered-only.md';
vi.mocked(discoverCommandPrompts).mockReturnValue([
{ type: 'file', file: sharedFilePath },
{ type: 'file', file: discoveredOnlyPath },
]);
const baseConfig: AgentConfig = {
llm: {
provider: 'anthropic',
model: 'claude-3-opus',
apiKey: 'test-api-key',
},
systemPrompt: 'You are a helpful assistant.',
prompts: [
{ type: 'file', file: sharedFilePath }, // Same as discovered
{ type: 'file', file: '/config/only-prompt.md' },
],
};
const enriched = enrichAgentConfig(baseConfig, 'test-agent');
// Should have 3 prompts: 2 from config + 1 discovered-only (not the duplicate)
expect(enriched.prompts).toHaveLength(3);
// Verify no duplicate file paths
const filePaths = enriched
.prompts!.filter((p): p is { type: 'file'; file: string } => p.type === 'file')
.map((p) => p.file);
expect(filePaths).toContain(sharedFilePath);
expect(filePaths).toContain('/config/only-prompt.md');
expect(filePaths).toContain(discoveredOnlyPath);
// Count occurrences of shared path - should be exactly 1
const sharedPathCount = filePaths.filter((p) => p === sharedFilePath).length;
expect(sharedPathCount).toBe(1);
});
it('should deduplicate with different path formats (resolved paths)', () => {
// Test that path.resolve normalization works with different path representations
vi.mocked(discoverCommandPrompts).mockReturnValue([
// Discovered path uses parent directory traversal
{ type: 'file', file: '/projects/myapp/commands/../commands/prompt.md' },
]);
const baseConfig: AgentConfig = {
llm: {
provider: 'anthropic',
model: 'claude-3-opus',
apiKey: 'test-api-key',
},
systemPrompt: 'You are a helpful assistant.',
prompts: [
// Config uses clean absolute path - path.resolve normalizes both to same path
{ type: 'file', file: '/projects/myapp/commands/prompt.md' },
],
};
const enriched = enrichAgentConfig(baseConfig, 'test-agent');
// Should have only 1 prompt (deduplicated)
const filePaths = enriched
.prompts!.filter((p): p is { type: 'file'; file: string } => p.type === 'file')
.map((p) => p.file);
expect(filePaths).toHaveLength(1);
});
it('should preserve config prompts when no discovered prompts overlap', () => {
vi.mocked(discoverCommandPrompts).mockReturnValue([
{ type: 'file', file: '/discovered/prompt1.md' },
{ type: 'file', file: '/discovered/prompt2.md' },
]);
const baseConfig: AgentConfig = {
llm: {
provider: 'anthropic',
model: 'claude-3-opus',
apiKey: 'test-api-key',
},
systemPrompt: 'You are a helpful assistant.',
prompts: [
{ type: 'file', file: '/config/prompt1.md' },
{ type: 'inline', id: 'inline-prompt', prompt: 'test prompt' },
],
};
const enriched = enrichAgentConfig(baseConfig, 'test-agent');
// Should have all 4 prompts (2 config + 2 discovered, no overlap)
expect(enriched.prompts).toHaveLength(4);
});
it('should preserve inline prompts without deduplication issues', () => {
vi.mocked(discoverCommandPrompts).mockReturnValue([]);
const baseConfig: AgentConfig = {
llm: {
provider: 'anthropic',
model: 'claude-3-opus',
apiKey: 'test-api-key',
},
systemPrompt: 'You are a helpful assistant.',
prompts: [{ type: 'inline', id: 'test-prompt', prompt: 'Hello world' }],
};
const enriched = enrichAgentConfig(baseConfig, 'test-agent');
const inlinePrompts = enriched.prompts?.filter((p) => p.type === 'inline');
expect(inlinePrompts).toHaveLength(1);
});
it('should handle empty config prompts with discovered prompts', () => {
vi.mocked(discoverCommandPrompts).mockReturnValue([
{ type: 'file', file: '/discovered/prompt.md' },
]);
const baseConfig: AgentConfig = {
llm: {
provider: 'anthropic',
model: 'claude-3-opus',
apiKey: 'test-api-key',
},
systemPrompt: 'You are a helpful assistant.',
prompts: [],
};
const enriched = enrichAgentConfig(baseConfig, 'test-agent');
expect(enriched.prompts).toHaveLength(1);
});
it('should handle undefined config prompts with discovered prompts', () => {
vi.mocked(discoverCommandPrompts).mockReturnValue([
{ type: 'file', file: '/discovered/prompt.md' },
]);
const baseConfig: AgentConfig = {
llm: {
provider: 'anthropic',
model: 'claude-3-opus',
apiKey: 'test-api-key',
},
systemPrompt: 'You are a helpful assistant.',
};
const enriched = enrichAgentConfig(baseConfig, 'test-agent');
expect(enriched.prompts).toHaveLength(1);
});
it('should not add prompts when no discovered prompts and no config prompts', () => {
vi.mocked(discoverCommandPrompts).mockReturnValue([]);
const baseConfig: AgentConfig = {
llm: {
provider: 'anthropic',
model: 'claude-3-opus',
apiKey: 'test-api-key',
},
systemPrompt: 'You are a helpful assistant.',
};
const enriched = enrichAgentConfig(baseConfig, 'test-agent');
// prompts should be undefined (not modified)
expect(enriched.prompts).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,375 @@
/**
* Config Enrichment Layer
*
* Provides per-agent path defaults for file-based resources (logs, database, blobs, backups).
* This layer runs before agent initialization and injects explicit paths
* into the configuration, eliminating the need for core services to resolve paths themselves.
*
* Also discovers command prompts from (in priority order):
* - Local: <projectRoot>/commands/ (dexto-source dev mode or dexto-project only)
* - Local: <cwd>/.dexto/commands/
* - Global: ~/.dexto/commands/
*
* Core services now require explicit paths - this enrichment layer provides them.
*/
import { getDextoPath } from '../utils/path.js';
import type { AgentConfig } from '@dexto/core';
import * as path from 'path';
import { discoverCommandPrompts, discoverAgentInstructionFile } from './discover-prompts.js';
import {
discoverClaudeCodePlugins,
loadClaudeCodePlugin,
discoverStandaloneSkills,
} from '../plugins/index.js';
// Re-export for backwards compatibility
export { discoverCommandPrompts, discoverAgentInstructionFile } from './discover-prompts.js';
/**
* Derives an agent ID from config or file path for per-agent isolation.
* Priority: explicit agentId > agentCard.name > filename (without extension) > 'coding-agent'
*/
export function deriveAgentId(config: AgentConfig, configPath?: string): string {
// 0. If agentId is explicitly set in config, use it (highest priority)
if (config.agentId) {
// Sanitize for filesystem use (same as agentCard.name)
const sanitizedId = config.agentId
.toLowerCase()
.replace(/[^a-z0-9-_]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return sanitizedId || 'coding-agent';
}
// 1. Try agentCard.name if available
if (config.agentCard?.name) {
// Sanitize name for filesystem use (remove spaces, special chars)
const sanitizedName = config.agentCard.name
.toLowerCase()
.replace(/[^a-z0-9-_]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
if (sanitizedName) {
return sanitizedName;
}
}
// 2. Try filename (without extension)
if (configPath) {
const basename = path.basename(configPath, path.extname(configPath));
if (basename && basename !== 'agent' && basename !== 'config') {
return basename;
}
}
// 3. Fallback to default
return 'coding-agent';
}
/**
* Options for enriching agent configuration
*/
export interface EnrichAgentConfigOptions {
/** Whether this is interactive CLI mode (affects logger transports - file only vs console+file) */
isInteractiveCli?: boolean;
/** Override log level (defaults to 'error' for SDK, CLI/server can override to 'info') */
logLevel?: 'error' | 'warn' | 'info' | 'debug';
/** Skip Claude Code plugin discovery (useful for subagents that don't need plugins) */
skipPluginDiscovery?: boolean;
/**
* Bundled plugin paths from image definition.
* These are absolute paths to plugin directories that are discovered alongside
* user/project plugins.
*/
bundledPlugins?: string[];
}
/**
* Enriches agent configuration with per-agent file paths and discovered commands.
* This function is called before creating the DextoAgent instance.
*
* Enrichment adds:
* - File transport to logger config (per-agent log file)
* - Full paths to storage config (SQLite database, blob storage)
* - Backup path to filesystem config (per-agent backups)
* - Discovered command prompts from local/global commands/ directories
*
* @param config Agent configuration from YAML file + CLI overrides
* @param configPath Path to the agent config file (used for agent ID derivation)
* @param options Enrichment options (isInteractiveCli, logLevel)
* @returns Enriched configuration with explicit per-agent paths and discovered prompts
*/
export function enrichAgentConfig(
config: AgentConfig,
configPath?: string,
options: EnrichAgentConfigOptions | boolean = {}
): AgentConfig {
// Handle backward compatibility: boolean arg was isInteractiveCli
const opts: EnrichAgentConfigOptions =
typeof options === 'boolean' ? { isInteractiveCli: options } : options;
const {
isInteractiveCli = false,
logLevel = 'error',
skipPluginDiscovery = false,
bundledPlugins = [],
} = opts;
const agentId = deriveAgentId(config, configPath);
// Generate per-agent paths
const logPath = getDextoPath('logs', `${agentId}.log`);
const dbPath = getDextoPath('database', `${agentId}.db`);
const blobPath = getDextoPath('blobs', agentId);
// Create enriched config (shallow copy with deep updates)
const enriched: AgentConfig = {
...config,
agentId, // Set agentId explicitly (single source of truth)
};
// Enrich logger config: only provide if not set
if (!config.logger) {
// User didn't specify logger - provide defaults based on mode
// Interactive CLI: only file (console would interfere with chat UI)
// Other modes: console + file
const transports = isInteractiveCli
? [
{
type: 'file' as const,
path: logPath,
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
},
]
: [
{ type: 'console' as const, colorize: true },
{
type: 'file' as const,
path: logPath,
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
},
];
enriched.logger = {
level: logLevel,
transports,
};
} else {
// User specified logger - keep their config as-is
enriched.logger = config.logger;
}
// Enrich storage config with per-agent paths
if (!config.storage) {
// User didn't specify storage at all - provide filesystem-based defaults
enriched.storage = {
cache: { type: 'in-memory' },
database: { type: 'sqlite', path: dbPath },
blob: { type: 'local', storePath: blobPath },
};
} else {
// User specified storage - start with their config, enrich paths where needed
enriched.storage = {
...config.storage,
};
// Enrich database path if SQLite with empty/missing path
if (config.storage.database?.type === 'sqlite') {
enriched.storage.database = {
...config.storage.database,
path: config.storage.database.path || dbPath,
};
}
// Enrich blob path if local with empty/missing storePath
if (config.storage.blob?.type === 'local') {
enriched.storage.blob = {
...config.storage.blob,
storePath: config.storage.blob.storePath || blobPath,
};
}
}
// Note: Filesystem service backup paths are configured separately
// and not part of agent config. If backup config is added to agent schema
// in the future, per-agent backup paths can be generated here.
// Discover and merge command prompts from commands/ directories
const discoveredPrompts = discoverCommandPrompts();
if (discoveredPrompts.length > 0) {
// Merge discovered prompts with existing config prompts
// Config prompts take precedence - deduplicate by file path to avoid
// metadata/content mismatch when same file appears in both arrays
const existingPrompts = config.prompts ?? [];
// Build set of existing file paths (normalized for comparison)
const existingFilePaths = new Set<string>();
for (const prompt of existingPrompts) {
if (prompt.type === 'file') {
// Normalize path for cross-platform comparison
existingFilePaths.add(path.resolve(prompt.file));
}
}
// Filter out discovered prompts that already exist in config
const filteredDiscovered = discoveredPrompts.filter(
(p) => !existingFilePaths.has(path.resolve(p.file))
);
enriched.prompts = [...existingPrompts, ...filteredDiscovered];
}
// Discover and load Claude Code plugins (skip for subagents to avoid duplicate warnings)
if (!skipPluginDiscovery) {
// Build set of existing file paths for deduplication
// This prevents duplicate prompts when same file appears in config and plugins/skills
const existingPromptPaths = new Set<string>();
for (const prompt of enriched.prompts ?? []) {
if (prompt.type === 'file') {
existingPromptPaths.add(path.resolve(prompt.file));
}
}
const discoveredPlugins = discoverClaudeCodePlugins(undefined, bundledPlugins);
for (const plugin of discoveredPlugins) {
const loaded = loadClaudeCodePlugin(plugin);
// Log warnings for unsupported features
// Note: Logging happens at enrichment time since we don't have a logger instance
// Warnings are stored in the loaded plugin and can be accessed by callers
for (const warning of loaded.warnings) {
console.warn(`[plugin] ${warning}`);
}
// Add commands/skills as prompts with namespace
// Note: Both commands and skills are user-invocable by default (per schema).
// SKILL.md frontmatter can override with `user-invocable: false` if needed.
for (const cmd of loaded.commands) {
const resolvedPath = path.resolve(cmd.file);
if (existingPromptPaths.has(resolvedPath)) {
continue; // Skip duplicate
}
existingPromptPaths.add(resolvedPath);
const promptEntry = {
type: 'file' as const,
file: cmd.file,
namespace: cmd.namespace,
};
// Add to enriched prompts
enriched.prompts = enriched.prompts ?? [];
enriched.prompts.push(promptEntry);
}
// Merge MCP config into mcpServers
// Note: Plugin MCP config is loosely typed; users are responsible for valid server configs
if (loaded.mcpConfig?.mcpServers) {
enriched.mcpServers = {
...enriched.mcpServers,
...(loaded.mcpConfig.mcpServers as typeof enriched.mcpServers),
};
}
// Auto-add custom tool providers declared by Dexto-native plugins
// These are added to customTools config if not already explicitly configured
if (loaded.customToolProviders.length > 0) {
for (const providerType of loaded.customToolProviders) {
// Check if already configured in customTools
const alreadyConfigured = enriched.customTools?.some(
(tool) =>
typeof tool === 'object' && tool !== null && tool.type === providerType
);
if (!alreadyConfigured) {
enriched.customTools = enriched.customTools ?? [];
// Add with default config (just the type)
enriched.customTools.push({ type: providerType } as Record<
string,
unknown
>);
}
}
}
}
// Discover standalone skills from ~/.dexto/skills/ and <cwd>/.dexto/skills/
// These are bare skill directories with SKILL.md files (not full plugins)
// Unlike plugin commands, standalone skills don't need namespace prefixing -
// the id from frontmatter or directory name is used directly.
const standaloneSkills = discoverStandaloneSkills();
for (const skill of standaloneSkills) {
const resolvedPath = path.resolve(skill.skillFile);
if (existingPromptPaths.has(resolvedPath)) {
continue; // Skip duplicate
}
existingPromptPaths.add(resolvedPath);
const promptEntry = {
type: 'file' as const,
file: skill.skillFile,
// No namespace for standalone skills - they use id directly
// (unlike plugin commands which need plugin:command naming)
};
enriched.prompts = enriched.prompts ?? [];
enriched.prompts.push(promptEntry);
}
}
// Discover agent instruction file (AGENTS.md, CLAUDE.md, GEMINI.md) in cwd
// Add as a file contributor to system prompt if found
const instructionFile = discoverAgentInstructionFile();
if (instructionFile) {
// Add file contributor to system prompt config
// Use a low priority (5) so it runs early but after any base prompt
const fileContributor = {
id: 'discovered-instructions',
type: 'file' as const,
priority: 5,
enabled: true,
files: [instructionFile],
options: {
includeFilenames: true,
errorHandling: 'skip' as const,
maxFileSize: 100000,
},
};
// Handle different systemPrompt config shapes
if (!config.systemPrompt) {
// No system prompt - create one with just the file contributor
enriched.systemPrompt = {
contributors: [fileContributor],
};
} else if (typeof config.systemPrompt === 'string') {
// String system prompt - convert to object with both static and file contributors
enriched.systemPrompt = {
contributors: [
{
id: 'inline',
type: 'static' as const,
content: config.systemPrompt,
priority: 0,
enabled: true,
},
fileContributor,
],
};
} else if ('contributors' in config.systemPrompt) {
// Already structured - add file contributor if not already present
const existingContributors = config.systemPrompt.contributors ?? [];
const hasDiscoveredInstructions = existingContributors.some(
(c) => c.id === 'discovered-instructions'
);
if (!hasDiscoveredInstructions) {
enriched.systemPrompt = {
contributors: [...existingContributors, fileContributor],
};
}
}
}
return enriched;
}

View File

@@ -0,0 +1,585 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'node:url';
import {
addPromptToAgentConfig,
removePromptFromAgentConfig,
deletePromptByMetadata,
updateMcpServerField,
removeMcpServerFromConfig,
} from './config-manager.js';
const tmpFile = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'temp-config-test.yml');
beforeEach(async () => {
try {
await fs.unlink(tmpFile);
} catch {
/* ignore error if file does not exist */
}
});
afterEach(async () => {
try {
await fs.unlink(tmpFile);
} catch {
/* ignore error if file does not exist */
}
});
describe('addPromptToAgentConfig', () => {
it('adds a file prompt to existing prompts array', async () => {
const yamlContent = `llm:
provider: test
model: test-model
prompts:
- type: inline
id: existing
prompt: Existing prompt
`;
await fs.writeFile(tmpFile, yamlContent);
await addPromptToAgentConfig(tmpFile, {
type: 'file',
file: '/path/to/prompts/new-prompt.md',
});
const result = await fs.readFile(tmpFile, 'utf-8');
expect(result).toContain('type: file');
expect(result).toContain('/path/to/prompts/new-prompt.md');
expect(result).toContain('type: inline'); // Original still there
expect(result).toContain('id: existing');
});
it('adds an inline prompt to existing prompts array', async () => {
const yamlContent = `llm:
provider: test
model: test-model
prompts:
- type: file
file: existing.md
`;
await fs.writeFile(tmpFile, yamlContent);
await addPromptToAgentConfig(tmpFile, {
type: 'inline',
id: 'new-inline',
prompt: 'New inline prompt content',
});
const result = await fs.readFile(tmpFile, 'utf-8');
expect(result).toContain('type: inline');
expect(result).toContain('id: new-inline');
expect(result).toContain('prompt: New inline prompt content');
expect(result).toContain('type: file'); // Original still there
});
it('creates prompts array when none exists', async () => {
const yamlContent = `llm:
provider: test
model: test-model
mcpServers:
test:
type: stdio
command: echo
`;
await fs.writeFile(tmpFile, yamlContent);
await addPromptToAgentConfig(tmpFile, {
type: 'file',
file: '/path/to/prompts/first.md',
});
const result = await fs.readFile(tmpFile, 'utf-8');
expect(result).toContain('prompts:');
expect(result).toContain('type: file');
expect(result).toContain('/path/to/prompts/first.md');
});
it('preserves comments and formatting', async () => {
const yamlContent = `# Main config
llm:
provider: test
model: test-model
# Prompts section
prompts:
- type: inline
id: existing
prompt: Test
`;
await fs.writeFile(tmpFile, yamlContent);
await addPromptToAgentConfig(tmpFile, {
type: 'file',
file: 'new.md',
});
const result = await fs.readFile(tmpFile, 'utf-8');
expect(result).toContain('# Main config');
expect(result).toContain('# Prompts section');
});
});
describe('removePromptFromAgentConfig', () => {
it('removes a file prompt by file pattern', async () => {
const yamlContent = `llm:
provider: test
model: test-model
prompts:
- type: file
file: /agent/prompts/to-remove.md
- type: file
file: /agent/prompts/keep.md
`;
await fs.writeFile(tmpFile, yamlContent);
await removePromptFromAgentConfig(tmpFile, {
type: 'file',
filePattern: '/prompts/to-remove.md',
});
const result = await fs.readFile(tmpFile, 'utf-8');
expect(result).not.toContain('to-remove.md');
expect(result).toContain('keep.md');
});
it('removes an inline prompt by id', async () => {
const yamlContent = `llm:
provider: test
model: test-model
prompts:
- type: inline
id: to-remove
prompt: Remove this
- type: inline
id: keep-this
prompt: Keep this
`;
await fs.writeFile(tmpFile, yamlContent);
await removePromptFromAgentConfig(tmpFile, {
type: 'inline',
id: 'to-remove',
});
const result = await fs.readFile(tmpFile, 'utf-8');
expect(result).not.toContain('to-remove');
expect(result).not.toContain('Remove this');
expect(result).toContain('keep-this');
expect(result).toContain('Keep this');
});
it('removes multi-line file prompt entry', async () => {
const yamlContent = `llm:
provider: test
model: test-model
prompts:
- type: file
file: /agent/prompts/multi-line.md
showInStarters: true
- type: inline
id: keep
prompt: Keep
`;
await fs.writeFile(tmpFile, yamlContent);
await removePromptFromAgentConfig(tmpFile, {
type: 'file',
filePattern: '/prompts/multi-line.md',
});
const result = await fs.readFile(tmpFile, 'utf-8');
expect(result).not.toContain('multi-line.md');
expect(result).not.toContain('showInStarters');
expect(result).toContain('id: keep');
});
it('removes multi-line inline prompt entry', async () => {
const yamlContent = `llm:
provider: test
model: test-model
prompts:
- type: inline
id: multi-field
title: Multi Field Prompt
description: Has many fields
prompt: The actual prompt
category: testing
priority: 5
- type: inline
id: keep
prompt: Keep
`;
await fs.writeFile(tmpFile, yamlContent);
await removePromptFromAgentConfig(tmpFile, {
type: 'inline',
id: 'multi-field',
});
const result = await fs.readFile(tmpFile, 'utf-8');
expect(result).not.toContain('multi-field');
expect(result).not.toContain('Multi Field Prompt');
expect(result).not.toContain('Has many fields');
expect(result).not.toContain('priority: 5');
expect(result).toContain('id: keep');
});
it('does nothing when prompt not found', async () => {
const yamlContent = `llm:
provider: test
model: test-model
prompts:
- type: inline
id: existing
prompt: Existing
`;
await fs.writeFile(tmpFile, yamlContent);
await removePromptFromAgentConfig(tmpFile, {
type: 'inline',
id: 'nonexistent',
});
const result = await fs.readFile(tmpFile, 'utf-8');
expect(result).toContain('id: existing');
});
it('handles empty prompts array', async () => {
const yamlContent = `llm:
provider: test
model: test-model
prompts: []
`;
await fs.writeFile(tmpFile, yamlContent);
await removePromptFromAgentConfig(tmpFile, {
type: 'inline',
id: 'nonexistent',
});
const result = await fs.readFile(tmpFile, 'utf-8');
expect(result).toContain('prompts: []');
});
it('removes file prompt with template variable in path', async () => {
// Use a raw string to include the template syntax literally
const yamlContent =
'llm:\n provider: test\n model: test-model\nprompts:\n - type: file\n file: ${{dexto.agent_dir}}/prompts/test-prompt.md\n - type: file\n file: ${{dexto.agent_dir}}/prompts/keep.md\n';
await fs.writeFile(tmpFile, yamlContent);
await removePromptFromAgentConfig(tmpFile, {
type: 'file',
filePattern: '/prompts/test-prompt.md',
});
const result = await fs.readFile(tmpFile, 'utf-8');
expect(result).not.toContain('test-prompt.md');
expect(result).toContain('keep.md');
});
});
describe('updateMcpServerField', () => {
it('updates existing field value', async () => {
const yamlContent = `llm:
provider: test
model: test-model
mcpServers:
filesystem:
type: stdio
command: mcp-server
enabled: true
`;
await fs.writeFile(tmpFile, yamlContent);
const result = await updateMcpServerField(tmpFile, 'filesystem', 'enabled', false);
expect(result).toBe(true);
const content = await fs.readFile(tmpFile, 'utf-8');
expect(content).toContain('enabled: false');
});
it('adds field when it does not exist', async () => {
const yamlContent = `llm:
provider: test
model: test-model
mcpServers:
filesystem:
type: stdio
command: mcp-server
`;
await fs.writeFile(tmpFile, yamlContent);
const result = await updateMcpServerField(tmpFile, 'filesystem', 'enabled', false);
expect(result).toBe(true);
const content = await fs.readFile(tmpFile, 'utf-8');
expect(content).toContain('enabled: false');
});
it('returns false when server not found', async () => {
const yamlContent = `llm:
provider: test
model: test-model
mcpServers:
filesystem:
type: stdio
command: mcp-server
`;
await fs.writeFile(tmpFile, yamlContent);
const result = await updateMcpServerField(tmpFile, 'nonexistent', 'enabled', false);
expect(result).toBe(false);
});
it('preserves other servers', async () => {
const yamlContent = `llm:
provider: test
model: test-model
mcpServers:
server1:
type: stdio
command: cmd1
server2:
type: stdio
command: cmd2
`;
await fs.writeFile(tmpFile, yamlContent);
await updateMcpServerField(tmpFile, 'server1', 'enabled', true);
const content = await fs.readFile(tmpFile, 'utf-8');
expect(content).toContain('server1:');
expect(content).toContain('server2:');
expect(content).toContain('command: cmd1');
expect(content).toContain('command: cmd2');
});
});
describe('removePromptFromAgentConfig - edge cases', () => {
it('removes file prompt at end of prompts section followed by comment and other section', async () => {
// This matches the actual format in default-agent.yml
const yamlContent = `llm:
provider: test
model: test-model
prompts:
- type: inline
id: connect-tools
title: "Connect New Tools"
prompt: Some prompt
category: tools
priority: 3
showInStarters: true
- type: file
file: \${{dexto.agent_dir}}/prompts/test-prompt.md
# Telemetry configuration
telemetry:
enabled: false
`;
await fs.writeFile(tmpFile, yamlContent);
await removePromptFromAgentConfig(tmpFile, {
type: 'file',
filePattern: '/prompts/test-prompt.md',
});
const result = await fs.readFile(tmpFile, 'utf-8');
expect(result).not.toContain('test-prompt.md');
expect(result).toContain('connect-tools'); // Other prompt should remain
expect(result).toContain('telemetry:'); // Following section should remain
});
});
describe('removeMcpServerFromConfig', () => {
it('removes server from config', async () => {
const yamlContent = `llm:
provider: test
model: test-model
mcpServers:
toRemove:
type: stdio
command: remove-me
toKeep:
type: stdio
command: keep-me
`;
await fs.writeFile(tmpFile, yamlContent);
const result = await removeMcpServerFromConfig(tmpFile, 'toRemove');
expect(result).toBe(true);
const content = await fs.readFile(tmpFile, 'utf-8');
expect(content).not.toContain('toRemove');
expect(content).not.toContain('remove-me');
expect(content).toContain('toKeep');
expect(content).toContain('keep-me');
});
it('removes multi-line server entry', async () => {
const yamlContent = `llm:
provider: test
model: test-model
mcpServers:
complexServer:
type: stdio
command: complex-cmd
args:
- arg1
- arg2
env:
KEY: value
enabled: true
simpleServer:
type: stdio
command: simple
`;
await fs.writeFile(tmpFile, yamlContent);
const result = await removeMcpServerFromConfig(tmpFile, 'complexServer');
expect(result).toBe(true);
const content = await fs.readFile(tmpFile, 'utf-8');
expect(content).not.toContain('complexServer');
expect(content).not.toContain('complex-cmd');
expect(content).not.toContain('arg1');
expect(content).not.toContain('KEY: value');
expect(content).toContain('simpleServer');
});
it('returns false when server not found', async () => {
const yamlContent = `llm:
provider: test
model: test-model
mcpServers:
existing:
type: stdio
command: cmd
`;
await fs.writeFile(tmpFile, yamlContent);
const result = await removeMcpServerFromConfig(tmpFile, 'nonexistent');
expect(result).toBe(false);
const content = await fs.readFile(tmpFile, 'utf-8');
expect(content).toContain('existing');
});
});
describe('deletePromptByMetadata', () => {
const tmpPromptFile = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'temp-prompt-test.md'
);
afterEach(async () => {
try {
await fs.unlink(tmpPromptFile);
} catch {
/* ignore */
}
});
it('deletes file prompt and removes from config', async () => {
const yamlContent =
'llm:\n provider: test\n model: test-model\nprompts:\n - type: file\n file: ${{dexto.agent_dir}}/prompts/temp-prompt-test.md\n - type: inline\n id: keep\n prompt: Keep\n';
await fs.writeFile(tmpFile, yamlContent);
await fs.writeFile(tmpPromptFile, '---\nid: temp-prompt-test\n---\nTest content');
const result = await deletePromptByMetadata(
tmpFile,
{
name: 'temp-prompt-test',
metadata: { filePath: tmpPromptFile },
},
{ deleteFile: true }
);
expect(result.success).toBe(true);
expect(result.deletedFile).toBe(true);
expect(result.removedFromConfig).toBe(true);
const configContent = await fs.readFile(tmpFile, 'utf-8');
expect(configContent).not.toContain('temp-prompt-test.md');
expect(configContent).toContain('id: keep');
// File should be deleted
await expect(fs.access(tmpPromptFile)).rejects.toThrow();
});
it('deletes inline prompt from config', async () => {
const yamlContent = `llm:
provider: test
model: test-model
prompts:
- type: inline
id: to-delete
prompt: Delete me
- type: inline
id: keep
prompt: Keep me
`;
await fs.writeFile(tmpFile, yamlContent);
const result = await deletePromptByMetadata(tmpFile, {
name: 'to-delete',
});
expect(result.success).toBe(true);
expect(result.deletedFile).toBe(false);
expect(result.removedFromConfig).toBe(true);
const configContent = await fs.readFile(tmpFile, 'utf-8');
expect(configContent).not.toContain('to-delete');
expect(configContent).not.toContain('Delete me');
expect(configContent).toContain('id: keep');
});
it('skips config removal for shared prompts (commands directory)', async () => {
const yamlContent = `llm:
provider: test
model: test-model
prompts:
- type: inline
id: keep
prompt: Keep me
`;
await fs.writeFile(tmpFile, yamlContent);
await fs.writeFile(tmpPromptFile, '---\nid: shared\n---\nShared content');
// Simulate a commands directory path
const result = await deletePromptByMetadata(
tmpFile,
{
name: 'shared-prompt',
metadata: { filePath: '/some/path/.dexto/commands/shared-prompt.md' },
},
{ deleteFile: false } // Don't try to delete non-existent file
);
expect(result.success).toBe(true);
expect(result.removedFromConfig).toBe(false); // Should NOT remove from config
});
it('handles missing file gracefully', async () => {
const yamlContent =
'llm:\n provider: test\n model: test-model\nprompts:\n - type: file\n file: ${{dexto.agent_dir}}/prompts/nonexistent.md\n';
await fs.writeFile(tmpFile, yamlContent);
const result = await deletePromptByMetadata(
tmpFile,
{
name: 'nonexistent',
metadata: { filePath: '/path/to/nonexistent.md' },
},
{ deleteFile: true }
);
expect(result.success).toBe(true);
expect(result.deletedFile).toBe(false); // File didn't exist
expect(result.removedFromConfig).toBe(true);
});
});

View File

@@ -0,0 +1,735 @@
import { promises as fs } from 'fs';
import * as path from 'path';
import { parseDocument, stringify } from 'yaml';
import { loadAgentConfig } from './loader.js';
import { enrichAgentConfig } from './config-enrichment.js';
import type { AgentConfig, ValidatedAgentConfig } from '@dexto/core';
import { AgentConfigSchema } from '@dexto/core';
import { DextoValidationError } from '@dexto/core';
import { fail, zodToIssues } from '@dexto/core';
/**
* Input type for adding a file-based prompt
*/
export interface FilePromptInput {
type: 'file';
file: string;
showInStarters?: boolean;
}
/**
* Input type for adding an inline prompt
*/
export interface InlinePromptInput {
type: 'inline';
id: string;
prompt: string;
title?: string;
description?: string;
category?: string;
priority?: number;
showInStarters?: boolean;
}
export type PromptInput = FilePromptInput | InlinePromptInput;
/**
* Updates an agent configuration file with partial updates.
* Reads raw YAML, merges updates, enriches for validation, and writes back atomically.
* Preserves comments, formatting, and environment variable placeholders.
*
* Note: The file is kept "raw" (no enriched paths written), but the returned config
* is enriched and validated so it can be passed directly to agent.reload().
*
* This is a CLI/server concern - handles file I/O for config updates.
* After calling this, you should call agent.reload() with the returned config.
*
* @param configPath Path to the agent configuration file
* @param updates Partial configuration updates to apply
* @returns The validated, enriched merged configuration (ready for agent.reload())
* @throws DextoValidationError if validation fails
* @throws Error if file operations fail
*
* @example
* ```typescript
* const newConfig = await updateAgentConfigFile('/path/to/agent.yml', {
* mcpServers: {
* ...currentConfig.mcpServers,
* newServer: { command: 'mcp-server', type: 'stdio' }
* }
* });
*
* const reloadResult = await agent.reload(newConfig);
* ```
*/
export async function updateAgentConfigFile(
configPath: string,
updates: Partial<AgentConfig>
): Promise<ValidatedAgentConfig> {
// Read raw YAML from disk (without env var expansion)
const rawYaml = await fs.readFile(configPath, 'utf-8');
// Use YAML Document API to preserve comments/anchors/formatting
const doc = parseDocument(rawYaml);
const rawConfig = doc.toJSON() as Record<string, unknown>;
// Shallow merge top-level updates into raw config
const updatedRawConfig = { ...rawConfig, ...updates } as AgentConfig;
// Enrich the merged config (adds storage paths, logger defaults, etc.)
// This is required because AgentConfigSchema expects enriched fields
const enrichedConfig = enrichAgentConfig(updatedRawConfig, configPath);
// Validate the enriched config
const parsed = AgentConfigSchema.safeParse(enrichedConfig);
if (!parsed.success) {
// Convert Zod errors to DextoValidationError
const result = fail(zodToIssues(parsed.error, 'error'));
throw new DextoValidationError(result.issues);
}
// Apply ONLY the updates to the YAML document (preserves formatting/comments)
// We don't write enriched fields - the file stays "raw"
for (const [key, value] of Object.entries(updates)) {
doc.set(key, value);
}
// Serialize the Document back to YAML
const yamlContent = String(doc);
// Atomic write: write to temp file then rename
const tmpPath = `${configPath}.tmp`;
await fs.writeFile(tmpPath, yamlContent, 'utf-8');
await fs.rename(tmpPath, configPath);
// Return the enriched, validated config (ready for agent.reload())
return parsed.data;
}
/**
* Reloads an agent configuration from disk.
* This is a CLI/server concern - handles file I/O for config loading.
* After calling this, you should call agent.reloadConfig() with the returned config.
*
* @param configPath Path to the agent configuration file
* @returns The loaded agent configuration
* @throws ConfigError if file cannot be read or parsed
*
* @example
* ```typescript
* const newConfig = await reloadAgentConfigFromFile('/path/to/agent.yml');
* const reloadResult = await agent.reloadConfig(newConfig);
* if (reloadResult.restartRequired.length > 0) {
* await agent.restart();
* }
* ```
*/
export async function reloadAgentConfigFromFile(configPath: string): Promise<AgentConfig> {
return await loadAgentConfig(configPath);
}
// ============================================================================
// Surgical Config Mutation Helpers
// These functions modify specific parts of the config without affecting others
// ============================================================================
/**
* Helper to write file atomically
*/
async function writeFileAtomic(configPath: string, content: string): Promise<void> {
const tmpPath = `${configPath}.tmp`;
await fs.writeFile(tmpPath, content, 'utf-8');
await fs.rename(tmpPath, configPath);
}
// ============================================================================
// MCP Server Config Helpers
// ============================================================================
/**
* Finds the line range of a specific MCP server in the YAML file.
* Returns the start and end line indices (inclusive) of the server block.
*/
function findMcpServerRange(
lines: string[],
serverName: string
): { startLine: number; endLine: number; indent: string } | null {
let inMcpServersSection = false;
let mcpServersIndent = '';
let serverLevelIndent = -1; // Indent level for server names (one level below mcpServers)
let serverIndent = '';
let inTargetServer = false;
let serverStartLine = -1;
let serverEndLine = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? '';
const trimmed = line.trimStart();
// Skip empty lines and comments (but track them as part of server block)
if (!trimmed || trimmed.startsWith('#')) {
if (inTargetServer && serverStartLine >= 0) {
// Don't extend serverEndLine for trailing empty lines/comments
}
continue;
}
const currentIndent = line.slice(0, line.length - trimmed.length);
const currentIndentLen = currentIndent.length;
// Find the start of mcpServers section
if (!inMcpServersSection && trimmed.startsWith('mcpServers:')) {
inMcpServersSection = true;
mcpServersIndent = currentIndent;
continue;
}
if (inMcpServersSection) {
// Check if we've exited the mcpServers section (same or lower indent)
if (currentIndentLen <= mcpServersIndent.length && trimmed.includes(':')) {
// We've hit a new section - close any open server
if (inTargetServer && serverStartLine >= 0) {
return {
startLine: serverStartLine,
endLine: serverEndLine >= 0 ? serverEndLine : serverStartLine,
indent: serverIndent,
};
}
return null;
}
// Determine server-level indent from first server we see
if (serverLevelIndent < 0 && currentIndentLen > mcpServersIndent.length) {
serverLevelIndent = currentIndentLen;
}
// Check if this is a server name line (at server level indent)
if (serverLevelIndent >= 0 && currentIndentLen === serverLevelIndent) {
const serverMatch = trimmed.match(/^([a-zA-Z0-9_-]+):(\s|$)/);
if (serverMatch) {
const foundServerName = serverMatch[1];
// Close previous server if we were tracking one
if (inTargetServer && serverStartLine >= 0) {
return {
startLine: serverStartLine,
endLine: serverEndLine >= 0 ? serverEndLine : serverStartLine,
indent: serverIndent,
};
}
// Check if this is the target server
if (foundServerName === serverName) {
inTargetServer = true;
serverStartLine = i;
serverEndLine = i;
serverIndent = currentIndent;
} else {
inTargetServer = false;
}
}
} else if (inTargetServer && currentIndentLen > serverLevelIndent) {
// Content of current server (deeper indent)
serverEndLine = i;
}
}
}
// If we reached end of file while tracking the target server
if (inTargetServer && serverStartLine >= 0) {
return {
startLine: serverStartLine,
endLine: serverEndLine >= 0 ? serverEndLine : serverStartLine,
indent: serverIndent,
};
}
return null;
}
/**
* Updates a specific field within an MCP server configuration.
* Uses string manipulation to preserve all formatting, comments, and structure.
*
* @param configPath Path to the agent configuration file
* @param serverName Name of the MCP server to update
* @param field Field name to update (e.g., 'enabled')
* @param value New value for the field
* @returns true if the field was updated, false if server not found
*
* @example
* ```typescript
* // Toggle enabled state
* await updateMcpServerField('/path/to/agent.yml', 'filesystem', 'enabled', true);
* ```
*/
export async function updateMcpServerField(
configPath: string,
serverName: string,
field: string,
value: boolean | string | number
): Promise<boolean> {
const rawYaml = await fs.readFile(configPath, 'utf-8');
const lines = rawYaml.split('\n');
const serverRange = findMcpServerRange(lines, serverName);
if (!serverRange) {
return false;
}
// Format the value for YAML
const formattedValue =
typeof value === 'string' ? (value.includes(':') ? `"${value}"` : value) : String(value);
// Look for existing field within the server block
// Field should be at server indent + 2 spaces
const fieldIndent = serverRange.indent + ' ';
const fieldPrefix = `${fieldIndent}${field}:`;
let fieldLineIndex = -1;
for (let i = serverRange.startLine + 1; i <= serverRange.endLine; i++) {
const line = lines[i] ?? '';
// Check if line starts with the field prefix (e.g., " enabled:")
if (line.startsWith(fieldPrefix)) {
fieldLineIndex = i;
break;
}
}
if (fieldLineIndex >= 0) {
// Replace the existing field line
lines[fieldLineIndex] = `${fieldIndent}${field}: ${formattedValue}`;
} else {
// Field doesn't exist, add it after the server name line
const newFieldLine = `${fieldIndent}${field}: ${formattedValue}`;
lines.splice(serverRange.startLine + 1, 0, newFieldLine);
}
await writeFileAtomic(configPath, lines.join('\n'));
return true;
}
/**
* Removes an MCP server from the agent configuration file.
* Uses string manipulation to preserve all formatting, comments, and structure.
*
* @param configPath Path to the agent configuration file
* @param serverName Name of the MCP server to remove
* @returns true if the server was removed, false if not found
*
* @example
* ```typescript
* await removeMcpServerFromConfig('/path/to/agent.yml', 'filesystem');
* ```
*/
export async function removeMcpServerFromConfig(
configPath: string,
serverName: string
): Promise<boolean> {
const rawYaml = await fs.readFile(configPath, 'utf-8');
const lines = rawYaml.split('\n');
const serverRange = findMcpServerRange(lines, serverName);
if (!serverRange) {
return false;
}
// Remove the server lines
lines.splice(serverRange.startLine, serverRange.endLine - serverRange.startLine + 1);
await writeFileAtomic(configPath, lines.join('\n'));
return true;
}
/**
* Finds the end position of the prompts array in the YAML file.
* Returns the line index where we should insert a new prompt entry.
*/
function findPromptsArrayEndPosition(
lines: string[]
): { insertIndex: number; indent: string } | null {
let inPromptsSection = false;
let promptsIndent = '';
let lastPromptEntryEnd = -1;
let itemIndent = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? '';
const trimmed = line.trimStart();
// Find the start of prompts section
if (trimmed.startsWith('prompts:')) {
inPromptsSection = true;
const idx = line.indexOf('prompts:');
promptsIndent = idx >= 0 ? line.slice(0, idx) : '';
continue;
}
if (inPromptsSection) {
// Check if we've exited the prompts section (new top-level key)
if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('-')) {
const currentIndent = line.slice(0, line.length - trimmed.length);
if (currentIndent.length <= promptsIndent.length && trimmed.includes(':')) {
// We've hit a new section at same or lower indent level
return { insertIndex: lastPromptEntryEnd + 1, indent: itemIndent };
}
}
// Track array items (- type: ...)
if (trimmed.startsWith('- ')) {
const dashIdx = line.indexOf('-');
itemIndent = dashIdx >= 0 ? line.slice(0, dashIdx) : '';
lastPromptEntryEnd = i;
} else if (lastPromptEntryEnd >= 0 && trimmed && !trimmed.startsWith('#')) {
// Content continuation of current item
lastPromptEntryEnd = i;
}
}
}
// If we reached end of file while in prompts section
if (inPromptsSection && lastPromptEntryEnd >= 0) {
return { insertIndex: lastPromptEntryEnd + 1, indent: itemIndent };
}
return null;
}
/**
* Adds a prompt to the agent configuration file.
* Uses string manipulation to preserve all formatting, comments, and structure.
* Only modifies the prompts array by appending a new entry.
*
* @param configPath Path to the agent configuration file
* @param prompt The prompt to add (file or inline)
* @throws Error if file operations fail
*
* @example
* ```typescript
* // Add a file-based prompt
* await addPromptToAgentConfig('/path/to/agent.yml', {
* type: 'file',
* file: '${{dexto.agent_dir}}/prompts/my-prompt.md'
* });
* ```
*/
export async function addPromptToAgentConfig(
configPath: string,
prompt: PromptInput
): Promise<void> {
const rawYaml = await fs.readFile(configPath, 'utf-8');
const lines = rawYaml.split('\n');
const position = findPromptsArrayEndPosition(lines);
if (position) {
// Format the new prompt entry
const promptYaml = stringify([prompt], { indent: 2, lineWidth: 0 }).trim();
// The stringify gives us "- type: file\n file: ...", we need to indent it
const indentedPrompt = promptYaml
.split('\n')
.map((line) => position.indent + line)
.join('\n');
// Insert the new prompt
lines.splice(position.insertIndex, 0, indentedPrompt);
} else {
// No prompts section found - append one at the end
const promptYaml = stringify({ prompts: [prompt] }, { indent: 2, lineWidth: 0 }).trim();
lines.push('', promptYaml);
}
await writeFileAtomic(configPath, lines.join('\n'));
}
/**
* Finds the line ranges of prompt entries in the prompts array.
* Each entry is a range [startLine, endLine] (inclusive).
*/
function findPromptEntryRanges(
lines: string[]
): Array<{ startLine: number; endLine: number; content: string }> {
const entries: Array<{ startLine: number; endLine: number; content: string }> = [];
let inPromptsSection = false;
let promptsIndent = '';
let currentEntryStart = -1;
let currentEntryEnd = -1;
let itemIndent = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? '';
const trimmed = line.trimStart();
// Find the start of prompts section
if (!inPromptsSection && trimmed.startsWith('prompts:')) {
inPromptsSection = true;
const idx = line.indexOf('prompts:');
promptsIndent = idx >= 0 ? line.slice(0, idx) : '';
continue;
}
if (inPromptsSection) {
// Check if we've exited the prompts section (new top-level key or comment at top level)
if (trimmed && !trimmed.startsWith('-')) {
const currentIndent = line.slice(0, line.length - trimmed.length);
// Exit if we hit a top-level key (same or less indent than prompts:)
if (currentIndent.length <= promptsIndent.length && trimmed.includes(':')) {
// We've hit a new section - close any open entry
if (currentEntryStart >= 0) {
entries.push({
startLine: currentEntryStart,
endLine: currentEntryEnd >= 0 ? currentEntryEnd : currentEntryStart,
content: lines
.slice(
currentEntryStart,
(currentEntryEnd >= 0 ? currentEntryEnd : currentEntryStart) + 1
)
.join('\n'),
});
}
inPromptsSection = false;
break;
}
// Also exit if we hit a top-level comment (# at column 0 or at prompts indent)
if (trimmed.startsWith('#') && currentIndent.length <= promptsIndent.length) {
if (currentEntryStart >= 0) {
entries.push({
startLine: currentEntryStart,
endLine: currentEntryEnd >= 0 ? currentEntryEnd : currentEntryStart,
content: lines
.slice(
currentEntryStart,
(currentEntryEnd >= 0 ? currentEntryEnd : currentEntryStart) + 1
)
.join('\n'),
});
}
inPromptsSection = false;
break;
}
}
// Track array items (- type: ...)
if (trimmed.startsWith('- ')) {
// Close previous entry if any
if (currentEntryStart >= 0) {
entries.push({
startLine: currentEntryStart,
endLine: currentEntryEnd >= 0 ? currentEntryEnd : currentEntryStart,
content: lines
.slice(
currentEntryStart,
(currentEntryEnd >= 0 ? currentEntryEnd : currentEntryStart) + 1
)
.join('\n'),
});
}
currentEntryStart = i;
currentEntryEnd = i;
const dashIdx = line.indexOf('-');
itemIndent = dashIdx >= 0 ? line.slice(0, dashIdx) : '';
} else if (currentEntryStart >= 0 && trimmed) {
// Check if this line is still part of current entry (more indented than the dash)
const lineIndent = line.slice(0, line.length - trimmed.length);
if (lineIndent.length > itemIndent.length) {
currentEntryEnd = i;
}
}
}
}
// Close final entry if still open (prompts section goes to end of file)
if (inPromptsSection && currentEntryStart >= 0) {
entries.push({
startLine: currentEntryStart,
endLine: currentEntryEnd >= 0 ? currentEntryEnd : currentEntryStart,
content: lines
.slice(
currentEntryStart,
(currentEntryEnd >= 0 ? currentEntryEnd : currentEntryStart) + 1
)
.join('\n'),
});
}
return entries;
}
/**
* Removes a prompt from the agent configuration file.
* Uses string manipulation to preserve all formatting, comments, and structure.
* Only removes the matching prompt entry lines.
*
* For file prompts: matches by file path pattern
* For inline prompts: matches by id
*
* @param configPath Path to the agent configuration file
* @param matcher Criteria to match prompts to remove
* @throws Error if file operations fail
*
* @example
* ```typescript
* // Remove by file path pattern
* await removePromptFromAgentConfig('/path/to/agent.yml', {
* type: 'file',
* filePattern: '/prompts/my-prompt.md'
* });
*
* // Remove by inline prompt id
* await removePromptFromAgentConfig('/path/to/agent.yml', {
* type: 'inline',
* id: 'quick-help'
* });
* ```
*/
export async function removePromptFromAgentConfig(
configPath: string,
matcher: { type: 'file'; filePattern: string } | { type: 'inline'; id: string }
): Promise<void> {
const rawYaml = await fs.readFile(configPath, 'utf-8');
const lines = rawYaml.split('\n');
const entries = findPromptEntryRanges(lines);
if (entries.length === 0) {
return; // No prompts to remove
}
// Find entries to remove based on matcher
const entriesToRemove: Array<{ startLine: number; endLine: number }> = [];
for (const entry of entries) {
if (matcher.type === 'file') {
// Check if this entry contains the file pattern
if (
entry.content.includes('type: file') &&
entry.content.includes(matcher.filePattern)
) {
entriesToRemove.push(entry);
}
} else if (matcher.type === 'inline') {
// Check if this entry has the matching id
if (
entry.content.includes('type: inline') &&
entry.content.includes(`id: ${matcher.id}`)
) {
entriesToRemove.push(entry);
}
}
}
if (entriesToRemove.length === 0) {
return; // Nothing to remove
}
// Remove entries in reverse order to maintain correct indices
const sortedEntries = [...entriesToRemove].sort((a, b) => b.startLine - a.startLine);
for (const entry of sortedEntries) {
lines.splice(entry.startLine, entry.endLine - entry.startLine + 1);
}
await writeFileAtomic(configPath, lines.join('\n'));
}
/**
* Prompt metadata expected from core's PromptInfo
*/
export interface PromptMetadataForDeletion {
name: string;
metadata?: {
filePath?: string | undefined;
originalId?: string | undefined;
};
}
/**
* Result of prompt deletion operation
*/
export interface PromptDeletionResult {
success: boolean;
deletedFile: boolean;
removedFromConfig: boolean;
error?: string;
}
/**
* Higher-level function to delete a prompt using its metadata.
* Handles both file-based and inline prompts, including file deletion.
*
* @param configPath - Path to the agent config file
* @param prompt - Prompt metadata (name and optional filePath in metadata)
* @param options - Options for deletion behavior
* @returns Result indicating what was deleted
*
* @example
* ```typescript
* // Delete a file-based prompt (deletes file and removes from config)
* await deletePromptByMetadata('/path/to/agent.yml', {
* name: 'test-prompt',
* metadata: { filePath: '/path/to/prompts/test-prompt.md' }
* });
*
* // Delete an inline prompt (only removes from config)
* await deletePromptByMetadata('/path/to/agent.yml', {
* name: 'quick-help'
* });
* ```
*/
export async function deletePromptByMetadata(
configPath: string,
prompt: PromptMetadataForDeletion,
options: { deleteFile?: boolean } = { deleteFile: true }
): Promise<PromptDeletionResult> {
const result: PromptDeletionResult = {
success: false,
deletedFile: false,
removedFromConfig: false,
};
const filePath = prompt.metadata?.filePath;
try {
if (filePath) {
// File-based prompt
const fileName = path.basename(filePath);
// Check if this is a config-based prompt (in prompts/ dir) vs shared (in commands/ dir)
const isSharedPrompt =
filePath.includes('/commands/') || filePath.includes('/.dexto/commands/');
if (!isSharedPrompt) {
// Remove from config file first
await removePromptFromAgentConfig(configPath, {
type: 'file',
filePattern: `/prompts/${fileName}`,
});
result.removedFromConfig = true;
}
// Delete the actual file if requested
if (options.deleteFile) {
try {
await fs.unlink(filePath);
result.deletedFile = true;
} catch {
// File might not exist, that's okay
}
}
result.success = true;
} else {
// Inline prompt - remove from config by id
// Use originalId from metadata if available (name might have provider prefix like "config:")
const promptId = prompt.metadata?.originalId || prompt.name;
await removePromptFromAgentConfig(configPath, {
type: 'inline',
id: promptId,
});
result.removedFromConfig = true;
result.success = true;
}
} catch (err) {
result.error = err instanceof Error ? err.message : String(err);
}
return result;
}

View File

@@ -0,0 +1,620 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import type { Dirent } from 'fs';
// Mock fs module
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
readdirSync: vi.fn(),
existsSync: vi.fn(),
};
});
// Mock execution context utilities
vi.mock('../utils/execution-context.js', () => ({
getExecutionContext: vi.fn(),
findDextoSourceRoot: vi.fn(),
findDextoProjectRoot: vi.fn(),
}));
// Mock path utilities
vi.mock('../utils/path.js', () => ({
getDextoGlobalPath: vi.fn((subpath: string) => `/home/user/.dexto/${subpath}`),
}));
import { discoverAgentInstructionFile, discoverCommandPrompts } from './discover-prompts.js';
import {
getExecutionContext,
findDextoSourceRoot,
findDextoProjectRoot,
} from '../utils/execution-context.js';
import { getDextoGlobalPath } from '../utils/path.js';
describe('discoverAgentInstructionFile', () => {
const originalCwd = process.cwd;
beforeEach(() => {
vi.mocked(fs.readdirSync).mockReset();
process.cwd = vi.fn(() => '/test/project');
});
afterEach(() => {
process.cwd = originalCwd;
});
describe('case-insensitive matching', () => {
it('should find CLAUDE.md (uppercase)', () => {
vi.mocked(fs.readdirSync).mockReturnValue([
'README.md',
'CLAUDE.md',
'package.json',
] as any);
const result = discoverAgentInstructionFile();
expect(result).toBe('/test/project/CLAUDE.md');
});
it('should find claude.md (lowercase)', () => {
vi.mocked(fs.readdirSync).mockReturnValue([
'README.md',
'claude.md',
'package.json',
] as any);
const result = discoverAgentInstructionFile();
expect(result).toBe('/test/project/claude.md');
});
it('should find Claude.md (mixed case)', () => {
vi.mocked(fs.readdirSync).mockReturnValue([
'README.md',
'Claude.md',
'package.json',
] as any);
const result = discoverAgentInstructionFile();
expect(result).toBe('/test/project/Claude.md');
});
it('should find AGENTS.md (uppercase)', () => {
vi.mocked(fs.readdirSync).mockReturnValue(['AGENTS.md', 'other.txt'] as any);
const result = discoverAgentInstructionFile();
expect(result).toBe('/test/project/AGENTS.md');
});
it('should find Gemini.md (mixed case)', () => {
vi.mocked(fs.readdirSync).mockReturnValue(['Gemini.md', 'other.txt'] as any);
const result = discoverAgentInstructionFile();
expect(result).toBe('/test/project/Gemini.md');
});
});
describe('priority order', () => {
it('should prefer AGENTS.md over CLAUDE.md', () => {
vi.mocked(fs.readdirSync).mockReturnValue([
'CLAUDE.md',
'AGENTS.md',
'GEMINI.md',
] as any);
const result = discoverAgentInstructionFile();
expect(result).toBe('/test/project/AGENTS.md');
});
it('should prefer CLAUDE.md over GEMINI.md when no AGENTS.md', () => {
vi.mocked(fs.readdirSync).mockReturnValue(['GEMINI.md', 'CLAUDE.md'] as any);
const result = discoverAgentInstructionFile();
expect(result).toBe('/test/project/CLAUDE.md');
});
it('should return GEMINI.md when only option', () => {
vi.mocked(fs.readdirSync).mockReturnValue(['GEMINI.md', 'other.txt'] as any);
const result = discoverAgentInstructionFile();
expect(result).toBe('/test/project/GEMINI.md');
});
it('should prefer agents.md over claude.md (lowercase)', () => {
vi.mocked(fs.readdirSync).mockReturnValue(['claude.md', 'agents.md'] as any);
const result = discoverAgentInstructionFile();
expect(result).toBe('/test/project/agents.md');
});
});
describe('no match scenarios', () => {
it('should return null when no instruction files exist', () => {
vi.mocked(fs.readdirSync).mockReturnValue(['README.md', 'package.json', 'src'] as any);
const result = discoverAgentInstructionFile();
expect(result).toBeNull();
});
it('should return null when directory is empty', () => {
vi.mocked(fs.readdirSync).mockReturnValue([] as any);
const result = discoverAgentInstructionFile();
expect(result).toBeNull();
});
it('should return null when readdirSync throws', () => {
vi.mocked(fs.readdirSync).mockImplementation(() => {
throw new Error('ENOENT: no such file or directory');
});
const result = discoverAgentInstructionFile();
expect(result).toBeNull();
});
});
describe('preserves actual filename casing', () => {
it('should return path with actual filename casing from filesystem', () => {
// Even though we search for 'claude.md' (lowercase), the returned path
// should use the actual casing from the filesystem
vi.mocked(fs.readdirSync).mockReturnValue(['CLAUDE.MD'] as any);
const result = discoverAgentInstructionFile();
expect(result).toBe('/test/project/CLAUDE.MD');
});
});
});
describe('discoverCommandPrompts', () => {
const originalCwd = process.cwd;
const originalEnv = { ...process.env };
// Helper to create mock Dirent objects
const createDirent = (name: string, isFile: boolean): Dirent => ({
name,
isFile: () => isFile,
isDirectory: () => !isFile,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
path: '',
parentPath: '',
});
beforeEach(() => {
vi.mocked(fs.readdirSync).mockReset();
vi.mocked(fs.existsSync).mockReset();
vi.mocked(getExecutionContext).mockReset();
vi.mocked(findDextoSourceRoot).mockReset();
vi.mocked(findDextoProjectRoot).mockReset();
vi.mocked(getDextoGlobalPath).mockReset();
// Default mocks
process.cwd = vi.fn(() => '/test/project');
process.env.HOME = '/home/user';
process.env.DEXTO_DEV_MODE = undefined;
vi.mocked(getExecutionContext).mockReturnValue('global-cli');
vi.mocked(getDextoGlobalPath).mockImplementation(
(subpath: string) => `/home/user/.dexto/${subpath}`
);
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
process.cwd = originalCwd;
process.env = { ...originalEnv };
});
describe('discovery from local .dexto/commands/', () => {
it('should discover commands from <cwd>/.dexto/commands/', () => {
vi.mocked(fs.existsSync).mockImplementation(
(p) => p === '/test/project/.dexto/commands'
);
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/commands') {
return [createDirent('build.md', true), createDirent('test.md', true)] as any;
}
return [];
});
const result = discoverCommandPrompts();
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: 'file',
file: '/test/project/.dexto/commands/build.md',
});
expect(result[1]).toEqual({
type: 'file',
file: '/test/project/.dexto/commands/test.md',
});
});
});
describe('discovery from local .claude/commands/', () => {
it('should discover commands from <cwd>/.claude/commands/', () => {
vi.mocked(fs.existsSync).mockImplementation(
(p) => p === '/test/project/.claude/commands'
);
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.claude/commands') {
return [
createDirent('quality-checks.md', true),
createDirent('deploy.md', true),
] as any;
}
return [];
});
const result = discoverCommandPrompts();
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: 'file',
file: '/test/project/.claude/commands/quality-checks.md',
});
expect(result[1]).toEqual({
type: 'file',
file: '/test/project/.claude/commands/deploy.md',
});
});
});
describe('discovery from local .cursor/commands/', () => {
it('should discover commands from <cwd>/.cursor/commands/', () => {
vi.mocked(fs.existsSync).mockImplementation(
(p) => p === '/test/project/.cursor/commands'
);
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.cursor/commands') {
return [createDirent('lint.md', true)] as any;
}
return [];
});
const result = discoverCommandPrompts();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'file',
file: '/test/project/.cursor/commands/lint.md',
});
});
});
describe('discovery from global directories', () => {
it('should discover commands from ~/.dexto/commands/', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => p === '/home/user/.dexto/commands');
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/home/user/.dexto/commands') {
return [createDirent('global-cmd.md', true)] as any;
}
return [];
});
const result = discoverCommandPrompts();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'file',
file: '/home/user/.dexto/commands/global-cmd.md',
});
});
it('should discover commands from ~/.claude/commands/', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => p === '/home/user/.claude/commands');
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/home/user/.claude/commands') {
return [createDirent('claude-global.md', true)] as any;
}
return [];
});
const result = discoverCommandPrompts();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'file',
file: '/home/user/.claude/commands/claude-global.md',
});
});
it('should discover commands from ~/.cursor/commands/', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => p === '/home/user/.cursor/commands');
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/home/user/.cursor/commands') {
return [createDirent('cursor-global.md', true)] as any;
}
return [];
});
const result = discoverCommandPrompts();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'file',
file: '/home/user/.cursor/commands/cursor-global.md',
});
});
});
describe('priority and deduplication', () => {
it('should deduplicate by basename (case-insensitive), first found wins', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
// Local .dexto has build.md
if (dir === '/test/project/.dexto/commands') {
return [createDirent('build.md', true)] as any;
}
// Global .dexto also has build.md
if (dir === '/home/user/.dexto/commands') {
return [createDirent('build.md', true)] as any;
}
return [];
});
const result = discoverCommandPrompts();
// Should only have one build.md - the first one found (from local .dexto/commands)
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'file',
file: '/test/project/.dexto/commands/build.md',
});
});
it('should respect priority order: local .dexto > global .dexto', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/commands') {
return [createDirent('dexto-local.md', true)] as any;
}
if (dir === '/home/user/.dexto/commands') {
return [createDirent('dexto-global.md', true)] as any;
}
return [];
});
const result = discoverCommandPrompts();
// All unique files should be discovered in priority order
expect(result).toHaveLength(2);
expect(result.map((r) => r.file)).toEqual([
'/test/project/.dexto/commands/dexto-local.md',
'/home/user/.dexto/commands/dexto-global.md',
]);
});
it('should allow local to override global with same basename', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
// Local .dexto has deploy.md
if (dir === '/test/project/.dexto/commands') {
return [createDirent('deploy.md', true)] as any;
}
// Global .dexto also has deploy.md
if (dir === '/home/user/.dexto/commands') {
return [createDirent('deploy.md', true)] as any;
}
return [];
});
const result = discoverCommandPrompts();
// Local wins over global
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'file',
file: '/test/project/.dexto/commands/deploy.md',
});
});
});
describe('dexto-source context (dev mode)', () => {
it('should include local commands/ when DEXTO_DEV_MODE=true', () => {
vi.mocked(getExecutionContext).mockReturnValue('dexto-source');
vi.mocked(findDextoSourceRoot).mockReturnValue('/dexto-source');
process.env.DEXTO_DEV_MODE = 'true';
vi.mocked(fs.existsSync).mockImplementation((p) => p === '/dexto-source/commands');
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/dexto-source/commands') {
return [createDirent('dev-command.md', true)] as any;
}
return [];
});
const result = discoverCommandPrompts();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'file',
file: '/dexto-source/commands/dev-command.md',
});
});
it('should NOT include local commands/ when DEXTO_DEV_MODE is not set', () => {
vi.mocked(getExecutionContext).mockReturnValue('dexto-source');
vi.mocked(findDextoSourceRoot).mockReturnValue('/dexto-source');
process.env.DEXTO_DEV_MODE = undefined;
vi.mocked(fs.existsSync).mockImplementation(
(p) => p === '/dexto-source/commands' || p === '/test/project/.dexto/commands'
);
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/dexto-source/commands') {
return [createDirent('dev-command.md', true)] as any;
}
if (dir === '/test/project/.dexto/commands') {
return [createDirent('local-dexto.md', true)] as any;
}
return [];
});
const result = discoverCommandPrompts();
// Should not include dev-command.md, but should include local .dexto/commands
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'file',
file: '/test/project/.dexto/commands/local-dexto.md',
});
});
});
describe('dexto-project context', () => {
it('should include local commands/ from project root', () => {
vi.mocked(getExecutionContext).mockReturnValue('dexto-project');
vi.mocked(findDextoProjectRoot).mockReturnValue('/my-dexto-project');
vi.mocked(fs.existsSync).mockImplementation((p) => p === '/my-dexto-project/commands');
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/my-dexto-project/commands') {
return [createDirent('project-cmd.md', true)] as any;
}
return [];
});
const result = discoverCommandPrompts();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'file',
file: '/my-dexto-project/commands/project-cmd.md',
});
});
});
describe('file filtering', () => {
it('should only include .md files', () => {
vi.mocked(fs.existsSync).mockImplementation(
(p) => p === '/test/project/.dexto/commands'
);
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/commands') {
return [
createDirent('command.md', true),
createDirent('script.sh', true),
createDirent('config.json', true),
createDirent('another.md', true),
] as any;
}
return [];
});
const result = discoverCommandPrompts();
expect(result).toHaveLength(2);
expect(result.map((r) => r.file)).toEqual([
'/test/project/.dexto/commands/command.md',
'/test/project/.dexto/commands/another.md',
]);
});
it('should exclude README.md', () => {
vi.mocked(fs.existsSync).mockImplementation(
(p) => p === '/test/project/.dexto/commands'
);
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/commands') {
return [
createDirent('README.md', true),
createDirent('command.md', true),
] as any;
}
return [];
});
const result = discoverCommandPrompts();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'file',
file: '/test/project/.dexto/commands/command.md',
});
});
it('should exclude directories', () => {
vi.mocked(fs.existsSync).mockImplementation(
(p) => p === '/test/project/.dexto/commands'
);
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/commands') {
return [
createDirent('command.md', true),
createDirent('subdir', false), // directory
] as any;
}
return [];
});
const result = discoverCommandPrompts();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'file',
file: '/test/project/.dexto/commands/command.md',
});
});
});
describe('edge cases', () => {
it('should return empty array when no command directories exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = discoverCommandPrompts();
expect(result).toEqual([]);
});
it('should handle missing HOME environment variable', () => {
delete process.env.HOME;
delete process.env.USERPROFILE;
vi.mocked(fs.existsSync).mockImplementation(
(p) => p === '/test/project/.dexto/commands'
);
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/commands') {
return [createDirent('local.md', true)] as any;
}
return [];
});
const result = discoverCommandPrompts();
// Should still work for local commands
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'file',
file: '/test/project/.dexto/commands/local.md',
});
});
it('should handle errors reading directories gracefully', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockImplementation(() => {
throw new Error('Permission denied');
});
const result = discoverCommandPrompts();
// Should return empty array, not throw
expect(result).toEqual([]);
});
});
});

View File

@@ -0,0 +1,199 @@
/**
* Command Prompt Discovery
*
* Discovers command prompts from commands/ directories based on execution context.
* Extracted to separate file to enable proper unit testing with mocks.
*
* Discovery locations (in priority order):
*
* Local commands (project-specific):
* 1. <projectRoot>/commands/ (dexto-source dev mode or dexto-project only)
* 2. <cwd>/.dexto/commands/
* 3. <cwd>/.claude/commands/ (Claude Code compatibility)
* 4. <cwd>/.cursor/commands/ (Cursor compatibility)
*
* Global commands (user-wide):
* 5. ~/.dexto/commands/
* 6. ~/.claude/commands/ (Claude Code compatibility)
* 7. ~/.cursor/commands/ (Cursor compatibility)
*
* Files with the same basename are deduplicated (first found wins).
*/
import {
getExecutionContext,
findDextoSourceRoot,
findDextoProjectRoot,
} from '../utils/execution-context.js';
import { getDextoGlobalPath } from '../utils/path.js';
import * as path from 'path';
import { existsSync, readdirSync } from 'fs';
/**
* File prompt entry for discovered commands
*/
export interface FilePromptEntry {
type: 'file';
file: string;
showInStarters?: boolean;
}
/**
* Discovers command prompts from commands/ directories.
*
* @returns Array of file prompt entries for discovered .md files
*/
export function discoverCommandPrompts(): FilePromptEntry[] {
const prompts: FilePromptEntry[] = [];
const seenFiles = new Set<string>();
const cwd = process.cwd();
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
// Helper to scan a directory and add unique files
const scanAndAdd = (dir: string): void => {
if (!existsSync(dir)) return;
const files = scanCommandsDirectory(dir);
for (const file of files) {
// Normalize to lowercase for case-insensitive deduplication (Windows/macOS)
const basename = path.basename(file).toLowerCase();
if (!seenFiles.has(basename)) {
seenFiles.add(basename);
prompts.push({ type: 'file', file });
}
}
};
// Determine local commands/ directory based on context (dexto-native projects only)
const context = getExecutionContext();
let localCommandsDir: string | null = null;
switch (context) {
case 'dexto-source': {
// Only use local commands in dev mode
const isDevMode = process.env.DEXTO_DEV_MODE === 'true';
if (isDevMode) {
const sourceRoot = findDextoSourceRoot();
if (sourceRoot) {
localCommandsDir = path.join(sourceRoot, 'commands');
}
}
break;
}
case 'dexto-project': {
const projectRoot = findDextoProjectRoot();
if (projectRoot) {
localCommandsDir = path.join(projectRoot, 'commands');
}
break;
}
case 'global-cli':
// No local commands/ for global CLI (but .dexto/commands etc. still apply)
break;
}
// Scan in priority order (first found wins for same basename)
// === Local commands (project-specific) ===
// 1. Local commands/ directory (dexto-native projects only)
if (localCommandsDir) {
scanAndAdd(localCommandsDir);
}
// 2. Dexto local commands: <cwd>/.dexto/commands/
scanAndAdd(path.join(cwd, '.dexto', 'commands'));
// 3. Claude Code local commands: <cwd>/.claude/commands/
scanAndAdd(path.join(cwd, '.claude', 'commands'));
// 4. Cursor local commands: <cwd>/.cursor/commands/
scanAndAdd(path.join(cwd, '.cursor', 'commands'));
// === Global commands (user-wide) ===
// 5. Dexto global commands: ~/.dexto/commands/
scanAndAdd(getDextoGlobalPath('commands'));
// 6. Claude Code global commands: ~/.claude/commands/
if (homeDir) {
scanAndAdd(path.join(homeDir, '.claude', 'commands'));
}
// 7. Cursor global commands: ~/.cursor/commands/
if (homeDir) {
scanAndAdd(path.join(homeDir, '.cursor', 'commands'));
}
return prompts;
}
/**
* Scans a directory for .md command files
* @param dir Directory to scan
* @returns Array of absolute file paths
*/
function scanCommandsDirectory(dir: string): string[] {
const files: string[] = [];
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md') {
files.push(path.join(dir, entry.name));
}
}
} catch {
// Directory doesn't exist or can't be read - ignore
}
return files;
}
/**
* Agent instruction file names to discover (in priority order, case-insensitive)
* First found file wins - only one file is used
*
* Conventions:
* - AGENTS.md: Open standard for AI coding agents (Linux Foundation/AAIF)
* - CLAUDE.md: Anthropic's Claude Code instruction format
* - GEMINI.md: Google's Gemini CLI instruction format
*/
const AGENT_INSTRUCTION_FILES = ['agents.md', 'claude.md', 'gemini.md'] as const;
/**
* Discovers agent instruction files from the current working directory.
*
* Looks for files in this order of priority (case-insensitive):
* 1. AGENTS.md (or agents.md, Agents.md, etc.)
* 2. CLAUDE.md (or claude.md, Claude.md, etc.)
* 3. GEMINI.md (or gemini.md, Gemini.md, etc.)
*
* Only the first found file is returned (we don't want multiple instruction files).
*
* @returns The absolute path to the first found instruction file, or null if none found
*/
export function discoverAgentInstructionFile(): string | null {
const cwd = process.cwd();
// Read directory once for case-insensitive matching
let dirEntries: string[];
try {
dirEntries = readdirSync(cwd);
} catch {
return null;
}
// Build a map of lowercase filename -> actual filename for case-insensitive lookup
const lowercaseMap = new Map<string, string>();
for (const entry of dirEntries) {
lowercaseMap.set(entry.toLowerCase(), entry);
}
// Find first matching file in priority order
for (const filename of AGENT_INSTRUCTION_FILES) {
const actualFilename = lowercaseMap.get(filename);
if (actualFilename) {
return path.join(cwd, actualFilename);
}
}
return null;
}

View File

@@ -0,0 +1,20 @@
/**
* Config-specific error codes
* Includes file operations, parsing, and validation errors for configuration management
*/
export enum ConfigErrorCode {
// File operations
FILE_NOT_FOUND = 'config_file_not_found',
FILE_READ_ERROR = 'config_file_read_error',
FILE_WRITE_ERROR = 'config_file_write_error',
// Parsing errors
PARSE_ERROR = 'config_parse_error',
// Resolution errors
NO_PROJECT_DEFAULT = 'config_no_project_default',
NO_GLOBAL_PREFERENCES = 'config_no_global_preferences',
SETUP_INCOMPLETE = 'config_setup_incomplete',
BUNDLED_NOT_FOUND = 'config_bundled_not_found',
UNKNOWN_CONTEXT = 'config_unknown_context',
}

View File

@@ -0,0 +1,110 @@
import { DextoRuntimeError, ErrorScope, ErrorType } from '@dexto/core';
import { ConfigErrorCode } from './error-codes.js';
/**
* Config runtime error factory methods
* Creates properly typed errors for configuration operations
*/
export class ConfigError {
// File operation errors
static fileNotFound(configPath: string) {
return new DextoRuntimeError(
ConfigErrorCode.FILE_NOT_FOUND,
ErrorScope.CONFIG,
ErrorType.USER,
`Configuration file not found: ${configPath}`,
{ configPath },
'Ensure the configuration file exists at the specified path'
);
}
static fileReadError(configPath: string, cause: string) {
return new DextoRuntimeError(
ConfigErrorCode.FILE_READ_ERROR,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to read configuration file: ${cause}`,
{ configPath, cause },
'Check file permissions and ensure the file is not corrupted'
);
}
static fileWriteError(configPath: string, cause: string) {
return new DextoRuntimeError(
ConfigErrorCode.FILE_WRITE_ERROR,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to write configuration file '${configPath}': ${cause}`,
{ configPath, cause },
'Check file permissions and available disk space'
);
}
// Parsing errors
static parseError(configPath: string, cause: string) {
return new DextoRuntimeError(
ConfigErrorCode.PARSE_ERROR,
ErrorScope.CONFIG,
ErrorType.USER,
`Failed to parse configuration file: ${cause}`,
{ configPath, cause },
'Ensure the configuration file contains valid YAML syntax'
);
}
// Resolution errors
static noProjectDefault(projectPath: string) {
return new DextoRuntimeError(
ConfigErrorCode.NO_PROJECT_DEFAULT,
ErrorScope.CONFIG,
ErrorType.USER,
`No project coding-agent.yml found and no global preferences configured.\nEither create coding-agent.yml in your project root (${projectPath}) or run \`dexto setup\` to configure preferences.`,
{ projectPath },
'Run `dexto setup` or create a project-specific agent config'
);
}
static noGlobalPreferences() {
return new DextoRuntimeError(
ConfigErrorCode.NO_GLOBAL_PREFERENCES,
ErrorScope.CONFIG,
ErrorType.USER,
`No global preferences found. Run \`dexto setup\` to get started.`,
{},
'Run `dexto setup` to configure your AI preferences'
);
}
static setupIncomplete() {
return new DextoRuntimeError(
ConfigErrorCode.SETUP_INCOMPLETE,
ErrorScope.CONFIG,
ErrorType.USER,
`Global preferences setup is incomplete. Run \`dexto setup\` to complete.`,
{},
'Run `dexto setup` to complete your configuration'
);
}
static bundledNotFound(bundledPath: string) {
return new DextoRuntimeError(
ConfigErrorCode.BUNDLED_NOT_FOUND,
ErrorScope.CONFIG,
ErrorType.NOT_FOUND,
`Bundled default agent not found: ${bundledPath}. Run npm run build first.`,
{ path: bundledPath },
'Run `npm run build` to build the bundled agents'
);
}
static unknownContext(context: string) {
return new DextoRuntimeError(
ConfigErrorCode.UNKNOWN_CONTEXT,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Unknown execution context: ${context}`,
{ context },
'This is an internal error - please report it'
);
}
}

View File

@@ -0,0 +1,23 @@
export {
updateAgentConfigFile,
reloadAgentConfigFromFile,
addPromptToAgentConfig,
removePromptFromAgentConfig,
deletePromptByMetadata,
updateMcpServerField,
removeMcpServerFromConfig,
type FilePromptInput,
type InlinePromptInput,
type PromptInput,
type PromptMetadataForDeletion,
type PromptDeletionResult,
} from './config-manager.js';
export { loadAgentConfig } from './loader.js';
export {
enrichAgentConfig,
deriveAgentId,
discoverCommandPrompts,
type EnrichAgentConfigOptions,
} from './config-enrichment.js';
export { ConfigError } from './errors.js';
export { ConfigErrorCode } from './error-codes.js';

View File

@@ -0,0 +1,252 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'node:url';
import { loadAgentConfig } from './loader.js';
import { ErrorScope, ErrorType } from '@dexto/core';
import { ConfigErrorCode } from './error-codes.js';
// Temp config file path relative to this test file (stable across monorepo runners)
const tmpFile = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'temp-config.yml');
beforeEach(async () => {
delete process.env.TEST_VAR;
delete process.env.MAX_TOKENS;
try {
await fs.unlink(tmpFile);
} catch {
/* ignore error if file does not exist */
}
});
afterEach(async () => {
delete process.env.TEST_VAR;
delete process.env.MAX_TOKENS;
try {
await fs.unlink(tmpFile);
} catch {
/* ignore error if file does not exist */
}
});
describe('loadAgentConfig', () => {
it('loads raw config without expanding environment variables', async () => {
process.env.TEST_VAR = '0.7';
process.env.MAX_TOKENS = '4000';
const yamlContent = `
llm:
provider: 'test-provider'
model: 'test-model'
systemPrompt: 'base-prompt'
temperature: \${TEST_VAR}
maxOutputTokens: \${MAX_TOKENS}
mcpServers:
testServer:
type: 'stdio'
command: 'echo'
args: ['hello']
`;
await fs.writeFile(tmpFile, yamlContent);
const config = await loadAgentConfig(tmpFile);
// Config loader no longer expands env vars - Zod schema handles it
expect(config.llm?.temperature).toBe('${TEST_VAR}');
expect(config.llm?.maxOutputTokens).toBe('${MAX_TOKENS}');
});
it('throws DextoRuntimeError with file not found code when file does not exist', async () => {
const missing = path.resolve(process.cwd(), 'nonexistent.yml');
await expect(loadAgentConfig(missing)).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.FILE_NOT_FOUND,
scope: ErrorScope.CONFIG,
type: ErrorType.USER,
})
);
});
it('throws DextoRuntimeError with file read error code when file cannot be read', async () => {
await fs.writeFile(tmpFile, 'some content', { mode: 0o000 });
await expect(loadAgentConfig(tmpFile)).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.FILE_READ_ERROR,
scope: ErrorScope.CONFIG,
type: ErrorType.SYSTEM,
})
);
await fs.unlink(tmpFile);
});
it('throws DextoRuntimeError with parse error code when file content is invalid YAML', async () => {
const invalidYamlContent = `
llm:
provider: 'test-provider'
model: 'test-model'
temperature: 0.5
malformed:
mcpServers:
testServer:
type: 'stdio'
command: 'echo'
args: ['hello']
`;
await fs.writeFile(tmpFile, invalidYamlContent);
await expect(loadAgentConfig(tmpFile)).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.PARSE_ERROR,
scope: ErrorScope.CONFIG,
type: ErrorType.USER,
})
);
});
it('throws file not found error when no default config exists', async () => {
// Test with a non-existent path to ensure predictable behavior
const nonExistentPath = '/tmp/definitely-does-not-exist/agent.yml';
await expect(loadAgentConfig(nonExistentPath)).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.FILE_NOT_FOUND,
scope: ErrorScope.CONFIG,
type: ErrorType.USER,
})
);
});
it('loads config with undefined environment variables as raw strings', async () => {
const yamlContent = `
llm:
provider: 'test-provider'
model: 'test-model'
apiKey: \${UNDEFINED_API_KEY} # This variable is intentionally not set
mcpServers:
testServer:
type: 'stdio'
command: 'echo'
args: ['hello']
`;
await fs.writeFile(tmpFile, yamlContent);
delete process.env.UNDEFINED_API_KEY;
// Should not throw - env var expansion now handled by Zod schema
const config = await loadAgentConfig(tmpFile);
expect(config.llm?.apiKey).toBe('${UNDEFINED_API_KEY}');
});
it('expands template variables in config', async () => {
const yamlContent = `
llm:
provider: 'test-provider'
model: 'test-model'
mcpServers:
testServer:
type: 'stdio'
command: 'echo'
args:
- 'hello'
- '\${{dexto.agent_dir}}/data/file.txt'
systemPrompt:
contributors:
- type: file
files:
- '\${{dexto.agent_dir}}/docs/prompt.md'
`;
await fs.writeFile(tmpFile, yamlContent);
const config = await loadAgentConfig(tmpFile);
const expectedDir = path.dirname(tmpFile);
const expectedDataFile = path.join(expectedDir, 'data', 'file.txt');
const expectedPromptFile = path.join(expectedDir, 'docs', 'prompt.md');
// Template variables should be expanded (cross-platform)
expect(path.normalize((config.mcpServers?.testServer as any)?.args?.[1] as string)).toBe(
path.normalize(expectedDataFile)
);
expect(
path.normalize((config.systemPrompt as any)?.contributors?.[0]?.files?.[0] as string)
).toBe(path.normalize(expectedPromptFile));
});
it('handles config without template variables', async () => {
const yamlContent = `
llm:
provider: 'test-provider'
model: 'test-model'
mcpServers:
testServer:
type: 'stdio'
command: 'echo'
args: ['hello', 'world']
`;
await fs.writeFile(tmpFile, yamlContent);
const config = await loadAgentConfig(tmpFile);
// Regular config should work normally
expect((config.mcpServers?.testServer as any)?.args).toEqual(['hello', 'world']);
});
it('throws error on path traversal in template expansion', async () => {
const yamlContent = `
llm:
provider: 'test-provider'
model: 'test-model'
mcpServers:
testServer:
type: 'stdio'
command: 'echo'
args: ['\${{dexto.agent_dir}}/../../../sensitive/file']
`;
await fs.writeFile(tmpFile, yamlContent);
await expect(loadAgentConfig(tmpFile)).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.PARSE_ERROR,
scope: ErrorScope.CONFIG,
type: ErrorType.USER,
})
);
});
it('expands ${{dexto.project_dir}} template variable', async () => {
const yamlContent = `
llm:
provider: 'test-provider'
model: 'test-model'
customTools:
- type: plan-tools
basePath: '\${{dexto.project_dir}}/plans'
`;
await fs.writeFile(tmpFile, yamlContent);
const config = await loadAgentConfig(tmpFile);
// project_dir should be expanded to the context-aware .dexto path
const basePath = (config.customTools as any)?.[0]?.basePath as string;
expect(basePath).toBeDefined();
expect(basePath).toContain('.dexto');
expect(basePath).toContain('plans');
// Should be an absolute path
expect(path.isAbsolute(basePath)).toBe(true);
});
it('throws error on path traversal in project_dir template expansion', async () => {
const yamlContent = `
llm:
provider: 'test-provider'
model: 'test-model'
customTools:
- type: plan-tools
basePath: '\${{dexto.project_dir}}/../../../etc/passwd'
`;
await fs.writeFile(tmpFile, yamlContent);
await expect(loadAgentConfig(tmpFile)).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.PARSE_ERROR,
scope: ErrorScope.CONFIG,
type: ErrorType.USER,
})
);
});
});

View File

@@ -0,0 +1,185 @@
import { promises as fs } from 'fs';
import path from 'path';
import { parse as parseYaml } from 'yaml';
import type { AgentConfig } from '@dexto/core';
import type { IDextoLogger } from '@dexto/core';
import { ConfigError } from './errors.js';
import { getDextoPath } from '../utils/path.js';
/**
* Template variables context for expansion
*/
interface TemplateContext {
/** Agent directory (where the config file is located) */
agentDir: string;
/** Project .dexto directory (context-aware via getDextoPath) */
projectDir: string;
}
/**
* Expand template variables in agent configuration
*
* Supported variables:
* - ${{dexto.agent_dir}} - Agent's directory path (where config is located)
* - ${{dexto.project_dir}} - Context-aware .dexto directory:
* - dexto-source + dev mode: <repo>/.dexto
* - dexto-project: <project>/.dexto
* - global-cli: ~/.dexto
*/
function expandTemplateVars(config: unknown, context: TemplateContext): unknown {
// Deep clone to avoid mutations
const result = JSON.parse(JSON.stringify(config));
// Walk the config recursively
function walk(obj: unknown): unknown {
if (typeof obj === 'string') {
return expandString(obj, context);
}
if (Array.isArray(obj)) {
return obj.map(walk);
}
if (obj !== null && typeof obj === 'object') {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = walk(value);
}
return result;
}
return obj;
}
return walk(result);
}
/**
* Expand template variables in a string value
*/
function expandString(str: string, context: TemplateContext): string {
let result = str;
let hasAgentDirExpansion = false;
let hasProjectDirExpansion = false;
// Replace ${{dexto.agent_dir}} with absolute path
if (/\${{\s*dexto\.agent_dir\s*}}/.test(result)) {
result = result.replace(/\${{\s*dexto\.agent_dir\s*}}/g, context.agentDir);
hasAgentDirExpansion = true;
}
// Replace ${{dexto.project_dir}} with absolute path
if (/\${{\s*dexto\.project_dir\s*}}/.test(result)) {
result = result.replace(/\${{\s*dexto\.project_dir\s*}}/g, context.projectDir);
hasProjectDirExpansion = true;
}
// Security: Validate no path traversal for expanded paths
if (hasAgentDirExpansion) {
validateExpandedPath(str, result, context.agentDir, 'agent_dir');
}
if (hasProjectDirExpansion) {
validateExpandedPath(str, result, context.projectDir, 'project_dir');
}
return result;
}
/**
* Validate that template expansion doesn't allow path traversal
*/
function validateExpandedPath(
original: string,
expanded: string,
rootDir: string,
varName: string
): void {
const resolved = path.resolve(expanded);
const root = path.resolve(rootDir);
const relative = path.relative(root, resolved);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(
`Security: Template expansion attempted to escape ${varName} directory.\n` +
`Original: ${original}\n` +
`Expanded: ${expanded}\n` +
`Root: ${root}`
);
}
}
/**
* Asynchronously loads and processes an agent configuration file.
* This function handles file reading, YAML parsing, and template variable expansion.
* Environment variable expansion is handled by the Zod schema during validation.
*
* Note: Path resolution should be done before calling this function using resolveConfigPath().
*
* @param configPath - Path to the configuration file (absolute or relative)
* @param logger - logger instance for logging
* @returns A Promise that resolves to the parsed `AgentConfig` object with template variables expanded
* @throws {ConfigError} with FILE_NOT_FOUND if the configuration file does not exist
* @throws {ConfigError} with FILE_READ_ERROR if file read fails (e.g., permissions issues)
* @throws {ConfigError} with PARSE_ERROR if the content is not valid YAML or template expansion fails
*/
export async function loadAgentConfig(
configPath: string,
logger?: IDextoLogger
): Promise<AgentConfig> {
const absolutePath = path.resolve(configPath);
// --- Step 1: Verify the configuration file exists and is accessible ---
try {
// Attempt to access the file. If it doesn't exist or permissions are insufficient,
// `fs.access` will throw an error, which we catch.
await fs.access(absolutePath);
} catch (_error) {
// Throw a specific error indicating that the configuration file was not found.
throw ConfigError.fileNotFound(absolutePath);
}
let fileContent: string;
// --- Step 2: Read the content of the configuration file ---
try {
// Read the file content as a UTF-8 encoded string.
fileContent = await fs.readFile(absolutePath, 'utf-8');
} catch (error) {
// If an error occurs during file reading (e.g., I/O error, corrupted file),
// throw a `ConfigFileReadError` with the absolute path and the underlying cause.
throw ConfigError.fileReadError(
absolutePath,
error instanceof Error ? error.message : String(error)
);
}
// --- Step 3: Parse the file content as YAML ---
let config: unknown;
try {
// Attempt to parse the string content into a JavaScript object using a YAML parser.
config = parseYaml(fileContent);
} catch (error) {
// If the content is not valid YAML, `parseYaml` will throw an error.
// Catch it and throw a `ConfigParseError` with details.
throw ConfigError.parseError(
absolutePath,
error instanceof Error ? error.message : String(error)
);
}
// --- Step 4: Expand template variables ---
try {
const agentDir = path.dirname(absolutePath);
// Use context-aware path resolution for project_dir
// getDextoPath('') returns the .dexto directory for the current context
const projectDir = getDextoPath('');
const context: TemplateContext = { agentDir, projectDir };
config = expandTemplateVars(config, context);
logger?.debug(
`Expanded template variables for agent in: ${agentDir}, project: ${projectDir}`
);
} catch (error) {
throw ConfigError.parseError(
absolutePath,
`Template expansion failed: ${error instanceof Error ? error.message : String(error)}`
);
}
// Return expanded config - environment variable expansion handled by Zod schema
return config as AgentConfig;
}

View File

@@ -0,0 +1,230 @@
// Agent registry
export { getAgentRegistry, loadBundledRegistryAgents } from './registry/registry.js';
export type { AgentRegistry, AgentRegistryEntry, Registry } from './registry/types.js';
export { deriveDisplayName } from './registry/types.js';
export { RegistryError } from './registry/errors.js';
export { RegistryErrorCode } from './registry/error-codes.js';
// Global preferences
export {
loadGlobalPreferences,
saveGlobalPreferences,
globalPreferencesExist,
getGlobalPreferencesPath,
createInitialPreferences,
updateGlobalPreferences,
type GlobalPreferencesUpdates,
type CreatePreferencesOptions,
} from './preferences/loader.js';
export type { GlobalPreferences } from './preferences/schemas.js';
export { PreferenceError, PreferenceErrorCode } from './preferences/errors.js';
// Agent resolver
export { resolveAgentPath, updateDefaultAgentPreference } from './resolver.js';
// Config writer
export {
writeConfigFile,
writeLLMPreferences,
writePreferencesToAgent,
type LLMOverrides,
} from './writer.js';
// Agent manager (simple registry-based lifecycle management)
export { AgentManager, type AgentMetadata } from './AgentManager.js';
// Installation utilities
export {
installBundledAgent,
installCustomAgent,
uninstallAgent,
listInstalledAgents,
type InstallOptions,
} from './installation.js';
// Static API for agent management
export { AgentFactory, type CreateAgentOptions } from './AgentFactory.js';
// Path utilities (duplicated from core for short-term compatibility)
export {
getDextoPath,
getDextoGlobalPath,
getDextoEnvPath,
copyDirectory,
isPath,
findPackageRoot,
resolveBundledScript,
ensureDextoGlobalDirectory,
} from './utils/path.js';
export {
getExecutionContext,
findDextoSourceRoot,
findDextoProjectRoot,
type ExecutionContext,
} from './utils/execution-context.js';
export { walkUpDirectories } from './utils/fs-walk.js';
export { updateEnvFile } from './utils/env-file.js';
export { isDextoAuthEnabled } from './utils/feature-flags.js';
export {
isDextoAuthenticated,
getDextoApiKeyFromAuth,
canUseDextoProvider,
} from './utils/dexto-auth.js';
// Config management utilities
export {
updateAgentConfigFile,
reloadAgentConfigFromFile,
loadAgentConfig,
enrichAgentConfig,
deriveAgentId,
addPromptToAgentConfig,
removePromptFromAgentConfig,
deletePromptByMetadata,
updateMcpServerField,
removeMcpServerFromConfig,
ConfigError,
ConfigErrorCode,
type FilePromptInput,
type InlinePromptInput,
type PromptInput,
type PromptMetadataForDeletion,
type PromptDeletionResult,
} from './config/index.js';
// API Key utilities
export {
saveProviderApiKey,
getProviderKeyStatus,
listProviderKeyStatus,
determineApiKeyStorage,
SHARED_API_KEY_PROVIDERS,
type ApiKeyStorageStrategy,
} from './utils/api-key-store.js';
export {
resolveApiKeyForProvider,
getPrimaryApiKeyEnvVar,
PROVIDER_API_KEY_MAP,
} from './utils/api-key-resolver.js';
// Custom models
export {
loadCustomModels,
saveCustomModel,
deleteCustomModel,
getCustomModel,
getCustomModelsPath,
CustomModelSchema,
CUSTOM_MODEL_PROVIDERS,
type CustomModel,
type CustomModelProvider,
} from './models/custom-models.js';
// Local model management
export {
// Path resolver
getModelsDirectory,
getModelFilePath,
getModelDirectory,
getModelStatePath,
getModelTempDirectory,
ensureModelsDirectory,
ensureModelDirectory,
modelFileExists,
getModelFileSize,
deleteModelDirectory,
listModelDirectories,
getModelsDiskUsage,
formatSize,
// State manager
type ModelSource,
type InstalledModel,
type ModelState,
loadModelState,
saveModelState,
addInstalledModel,
removeInstalledModel,
getInstalledModel,
getAllInstalledModels,
isModelInstalled,
updateModelLastUsed,
setActiveModel,
getActiveModelId,
getActiveModel,
addToDownloadQueue,
removeFromDownloadQueue,
getDownloadQueue,
syncStateWithFilesystem,
getTotalInstalledSize,
getInstalledModelCount,
registerManualModel,
} from './models/index.js';
// Multi-Agent Runtime
export * from './runtime/index.js';
// Agent Spawner Tool Provider
export * from './tool-provider/index.js';
// Claude Code Plugin Loader
export {
// Discovery
discoverClaudeCodePlugins,
getPluginSearchPaths,
// Loading
loadClaudeCodePlugin,
// Validation
validatePluginDirectory,
tryLoadManifest,
// Listing
listInstalledPlugins,
getDextoInstalledPluginsPath,
// Installation
installPluginFromPath,
loadDextoInstalledPlugins,
saveDextoInstalledPlugins,
isPluginInstalled,
// Uninstallation
uninstallPlugin,
// Schemas
PluginManifestSchema,
PluginMCPConfigSchema,
// Error handling
PluginErrorCode,
PluginError,
// Marketplace
DEFAULT_MARKETPLACES,
addMarketplace,
removeMarketplace,
updateMarketplace,
listMarketplaces,
listAllMarketplacePlugins,
installPluginFromMarketplace,
getUninstalledDefaults,
isDefaultMarketplace,
MarketplaceErrorCode,
MarketplaceError,
// Types
type PluginManifest,
type DiscoveredPlugin,
type PluginCommand,
type PluginMCPConfig,
type LoadedPlugin,
type PluginInstallScope,
type InstalledPluginEntry,
type InstalledPluginsFile,
type ListedPlugin,
type PluginValidationResult,
type PluginInstallResult,
type PluginUninstallResult,
type ValidatedPluginManifest,
type ValidatedPluginMCPConfig,
type InstallPluginOptions,
type UninstallPluginOptions,
// Marketplace types
type MarketplaceEntry,
type MarketplacePlugin,
type MarketplaceAddResult,
type MarketplaceUpdateResult,
type MarketplaceInstallResult,
} from './plugins/index.js';

View File

@@ -0,0 +1,359 @@
// TODO: Consider whether installation.ts belongs in agent-management or cli package.
// Currently here because resolver.ts needs auto-install for resolveAgentPath(name, autoInstall=true).
// Options to evaluate:
// 1. Keep here - installation is part of "agent management" domain
// 2. Move to CLI - cleaner separation, remove auto-install from resolver
// 3. Create @dexto/installation package - both agent-management and CLI import from it
import { promises as fs } from 'fs';
import path from 'path';
import { logger } from '@dexto/core';
import { getDextoGlobalPath, resolveBundledScript, copyDirectory } from './utils/path.js';
import { RegistryError } from './registry/errors.js';
import { ConfigError } from './config/errors.js';
import type { AgentMetadata } from './AgentManager.js';
export interface InstallOptions {
/** Directory where agents are stored (default: ~/.dexto/agents) */
agentsDir?: string;
}
/**
* Get the default agents directory
*/
function getAgentsDir(options?: InstallOptions): string {
return options?.agentsDir ?? getDextoGlobalPath('agents');
}
/**
* Get the user registry path for installed agents
*/
function getUserRegistryPath(agentsDir: string): string {
return path.join(agentsDir, 'registry.json');
}
/**
* Load user registry (creates empty if doesn't exist)
*/
async function loadUserRegistry(registryPath: string): Promise<{ agents: any[] }> {
try {
const content = await fs.readFile(registryPath, 'utf-8');
return JSON.parse(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { agents: [] };
}
throw error;
}
}
/**
* Save user registry
*/
async function saveUserRegistry(registryPath: string, registry: { agents: any[] }): Promise<void> {
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(registryPath, JSON.stringify(registry, null, 2));
}
/**
* Install agent from bundled registry to local directory
*
* @param agentId ID of the agent to install from bundled registry
* @param options Installation options
* @returns Path to the installed agent's main config file
*
* @throws {DextoRuntimeError} If agent not found in bundled registry or installation fails
*
* @example
* ```typescript
* await installBundledAgent('coding-agent');
* console.log('Agent installed to ~/.dexto/agents/coding-agent');
* ```
*/
export async function installBundledAgent(
agentId: string,
options?: InstallOptions
): Promise<string> {
const agentsDir = getAgentsDir(options);
const bundledRegistryPath = resolveBundledScript('agents/agent-registry.json');
logger.info(`Installing agent: ${agentId}`);
// Load bundled registry
let bundledRegistry: any;
try {
const content = await fs.readFile(bundledRegistryPath, 'utf-8');
bundledRegistry = JSON.parse(content);
} catch (error) {
throw RegistryError.registryParseError(
bundledRegistryPath,
error instanceof Error ? error.message : String(error)
);
}
const agentEntry = bundledRegistry.agents[agentId];
if (!agentEntry) {
const available = Object.keys(bundledRegistry.agents);
throw RegistryError.agentNotFound(agentId, available);
}
const targetDir = path.join(agentsDir, agentId);
// Check if already installed
try {
await fs.access(targetDir);
logger.info(`Agent '${agentId}' already installed`);
// Return path to main config (consistent with post-install logic)
const mainFile = agentEntry.main || path.basename(agentEntry.source);
return path.join(targetDir, mainFile);
} catch {
// Not installed, continue
}
// Ensure agents directory exists
await fs.mkdir(agentsDir, { recursive: true });
// Copy from bundled source
const sourcePath = resolveBundledScript(`agents/${agentEntry.source}`);
const tempDir = `${targetDir}.tmp.${Date.now()}`;
try {
if (agentEntry.source.endsWith('/')) {
// Directory agent - copy entire directory
await copyDirectory(sourcePath, tempDir);
} else {
// Single file agent - create directory and copy file
await fs.mkdir(tempDir, { recursive: true });
const targetFile = path.join(tempDir, path.basename(sourcePath));
await fs.copyFile(sourcePath, targetFile);
}
// Atomic rename
await fs.rename(tempDir, targetDir);
logger.info(`✓ Installed agent '${agentId}' to ${targetDir}`);
// Add to user registry
const userRegistryPath = getUserRegistryPath(agentsDir);
const userRegistry = await loadUserRegistry(userRegistryPath);
if (!userRegistry.agents.some((a: any) => a.id === agentId)) {
const mainFile = agentEntry.main || path.basename(agentEntry.source);
userRegistry.agents.push({
id: agentId,
name: agentEntry.name,
description: agentEntry.description,
configPath: `./${agentId}/${mainFile}`,
author: agentEntry.author,
tags: agentEntry.tags,
});
await saveUserRegistry(userRegistryPath, userRegistry);
}
return path.join(targetDir, agentEntry.main || path.basename(agentEntry.source));
} catch (error) {
// Clean up temp directory on failure
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
throw RegistryError.installationFailed(
agentId,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Install custom agent from local path
*
* @param agentId Unique ID for the custom agent
* @param sourcePath Absolute path to agent YAML file or directory
* @param metadata Agent metadata (name, description, author, tags)
* @param options Installation options
* @returns Path to the installed agent's main config file
*
* @throws {DextoRuntimeError} If agent ID already exists or installation fails
*
* @example
* ```typescript
* await installCustomAgent('my-agent', '/path/to/agent.yml', {
* name: 'My Agent',
* description: 'Custom agent for my use case',
* author: 'John Doe',
* tags: ['custom']
* });
* ```
*/
export async function installCustomAgent(
agentId: string,
sourcePath: string,
metadata: Pick<AgentMetadata, 'name' | 'description' | 'author' | 'tags'>,
options?: InstallOptions
): Promise<string> {
const agentsDir = getAgentsDir(options);
const targetDir = path.join(agentsDir, agentId);
logger.info(`Installing custom agent: ${agentId}`);
// Validate custom agent ID doesn't conflict with bundled agents
try {
const bundledRegistryPath = resolveBundledScript('agents/agent-registry.json');
const bundledContent = await fs.readFile(bundledRegistryPath, 'utf-8');
const bundledRegistry = JSON.parse(bundledContent);
if (agentId in bundledRegistry.agents) {
throw RegistryError.customAgentNameConflict(agentId);
}
} catch (error) {
// If it's a RegistryError (our conflict error), rethrow it
if (error instanceof Error && error.message.includes('conflicts with builtin')) {
throw error;
}
// Otherwise, bundled registry might not exist (testing scenario), continue
logger.debug(
`Could not validate against bundled registry: ${error instanceof Error ? error.message : String(error)}`
);
}
// Check if already exists
try {
await fs.access(targetDir);
throw RegistryError.agentAlreadyExists(agentId);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
// Doesn't exist, continue
}
// Validate source exists
const resolvedSource = path.resolve(sourcePath);
let stat;
try {
stat = await fs.stat(resolvedSource);
} catch (_error) {
throw ConfigError.fileNotFound(resolvedSource);
}
// Ensure agents directory exists
await fs.mkdir(agentsDir, { recursive: true });
// Copy source to target
try {
if (stat.isDirectory()) {
await copyDirectory(resolvedSource, targetDir);
} else {
await fs.mkdir(targetDir, { recursive: true });
const filename = path.basename(resolvedSource);
await fs.copyFile(resolvedSource, path.join(targetDir, filename));
}
logger.info(`✓ Installed custom agent '${agentId}' to ${targetDir}`);
// Add to user registry
const userRegistryPath = getUserRegistryPath(agentsDir);
const userRegistry = await loadUserRegistry(userRegistryPath);
const configFile = stat.isDirectory() ? 'agent.yml' : path.basename(resolvedSource);
userRegistry.agents.push({
id: agentId,
name: metadata.name || agentId,
description: metadata.description,
configPath: `./${agentId}/${configFile}`,
author: metadata.author,
tags: metadata.tags || [],
});
await saveUserRegistry(userRegistryPath, userRegistry);
return path.join(targetDir, configFile);
} catch (error) {
// Clean up on failure
try {
await fs.rm(targetDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
throw RegistryError.installationFailed(
agentId,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Uninstall agent by removing it from disk and user registry
*
* @param agentId ID of the agent to uninstall
* @param options Installation options
*
* @throws {DextoRuntimeError} If agent not installed
*
* @example
* ```typescript
* await uninstallAgent('my-custom-agent');
* console.log('Agent uninstalled');
* ```
*/
export async function uninstallAgent(agentId: string, options?: InstallOptions): Promise<void> {
const agentsDir = getAgentsDir(options);
const targetDir = path.join(agentsDir, agentId);
logger.info(`Uninstalling agent: ${agentId}`);
// Check if exists
try {
await fs.access(targetDir);
} catch (_error) {
throw RegistryError.agentNotInstalled(agentId);
}
// Remove from disk
await fs.rm(targetDir, { recursive: true, force: true });
logger.info(`✓ Removed agent directory: ${targetDir}`);
// Remove from user registry
const userRegistryPath = getUserRegistryPath(agentsDir);
try {
const userRegistry = await loadUserRegistry(userRegistryPath);
userRegistry.agents = userRegistry.agents.filter((a: any) => a.id !== agentId);
await saveUserRegistry(userRegistryPath, userRegistry);
logger.info(`✓ Removed '${agentId}' from user registry`);
} catch (error) {
logger.warn(
`Failed to update user registry: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* List installed agents
*
* @param options Installation options
* @returns Array of installed agent IDs
*
* @example
* ```typescript
* const installed = await listInstalledAgents();
* console.log(installed); // ['coding-agent', 'my-custom-agent']
* ```
*/
export async function listInstalledAgents(options?: InstallOptions): Promise<string[]> {
const agentsDir = getAgentsDir(options);
try {
const entries = await fs.readdir(agentsDir, { withFileTypes: true });
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
}

View File

@@ -0,0 +1,178 @@
/**
* Custom Models Persistence
*
* Manages saved custom model configurations for openai-compatible and openrouter providers.
* Stored in ~/.dexto/models/custom-models.json
*/
import { z } from 'zod';
import { promises as fs } from 'fs';
import * as path from 'path';
import { getDextoGlobalPath } from '../utils/path.js';
/** Providers that support custom models */
export const CUSTOM_MODEL_PROVIDERS = [
'openai-compatible',
'openrouter',
'litellm',
'glama',
'bedrock',
'ollama',
'local',
'vertex',
'dexto',
] as const;
export type CustomModelProvider = (typeof CUSTOM_MODEL_PROVIDERS)[number];
/**
* Schema for a saved custom model configuration.
* - openai-compatible: requires baseURL, optional per-model apiKey
* - openrouter: baseURL is auto-injected, maxInputTokens from registry
* - litellm: requires baseURL, uses LITELLM_API_KEY or per-model override
* - glama: fixed baseURL, uses GLAMA_API_KEY or per-model override
* - bedrock: no baseURL, uses AWS credentials from environment
* - ollama: optional baseURL (defaults to http://localhost:11434)
* - local: no baseURL, uses local GGUF files via node-llama-cpp
* - vertex: no baseURL, uses Google Cloud ADC
* - dexto: OpenRouter gateway using Dexto credits, requires auth login, uses OpenRouter model IDs
*
* TODO: For hosted deployments, API keys should be stored in a secure
* key management service (e.g., AWS Secrets Manager, HashiCorp Vault)
* rather than in the local JSON file. Current approach is suitable for
* local CLI usage where the file is in ~/.dexto/ (user-private).
*/
export const CustomModelSchema = z
.object({
name: z.string().min(1),
provider: z.enum(CUSTOM_MODEL_PROVIDERS).default('openai-compatible'),
baseURL: z.string().url().optional(),
displayName: z.string().optional(),
maxInputTokens: z.number().int().positive().optional(),
maxOutputTokens: z.number().int().positive().optional(),
// Optional per-model API key. For openai-compatible this is the primary key source.
// For litellm/glama/openrouter this overrides the provider-level env var key.
apiKey: z.string().optional(),
// File path for local GGUF models. Required when provider is 'local'.
// Stores the absolute path to the .gguf file on disk.
filePath: z.string().optional(),
// OpenAI reasoning effort level for reasoning-capable models (o1, o3, codex, gpt-5.x).
// Controls how many reasoning tokens the model generates before producing a response.
reasoningEffort: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']).optional(),
})
.superRefine((data, ctx) => {
// baseURL is required for openai-compatible and litellm
if (
(data.provider === 'openai-compatible' || data.provider === 'litellm') &&
!data.baseURL
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['baseURL'],
message: `Base URL is required for ${data.provider} provider`,
});
}
// filePath is required for local provider
if (data.provider === 'local' && !data.filePath) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['filePath'],
message: 'File path is required for local provider',
});
}
// filePath must end with .gguf for local provider
if (data.provider === 'local' && data.filePath && !data.filePath.endsWith('.gguf')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['filePath'],
message: 'File path must be a .gguf file',
});
}
});
export type CustomModel = z.output<typeof CustomModelSchema>;
const StorageSchema = z.object({
version: z.literal(1),
models: z.array(CustomModelSchema),
});
/**
* Get the path to the custom models storage file.
*/
export function getCustomModelsPath(): string {
return getDextoGlobalPath('models', 'custom-models.json');
}
/**
* Load custom models from storage.
*/
export async function loadCustomModels(): Promise<CustomModel[]> {
const filePath = getCustomModelsPath();
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = StorageSchema.safeParse(JSON.parse(content));
if (!parsed.success) {
console.warn(
`[custom-models] Failed to parse ${filePath}: ${parsed.error.issues.map((i) => i.message).join(', ')}`
);
return [];
}
return parsed.data.models;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
}
/**
* Save a custom model to storage.
*/
export async function saveCustomModel(model: CustomModel): Promise<void> {
const parsed = CustomModelSchema.safeParse(model);
if (!parsed.success) {
throw new Error(`Invalid model: ${parsed.error.issues.map((i) => i.message).join(', ')}`);
}
const models = await loadCustomModels();
const existingIndex = models.findIndex((m) => m.name === parsed.data.name);
if (existingIndex >= 0) {
models[existingIndex] = parsed.data;
} else {
models.push(parsed.data);
}
await writeCustomModels(models);
}
/**
* Delete a custom model by name.
*/
export async function deleteCustomModel(name: string): Promise<boolean> {
const models = await loadCustomModels();
const filtered = models.filter((m) => m.name !== name);
if (filtered.length === models.length) {
return false;
}
await writeCustomModels(filtered);
return true;
}
/**
* Get a specific custom model by name.
*/
export async function getCustomModel(name: string): Promise<CustomModel | null> {
const models = await loadCustomModels();
return models.find((m) => m.name === name) ?? null;
}
async function writeCustomModels(models: CustomModel[]): Promise<void> {
const filePath = getCustomModelsPath();
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify({ version: 1, models }, null, 2), 'utf-8');
}

View File

@@ -0,0 +1,50 @@
/**
* Model management for local GGUF models.
*
* This module handles:
* - Path resolution for ~/.dexto/models/
* - State tracking for installed models
* - Download queue management
*/
// Path resolver
export {
getModelsDirectory,
getModelFilePath,
getModelDirectory,
getModelStatePath,
getModelTempDirectory,
ensureModelsDirectory,
ensureModelDirectory,
modelFileExists,
getModelFileSize,
deleteModelDirectory,
listModelDirectories,
getModelsDiskUsage,
formatSize,
} from './path-resolver.js';
// State manager
export {
type ModelSource,
type InstalledModel,
type ModelState,
loadModelState,
saveModelState,
addInstalledModel,
removeInstalledModel,
getInstalledModel,
getAllInstalledModels,
isModelInstalled,
updateModelLastUsed,
setActiveModel,
getActiveModelId,
getActiveModel,
addToDownloadQueue,
removeFromDownloadQueue,
getDownloadQueue,
syncStateWithFilesystem,
getTotalInstalledSize,
getInstalledModelCount,
registerManualModel,
} from './state-manager.js';

View File

@@ -0,0 +1,176 @@
/**
* Path resolver for local model storage.
*
* Models are stored globally at ~/.dexto/models/ to be shared across projects.
* This avoids duplicating large model files for each project.
*/
import * as path from 'path';
import { promises as fs } from 'fs';
import { homedir } from 'os';
/**
* Get the base models directory path.
* Always returns global path: ~/.dexto/models/
*/
export function getModelsDirectory(): string {
return path.join(homedir(), '.dexto', 'models');
}
/**
* Get the path to a specific model file.
* @param modelId Model ID from registry
* @param filename GGUF filename
*/
export function getModelFilePath(modelId: string, filename: string): string {
return path.join(getModelsDirectory(), modelId, filename);
}
/**
* Get the path to a model's directory.
* @param modelId Model ID from registry
*/
export function getModelDirectory(modelId: string): string {
return path.join(getModelsDirectory(), modelId);
}
/**
* Get the path to the model state file.
* Stores download status, hashes, and usage metadata.
*/
export function getModelStatePath(): string {
return path.join(getModelsDirectory(), 'state.json');
}
/**
* Get the path to the model download temp directory.
* Used for in-progress downloads.
*/
export function getModelTempDirectory(): string {
return path.join(getModelsDirectory(), '.tmp');
}
/**
* Ensure the models directory and subdirectories exist.
*/
export async function ensureModelsDirectory(): Promise<void> {
const modelsDir = getModelsDirectory();
const tempDir = getModelTempDirectory();
await fs.mkdir(modelsDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
}
/**
* Ensure a specific model's directory exists.
* @param modelId Model ID from registry
*/
export async function ensureModelDirectory(modelId: string): Promise<string> {
const modelDir = getModelDirectory(modelId);
await fs.mkdir(modelDir, { recursive: true });
return modelDir;
}
/**
* Check if a model file exists at the expected path.
* @param modelId Model ID from registry
* @param filename GGUF filename
*/
export async function modelFileExists(modelId: string, filename: string): Promise<boolean> {
const filePath = getModelFilePath(modelId, filename);
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Get file size of an installed model.
* @param modelId Model ID from registry
* @param filename GGUF filename
* @returns File size in bytes, or null if file doesn't exist
*/
export async function getModelFileSize(modelId: string, filename: string): Promise<number | null> {
const filePath = getModelFilePath(modelId, filename);
try {
const stats = await fs.stat(filePath);
return stats.size;
} catch {
return null;
}
}
/**
* Delete a model's directory and all its files.
* @param modelId Model ID to delete
* @returns True if deleted, false if not found
*/
export async function deleteModelDirectory(modelId: string): Promise<boolean> {
const modelDir = getModelDirectory(modelId);
try {
await fs.rm(modelDir, { recursive: true, force: true });
return true;
} catch {
return false;
}
}
/**
* List all model directories in the models folder.
* @returns Array of model IDs (directory names)
*/
export async function listModelDirectories(): Promise<string[]> {
const modelsDir = getModelsDirectory();
try {
const entries = await fs.readdir(modelsDir, { withFileTypes: true });
return entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name);
} catch {
return [];
}
}
/**
* Get disk usage statistics for the models directory.
* @returns Total bytes used by models, or 0 if directory doesn't exist
*/
export async function getModelsDiskUsage(): Promise<number> {
const modelsDir = getModelsDirectory();
async function getDirSize(dir: string): Promise<number> {
let size = 0;
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
size += await getDirSize(entryPath);
} else if (entry.isFile()) {
const stats = await fs.stat(entryPath);
size += stats.size;
}
}
} catch {
// Ignore errors for inaccessible directories
}
return size;
}
return getDirSize(modelsDir);
}
/**
* Format bytes to human-readable string.
* @param bytes Number of bytes
* @returns Formatted string (e.g., "4.5 GB")
*/
export function formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${units[i]}`;
}

View File

@@ -0,0 +1,359 @@
/**
* Model state manager for tracking downloaded local models.
*
* Persists model metadata to ~/.dexto/models/state.json including:
* - Which models are installed
* - File paths and sizes
* - Download timestamps
* - Usage tracking
*
* Note: ModelSource and InstalledModel types here intentionally differ from
* packages/core/src/llm/providers/local/types.ts. This package extends the core
* types with agent-management specific needs:
* - ModelSource adds 'manual' for user-placed model files
* - InstalledModel adds 'filename' for file system operations (isModelInstalled, syncStateWithFilesystem)
*/
import { promises as fs } from 'fs';
import {
getModelStatePath,
ensureModelsDirectory,
modelFileExists,
getModelFilePath,
} from './path-resolver.js';
/**
* Source of the model download.
*/
export type ModelSource = 'huggingface' | 'manual';
/**
* Installed model metadata.
*/
export interface InstalledModel {
/** Model ID from registry */
id: string;
/** Absolute path to the .gguf file */
filePath: string;
/** File size in bytes */
sizeBytes: number;
/** When the model was downloaded (ISO timestamp) */
downloadedAt: string;
/** When the model was last used (ISO timestamp) */
lastUsedAt?: string;
/** SHA-256 hash of the file for integrity verification */
sha256?: string;
/** Source of the download */
source: ModelSource;
/** GGUF filename */
filename: string;
}
/**
* Persisted model state.
*/
export interface ModelState {
/** Schema version for migrations */
version: string;
/** Map of model ID to installed model info */
installed: Record<string, InstalledModel>;
/** Currently active/selected model ID */
activeModelId?: string;
/** Queue of model IDs pending download */
downloadQueue: string[];
}
const CURRENT_VERSION = '1.0';
/**
* Create default empty state.
*/
function createDefaultState(): ModelState {
// Note: activeModelId is intentionally omitted (not set to undefined)
// due to exactOptionalPropertyTypes
return {
version: CURRENT_VERSION,
installed: {},
downloadQueue: [],
};
}
/**
* Load model state from disk.
* Returns default state if file doesn't exist.
*/
export async function loadModelState(): Promise<ModelState> {
const statePath = getModelStatePath();
try {
const content = await fs.readFile(statePath, 'utf-8');
const state = JSON.parse(content) as ModelState;
// Handle version migrations if needed
if (state.version !== CURRENT_VERSION) {
return migrateState(state);
}
return state;
} catch (error) {
// File doesn't exist or is invalid - return default
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return createDefaultState();
}
// Invalid JSON - reset to default
console.warn('Invalid model state file, resetting to default');
return createDefaultState();
}
}
/**
* Save model state to disk.
*/
export async function saveModelState(state: ModelState): Promise<void> {
await ensureModelsDirectory();
const statePath = getModelStatePath();
await fs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
}
/**
* Migrate state from older versions.
*/
function migrateState(state: ModelState): ModelState {
// Currently no migrations needed
return {
...state,
version: CURRENT_VERSION,
};
}
/**
* Add an installed model to state.
*
* Note: These operations are not atomic. Ensure single-threaded access
* or implement file locking for concurrent usage scenarios.
*/
export async function addInstalledModel(model: InstalledModel): Promise<void> {
const state = await loadModelState();
state.installed[model.id] = model;
await saveModelState(state);
}
/**
* Remove an installed model from state.
*
* Note: These operations are not atomic. Ensure single-threaded access
* or implement file locking for concurrent usage scenarios.
*/
export async function removeInstalledModel(modelId: string): Promise<boolean> {
const state = await loadModelState();
if (!state.installed[modelId]) {
return false;
}
delete state.installed[modelId];
// Clear active model if it was removed
if (state.activeModelId === modelId) {
delete state.activeModelId;
}
// Remove from download queue if present
state.downloadQueue = state.downloadQueue.filter((id) => id !== modelId);
await saveModelState(state);
return true;
}
/**
* Get installed model info.
*/
export async function getInstalledModel(modelId: string): Promise<InstalledModel | null> {
const state = await loadModelState();
return state.installed[modelId] ?? null;
}
/**
* Get all installed models.
*/
export async function getAllInstalledModels(): Promise<InstalledModel[]> {
const state = await loadModelState();
return Object.values(state.installed);
}
/**
* Check if a model is installed.
*/
export async function isModelInstalled(modelId: string): Promise<boolean> {
const model = await getInstalledModel(modelId);
if (!model) {
return false;
}
// Verify file still exists
return modelFileExists(modelId, model.filename);
}
/**
* Update last used timestamp for a model.
*/
export async function updateModelLastUsed(modelId: string): Promise<void> {
const state = await loadModelState();
const model = state.installed[modelId];
if (model) {
model.lastUsedAt = new Date().toISOString();
await saveModelState(state);
}
}
/**
* Set the active model.
*/
export async function setActiveModel(modelId: string | undefined): Promise<void> {
const state = await loadModelState();
if (modelId === undefined) {
delete state.activeModelId;
} else {
state.activeModelId = modelId;
}
await saveModelState(state);
}
/**
* Get the active model ID.
*/
export async function getActiveModelId(): Promise<string | undefined> {
const state = await loadModelState();
return state.activeModelId;
}
/**
* Get the active model info.
*/
export async function getActiveModel(): Promise<InstalledModel | null> {
const activeId = await getActiveModelId();
if (!activeId) {
return null;
}
return getInstalledModel(activeId);
}
/**
* Add a model to the download queue.
*/
export async function addToDownloadQueue(modelId: string): Promise<void> {
const state = await loadModelState();
if (!state.downloadQueue.includes(modelId)) {
state.downloadQueue.push(modelId);
await saveModelState(state);
}
}
/**
* Remove a model from the download queue.
*/
export async function removeFromDownloadQueue(modelId: string): Promise<void> {
const state = await loadModelState();
state.downloadQueue = state.downloadQueue.filter((id) => id !== modelId);
await saveModelState(state);
}
/**
* Get the download queue.
*/
export async function getDownloadQueue(): Promise<string[]> {
const state = await loadModelState();
return [...state.downloadQueue];
}
/**
* Sync state with actual filesystem.
* Removes entries for models that no longer exist on disk.
*/
export async function syncStateWithFilesystem(): Promise<{
removed: string[];
kept: string[];
}> {
const state = await loadModelState();
const removed: string[] = [];
const kept: string[] = [];
for (const [modelId, model] of Object.entries(state.installed)) {
const exists = await modelFileExists(modelId, model.filename);
if (exists) {
kept.push(modelId);
} else {
removed.push(modelId);
delete state.installed[modelId];
}
}
// Clear active model if it was removed
if (state.activeModelId && removed.includes(state.activeModelId)) {
delete state.activeModelId;
}
if (removed.length > 0) {
await saveModelState(state);
}
return { removed, kept };
}
/**
* Get total size of all installed models.
*/
export async function getTotalInstalledSize(): Promise<number> {
const state = await loadModelState();
return Object.values(state.installed).reduce((total, model) => total + model.sizeBytes, 0);
}
/**
* Get count of installed models.
*/
export async function getInstalledModelCount(): Promise<number> {
const state = await loadModelState();
return Object.keys(state.installed).length;
}
/**
* Register a manually added model file.
* Used when user places a GGUF file directly in the models directory.
*/
export async function registerManualModel(
modelId: string,
filename: string,
sizeBytes: number,
sha256?: string
): Promise<void> {
const filePath = getModelFilePath(modelId, filename);
const model: InstalledModel = {
id: modelId,
filePath,
sizeBytes,
downloadedAt: new Date().toISOString(),
source: 'manual',
filename,
};
// Only add sha256 if provided (exactOptionalPropertyTypes)
if (sha256 !== undefined) {
model.sha256 = sha256;
}
await addInstalledModel(model);
}

View File

@@ -0,0 +1,589 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
// Mock fs module
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
readdirSync: vi.fn(),
existsSync: vi.fn(),
readFileSync: vi.fn(),
};
});
// Mock path utilities
vi.mock('../utils/path.js', () => ({
getDextoGlobalPath: vi.fn((type: string, filename?: string) =>
filename ? `/home/user/.dexto/${type}/${filename}` : `/home/user/.dexto/${type}`
),
}));
import { discoverClaudeCodePlugins, getPluginSearchPaths } from './discover-plugins.js';
import { getDextoGlobalPath } from '../utils/path.js';
describe('discoverClaudeCodePlugins', () => {
const originalCwd = process.cwd;
const originalEnv = { ...process.env };
// Helper to create mock Dirent-like objects for testing
const createDirent = (name: string, isDir: boolean) => ({
name,
isFile: () => !isDir,
isDirectory: () => isDir,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
path: '',
parentPath: '',
});
beforeEach(() => {
vi.mocked(fs.readdirSync).mockReset();
vi.mocked(fs.existsSync).mockReset();
vi.mocked(fs.readFileSync).mockReset();
vi.mocked(getDextoGlobalPath).mockReset();
// Default mocks
process.cwd = vi.fn(() => '/test/project');
process.env.HOME = '/home/user';
vi.mocked(getDextoGlobalPath).mockImplementation((type: string, filename?: string) =>
filename ? `/home/user/.dexto/${type}/${filename}` : `/home/user/.dexto/${type}`
);
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
process.cwd = originalCwd;
process.env = { ...originalEnv };
});
describe('plugin discovery from project directories', () => {
it('should discover plugins from <cwd>/.dexto/plugins/', () => {
const manifestContent = JSON.stringify({
name: 'test-plugin',
description: 'A test plugin',
version: '1.0.0',
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/test/project/.dexto/plugins') return true;
if (p === '/test/project/.dexto/plugins/my-plugin/.claude-plugin/plugin.json')
return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/plugins') {
return [createDirent('my-plugin', true)] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockReturnValue(manifestContent);
const result = discoverClaudeCodePlugins();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
path: '/test/project/.dexto/plugins/my-plugin',
source: 'project',
manifest: {
name: 'test-plugin',
description: 'A test plugin',
version: '1.0.0',
},
});
});
});
describe('plugin discovery from user directories', () => {
it('should discover plugins from ~/.dexto/plugins/', () => {
const manifestContent = JSON.stringify({
name: 'user-plugin',
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/plugins') return true;
if (p === '/home/user/.dexto/plugins/global-plugin/.claude-plugin/plugin.json')
return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/home/user/.dexto/plugins') {
return [createDirent('global-plugin', true)] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockReturnValue(manifestContent);
const result = discoverClaudeCodePlugins();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
path: '/home/user/.dexto/plugins/global-plugin',
source: 'user',
manifest: {
name: 'user-plugin',
},
});
});
});
describe('deduplication by plugin name', () => {
it('should deduplicate by plugin name (first found wins)', () => {
const projectManifest = JSON.stringify({
name: 'duplicate-plugin',
description: 'Project version',
});
const userManifest = JSON.stringify({
name: 'duplicate-plugin',
description: 'User version',
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/test/project/.dexto/plugins') return true;
if (p === '/home/user/.dexto/plugins') return true;
if (p === '/test/project/.dexto/plugins/plugin-a/.claude-plugin/plugin.json')
return true;
if (p === '/home/user/.dexto/plugins/plugin-b/.claude-plugin/plugin.json')
return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/plugins') {
return [createDirent('plugin-a', true)] as any;
}
if (dir === '/home/user/.dexto/plugins') {
return [createDirent('plugin-b', true)] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (String(p).includes('plugin-a')) return projectManifest;
if (String(p).includes('plugin-b')) return userManifest;
return '';
});
const result = discoverClaudeCodePlugins();
// Should only have one plugin - the project version (first found)
expect(result).toHaveLength(1);
expect(result[0]!.manifest.description).toBe('Project version');
expect(result[0]!.source).toBe('project');
});
it('should be case-insensitive when deduplicating', () => {
const manifest1 = JSON.stringify({ name: 'My-Plugin' });
const manifest2 = JSON.stringify({ name: 'my-plugin' }); // Different case
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/test/project/.dexto/plugins') return true;
if (p === '/home/user/.dexto/plugins') return true;
if (p === '/test/project/.dexto/plugins/plugin1/.claude-plugin/plugin.json')
return true;
if (p === '/home/user/.dexto/plugins/plugin2/.claude-plugin/plugin.json')
return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/plugins') {
return [createDirent('plugin1', true)] as any;
}
if (dir === '/home/user/.dexto/plugins') {
return [createDirent('plugin2', true)] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (String(p).includes('plugin1')) return manifest1;
if (String(p).includes('plugin2')) return manifest2;
return '';
});
const result = discoverClaudeCodePlugins();
// Should only have one plugin - case-insensitive dedup
expect(result).toHaveLength(1);
expect(result[0]!.manifest.name).toBe('My-Plugin');
});
});
describe('invalid manifests', () => {
it('should skip plugins without plugin.json', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/test/project/.dexto/plugins') return true;
// No plugin.json exists
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/plugins') {
return [createDirent('incomplete-plugin', true)] as any;
}
return [];
});
const result = discoverClaudeCodePlugins();
expect(result).toHaveLength(0);
});
it('should skip plugins with invalid JSON in manifest', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/test/project/.dexto/plugins') return true;
if (p === '/test/project/.dexto/plugins/bad-json/.claude-plugin/plugin.json')
return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/plugins') {
return [createDirent('bad-json', true)] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json }');
const result = discoverClaudeCodePlugins();
expect(result).toHaveLength(0);
});
it('should skip manifests missing required name field silently', () => {
// Note: Invalid manifests cause tryLoadManifest to throw PluginError,
// but the error is caught in the scanPluginsDir try/catch and silently skipped.
// This is intentional - we don't want one invalid plugin to prevent others from loading.
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/test/project/.dexto/plugins') return true;
if (p === '/test/project/.dexto/plugins/no-name/.claude-plugin/plugin.json')
return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/plugins') {
return [createDirent('no-name', true)] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ description: 'No name' }));
// Invalid manifests are silently skipped - result is empty
const result = discoverClaudeCodePlugins();
expect(result).toHaveLength(0);
});
});
describe('edge cases', () => {
it('should return empty array when no plugin directories exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = discoverClaudeCodePlugins();
expect(result).toEqual([]);
});
it('should skip non-directory entries in plugins folder', () => {
const manifestContent = JSON.stringify({ name: 'valid-plugin' });
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/test/project/.dexto/plugins') return true;
if (p === '/test/project/.dexto/plugins/valid-plugin/.claude-plugin/plugin.json')
return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/plugins') {
return [
createDirent('valid-plugin', true),
createDirent('some-file.txt', false), // File, not directory
] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockReturnValue(manifestContent);
const result = discoverClaudeCodePlugins();
expect(result).toHaveLength(1);
expect(result[0]!.manifest.name).toBe('valid-plugin');
});
it('should handle missing HOME environment variable', () => {
delete process.env.HOME;
delete process.env.USERPROFILE;
const manifestContent = JSON.stringify({ name: 'local-only' });
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/test/project/.dexto/plugins') return true;
if (p === '/test/project/.dexto/plugins/local/.claude-plugin/plugin.json')
return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/plugins') {
return [createDirent('local', true)] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockReturnValue(manifestContent);
const result = discoverClaudeCodePlugins();
// Should still work for local plugins
expect(result).toHaveLength(1);
});
});
describe('installed_plugins.json reading', () => {
it('should discover plugins from installed_plugins.json', () => {
const installedPluginsJson = JSON.stringify({
version: 2,
plugins: {
'code-review@claude-code-plugins': [
{
scope: 'user',
installPath:
'/home/user/.dexto/plugins/cache/claude-code-plugins/code-review/1.0.0',
version: '1.0.0',
},
],
},
});
const manifestContent = JSON.stringify({
name: 'code-review',
version: '1.0.0',
description: 'Code review plugin',
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/plugins/installed_plugins.json') return true;
if (p === '/home/user/.dexto/plugins/cache/claude-code-plugins/code-review/1.0.0')
return true;
if (
p ===
'/home/user/.dexto/plugins/cache/claude-code-plugins/code-review/1.0.0/.claude-plugin/plugin.json'
)
return true;
return false;
});
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/plugins/installed_plugins.json') {
return installedPluginsJson;
}
return manifestContent;
});
vi.mocked(fs.readdirSync).mockReturnValue([]);
const result = discoverClaudeCodePlugins();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
path: '/home/user/.dexto/plugins/cache/claude-code-plugins/code-review/1.0.0',
manifest: {
name: 'code-review',
version: '1.0.0',
},
source: 'user',
});
});
it('should filter project-scoped plugins by current project path', () => {
const installedPluginsJson = JSON.stringify({
version: 2,
plugins: {
'my-plugin@marketplace': [
{
scope: 'project',
installPath:
'/home/user/.dexto/plugins/cache/marketplace/my-plugin/1.0.0',
version: '1.0.0',
projectPath: '/test/project', // Matches current project
},
{
scope: 'project',
installPath:
'/home/user/.dexto/plugins/cache/marketplace/my-plugin/1.0.0',
version: '1.0.0',
projectPath: '/other/project', // Different project
},
],
},
});
const manifestContent = JSON.stringify({
name: 'my-plugin',
version: '1.0.0',
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/plugins/installed_plugins.json') return true;
if (p === '/home/user/.dexto/plugins/cache/marketplace/my-plugin/1.0.0')
return true;
if (
p ===
'/home/user/.dexto/plugins/cache/marketplace/my-plugin/1.0.0/.claude-plugin/plugin.json'
)
return true;
return false;
});
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/plugins/installed_plugins.json') {
return installedPluginsJson;
}
return manifestContent;
});
vi.mocked(fs.readdirSync).mockReturnValue([]);
const result = discoverClaudeCodePlugins();
// Should only include the plugin for the current project
expect(result).toHaveLength(1);
expect(result[0]!.source).toBe('project');
});
it('should filter local-scoped plugins by current project path', () => {
const installedPluginsJson = JSON.stringify({
version: 2,
plugins: {
'local-plugin@marketplace': [
{
scope: 'local',
installPath:
'/home/user/.dexto/plugins/cache/marketplace/local-plugin/1.0.0',
version: '1.0.0',
projectPath: '/other/project', // Different project - should be filtered out
},
],
'user-plugin@marketplace': [
{
scope: 'user',
installPath:
'/home/user/.dexto/plugins/cache/marketplace/user-plugin/1.0.0',
version: '1.0.0',
// No projectPath - user scope applies everywhere
},
],
},
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/plugins/installed_plugins.json') return true;
if (p === '/home/user/.dexto/plugins/cache/marketplace/local-plugin/1.0.0')
return true;
if (p === '/home/user/.dexto/plugins/cache/marketplace/user-plugin/1.0.0')
return true;
if (
p ===
'/home/user/.dexto/plugins/cache/marketplace/local-plugin/1.0.0/.claude-plugin/plugin.json'
)
return true;
if (
p ===
'/home/user/.dexto/plugins/cache/marketplace/user-plugin/1.0.0/.claude-plugin/plugin.json'
)
return true;
return false;
});
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/plugins/installed_plugins.json') {
return installedPluginsJson;
}
if (String(p).includes('local-plugin')) {
return JSON.stringify({ name: 'local-plugin', version: '1.0.0' });
}
return JSON.stringify({ name: 'user-plugin', version: '1.0.0' });
});
vi.mocked(fs.readdirSync).mockReturnValue([]);
const result = discoverClaudeCodePlugins();
// Should only include the user-scoped plugin, not the local-scoped one for a different project
expect(result).toHaveLength(1);
expect(result[0]!.manifest.name).toBe('user-plugin');
expect(result[0]!.source).toBe('user');
});
it('should skip cache and marketplaces directories in directory scan', () => {
const manifestContent = JSON.stringify({ name: 'direct-plugin' });
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/plugins') return true;
if (p === '/home/user/.dexto/plugins/direct-plugin/.claude-plugin/plugin.json')
return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/home/user/.dexto/plugins') {
return [
createDirent('cache', true), // Should be skipped
createDirent('marketplaces', true), // Should be skipped
createDirent('direct-plugin', true), // Should be scanned
] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockReturnValue(manifestContent);
const result = discoverClaudeCodePlugins();
expect(result).toHaveLength(1);
expect(result[0]!.manifest.name).toBe('direct-plugin');
});
});
});
describe('getPluginSearchPaths', () => {
const originalCwd = process.cwd;
const originalEnv = { ...process.env };
beforeEach(() => {
process.cwd = vi.fn(() => '/test/project');
process.env.HOME = '/home/user';
vi.mocked(getDextoGlobalPath).mockImplementation((type: string, filename?: string) =>
filename ? `/home/user/.dexto/${type}/${filename}` : `/home/user/.dexto/${type}`
);
});
afterEach(() => {
process.cwd = originalCwd;
process.env = { ...originalEnv };
});
it('should return all search paths in priority order', () => {
const paths = getPluginSearchPaths();
expect(paths).toEqual([
// Dexto's installed_plugins.json (highest priority)
'/home/user/.dexto/plugins/installed_plugins.json',
// Directory scan locations
'/test/project/.dexto/plugins',
'/home/user/.dexto/plugins',
]);
});
});

View File

@@ -0,0 +1,260 @@
/**
* Plugin Discovery
*
* Discovers plugins from Dexto locations following the plugin format.
* Plugins must have a .claude-plugin/plugin.json manifest file.
*
* Discovery Methods (Priority Order):
* 1. Read ~/.dexto/plugins/installed_plugins.json for Dexto installed plugins
* 2. Scan directories for plugins with .claude-plugin/plugin.json manifests
*
* Search Locations for Directory Scanning:
* 1. <cwd>/.dexto/plugins/* (project)
* 2. ~/.dexto/plugins/* (user)
*
* First found wins on name collision (by plugin name).
*/
import * as path from 'path';
import { existsSync, readdirSync, readFileSync } from 'fs';
import { getDextoGlobalPath } from '../utils/path.js';
import { InstalledPluginsFileSchema } from './schemas.js';
import { tryLoadManifest } from './validate-plugin.js';
import type {
DiscoveredPlugin,
PluginManifest,
DextoPluginManifest,
PluginFormat,
} from './types.js';
/**
* Discovers plugins from Dexto locations.
*
* @param projectPath Optional project path for filtering project-scoped plugins
* @param bundledPluginPaths Optional array of absolute paths to bundled plugins from image definition
* @returns Array of discovered plugins, deduplicated by name (first found wins)
*/
export function discoverClaudeCodePlugins(
projectPath?: string,
bundledPluginPaths?: string[]
): DiscoveredPlugin[] {
const plugins: DiscoveredPlugin[] = [];
const seenNames = new Set<string>();
const cwd = projectPath || process.cwd();
/**
* Adds a plugin if not already seen (deduplication by name)
*/
const addPlugin = (plugin: DiscoveredPlugin): boolean => {
const normalizedName = plugin.manifest.name.toLowerCase();
if (seenNames.has(normalizedName)) {
return false;
}
seenNames.add(normalizedName);
plugins.push(plugin);
return true;
};
// === Method 1: Read Dexto's installed_plugins.json (highest priority) ===
const dextoInstalledPluginsPath = getDextoGlobalPath('plugins', 'installed_plugins.json');
const dextoInstalledPlugins = readInstalledPluginsFile(dextoInstalledPluginsPath, cwd);
for (const plugin of dextoInstalledPlugins) {
addPlugin(plugin);
}
/**
* Scans a plugins directory and adds valid plugins to the list
*/
const scanPluginsDir = (dir: string, source: 'project' | 'user'): void => {
if (!existsSync(dir)) return;
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Skip 'cache' and 'marketplaces' directories - these are handled via installed_plugins.json
if (entry.name === 'cache' || entry.name === 'marketplaces') continue;
const pluginPath = path.join(dir, entry.name);
let loadResult: {
manifest: PluginManifest | DextoPluginManifest;
format: PluginFormat;
} | null;
try {
loadResult = tryLoadManifest(pluginPath, true);
} catch {
// Skip invalid plugin without aborting the directory scan
continue;
}
if (loadResult) {
addPlugin({
path: pluginPath,
manifest: loadResult.manifest,
source,
format: loadResult.format,
});
}
}
} catch {
// Directory read error - silently skip
}
};
// === Method 2: Scan directories ===
// Project plugins: <cwd>/.dexto/plugins/
scanPluginsDir(path.join(cwd, '.dexto', 'plugins'), 'project');
// User plugins: ~/.dexto/plugins/
scanPluginsDir(getDextoGlobalPath('plugins'), 'user');
// === Method 3: Bundled plugins from image definition ===
// These have lowest priority so users can override bundled plugins
if (bundledPluginPaths && bundledPluginPaths.length > 0) {
for (const pluginPath of bundledPluginPaths) {
if (!existsSync(pluginPath)) {
continue;
}
let loadResult: {
manifest: PluginManifest | DextoPluginManifest;
format: PluginFormat;
} | null;
try {
loadResult = tryLoadManifest(pluginPath, true);
} catch {
// Skip invalid bundled plugin
continue;
}
if (loadResult) {
addPlugin({
path: pluginPath,
manifest: loadResult.manifest,
source: 'user', // Treat as user-level since they come from image
format: loadResult.format,
});
}
}
}
return plugins;
}
/**
* Reads and parses installed_plugins.json
*
* Plugins are stored at paths like:
* ~/.dexto/plugins/cache/<marketplace>/<plugin-name>/<version>/
*
* @param filePath Path to installed_plugins.json
* @param currentProjectPath Current project path for filtering project-scoped plugins
* @returns Array of discovered plugins from the installed plugins file
*/
function readInstalledPluginsFile(
filePath: string,
currentProjectPath: string
): DiscoveredPlugin[] {
const plugins: DiscoveredPlugin[] = [];
if (!existsSync(filePath)) {
return plugins;
}
try {
const content = readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(content);
const result = InstalledPluginsFileSchema.safeParse(parsed);
if (!result.success) {
// Invalid format - skip silently
return plugins;
}
const installedPlugins = result.data;
// Iterate over all plugin entries
for (const installations of Object.values(installedPlugins.plugins)) {
// Each plugin can have multiple installations (different scopes/projects)
for (const installation of installations) {
const { scope, installPath, projectPath } = installation;
// Skip if installPath doesn't exist
if (!existsSync(installPath)) {
continue;
}
// For project-scoped and local-scoped plugins, only include if projectPath matches current project
if ((scope === 'project' || scope === 'local') && projectPath) {
// Normalize paths for comparison
// Use case-insensitive comparison for Windows/macOS compatibility
const normalizedProjectPath = path.resolve(projectPath).toLowerCase();
const normalizedCurrentPath = path.resolve(currentProjectPath).toLowerCase();
if (normalizedProjectPath !== normalizedCurrentPath) {
continue;
}
}
// Try to load the manifest from the installPath
// Wrap in try/catch so one invalid plugin doesn't abort the entire scan
let loadResult: {
manifest: PluginManifest | DextoPluginManifest;
format: PluginFormat;
} | null;
try {
loadResult = tryLoadManifest(installPath, true);
} catch {
// Skip invalid plugin without aborting the scan
continue;
}
if (loadResult) {
// Map scope to source type
const source: 'project' | 'user' =
scope === 'project' || scope === 'local' ? 'project' : 'user';
plugins.push({
path: installPath,
manifest: loadResult.manifest,
source,
format: loadResult.format,
});
}
}
}
} catch {
// File read/parse error - silently skip
}
return plugins;
}
/**
* Gets the search locations for plugins in priority order.
* Useful for debugging and testing.
*
* @returns Array of plugin search paths
*/
export function getPluginSearchPaths(): string[] {
const cwd = process.cwd();
return [
// Dexto's installed_plugins.json (highest priority)
getDextoGlobalPath('plugins', 'installed_plugins.json'),
// Directory scan locations
path.join(cwd, '.dexto', 'plugins'),
getDextoGlobalPath('plugins'),
];
}
/**
* Gets the path to Dexto's installed_plugins.json file.
*
* @returns Absolute path to installed_plugins.json
*/
export function getInstalledPluginsPath(): string {
return getDextoGlobalPath('plugins', 'installed_plugins.json');
}

View File

@@ -0,0 +1,273 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
// Mock fs module
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: vi.fn(),
readdirSync: vi.fn(),
};
});
import { discoverStandaloneSkills, getSkillSearchPaths } from './discover-skills.js';
// Mock Dirent type that matches fs.Dirent interface
interface MockDirent
extends Pick<
fs.Dirent,
| 'name'
| 'isFile'
| 'isDirectory'
| 'isBlockDevice'
| 'isCharacterDevice'
| 'isSymbolicLink'
| 'isFIFO'
| 'isSocket'
| 'path'
| 'parentPath'
> {}
describe('discoverStandaloneSkills', () => {
const originalCwd = process.cwd;
const originalEnv = { ...process.env };
// Helper to create mock Dirent-like objects
const createDirent = (name: string, isDir: boolean): MockDirent => ({
name,
isFile: () => !isDir,
isDirectory: () => isDir,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
path: '',
parentPath: '',
});
beforeEach(() => {
vi.resetAllMocks();
process.cwd = vi.fn(() => '/test/project');
process.env.HOME = '/home/user';
});
afterEach(() => {
process.cwd = originalCwd;
process.env = { ...originalEnv };
});
describe('skill discovery from user directory', () => {
it('should discover skills from ~/.dexto/skills/', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/skills') return true;
if (p === '/home/user/.dexto/skills/remotion-video/SKILL.md') return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/home/user/.dexto/skills') {
return [createDirent('remotion-video', true)] as unknown as ReturnType<
typeof fs.readdirSync
>;
}
return [];
});
const result = discoverStandaloneSkills();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
name: 'remotion-video',
path: '/home/user/.dexto/skills/remotion-video',
skillFile: '/home/user/.dexto/skills/remotion-video/SKILL.md',
source: 'user',
});
});
it('should skip directories without SKILL.md', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/skills') return true;
// SKILL.md does not exist
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/home/user/.dexto/skills') {
return [createDirent('incomplete-skill', true)] as unknown as ReturnType<
typeof fs.readdirSync
>;
}
return [];
});
const result = discoverStandaloneSkills();
expect(result).toHaveLength(0);
});
});
describe('skill discovery from project directory', () => {
it('should discover skills from <cwd>/.dexto/skills/', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/test/project/.dexto/skills') return true;
if (p === '/test/project/.dexto/skills/my-project-skill/SKILL.md') return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/skills') {
return [createDirent('my-project-skill', true)] as unknown as ReturnType<
typeof fs.readdirSync
>;
}
return [];
});
const result = discoverStandaloneSkills();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
name: 'my-project-skill',
source: 'project',
});
});
it('should prioritize project skills over user skills with same name', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/test/project/.dexto/skills') return true;
if (p === '/test/project/.dexto/skills/shared-skill/SKILL.md') return true;
if (p === '/home/user/.dexto/skills') return true;
if (p === '/home/user/.dexto/skills/shared-skill/SKILL.md') return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/skills') {
return [createDirent('shared-skill', true)] as unknown as ReturnType<
typeof fs.readdirSync
>;
}
if (dir === '/home/user/.dexto/skills') {
return [createDirent('shared-skill', true)] as unknown as ReturnType<
typeof fs.readdirSync
>;
}
return [];
});
const result = discoverStandaloneSkills();
// Should only have one skill (project takes priority)
expect(result).toHaveLength(1);
expect(result[0]!.source).toBe('project');
expect(result[0]!.path).toBe('/test/project/.dexto/skills/shared-skill');
});
});
describe('edge cases', () => {
it('should return empty array when no skills directories exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = discoverStandaloneSkills();
expect(result).toEqual([]);
});
it('should skip non-directory entries', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/skills') return true;
if (p === '/home/user/.dexto/skills/valid-skill/SKILL.md') return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/home/user/.dexto/skills') {
return [
createDirent('valid-skill', true),
createDirent('some-file.md', false), // File, not directory
] as unknown as ReturnType<typeof fs.readdirSync>;
}
return [];
});
const result = discoverStandaloneSkills();
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('valid-skill');
});
it('should handle missing HOME environment variable', () => {
delete process.env.HOME;
delete process.env.USERPROFILE;
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/test/project/.dexto/skills') return true;
if (p === '/test/project/.dexto/skills/local-skill/SKILL.md') return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/test/project/.dexto/skills') {
return [createDirent('local-skill', true)] as unknown as ReturnType<
typeof fs.readdirSync
>;
}
return [];
});
const result = discoverStandaloneSkills();
// Should still work for project skills
expect(result).toHaveLength(1);
});
it('should discover multiple skills from same directory', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/skills') return true;
if (p === '/home/user/.dexto/skills/skill-a/SKILL.md') return true;
if (p === '/home/user/.dexto/skills/skill-b/SKILL.md') return true;
if (p === '/home/user/.dexto/skills/skill-c/SKILL.md') return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/home/user/.dexto/skills') {
return [
createDirent('skill-a', true),
createDirent('skill-b', true),
createDirent('skill-c', true),
] as unknown as ReturnType<typeof fs.readdirSync>;
}
return [];
});
const result = discoverStandaloneSkills();
expect(result).toHaveLength(3);
expect(result.map((s) => s.name).sort()).toEqual(['skill-a', 'skill-b', 'skill-c']);
});
});
});
describe('getSkillSearchPaths', () => {
const originalCwd = process.cwd;
const originalEnv = { ...process.env };
beforeEach(() => {
process.cwd = vi.fn(() => '/test/project');
process.env.HOME = '/home/user';
});
afterEach(() => {
process.cwd = originalCwd;
process.env = { ...originalEnv };
});
it('should return all search paths in priority order', () => {
const paths = getSkillSearchPaths();
expect(paths).toEqual(['/test/project/.dexto/skills', '/home/user/.dexto/skills']);
});
});

View File

@@ -0,0 +1,119 @@
/**
* Standalone Skill Discovery
*
* Discovers standalone skills from ~/.dexto/skills/ directory.
* These are different from plugin skills - they're just directories containing a SKILL.md file.
*
* Structure:
* ~/.dexto/skills/
* └── skill-name/
* ├── SKILL.md (required - the skill prompt)
* └── references/ (optional - reference files)
*
* These skills are loaded as prompts directly, not as part of a plugin package.
*/
import * as path from 'path';
import { existsSync, readdirSync } from 'fs';
/**
* Represents a discovered standalone skill
*/
export interface DiscoveredSkill {
/** Unique skill name (directory name) */
name: string;
/** Absolute path to the skill directory */
path: string;
/** Absolute path to the SKILL.md file */
skillFile: string;
/** Source location */
source: 'user' | 'project';
}
/**
* Discovers standalone skills from standard locations.
*
* Search Locations:
* 1. <cwd>/.dexto/skills/* (project)
* 2. ~/.dexto/skills/* (user)
*
* @param projectPath Optional project path (defaults to cwd)
* @returns Array of discovered skills
*/
export function discoverStandaloneSkills(projectPath?: string): DiscoveredSkill[] {
const skills: DiscoveredSkill[] = [];
const seenNames = new Set<string>();
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
const cwd = projectPath || process.cwd();
/**
* Adds a skill if not already seen (deduplication by name)
*/
const addSkill = (skill: DiscoveredSkill): boolean => {
const normalizedName = skill.name.toLowerCase();
if (seenNames.has(normalizedName)) {
return false;
}
seenNames.add(normalizedName);
skills.push(skill);
return true;
};
/**
* Scans a skills directory and adds valid skills to the list
*/
const scanSkillsDir = (dir: string, source: 'project' | 'user'): void => {
if (!existsSync(dir)) return;
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillPath = path.join(dir, entry.name);
const skillFile = path.join(skillPath, 'SKILL.md');
// Check if SKILL.md exists
if (existsSync(skillFile)) {
addSkill({
name: entry.name,
path: skillPath,
skillFile,
source,
});
}
}
} catch {
// Directory read error - silently skip
}
};
// === Project skills ===
// 1. Dexto project skills: <cwd>/.dexto/skills/
scanSkillsDir(path.join(cwd, '.dexto', 'skills'), 'project');
// === User skills ===
// 2. Dexto user skills: ~/.dexto/skills/
if (homeDir) {
scanSkillsDir(path.join(homeDir, '.dexto', 'skills'), 'user');
}
return skills;
}
/**
* Gets the search locations for standalone skills.
* Useful for debugging and testing.
*
* @returns Array of skill search paths
*/
export function getSkillSearchPaths(): string[] {
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
const cwd = process.cwd();
return [
path.join(cwd, '.dexto', 'skills'),
homeDir ? path.join(homeDir, '.dexto', 'skills') : '',
].filter(Boolean);
}

View File

@@ -0,0 +1,36 @@
/**
* Plugin-specific error codes
* Includes discovery, validation, installation, and loading errors
*/
export enum PluginErrorCode {
// Manifest errors
MANIFEST_NOT_FOUND = 'plugin_manifest_not_found',
MANIFEST_INVALID = 'plugin_manifest_invalid',
MANIFEST_PARSE_ERROR = 'plugin_manifest_parse_error',
// Loading errors
DIRECTORY_READ_ERROR = 'plugin_directory_read_error',
MCP_CONFIG_INVALID = 'plugin_mcp_config_invalid',
// Discovery errors
DUPLICATE_PLUGIN = 'plugin_duplicate',
// Installation errors
INSTALL_SOURCE_NOT_FOUND = 'plugin_install_source_not_found',
INSTALL_ALREADY_EXISTS = 'plugin_install_already_exists',
INSTALL_COPY_FAILED = 'plugin_install_copy_failed',
INSTALL_MANIFEST_WRITE_FAILED = 'plugin_install_manifest_write_failed',
INSTALL_INVALID_SCOPE = 'plugin_install_invalid_scope',
// Import errors
IMPORT_NOT_FOUND = 'plugin_import_not_found',
// Uninstallation errors
UNINSTALL_NOT_FOUND = 'plugin_uninstall_not_found',
UNINSTALL_DELETE_FAILED = 'plugin_uninstall_delete_failed',
UNINSTALL_MANIFEST_UPDATE_FAILED = 'plugin_uninstall_manifest_update_failed',
// Validation errors
VALIDATION_INVALID_STRUCTURE = 'plugin_validation_invalid_structure',
VALIDATION_MISSING_REQUIRED = 'plugin_validation_missing_required',
}

View File

@@ -0,0 +1,190 @@
import { DextoRuntimeError, ErrorScope, ErrorType } from '@dexto/core';
import { PluginErrorCode } from './error-codes.js';
/**
* Plugin runtime error factory methods
* Creates properly typed errors for plugin operations
*/
export class PluginError {
// Manifest errors
static manifestNotFound(pluginPath: string) {
return new DextoRuntimeError(
PluginErrorCode.MANIFEST_NOT_FOUND,
ErrorScope.CONFIG,
ErrorType.USER,
`Plugin manifest not found: ${pluginPath}/.claude-plugin/plugin.json`,
{ pluginPath },
'Ensure the plugin has a valid .claude-plugin/plugin.json file'
);
}
static manifestInvalid(pluginPath: string, issues: string) {
return new DextoRuntimeError(
PluginErrorCode.MANIFEST_INVALID,
ErrorScope.CONFIG,
ErrorType.USER,
`Invalid plugin manifest at ${pluginPath}: ${issues}`,
{ pluginPath, issues },
'Check the plugin.json file matches the expected schema (name is required)'
);
}
static manifestParseError(pluginPath: string, cause: string) {
return new DextoRuntimeError(
PluginErrorCode.MANIFEST_PARSE_ERROR,
ErrorScope.CONFIG,
ErrorType.USER,
`Failed to parse plugin manifest at ${pluginPath}: ${cause}`,
{ pluginPath, cause },
'Ensure plugin.json contains valid JSON'
);
}
// Loading errors
static directoryReadError(pluginPath: string, cause: string) {
return new DextoRuntimeError(
PluginErrorCode.DIRECTORY_READ_ERROR,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to read plugin directory ${pluginPath}: ${cause}`,
{ pluginPath, cause },
'Check file permissions and that the directory exists'
);
}
static mcpConfigInvalid(pluginPath: string, cause: string) {
return new DextoRuntimeError(
PluginErrorCode.MCP_CONFIG_INVALID,
ErrorScope.CONFIG,
ErrorType.USER,
`Invalid MCP config in plugin ${pluginPath}: ${cause}`,
{ pluginPath, cause },
'Check the .mcp.json file contains valid JSON'
);
}
// Installation errors
static installSourceNotFound(sourcePath: string) {
return new DextoRuntimeError(
PluginErrorCode.INSTALL_SOURCE_NOT_FOUND,
ErrorScope.CONFIG,
ErrorType.USER,
`Plugin source not found: ${sourcePath}`,
{ sourcePath },
'Ensure the path points to a valid plugin directory with .claude-plugin/plugin.json'
);
}
static installAlreadyExists(pluginName: string, existingPath: string) {
return new DextoRuntimeError(
PluginErrorCode.INSTALL_ALREADY_EXISTS,
ErrorScope.CONFIG,
ErrorType.USER,
`Plugin '${pluginName}' is already installed at ${existingPath}`,
{ pluginName, existingPath },
'Use --force to overwrite the existing installation'
);
}
static installCopyFailed(sourcePath: string, destPath: string, cause: string) {
return new DextoRuntimeError(
PluginErrorCode.INSTALL_COPY_FAILED,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to copy plugin from ${sourcePath} to ${destPath}: ${cause}`,
{ sourcePath, destPath, cause },
'Check file permissions and disk space'
);
}
static installManifestWriteFailed(manifestPath: string, cause: string) {
return new DextoRuntimeError(
PluginErrorCode.INSTALL_MANIFEST_WRITE_FAILED,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to update installed plugins manifest at ${manifestPath}: ${cause}`,
{ manifestPath, cause },
'Check file permissions and ensure the directory exists'
);
}
static invalidScope(scope: unknown) {
return new DextoRuntimeError(
PluginErrorCode.INSTALL_INVALID_SCOPE,
ErrorScope.CONFIG,
ErrorType.USER,
`Invalid installation scope: ${scope}. Must be 'user', 'project', or 'local'.`,
{ scope },
"Check the scope parameter is one of: 'user', 'project', 'local'"
);
}
// Import errors
static importNotFound(pluginName: string) {
return new DextoRuntimeError(
PluginErrorCode.IMPORT_NOT_FOUND,
ErrorScope.CONFIG,
ErrorType.USER,
`Plugin '${pluginName}' not found`,
{ pluginName },
'Use `dexto plugin list` to see available plugins'
);
}
// Uninstallation errors
static uninstallNotFound(pluginName: string, hint?: string) {
return new DextoRuntimeError(
PluginErrorCode.UNINSTALL_NOT_FOUND,
ErrorScope.CONFIG,
ErrorType.USER,
`Plugin '${pluginName}' is not installed`,
{ pluginName },
hint || 'Use `dexto plugin list` to see installed plugins'
);
}
static uninstallDeleteFailed(pluginPath: string, cause: string) {
return new DextoRuntimeError(
PluginErrorCode.UNINSTALL_DELETE_FAILED,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to delete plugin at ${pluginPath}: ${cause}`,
{ pluginPath, cause },
'Check file permissions and ensure the plugin is not in use'
);
}
static uninstallManifestUpdateFailed(manifestPath: string, cause: string) {
return new DextoRuntimeError(
PluginErrorCode.UNINSTALL_MANIFEST_UPDATE_FAILED,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to update installed plugins manifest at ${manifestPath}: ${cause}`,
{ manifestPath, cause },
'Check file permissions'
);
}
// Validation errors
static validationInvalidStructure(pluginPath: string, details: string) {
return new DextoRuntimeError(
PluginErrorCode.VALIDATION_INVALID_STRUCTURE,
ErrorScope.CONFIG,
ErrorType.USER,
`Invalid plugin structure at ${pluginPath}: ${details}`,
{ pluginPath, details },
'Ensure the plugin has a .claude-plugin/plugin.json file'
);
}
static validationMissingRequired(pluginPath: string, missing: string[]) {
return new DextoRuntimeError(
PluginErrorCode.VALIDATION_MISSING_REQUIRED,
ErrorScope.CONFIG,
ErrorType.USER,
`Plugin at ${pluginPath} is missing required fields: ${missing.join(', ')}`,
{ pluginPath, missing },
'Add the missing fields to .claude-plugin/plugin.json'
);
}
}

View File

@@ -0,0 +1,132 @@
/**
* Plugin Loader
*
* Discovers and loads bundled plugins from community sources.
* Supports two formats:
* - .claude-plugin: Claude Code compatible format
* - .dexto-plugin: Dexto-native format with extended features (customToolProviders)
*/
// Types
export type {
PluginManifest,
DextoPluginManifest,
PluginFormat,
DiscoveredPlugin,
PluginCommand,
PluginMCPConfig,
LoadedPlugin,
PluginInstallScope,
InstalledPluginEntry,
InstalledPluginsFile,
ListedPlugin,
PluginValidationResult,
PluginInstallResult,
PluginUninstallResult,
} from './types.js';
// Schemas
export {
PluginManifestSchema,
DextoPluginManifestSchema,
PluginMCPConfigSchema,
InstalledPluginEntrySchema,
InstalledPluginsFileSchema,
} from './schemas.js';
export type {
ValidatedPluginManifest,
ValidatedDextoPluginManifest,
ValidatedPluginMCPConfig,
ValidatedInstalledPluginsFile,
ValidatedInstalledPluginEntry,
} from './schemas.js';
// Error handling
export { PluginErrorCode } from './error-codes.js';
export { PluginError } from './errors.js';
// Discovery
export {
discoverClaudeCodePlugins,
getPluginSearchPaths,
getInstalledPluginsPath,
} from './discover-plugins.js';
// Standalone skill discovery
export { discoverStandaloneSkills, getSkillSearchPaths } from './discover-skills.js';
export type { DiscoveredSkill } from './discover-skills.js';
// Loading
export { loadClaudeCodePlugin } from './load-plugin.js';
// Validation
export { validatePluginDirectory, tryLoadManifest } from './validate-plugin.js';
// Listing
export { listInstalledPlugins, getDextoInstalledPluginsPath } from './list-plugins.js';
// Installation
export {
installPluginFromPath,
loadDextoInstalledPlugins,
saveDextoInstalledPlugins,
isPluginInstalled,
type InstallPluginOptions,
} from './install-plugin.js';
// Uninstallation
export { uninstallPlugin, type UninstallPluginOptions } from './uninstall-plugin.js';
// Marketplace
export {
// Types
type MarketplaceSourceType,
type MarketplaceSource,
type MarketplaceEntry,
type KnownMarketplacesFile,
type MarketplacePlugin,
type MarketplaceAddResult,
type MarketplaceRemoveResult,
type MarketplaceUpdateResult,
type MarketplaceInstallResult,
type MarketplaceAddOptions,
type MarketplaceInstallOptions,
type MarketplaceManifest,
// Schemas
MarketplaceSourceSchema,
MarketplaceEntrySchema,
KnownMarketplacesFileSchema,
MarketplaceManifestSchema,
MarketplacePluginEntrySchema,
MarketplaceAddCommandSchema,
MarketplaceInstallCommandSchema,
// Errors
MarketplaceErrorCode,
MarketplaceError,
// Registry
DEFAULT_MARKETPLACES,
getMarketplacesRegistryPath,
getMarketplacesDir,
getMarketplaceCacheDir,
loadKnownMarketplaces,
saveKnownMarketplaces,
getMarketplaceEntry,
marketplaceExists,
getAllMarketplaces,
getUninstalledDefaults,
isDefaultMarketplace,
// Operations
parseMarketplaceSource,
deriveMarketplaceName,
addMarketplace,
removeMarketplace,
updateMarketplace,
listMarketplaces,
scanMarketplacePlugins,
listAllMarketplacePlugins,
findPluginInMarketplaces,
// Install from marketplace
parsePluginSpec,
installPluginFromMarketplace,
searchMarketplacePlugins,
} from './marketplace/index.js';

View File

@@ -0,0 +1,283 @@
/**
* Plugin Installation
*
* Installs plugins from local directories to Dexto's plugin directory.
* Manages Dexto's own installed_plugins.json for tracking installations.
*/
import * as path from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs';
import { getDextoGlobalPath, copyDirectory } from '../utils/path.js';
import { validatePluginDirectory } from './validate-plugin.js';
import { PluginError } from './errors.js';
import { InstalledPluginsFileSchema } from './schemas.js';
import type {
PluginInstallScope,
PluginInstallResult,
InstalledPluginsFile,
InstalledPluginEntry,
} from './types.js';
/**
* Options for plugin installation
*/
export interface InstallPluginOptions {
/** Installation scope: 'user', 'project', or 'local' */
scope: PluginInstallScope;
/** Project path for project-scoped plugins */
projectPath?: string;
/** Force overwrite if plugin already exists */
force?: boolean;
}
/**
* Path to Dexto's installed_plugins.json
*/
export function getDextoInstalledPluginsPath(): string {
return getDextoGlobalPath('plugins', 'installed_plugins.json');
}
/**
* Loads Dexto's installed_plugins.json
*/
export function loadDextoInstalledPlugins(): InstalledPluginsFile {
const filePath = getDextoInstalledPluginsPath();
if (!existsSync(filePath)) {
return { version: 1, plugins: {} };
}
try {
const content = readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(content);
const result = InstalledPluginsFileSchema.safeParse(parsed);
if (!result.success) {
// Invalid file - return fresh structure
return { version: 1, plugins: {} };
}
return result.data as InstalledPluginsFile;
} catch {
// File read/parse error - return fresh structure
return { version: 1, plugins: {} };
}
}
/**
* Saves Dexto's installed_plugins.json
*/
export function saveDextoInstalledPlugins(data: InstalledPluginsFile): void {
const filePath = getDextoInstalledPluginsPath();
const dirPath = path.dirname(filePath);
// Ensure directory exists
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
}
try {
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
} catch (error) {
throw PluginError.installManifestWriteFailed(
filePath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Checks if a plugin is already installed.
*
* @param pluginName Plugin name to check
* @param projectPath Optional project path for project-scoped check
* @returns Installation entry if found, null otherwise
*/
export function isPluginInstalled(
pluginName: string,
projectPath?: string
): InstalledPluginEntry | null {
const installed = loadDextoInstalledPlugins();
const normalizedName = pluginName.toLowerCase();
// Check all plugin entries
for (const [_id, installations] of Object.entries(installed.plugins)) {
for (const installation of installations) {
// Load manifest to check name
const manifestPath = path.join(
installation.installPath,
'.claude-plugin',
'plugin.json'
);
if (!existsSync(manifestPath)) continue;
try {
const content = readFileSync(manifestPath, 'utf-8');
const manifest = JSON.parse(content);
if (manifest.name?.toLowerCase() === normalizedName) {
// For project-scoped plugins, only match if project matches
if (
(installation.scope === 'project' || installation.scope === 'local') &&
installation.projectPath
) {
if (projectPath) {
const normalizedInstallProject = path
.resolve(installation.projectPath)
.toLowerCase();
const normalizedCurrentProject = path
.resolve(projectPath)
.toLowerCase();
if (normalizedInstallProject === normalizedCurrentProject) {
return installation;
}
}
// Project-scoped plugin but no projectPath provided - skip
continue;
}
return installation;
}
} catch {
continue;
}
}
}
return null;
}
/**
* Installs a plugin from a local directory.
*
* @param sourcePath Absolute or relative path to the plugin source directory
* @param options Installation options
* @returns Installation result with success status and warnings
*/
export async function installPluginFromPath(
sourcePath: string,
options: InstallPluginOptions
): Promise<PluginInstallResult> {
const { scope, projectPath, force = false } = options;
const warnings: string[] = [];
// Resolve source path
const absoluteSourcePath = path.isAbsolute(sourcePath) ? sourcePath : path.resolve(sourcePath);
// Validate source plugin
const validation = validatePluginDirectory(absoluteSourcePath);
if (!validation.valid) {
throw PluginError.installSourceNotFound(absoluteSourcePath);
}
if (!validation.manifest) {
throw PluginError.installSourceNotFound(absoluteSourcePath);
}
// Add validation warnings
warnings.push(...validation.warnings);
const pluginName = validation.manifest.name;
const currentProjectPath = projectPath || process.cwd();
// Check if already installed
const existingInstall = isPluginInstalled(pluginName, currentProjectPath);
if (existingInstall && !force) {
throw PluginError.installAlreadyExists(pluginName, existingInstall.installPath);
}
// Determine install path based on scope
let installPath: string;
let isLocal = false;
switch (scope) {
case 'user':
installPath = path.join(getDextoGlobalPath('plugins'), pluginName);
break;
case 'project':
installPath = path.join(currentProjectPath, '.dexto', 'plugins', pluginName);
break;
case 'local':
// Local scope - register in place, don't copy
installPath = absoluteSourcePath;
isLocal = true;
break;
default:
throw PluginError.invalidScope(scope);
}
// Copy plugin files (unless local scope)
if (!isLocal) {
// Remove existing if force is set
if (existingInstall && force) {
try {
rmSync(existingInstall.installPath, { recursive: true, force: true });
} catch (error) {
throw PluginError.installCopyFailed(
absoluteSourcePath,
installPath,
`Failed to remove existing: ${error instanceof Error ? error.message : String(error)}`
);
}
}
// Create parent directory
const parentDir = path.dirname(installPath);
if (!existsSync(parentDir)) {
mkdirSync(parentDir, { recursive: true });
}
// Copy plugin directory
try {
await copyDirectory(absoluteSourcePath, installPath);
} catch (error) {
throw PluginError.installCopyFailed(
absoluteSourcePath,
installPath,
error instanceof Error ? error.message : String(error)
);
}
}
// Update installed_plugins.json
const installed = loadDextoInstalledPlugins();
const now = new Date().toISOString();
const entry: InstalledPluginEntry = {
scope,
installPath,
version: validation.manifest.version,
installedAt: now,
lastUpdated: now,
...(scope !== 'user' && { projectPath: currentProjectPath }),
...(isLocal && { isLocal: true }),
};
// Use plugin name as the key
if (!installed.plugins[pluginName]) {
installed.plugins[pluginName] = [];
}
// Remove existing entry for this scope/project combination
installed.plugins[pluginName] = installed.plugins[pluginName].filter((e) => {
if (e.scope !== scope) return true;
if (scope === 'user') return false; // Remove existing user scope
// For project/local scope, only remove if same project
if (e.projectPath && currentProjectPath) {
return (
path.resolve(e.projectPath).toLowerCase() !==
path.resolve(currentProjectPath).toLowerCase()
);
}
return true;
});
installed.plugins[pluginName].push(entry);
saveDextoInstalledPlugins(installed);
return {
success: true,
pluginName,
installPath,
warnings,
};
}

View File

@@ -0,0 +1,266 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
// Mock fs module
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
readdirSync: vi.fn(),
existsSync: vi.fn(),
readFileSync: vi.fn(),
};
});
// Mock path utilities
vi.mock('../utils/path.js', () => ({
getDextoGlobalPath: vi.fn((type: string, filename?: string) =>
filename ? `/home/user/.dexto/${type}/${filename}` : `/home/user/.dexto/${type}`
),
}));
import { listInstalledPlugins } from './list-plugins.js';
import { getDextoGlobalPath } from '../utils/path.js';
describe('listInstalledPlugins', () => {
const originalCwd = process.cwd;
const originalEnv = { ...process.env };
// Helper to create mock Dirent-like objects for testing
const createDirent = (name: string, isDir: boolean) => ({
name,
isFile: () => !isDir,
isDirectory: () => isDir,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
path: '',
parentPath: '',
});
beforeEach(() => {
vi.mocked(fs.readdirSync).mockReset();
vi.mocked(fs.existsSync).mockReset();
vi.mocked(fs.readFileSync).mockReset();
vi.mocked(getDextoGlobalPath).mockReset();
// Default mocks
process.cwd = vi.fn(() => '/test/project');
process.env.HOME = '/home/user';
vi.mocked(getDextoGlobalPath).mockImplementation((type: string, filename?: string) =>
filename ? `/home/user/.dexto/${type}/${filename}` : `/home/user/.dexto/${type}`
);
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
process.cwd = originalCwd;
process.env = { ...originalEnv };
});
describe('scope filtering', () => {
it('should filter out project-scoped plugins from different projects', () => {
const installedPluginsContent = JSON.stringify({
version: 2,
plugins: {
'test-plugin@test-marketplace': [
{
scope: 'local',
projectPath: '/different/project',
installPath: '/home/user/.dexto/plugins/cache/test/test-plugin/1.0.0',
version: '1.0.0',
installedAt: '2026-01-01T00:00:00.000Z',
},
],
},
});
const manifestContent = JSON.stringify({
name: 'test-plugin',
description: 'Test plugin',
version: '1.0.0',
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/plugins/installed_plugins.json') return true;
if (p === '/home/user/.dexto/plugins/cache/test/test-plugin/1.0.0') return true;
if (
p ===
'/home/user/.dexto/plugins/cache/test/test-plugin/1.0.0/.claude-plugin/plugin.json'
)
return true;
return false;
});
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (typeof p === 'string' && p.endsWith('installed_plugins.json')) {
return installedPluginsContent;
}
if (typeof p === 'string' && p.endsWith('plugin.json')) {
return manifestContent;
}
return '';
});
const result = listInstalledPlugins('/test/project');
// Should be filtered out because projectPath doesn't match
expect(result).toHaveLength(0);
});
it('should include user-scoped plugins regardless of project', () => {
const installedPluginsContent = JSON.stringify({
version: 2,
plugins: {
'test-plugin@test-marketplace': [
{
scope: 'user',
installPath: '/home/user/.dexto/plugins/cache/test/test-plugin/1.0.0',
version: '1.0.0',
installedAt: '2026-01-01T00:00:00.000Z',
},
],
},
});
const manifestContent = JSON.stringify({
name: 'test-plugin',
description: 'Test plugin',
version: '1.0.0',
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/plugins/installed_plugins.json') return true;
if (p === '/home/user/.dexto/plugins/cache/test/test-plugin/1.0.0') return true;
if (
p ===
'/home/user/.dexto/plugins/cache/test/test-plugin/1.0.0/.claude-plugin/plugin.json'
)
return true;
return false;
});
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (typeof p === 'string' && p.endsWith('installed_plugins.json')) {
return installedPluginsContent;
}
if (typeof p === 'string' && p.endsWith('plugin.json')) {
return manifestContent;
}
return '';
});
const result = listInstalledPlugins('/test/project');
// Should be included because it's user-scoped
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('test-plugin');
});
it('should NOT re-add filtered plugins via cache scanning', () => {
// This is the bug fix test: plugins filtered by scope from installed_plugins.json
// should NOT reappear when scanning the cache directory
const installedPluginsContent = JSON.stringify({
version: 2,
plugins: {
'filtered-plugin@test-marketplace': [
{
scope: 'local',
projectPath: '/different/project',
installPath:
'/home/user/.dexto/plugins/cache/test/filtered-plugin/1.0.0',
version: '1.0.0',
installedAt: '2026-01-01T00:00:00.000Z',
},
],
},
});
const manifestContent = JSON.stringify({
name: 'filtered-plugin',
description: 'Plugin that should be filtered',
version: '1.0.0',
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/plugins/installed_plugins.json') return true;
if (p === '/home/user/.claude/plugins/cache') return true;
if (p === '/home/user/.dexto/plugins/cache/test/filtered-plugin/1.0.0') return true;
if (
p ===
'/home/user/.dexto/plugins/cache/test/filtered-plugin/1.0.0/.claude-plugin/plugin.json'
)
return true;
return false;
});
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (typeof p === 'string' && p.endsWith('installed_plugins.json')) {
return installedPluginsContent;
}
if (typeof p === 'string' && p.endsWith('plugin.json')) {
return manifestContent;
}
return '';
});
// Mock cache directory structure scanning
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
const dirStr = typeof dir === 'string' ? dir : dir.toString();
if (dirStr === '/home/user/.claude/plugins/cache') {
return [createDirent('test', true)] as any;
}
if (dirStr === '/home/user/.dexto/plugins/cache/test') {
return [createDirent('filtered-plugin', true)] as any;
}
if (dirStr === '/home/user/.dexto/plugins/cache/test/filtered-plugin') {
return [createDirent('1.0.0', true)] as any;
}
return [];
});
const result = listInstalledPlugins('/test/project');
// Should be 0 because:
// 1. Filtered out by scope check in readClaudeCodeInstalledPlugins
// 2. Should NOT be re-added by scanClaudeCodeCache (this is the bug fix)
expect(result).toHaveLength(0);
});
});
describe('cache scanning', () => {
it('does not list plugins in cache that are not tracked in installed_plugins.json', () => {
// Plugins must be tracked in installed_plugins.json or placed directly in
// ~/.dexto/plugins/ or <cwd>/.dexto/plugins/ (not in cache subdirectory)
// The cache directory is only for versioned copies managed by the install system
const installedPluginsContent = JSON.stringify({
version: 2,
plugins: {},
});
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/home/user/.dexto/plugins/installed_plugins.json') return true;
return false;
});
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (typeof p === 'string' && p.endsWith('installed_plugins.json')) {
return installedPluginsContent;
}
return '';
});
vi.mocked(fs.readdirSync).mockReturnValue([]);
const result = listInstalledPlugins('/test/project');
// No plugins should be found because nothing is tracked in installed_plugins.json
// and no plugins are directly in the plugins directories
expect(result).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,161 @@
/**
* Plugin Listing
*
* Lists all installed plugins managed by Dexto:
* 1. Dexto's installed_plugins.json (~/.dexto/plugins/installed_plugins.json)
* 2. Directory scanning of Dexto plugin directories (project and user)
*
* Deduplicates by plugin name (first found wins).
*/
import * as path from 'path';
import { existsSync, readFileSync, readdirSync } from 'fs';
import { getDextoGlobalPath } from '../utils/path.js';
import { InstalledPluginsFileSchema } from './schemas.js';
import { tryLoadManifest } from './validate-plugin.js';
import type { ListedPlugin, PluginInstallScope } from './types.js';
/**
* Path to Dexto's installed_plugins.json
*/
export function getDextoInstalledPluginsPath(): string {
return getDextoGlobalPath('plugins', 'installed_plugins.json');
}
/**
* Lists all installed plugins managed by Dexto.
*
* Discovery sources:
* 1. ~/.dexto/plugins/installed_plugins.json (tracked installations)
* 2. Directory scanning of .dexto/plugins (project and user)
*
* @param projectPath Optional project path for filtering project-scoped plugins
* @returns Array of listed plugins, deduplicated by name (first found wins)
*/
export function listInstalledPlugins(projectPath?: string): ListedPlugin[] {
const plugins: ListedPlugin[] = [];
const seenNames = new Set<string>();
const cwd = projectPath || process.cwd();
/**
* Adds a plugin if not already seen (deduplication by name)
*/
const addPlugin = (plugin: ListedPlugin): boolean => {
const normalizedName = plugin.name.toLowerCase();
if (seenNames.has(normalizedName)) {
return false;
}
seenNames.add(normalizedName);
plugins.push(plugin);
return true;
};
// === Source 1: Dexto's installed_plugins.json ===
const { plugins: dextoPlugins } = readDextoInstalledPlugins(cwd);
for (const plugin of dextoPlugins) {
addPlugin(plugin);
}
// === Source 2: Directory scanning of Dexto plugin directories ===
const scanPluginsDir = (dir: string): void => {
if (!existsSync(dir)) return;
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Skip 'cache' and 'marketplaces' directories
if (entry.name === 'cache' || entry.name === 'marketplaces') continue;
const pluginPath = path.join(dir, entry.name);
const manifest = tryLoadManifest(pluginPath);
if (manifest) {
addPlugin({
name: manifest.name,
description: manifest.description,
version: manifest.version,
path: pluginPath,
source: 'dexto',
});
}
}
} catch {
// Directory read error - silently skip
}
};
// Scan project plugins (.dexto/plugins)
scanPluginsDir(path.join(cwd, '.dexto', 'plugins'));
// Scan user plugins (~/.dexto/plugins)
scanPluginsDir(getDextoGlobalPath('plugins'));
return plugins;
}
/**
* Reads Dexto's installed_plugins.json and returns ListedPlugin array.
*/
function readDextoInstalledPlugins(currentProjectPath: string): {
plugins: ListedPlugin[];
} {
const plugins: ListedPlugin[] = [];
const filePath = getDextoInstalledPluginsPath();
if (!existsSync(filePath)) {
return { plugins };
}
try {
const content = readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(content);
const result = InstalledPluginsFileSchema.safeParse(parsed);
if (!result.success) {
return { plugins };
}
for (const [_pluginId, installations] of Object.entries(result.data.plugins)) {
for (const installation of installations) {
const { scope, installPath, version, installedAt, projectPath } = installation;
// Skip if installPath doesn't exist
if (!existsSync(installPath)) {
continue;
}
// Load manifest to get name
const manifest = tryLoadManifest(installPath);
if (manifest) {
// For project-scoped and local-scoped plugins, only include if projectPath matches
if ((scope === 'project' || scope === 'local') && projectPath) {
const normalizedProjectPath = path.resolve(projectPath).toLowerCase();
const normalizedCurrentPath = path
.resolve(currentProjectPath)
.toLowerCase();
if (normalizedProjectPath !== normalizedCurrentPath) {
continue;
}
}
plugins.push({
name: manifest.name,
description: manifest.description,
version: version || manifest.version,
path: installPath,
source: 'dexto',
scope: scope as PluginInstallScope,
installedAt,
});
}
}
}
} catch {
// File read/parse error - silently skip
}
return { plugins };
}

View File

@@ -0,0 +1,426 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
// Mock fs module
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
readdirSync: vi.fn(),
existsSync: vi.fn(),
readFileSync: vi.fn(),
};
});
import { loadClaudeCodePlugin } from './load-plugin.js';
import type { DiscoveredPlugin } from './types.js';
describe('loadClaudeCodePlugin', () => {
// Helper to create mock Dirent-like objects for testing
const createDirent = (name: string, isDir: boolean) => ({
name,
isFile: () => !isDir,
isDirectory: () => isDir,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
path: '',
parentPath: '',
});
const createPlugin = (name: string, pluginPath: string): DiscoveredPlugin => ({
path: pluginPath,
manifest: { name },
source: 'project',
format: 'claude-code',
});
beforeEach(() => {
vi.mocked(fs.readdirSync).mockReset();
vi.mocked(fs.existsSync).mockReset();
vi.mocked(fs.readFileSync).mockReset();
});
afterEach(() => {
vi.resetAllMocks();
});
describe('loading commands', () => {
it('should load commands from commands/*.md', () => {
const plugin = createPlugin('test-plugin', '/plugins/test-plugin');
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/test-plugin/commands') return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/plugins/test-plugin/commands') {
return [createDirent('build.md', false), createDirent('test.md', false)] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockReturnValue('# Command content');
const result = loadClaudeCodePlugin(plugin);
expect(result.commands).toHaveLength(2);
expect(result.commands[0]).toMatchObject({
file: '/plugins/test-plugin/commands/build.md',
namespace: 'test-plugin',
isSkill: false,
});
expect(result.commands[1]).toMatchObject({
file: '/plugins/test-plugin/commands/test.md',
namespace: 'test-plugin',
isSkill: false,
});
});
it('should exclude README.md from commands', () => {
const plugin = createPlugin('test-plugin', '/plugins/test-plugin');
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/test-plugin/commands') return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/plugins/test-plugin/commands') {
return [
createDirent('README.md', false),
createDirent('deploy.md', false),
] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockReturnValue('# Command');
const result = loadClaudeCodePlugin(plugin);
expect(result.commands).toHaveLength(1);
expect(result.commands[0]!.file).toContain('deploy.md');
});
});
describe('loading skills', () => {
it('should load skills from skills/*/SKILL.md', () => {
const plugin = createPlugin('test-plugin', '/plugins/test-plugin');
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/test-plugin/skills') return true;
if (p === '/plugins/test-plugin/skills/analyzer/SKILL.md') return true;
if (p === '/plugins/test-plugin/skills/optimizer/SKILL.md') return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/plugins/test-plugin/skills') {
return [createDirent('analyzer', true), createDirent('optimizer', true)] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockReturnValue('# Skill content');
const result = loadClaudeCodePlugin(plugin);
expect(result.commands).toHaveLength(2);
expect(result.commands[0]).toMatchObject({
file: '/plugins/test-plugin/skills/analyzer/SKILL.md',
namespace: 'test-plugin',
isSkill: true,
});
expect(result.commands[1]).toMatchObject({
file: '/plugins/test-plugin/skills/optimizer/SKILL.md',
namespace: 'test-plugin',
isSkill: true,
});
});
it('should skip skill directories without SKILL.md', () => {
const plugin = createPlugin('test-plugin', '/plugins/test-plugin');
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/test-plugin/skills') return true;
if (p === '/plugins/test-plugin/skills/complete/SKILL.md') return true;
// incomplete skill has no SKILL.md
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/plugins/test-plugin/skills') {
return [
createDirent('complete', true),
createDirent('incomplete', true),
] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockReturnValue('# Skill');
const result = loadClaudeCodePlugin(plugin);
expect(result.commands).toHaveLength(1);
expect(result.commands[0]!.file).toContain('complete');
});
});
describe('loading MCP config', () => {
it('should load .mcp.json when present', () => {
const plugin = createPlugin('test-plugin', '/plugins/test-plugin');
const mcpConfig = {
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
},
},
};
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/test-plugin/.mcp.json') return true;
return false;
});
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mcpConfig));
const result = loadClaudeCodePlugin(plugin);
expect(result.mcpConfig).toEqual(mcpConfig);
});
it('should return undefined mcpConfig when .mcp.json is absent', () => {
const plugin = createPlugin('test-plugin', '/plugins/test-plugin');
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = loadClaudeCodePlugin(plugin);
expect(result.mcpConfig).toBeUndefined();
});
it('should add warning for invalid .mcp.json', () => {
const plugin = createPlugin('test-plugin', '/plugins/test-plugin');
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/test-plugin/.mcp.json') return true;
return false;
});
vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json }');
const result = loadClaudeCodePlugin(plugin);
expect(result.mcpConfig).toBeUndefined();
expect(result.warnings).toContainEqual(
expect.stringContaining('Failed to parse .mcp.json')
);
});
it('should normalize Claude Code format (servers at root level) to mcpServers', () => {
const plugin = createPlugin('linear', '/plugins/linear');
// Claude Code format: servers directly at root, not under mcpServers
const claudeCodeFormat = {
linear: {
type: 'http',
url: 'https://mcp.linear.app/mcp',
},
};
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/linear/.mcp.json') return true;
return false;
});
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(claudeCodeFormat));
const result = loadClaudeCodePlugin(plugin);
// Should be normalized to have mcpServers key
expect(result.mcpConfig).toEqual({
mcpServers: {
linear: {
type: 'http',
url: 'https://mcp.linear.app/mcp',
},
},
});
});
it('should infer type: stdio when command is present but type is missing (Claude Code format)', () => {
const plugin = createPlugin('playwright', '/plugins/playwright');
// Claude Code format: no 'type' field, infer from 'command'
const claudeCodeFormat = {
playwright: {
command: 'npx',
args: ['@playwright/mcp@latest'],
},
};
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/playwright/.mcp.json') return true;
return false;
});
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(claudeCodeFormat));
const result = loadClaudeCodePlugin(plugin);
// Should normalize and infer type: 'stdio'
expect(result.mcpConfig).toEqual({
mcpServers: {
playwright: {
type: 'stdio',
command: 'npx',
args: ['@playwright/mcp@latest'],
},
},
});
});
it('should handle Claude Code stdio format', () => {
const plugin = createPlugin('filesystem', '/plugins/filesystem');
const claudeCodeFormat = {
filesystem: {
command: 'npx',
args: ['@modelcontextprotocol/server-filesystem'],
},
};
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/filesystem/.mcp.json') return true;
return false;
});
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(claudeCodeFormat));
const result = loadClaudeCodePlugin(plugin);
expect(result.mcpConfig).toEqual({
mcpServers: {
filesystem: {
type: 'stdio',
command: 'npx',
args: ['@modelcontextprotocol/server-filesystem'],
},
},
});
});
});
describe('unsupported feature warnings', () => {
it('should warn about hooks/hooks.json', () => {
const plugin = createPlugin('test-plugin', '/plugins/test-plugin');
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/test-plugin/hooks/hooks.json') return true;
return false;
});
const result = loadClaudeCodePlugin(plugin);
expect(result.warnings).toContainEqual(
expect.stringContaining('hooks/hooks.json detected but not supported')
);
});
it('should warn about .lsp.json', () => {
const plugin = createPlugin('test-plugin', '/plugins/test-plugin');
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/test-plugin/.lsp.json') return true;
return false;
});
const result = loadClaudeCodePlugin(plugin);
expect(result.warnings).toContainEqual(
expect.stringContaining('.lsp.json detected but not supported')
);
});
});
describe('combined loading', () => {
it('should load commands, skills, and MCP config together', () => {
const plugin = createPlugin('full-plugin', '/plugins/full-plugin');
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/full-plugin/commands') return true;
if (p === '/plugins/full-plugin/skills') return true;
if (p === '/plugins/full-plugin/skills/analyzer/SKILL.md') return true;
if (p === '/plugins/full-plugin/.mcp.json') return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
if (dir === '/plugins/full-plugin/commands') {
return [createDirent('build.md', false)] as any;
}
if (dir === '/plugins/full-plugin/skills') {
return [createDirent('analyzer', true)] as any;
}
return [];
});
vi.mocked(fs.readFileSync).mockImplementation((p) => {
if (String(p).endsWith('.mcp.json')) {
return JSON.stringify({ mcpServers: { test: {} } });
}
return '# Content';
});
const result = loadClaudeCodePlugin(plugin);
// Should have both command and skill
expect(result.commands).toHaveLength(2);
expect(result.commands.filter((c) => !c.isSkill)).toHaveLength(1);
expect(result.commands.filter((c) => c.isSkill)).toHaveLength(1);
// Should have MCP config
expect(result.mcpConfig).toBeDefined();
expect(result.mcpConfig?.mcpServers).toHaveProperty('test');
// Should preserve manifest
expect(result.manifest.name).toBe('full-plugin');
});
});
describe('edge cases', () => {
it('should handle empty plugin gracefully', () => {
const plugin = createPlugin('empty-plugin', '/plugins/empty-plugin');
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = loadClaudeCodePlugin(plugin);
expect(result.commands).toHaveLength(0);
expect(result.mcpConfig).toBeUndefined();
expect(result.warnings).toHaveLength(0);
expect(result.manifest.name).toBe('empty-plugin');
});
it('should handle read errors gracefully', () => {
const plugin = createPlugin('error-plugin', '/plugins/error-plugin');
vi.mocked(fs.existsSync).mockImplementation((p) => {
if (p === '/plugins/error-plugin/commands') return true;
return false;
});
vi.mocked(fs.readdirSync).mockImplementation(() => {
throw new Error('Permission denied');
});
// Should not throw
const result = loadClaudeCodePlugin(plugin);
expect(result.commands).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,275 @@
/**
* Plugin Loader
*
* Loads plugin contents including commands, skills, MCP configuration,
* and custom tool providers (Dexto-native plugins).
* Detects and warns about unsupported features (hooks, LSP).
*
* Supports two plugin formats:
* - .claude-plugin: Claude Code compatible format
* - .dexto-plugin: Dexto-native format with extended features (customToolProviders)
*/
import * as path from 'path';
import { existsSync, readdirSync, readFileSync } from 'fs';
import { PluginMCPConfigSchema } from './schemas.js';
import type {
DiscoveredPlugin,
LoadedPlugin,
PluginCommand,
PluginMCPConfig,
DextoPluginManifest,
} from './types.js';
/**
* Type guard to check if manifest is a Dexto-native manifest
*/
function isDextoManifest(manifest: unknown): manifest is DextoPluginManifest {
return (
typeof manifest === 'object' &&
manifest !== null &&
'customToolProviders' in manifest &&
Array.isArray((manifest as DextoPluginManifest).customToolProviders)
);
}
/**
* Loads a discovered plugin's contents.
*
* @param plugin The discovered plugin to load
* @returns Loaded plugin with commands, MCP config, custom tool providers, and warnings
*/
export function loadClaudeCodePlugin(plugin: DiscoveredPlugin): LoadedPlugin {
const warnings: string[] = [];
const commands: PluginCommand[] = [];
const pluginName = plugin.manifest.name;
const pluginPath = plugin.path;
const format = plugin.format;
// 1. Scan commands/*.md
const commandsDir = path.join(pluginPath, 'commands');
if (existsSync(commandsDir)) {
const commandFiles = scanMarkdownFiles(commandsDir);
for (const file of commandFiles) {
const content = readFileSafe(file);
if (!content) {
warnings.push(
`[${pluginName}] Command '${path.basename(file)}' could not be read and will be skipped`
);
continue;
}
commands.push({
file,
namespace: pluginName,
isSkill: false,
});
}
}
// 2. Scan skills/*/SKILL.md
const skillsDir = path.join(pluginPath, 'skills');
if (existsSync(skillsDir)) {
try {
const entries = readdirSync(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
if (existsSync(skillFile)) {
const content = readFileSafe(skillFile);
if (!content) {
warnings.push(
`[${pluginName}] Skill '${entry.name}' could not be read and will be skipped`
);
continue;
}
commands.push({
file: skillFile,
namespace: pluginName,
isSkill: true,
});
}
}
} catch {
// Skills directory read error - silently skip
}
}
// 3. Load .mcp.json if exists
const mcpConfig = loadMcpConfig(pluginPath, pluginName, warnings);
// 4. Check for unsupported features
checkUnsupportedFeatures(pluginPath, pluginName, warnings);
// Extract custom tool providers from Dexto-native plugins
const customToolProviders: string[] = [];
if (format === 'dexto' && isDextoManifest(plugin.manifest)) {
const providers = plugin.manifest.customToolProviders;
if (providers && providers.length > 0) {
customToolProviders.push(...providers);
}
}
return {
manifest: plugin.manifest,
format,
commands,
mcpConfig,
customToolProviders,
warnings,
};
}
/**
* Scans a directory for .md files (non-recursive).
*/
function scanMarkdownFiles(dir: string): string[] {
const files: string[] = [];
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md') {
files.push(path.join(dir, entry.name));
}
}
} catch {
// Directory read error - return empty
}
return files;
}
/**
* Safely reads a file's content, returning null on error.
*/
function readFileSafe(filePath: string): string | null {
try {
return readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
/**
* Loads MCP configuration from .mcp.json if it exists.
*
* Claude Code's .mcp.json format puts servers directly at the root level:
* { "serverName": { "type": "http", "url": "..." } }
*
* We normalize this to: { mcpServers: { "serverName": { ... } } }
*/
function loadMcpConfig(
pluginPath: string,
pluginName: string,
warnings: string[]
): PluginMCPConfig | undefined {
const mcpPath = path.join(pluginPath, '.mcp.json');
if (!existsSync(mcpPath)) {
return undefined;
}
try {
const content = readFileSync(mcpPath, 'utf-8');
const parsed = JSON.parse(content);
// Claude Code format: servers directly at root level
// Check if this looks like the Claude Code format (no mcpServers key, has server-like objects)
if (!parsed.mcpServers && typeof parsed === 'object' && parsed !== null) {
// Check if any root key looks like a server config (has 'type' or 'command' or 'url')
const hasServerConfig = Object.values(parsed).some(
(val) =>
typeof val === 'object' &&
val !== null &&
('type' in val || 'command' in val || 'url' in val)
);
if (hasServerConfig) {
// Normalize Claude Code format to Dexto format
// Claude Code doesn't require 'type' field - it infers from 'command' vs 'url'
const normalized: Record<string, unknown> = {};
for (const [serverName, serverConfig] of Object.entries(parsed)) {
if (
typeof serverConfig === 'object' &&
serverConfig !== null &&
!Array.isArray(serverConfig)
) {
const config = serverConfig as Record<string, unknown>;
// If type is already present, use as-is
if ('type' in config) {
normalized[serverName] = config;
}
// If command is present, infer type: 'stdio'
else if ('command' in config) {
normalized[serverName] = {
type: 'stdio',
...config,
};
}
// If url is present, infer type based on URL or default to 'http'
else if ('url' in config) {
const url = String(config.url || '');
// If URL contains /sse or ends with /sse, assume SSE
const inferredType = url.includes('/sse') ? 'sse' : 'http';
normalized[serverName] = {
type: inferredType,
...config,
};
} else {
// Unknown format - keep as-is and let validation catch it
normalized[serverName] = config;
}
}
}
return { mcpServers: normalized };
}
}
// Try standard schema validation
const result = PluginMCPConfigSchema.safeParse(parsed);
if (!result.success) {
const issues = result.error.issues.map((i) => i.message).join(', ');
warnings.push(`[${pluginName}] Invalid .mcp.json: ${issues}`);
return undefined;
}
return result.data;
} catch (error) {
if (error instanceof SyntaxError) {
warnings.push(`[${pluginName}] Failed to parse .mcp.json: invalid JSON`);
} else {
warnings.push(`[${pluginName}] Failed to load .mcp.json: ${String(error)}`);
}
return undefined;
}
}
/**
* Checks for unsupported Claude Code features and adds warnings.
*/
function checkUnsupportedFeatures(
pluginPath: string,
pluginName: string,
warnings: string[]
): void {
// Check for hooks/hooks.json (security risk - would allow arbitrary command execution)
const hooksPath = path.join(pluginPath, 'hooks', 'hooks.json');
if (existsSync(hooksPath)) {
warnings.push(
`[${pluginName}] hooks/hooks.json detected but not supported (security risk)`
);
}
// Check for .lsp.json (language server protocol)
const lspPath = path.join(pluginPath, '.lsp.json');
if (existsSync(lspPath)) {
warnings.push(`[${pluginName}] .lsp.json detected but not supported (LSP integration)`);
}
}

View File

@@ -0,0 +1,366 @@
/**
* Install from Marketplace Unit Tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as childProcess from 'child_process';
// Mock fs module
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: vi.fn(),
mkdirSync: vi.fn(),
};
});
// Mock child_process
vi.mock('child_process', async () => {
const actual = await vi.importActual<typeof import('child_process')>('child_process');
return {
...actual,
execSync: vi.fn(),
};
});
// Mock path utilities
vi.mock('../../../utils/path.js', () => ({
copyDirectory: vi.fn(),
}));
// Mock registry
vi.mock('../registry.js', () => ({
getMarketplaceCacheDir: vi.fn(() => '/home/testuser/.dexto/plugins/cache'),
getMarketplaceEntry: vi.fn(),
}));
// Mock operations
vi.mock('../operations.js', () => ({
findPluginInMarketplaces: vi.fn(),
scanMarketplacePlugins: vi.fn(),
}));
// Mock install-plugin
vi.mock('../../install-plugin.js', () => ({
installPluginFromPath: vi.fn(),
}));
import {
parsePluginSpec,
installPluginFromMarketplace,
searchMarketplacePlugins,
} from '../install-from-marketplace.js';
import { getMarketplaceEntry } from '../registry.js';
import { findPluginInMarketplaces, scanMarketplacePlugins } from '../operations.js';
import { installPluginFromPath } from '../../install-plugin.js';
import { copyDirectory } from '../../../utils/path.js';
import type { MarketplacePlugin, MarketplaceEntry } from '../types.js';
describe('Install from Marketplace', () => {
beforeEach(() => {
vi.mocked(fs.existsSync).mockReset();
vi.mocked(fs.mkdirSync).mockReset();
vi.mocked(childProcess.execSync).mockReset();
vi.mocked(getMarketplaceEntry).mockReset();
vi.mocked(findPluginInMarketplaces).mockReset();
vi.mocked(scanMarketplacePlugins).mockReset();
vi.mocked(installPluginFromPath).mockReset();
vi.mocked(copyDirectory).mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('parsePluginSpec', () => {
it('parses plugin name without marketplace', () => {
const result = parsePluginSpec('my-plugin');
expect(result).toEqual({ pluginName: 'my-plugin' });
});
it('parses plugin name with marketplace', () => {
const result = parsePluginSpec('my-plugin@my-marketplace');
expect(result).toEqual({
pluginName: 'my-plugin',
marketplace: 'my-marketplace',
});
});
it('handles plugin names starting with @', () => {
const result = parsePluginSpec('@scoped/plugin');
expect(result).toEqual({ pluginName: '@scoped/plugin' });
});
it('handles scoped plugin with marketplace', () => {
const result = parsePluginSpec('@scoped/plugin@marketplace');
expect(result).toEqual({
pluginName: '@scoped/plugin',
marketplace: 'marketplace',
});
});
it('uses last @ for marketplace separator', () => {
const result = parsePluginSpec('plugin@with@multiple@at@market');
expect(result).toEqual({
pluginName: 'plugin@with@multiple@at',
marketplace: 'market',
});
});
});
describe('installPluginFromMarketplace', () => {
const mockMarketplaceEntry: MarketplaceEntry = {
name: 'test-market',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path/to/marketplace',
};
const mockPlugin: MarketplacePlugin = {
name: 'test-plugin',
description: 'A test plugin',
sourcePath: '/path/to/marketplace/plugins/test-plugin',
marketplace: 'test-market',
};
it('throws error when specified marketplace not found', async () => {
vi.mocked(getMarketplaceEntry).mockReturnValue(null);
await expect(installPluginFromMarketplace('plugin@nonexistent-market')).rejects.toThrow(
/marketplace.*not found/i
);
});
it('throws error when plugin not found in specified marketplace', async () => {
vi.mocked(getMarketplaceEntry).mockReturnValue(mockMarketplaceEntry);
vi.mocked(scanMarketplacePlugins).mockReturnValue([]);
await expect(
installPluginFromMarketplace('nonexistent-plugin@test-market')
).rejects.toThrow(/plugin.*not found/i);
});
it('throws error when plugin not found in any marketplace', async () => {
vi.mocked(findPluginInMarketplaces).mockReturnValue(null);
await expect(installPluginFromMarketplace('nonexistent-plugin')).rejects.toThrow(
/plugin.*not found/i
);
});
it('installs plugin from specified marketplace', async () => {
vi.mocked(getMarketplaceEntry).mockReturnValue(mockMarketplaceEntry);
vi.mocked(scanMarketplacePlugins).mockReturnValue([mockPlugin]);
vi.mocked(fs.existsSync).mockImplementation((p) => {
// Cache doesn't exist yet
const pathStr = typeof p === 'string' ? p : p.toString();
return !pathStr.includes('cache');
});
vi.mocked(childProcess.execSync).mockReturnValue('abc123def456\n');
vi.mocked(installPluginFromPath).mockResolvedValue({
success: true,
pluginName: 'test-plugin',
installPath: '/installed/path',
warnings: [],
});
const result = await installPluginFromMarketplace('test-plugin@test-market');
expect(result.success).toBe(true);
expect(result.pluginName).toBe('test-plugin');
expect(result.marketplace).toBe('test-market');
expect(copyDirectory).toHaveBeenCalled();
});
it('searches all marketplaces when no marketplace specified', async () => {
vi.mocked(findPluginInMarketplaces).mockReturnValue(mockPlugin);
vi.mocked(getMarketplaceEntry).mockReturnValue(mockMarketplaceEntry);
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(childProcess.execSync).mockReturnValue('abc123\n');
vi.mocked(installPluginFromPath).mockResolvedValue({
success: true,
pluginName: 'test-plugin',
installPath: '/installed/path',
warnings: [],
});
const result = await installPluginFromMarketplace('test-plugin');
expect(findPluginInMarketplaces).toHaveBeenCalledWith('test-plugin');
expect(result.warnings).toContain('Found plugin in marketplace: test-market');
});
it('uses cached copy if already exists', async () => {
vi.mocked(findPluginInMarketplaces).mockReturnValue(mockPlugin);
vi.mocked(getMarketplaceEntry).mockReturnValue(mockMarketplaceEntry);
vi.mocked(fs.existsSync).mockReturnValue(true); // Cache exists
vi.mocked(childProcess.execSync).mockReturnValue('abc123\n');
vi.mocked(installPluginFromPath).mockResolvedValue({
success: true,
pluginName: 'test-plugin',
installPath: '/installed/path',
warnings: [],
});
await installPluginFromMarketplace('test-plugin');
// copyDirectory should not be called since cache exists
expect(copyDirectory).not.toHaveBeenCalled();
});
it('passes scope option to installPluginFromPath', async () => {
vi.mocked(findPluginInMarketplaces).mockReturnValue(mockPlugin);
vi.mocked(getMarketplaceEntry).mockReturnValue(mockMarketplaceEntry);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(childProcess.execSync).mockReturnValue('abc123\n');
vi.mocked(installPluginFromPath).mockResolvedValue({
success: true,
pluginName: 'test-plugin',
installPath: '/installed/path',
warnings: [],
});
await installPluginFromMarketplace('test-plugin', { scope: 'project' });
expect(installPluginFromPath).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ scope: 'project' })
);
});
it('passes force option to installPluginFromPath', async () => {
vi.mocked(findPluginInMarketplaces).mockReturnValue(mockPlugin);
vi.mocked(getMarketplaceEntry).mockReturnValue(mockMarketplaceEntry);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(childProcess.execSync).mockReturnValue('abc123\n');
vi.mocked(installPluginFromPath).mockResolvedValue({
success: true,
pluginName: 'test-plugin',
installPath: '/installed/path',
warnings: [],
});
await installPluginFromMarketplace('test-plugin', { force: true });
expect(installPluginFromPath).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ force: true })
);
});
it('includes git commit SHA in result', async () => {
vi.mocked(findPluginInMarketplaces).mockReturnValue(mockPlugin);
vi.mocked(getMarketplaceEntry).mockReturnValue(mockMarketplaceEntry);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(childProcess.execSync).mockReturnValue('abc123def456789\n');
vi.mocked(installPluginFromPath).mockResolvedValue({
success: true,
pluginName: 'test-plugin',
installPath: '/installed/path',
warnings: [],
});
const result = await installPluginFromMarketplace('test-plugin');
expect(result.gitCommitSha).toBe('abc123def456789');
});
});
describe('searchMarketplacePlugins', () => {
it('filters plugins by name', () => {
const mockEntry: MarketplaceEntry = {
name: 'test-market',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path/to/marketplace',
};
const plugins: MarketplacePlugin[] = [
{ name: 'commit-helper', sourcePath: '/p1', marketplace: 'test-market' },
{ name: 'test-runner', sourcePath: '/p2', marketplace: 'test-market' },
{ name: 'linter', sourcePath: '/p3', marketplace: 'test-market' },
];
vi.mocked(getMarketplaceEntry).mockReturnValue(mockEntry);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(scanMarketplacePlugins).mockReturnValue(plugins);
const result = searchMarketplacePlugins('commit', 'test-market');
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('commit-helper');
});
it('filters plugins by description', () => {
const mockEntry: MarketplaceEntry = {
name: 'test-market',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path/to/marketplace',
};
const plugins: MarketplacePlugin[] = [
{
name: 'plugin-a',
description: 'Helps with git commits',
sourcePath: '/p1',
marketplace: 'test-market',
},
{
name: 'plugin-b',
description: 'Runs tests',
sourcePath: '/p2',
marketplace: 'test-market',
},
];
vi.mocked(getMarketplaceEntry).mockReturnValue(mockEntry);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(scanMarketplacePlugins).mockReturnValue(plugins);
const result = searchMarketplacePlugins('commit', 'test-market');
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('plugin-a');
});
it('returns empty array when marketplace not found', () => {
vi.mocked(getMarketplaceEntry).mockReturnValue(null);
const result = searchMarketplacePlugins('query', 'nonexistent');
expect(result).toEqual([]);
});
it('returns empty array when marketplace location does not exist', () => {
vi.mocked(getMarketplaceEntry).mockReturnValue({
name: 'test-market',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/nonexistent/path',
});
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = searchMarketplacePlugins('query', 'test-market');
expect(result).toEqual([]);
});
it('performs case-insensitive search', () => {
const mockEntry: MarketplaceEntry = {
name: 'test-market',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path/to/marketplace',
};
const plugins: MarketplacePlugin[] = [
{ name: 'MyPlugin', sourcePath: '/p1', marketplace: 'test-market' },
{ name: 'other', sourcePath: '/p2', marketplace: 'test-market' },
];
vi.mocked(getMarketplaceEntry).mockReturnValue(mockEntry);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(scanMarketplacePlugins).mockReturnValue(plugins);
const result = searchMarketplacePlugins('myplugin', 'test-market');
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('MyPlugin');
});
});
});

View File

@@ -0,0 +1,508 @@
/**
* Marketplace Operations Unit Tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as childProcess from 'child_process';
// Mock fs module
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
readdirSync: vi.fn(),
rmSync: vi.fn(),
statSync: vi.fn(),
};
});
// Mock child_process
vi.mock('child_process', async () => {
const actual = await vi.importActual<typeof import('child_process')>('child_process');
return {
...actual,
execSync: vi.fn(),
};
});
// Mock registry functions
vi.mock('../registry.js', () => ({
getMarketplacesDir: vi.fn(() => '/home/testuser/.dexto/plugins/marketplaces'),
loadKnownMarketplaces: vi.fn(() => ({ version: 1, marketplaces: {} })),
addMarketplaceEntry: vi.fn(),
removeMarketplaceEntry: vi.fn(() => true),
getMarketplaceEntry: vi.fn(),
getAllMarketplaces: vi.fn(() => []),
updateMarketplaceTimestamp: vi.fn(),
}));
// Mock validate-plugin
vi.mock('../../validate-plugin.js', () => ({
tryLoadManifest: vi.fn(),
}));
import {
parseMarketplaceSource,
deriveMarketplaceName,
addMarketplace,
removeMarketplace,
updateMarketplace,
listMarketplaces,
scanMarketplacePlugins,
findPluginInMarketplaces,
} from '../operations.js';
import {
addMarketplaceEntry,
removeMarketplaceEntry,
getMarketplaceEntry,
getAllMarketplaces,
} from '../registry.js';
import { tryLoadManifest } from '../../validate-plugin.js';
import type { MarketplaceEntry } from '../types.js';
import type { PluginManifest } from '../../types.js';
describe('Marketplace Operations', () => {
beforeEach(() => {
vi.mocked(fs.existsSync).mockReset();
vi.mocked(fs.readFileSync).mockReset();
vi.mocked(fs.writeFileSync).mockReset();
vi.mocked(fs.readdirSync).mockReset();
vi.mocked(fs.rmSync).mockReset();
vi.mocked(childProcess.execSync).mockReset();
vi.mocked(getMarketplaceEntry).mockReset();
vi.mocked(getAllMarketplaces).mockReset();
vi.mocked(addMarketplaceEntry).mockReset();
vi.mocked(removeMarketplaceEntry).mockReset();
vi.mocked(tryLoadManifest).mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('parseMarketplaceSource', () => {
it('parses local absolute path', () => {
const result = parseMarketplaceSource('/path/to/marketplace');
expect(result).toEqual({ type: 'local', value: '/path/to/marketplace' });
});
it('parses local relative path starting with ./', () => {
const result = parseMarketplaceSource('./local-marketplace');
expect(result).toEqual({ type: 'local', value: './local-marketplace' });
});
it('parses local relative path starting with ../', () => {
const result = parseMarketplaceSource('../parent-marketplace');
expect(result).toEqual({ type: 'local', value: '../parent-marketplace' });
});
it('parses home-relative path', () => {
const result = parseMarketplaceSource('~/my-marketplace');
expect(result).toEqual({ type: 'local', value: '~/my-marketplace' });
});
it('parses GitHub shorthand (owner/repo)', () => {
const result = parseMarketplaceSource('anthropics/claude-plugins');
expect(result).toEqual({ type: 'github', value: 'anthropics/claude-plugins' });
});
it('parses HTTPS git URL', () => {
const result = parseMarketplaceSource('https://github.com/owner/repo.git');
expect(result).toEqual({
type: 'git',
value: 'https://github.com/owner/repo.git',
});
});
it('parses SSH git URL', () => {
const result = parseMarketplaceSource('git@github.com:owner/repo.git');
expect(result).toEqual({ type: 'git', value: 'git@github.com:owner/repo.git' });
});
it('parses URL ending with .git', () => {
const result = parseMarketplaceSource('example.com/repo.git');
expect(result).toEqual({ type: 'git', value: 'example.com/repo.git' });
});
it('defaults to git for unknown format', () => {
const result = parseMarketplaceSource('some-unknown-format');
expect(result).toEqual({ type: 'git', value: 'some-unknown-format' });
});
});
describe('deriveMarketplaceName', () => {
it('extracts repo name from GitHub shorthand', () => {
const result = deriveMarketplaceName({ type: 'github', value: 'owner/repo-name' });
expect(result).toBe('repo-name');
});
it('extracts repo name from git URL', () => {
const result = deriveMarketplaceName({
type: 'git',
value: 'https://github.com/owner/my-plugins.git',
});
expect(result).toBe('my-plugins');
});
it('uses directory name for local path', () => {
const result = deriveMarketplaceName({
type: 'local',
value: '/path/to/my-local-marketplace',
});
expect(result).toBe('my-local-marketplace');
});
it('handles home-relative paths', () => {
const originalHome = process.env.HOME;
process.env.HOME = '/home/testuser';
const result = deriveMarketplaceName({
type: 'local',
value: '~/plugins/marketplace',
});
expect(result).toBe('marketplace');
process.env.HOME = originalHome;
});
});
describe('addMarketplace', () => {
it('throws error when marketplace already exists', async () => {
vi.mocked(getMarketplaceEntry).mockReturnValue({
name: 'existing-market',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path/to/existing',
});
await expect(addMarketplace('owner/existing-market')).rejects.toThrow(
/already exists/i
);
});
it('throws error when local path does not exist', async () => {
vi.mocked(getMarketplaceEntry).mockReturnValue(null);
vi.mocked(fs.existsSync).mockReturnValue(false);
await expect(addMarketplace('/nonexistent/path')).rejects.toThrow();
});
it('clones GitHub repository', async () => {
vi.mocked(getMarketplaceEntry).mockReturnValue(null);
vi.mocked(fs.existsSync).mockImplementation((p) => {
// Marketplace path exists after clone
return typeof p === 'string' && p.includes('test-repo');
});
vi.mocked(childProcess.execSync).mockReturnValue('');
vi.mocked(fs.readdirSync).mockReturnValue([]);
const result = await addMarketplace('owner/test-repo');
expect(childProcess.execSync).toHaveBeenCalledWith(
expect.stringContaining('git clone'),
expect.any(Object)
);
expect(result.success).toBe(true);
expect(result.name).toBe('test-repo');
});
it('respects custom name option', async () => {
vi.mocked(getMarketplaceEntry).mockReturnValue(null);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(childProcess.execSync).mockReturnValue('');
vi.mocked(fs.readdirSync).mockReturnValue([]);
const result = await addMarketplace('owner/repo', { name: 'custom-name' });
expect(result.name).toBe('custom-name');
});
it('registers local marketplace without cloning', async () => {
vi.mocked(getMarketplaceEntry).mockReturnValue(null);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([]);
const result = await addMarketplace('/local/path/marketplace');
expect(childProcess.execSync).not.toHaveBeenCalledWith(
expect.stringContaining('git clone'),
expect.any(Object)
);
expect(result.success).toBe(true);
expect(addMarketplaceEntry).toHaveBeenCalled();
});
it('warns when no plugins found', async () => {
vi.mocked(getMarketplaceEntry).mockReturnValue(null);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([]);
const result = await addMarketplace('/local/marketplace');
expect(result.warnings).toContain('No plugins found in marketplace');
});
});
describe('removeMarketplace', () => {
it('throws error when marketplace not found', async () => {
vi.mocked(getMarketplaceEntry).mockReturnValue(null);
await expect(removeMarketplace('nonexistent')).rejects.toThrow(/not found/i);
});
it('deletes cloned directory for non-local marketplaces', async () => {
const mockEntry: MarketplaceEntry = {
name: 'test-market',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/home/testuser/.dexto/plugins/marketplaces/test-market',
};
vi.mocked(getMarketplaceEntry).mockReturnValue(mockEntry);
vi.mocked(fs.existsSync).mockReturnValue(true);
const result = await removeMarketplace('test-market');
expect(fs.rmSync).toHaveBeenCalledWith(mockEntry.installLocation, {
recursive: true,
force: true,
});
expect(result.success).toBe(true);
});
it('does not delete directory for local marketplaces', async () => {
const mockEntry: MarketplaceEntry = {
name: 'local-market',
source: { type: 'local', value: '/local/path' },
installLocation: '/local/path',
};
vi.mocked(getMarketplaceEntry).mockReturnValue(mockEntry);
const result = await removeMarketplace('local-market');
expect(fs.rmSync).not.toHaveBeenCalled();
expect(result.success).toBe(true);
});
});
describe('updateMarketplace', () => {
it('throws error when marketplace not found', async () => {
vi.mocked(getMarketplaceEntry).mockReturnValue(null);
await expect(updateMarketplace('nonexistent')).rejects.toThrow(/not found/i);
});
it('returns warning for local marketplaces', async () => {
const mockEntry: MarketplaceEntry = {
name: 'local-market',
source: { type: 'local', value: '/local/path' },
installLocation: '/local/path',
};
vi.mocked(getMarketplaceEntry).mockReturnValue(mockEntry);
const results = await updateMarketplace('local-market');
expect(results[0]!.hasChanges).toBe(false);
expect(results[0]!.warnings).toContain(
'Local marketplaces do not support automatic updates'
);
});
it('runs git pull for git-based marketplaces', async () => {
const mockEntry: MarketplaceEntry = {
name: 'git-market',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path/to/marketplace',
};
vi.mocked(getMarketplaceEntry).mockReturnValue(mockEntry);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(childProcess.execSync).mockReturnValue('abc123\n');
const results = await updateMarketplace('git-market');
expect(childProcess.execSync).toHaveBeenCalledWith('git pull --ff-only', {
cwd: mockEntry.installLocation,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
expect(results[0]!.success).toBe(true);
});
it('updates all marketplaces when name not specified', async () => {
const marketplaces: MarketplaceEntry[] = [
{
name: 'market1',
source: { type: 'github', value: 'owner/repo1' },
installLocation: '/path1',
},
{
name: 'market2',
source: { type: 'github', value: 'owner/repo2' },
installLocation: '/path2',
},
];
vi.mocked(getAllMarketplaces).mockReturnValue(marketplaces);
vi.mocked(getMarketplaceEntry).mockImplementation(
(name) => marketplaces.find((m) => m.name === name) || null
);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(childProcess.execSync).mockReturnValue('abc123\n');
const results = await updateMarketplace();
expect(results).toHaveLength(2);
});
});
describe('listMarketplaces', () => {
it('returns all registered marketplaces', () => {
const marketplaces: MarketplaceEntry[] = [
{
name: 'dexto-market',
source: { type: 'github', value: 'dexto/plugins' },
installLocation: '/dexto/path',
},
{
name: 'custom-market',
source: { type: 'github', value: 'user/plugins' },
installLocation: '/custom/path',
},
];
vi.mocked(getAllMarketplaces).mockReturnValue(marketplaces);
const result = listMarketplaces();
expect(result).toHaveLength(2);
expect(getAllMarketplaces).toHaveBeenCalled();
});
});
describe('scanMarketplacePlugins', () => {
const createDirent = (name: string, isDir: boolean) => ({
name,
isFile: () => !isDir,
isDirectory: () => isDir,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
path: '',
parentPath: '',
});
it('loads plugins from marketplace.json manifest', () => {
const manifest = {
name: 'Test Marketplace',
plugins: [
{ name: 'plugin1', description: 'First plugin', source: 'plugins/plugin1' },
{ name: 'plugin2', description: 'Second plugin', source: 'plugins/plugin2' },
],
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(manifest));
const result = scanMarketplacePlugins('/marketplace', 'test-market');
expect(result).toHaveLength(2);
expect(result[0]!.name).toBe('plugin1');
expect(result[1]!.name).toBe('plugin2');
});
it('scans plugins/ directory when no manifest', () => {
vi.mocked(fs.existsSync).mockImplementation((p) => {
const pathStr = typeof p === 'string' ? p : p.toString();
if (pathStr.endsWith('marketplace.json')) return false;
if (
pathStr === '/marketplace/plugins' ||
pathStr === '/marketplace/plugins/my-plugin'
)
return true;
// external_plugins doesn't exist
if (pathStr.includes('external_plugins')) return false;
return false;
});
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error('No manifest');
});
vi.mocked(fs.readdirSync).mockImplementation((dir) => {
const dirStr = typeof dir === 'string' ? dir : dir.toString();
if (dirStr === '/marketplace/plugins') {
return [createDirent('my-plugin', true)] as any;
}
if (dirStr === '/marketplace') {
// Root scan should return plugins dir (which we skip) and maybe .git
return [createDirent('plugins', true), createDirent('.git', true)] as any;
}
return [];
});
// @ts-expect-error - Mock implementation type doesn't match overloaded function signature
vi.mocked(tryLoadManifest).mockImplementation((p: string): PluginManifest | null => {
if (p === '/marketplace/plugins/my-plugin') {
return { name: 'my-plugin', description: 'A plugin' };
}
return null;
});
const result = scanMarketplacePlugins('/marketplace', 'test-market');
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('my-plugin');
});
});
describe('findPluginInMarketplaces', () => {
it('finds plugin by name (case-insensitive)', () => {
const mockEntry: MarketplaceEntry = {
name: 'test-market',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path/to/marketplace',
};
vi.mocked(getAllMarketplaces).mockReturnValue([mockEntry]);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
name: 'Test Marketplace',
plugins: [{ name: 'MyPlugin', source: 'plugins/myplugin' }],
})
);
const result = findPluginInMarketplaces('myplugin');
expect(result).not.toBeNull();
expect(result!.name).toBe('MyPlugin');
});
it('searches specific marketplace when specified', () => {
const mockEntry: MarketplaceEntry = {
name: 'specific-market',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path/to/specific',
};
vi.mocked(getMarketplaceEntry).mockReturnValue(mockEntry);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
name: 'Specific Marketplace',
plugins: [{ name: 'TargetPlugin', source: 'plugins/target' }],
})
);
const result = findPluginInMarketplaces('targetplugin', 'specific-market');
expect(result).not.toBeNull();
expect(result!.marketplace).toBe('specific-market');
});
it('returns null when plugin not found', () => {
vi.mocked(getAllMarketplaces).mockReturnValue([]);
const result = findPluginInMarketplaces('nonexistent');
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,449 @@
/**
* Marketplace Registry Unit Tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
// Mock fs module
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
};
});
// Mock os module
vi.mock('os', async () => {
const actual = await vi.importActual<typeof import('os')>('os');
return {
...actual,
homedir: vi.fn(() => '/home/testuser'),
};
});
import {
DEFAULT_MARKETPLACES,
getMarketplacesRegistryPath,
getMarketplacesDir,
getMarketplaceCacheDir,
loadKnownMarketplaces,
saveKnownMarketplaces,
getMarketplaceEntry,
marketplaceExists,
getAllMarketplaces,
addMarketplaceEntry,
removeMarketplaceEntry,
updateMarketplaceTimestamp,
getUninstalledDefaults,
isDefaultMarketplace,
} from '../registry.js';
import type { KnownMarketplacesFile, MarketplaceEntry } from '../types.js';
describe('Marketplace Registry', () => {
const mockHomedir = '/home/testuser';
beforeEach(() => {
vi.mocked(fs.existsSync).mockReset();
vi.mocked(fs.readFileSync).mockReset();
vi.mocked(fs.writeFileSync).mockReset();
vi.mocked(fs.mkdirSync).mockReset();
vi.mocked(os.homedir).mockReturnValue(mockHomedir);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('path functions', () => {
it('getMarketplacesRegistryPath returns correct path', () => {
const result = getMarketplacesRegistryPath();
expect(result).toBe(`${mockHomedir}/.dexto/plugins/known_marketplaces.json`);
});
it('getMarketplacesDir returns correct path', () => {
const result = getMarketplacesDir();
expect(result).toBe(`${mockHomedir}/.dexto/plugins/marketplaces`);
});
it('getMarketplaceCacheDir returns correct path', () => {
const result = getMarketplaceCacheDir();
expect(result).toBe(`${mockHomedir}/.dexto/plugins/cache`);
});
});
describe('loadKnownMarketplaces', () => {
it('returns empty structure when file does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = loadKnownMarketplaces();
expect(result).toEqual({ version: 1, marketplaces: {} });
});
it('loads and parses valid file', () => {
const mockData: KnownMarketplacesFile = {
version: 1,
marketplaces: {
'test-marketplace': {
name: 'test-marketplace',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path/to/marketplace',
lastUpdated: '2026-01-01T00:00:00.000Z',
},
},
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData));
const result = loadKnownMarketplaces();
expect(result.version).toBe(1);
expect(result.marketplaces['test-marketplace']).toBeDefined();
expect(result.marketplaces['test-marketplace']!.name).toBe('test-marketplace');
});
it('returns empty structure on invalid JSON', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('invalid json');
const result = loadKnownMarketplaces();
expect(result).toEqual({ version: 1, marketplaces: {} });
});
it('returns empty structure on schema validation failure', () => {
const invalidData = {
version: 'not-a-number', // Invalid: should be number
marketplaces: {},
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(invalidData));
const result = loadKnownMarketplaces();
expect(result).toEqual({ version: 1, marketplaces: {} });
});
});
describe('saveKnownMarketplaces', () => {
it('creates directory if it does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const data: KnownMarketplacesFile = { version: 1, marketplaces: {} };
saveKnownMarketplaces(data);
expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('.dexto/plugins'), {
recursive: true,
});
});
it('writes data as formatted JSON', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
const data: KnownMarketplacesFile = {
version: 1,
marketplaces: {
test: {
name: 'test',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path',
},
},
};
saveKnownMarketplaces(data);
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.any(String),
JSON.stringify(data, null, 2),
'utf-8'
);
});
it('throws MarketplaceError on write failure', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error('Write failed');
});
const data: KnownMarketplacesFile = { version: 1, marketplaces: {} };
expect(() => saveKnownMarketplaces(data)).toThrow('Write failed');
});
});
describe('getMarketplaceEntry', () => {
it('returns entry when it exists', () => {
const mockEntry: MarketplaceEntry = {
name: 'test-marketplace',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path/to/marketplace',
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
version: 1,
marketplaces: { 'test-marketplace': mockEntry },
})
);
const result = getMarketplaceEntry('test-marketplace');
expect(result).toEqual(mockEntry);
});
it('returns null when entry does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ version: 1, marketplaces: {} })
);
const result = getMarketplaceEntry('nonexistent');
expect(result).toBeNull();
});
});
describe('marketplaceExists', () => {
it('returns true when marketplace exists', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
version: 1,
marketplaces: {
'test-marketplace': {
name: 'test-marketplace',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path',
},
},
})
);
expect(marketplaceExists('test-marketplace')).toBe(true);
});
it('returns false when marketplace does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ version: 1, marketplaces: {} })
);
expect(marketplaceExists('nonexistent')).toBe(false);
});
});
describe('getAllMarketplaces', () => {
it('returns all marketplace entries as array', () => {
const marketplaces = {
market1: {
name: 'market1',
source: { type: 'github' as const, value: 'owner/repo1' },
installLocation: '/path1',
},
market2: {
name: 'market2',
source: { type: 'git' as const, value: 'https://git.example.com/repo' },
installLocation: '/path2',
},
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ version: 1, marketplaces })
);
const result = getAllMarketplaces();
expect(result).toHaveLength(2);
expect(result.map((m) => m.name)).toContain('market1');
expect(result.map((m) => m.name)).toContain('market2');
});
});
describe('addMarketplaceEntry', () => {
it('adds entry to registry', () => {
const existingData = { version: 1, marketplaces: {} };
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(existingData));
const newEntry: MarketplaceEntry = {
name: 'new-marketplace',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path/to/marketplace',
};
addMarketplaceEntry(newEntry);
expect(fs.writeFileSync).toHaveBeenCalled();
const writtenData = JSON.parse(vi.mocked(fs.writeFileSync).mock.calls[0]![1] as string);
expect(writtenData.marketplaces['new-marketplace']).toEqual(newEntry);
});
});
describe('removeMarketplaceEntry', () => {
it('removes entry from registry and returns true', () => {
const existingData = {
version: 1,
marketplaces: {
'to-remove': {
name: 'to-remove',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path',
},
},
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(existingData));
const result = removeMarketplaceEntry('to-remove');
expect(result).toBe(true);
expect(fs.writeFileSync).toHaveBeenCalled();
const writtenData = JSON.parse(vi.mocked(fs.writeFileSync).mock.calls[0]![1] as string);
expect(writtenData.marketplaces['to-remove']).toBeUndefined();
});
it('returns false when entry does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ version: 1, marketplaces: {} })
);
const result = removeMarketplaceEntry('nonexistent');
expect(result).toBe(false);
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
});
describe('updateMarketplaceTimestamp', () => {
it('updates lastUpdated field', () => {
const existingData = {
version: 1,
marketplaces: {
'test-market': {
name: 'test-market',
source: { type: 'github', value: 'owner/repo' },
installLocation: '/path',
lastUpdated: '2025-01-01T00:00:00.000Z',
},
},
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(existingData));
updateMarketplaceTimestamp('test-market');
expect(fs.writeFileSync).toHaveBeenCalled();
const writtenData = JSON.parse(vi.mocked(fs.writeFileSync).mock.calls[0]![1] as string);
const updatedTimestamp = writtenData.marketplaces['test-market'].lastUpdated;
expect(new Date(updatedTimestamp).getFullYear()).toBeGreaterThanOrEqual(2026);
});
it('does nothing when marketplace does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ version: 1, marketplaces: {} })
);
updateMarketplaceTimestamp('nonexistent');
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
});
describe('DEFAULT_MARKETPLACES', () => {
it('includes claude-plugins-official marketplace', () => {
expect(DEFAULT_MARKETPLACES).toContainEqual({
name: 'claude-plugins-official',
source: {
type: 'github',
value: 'anthropics/claude-plugins-official',
},
});
});
it('has at least one default marketplace', () => {
expect(DEFAULT_MARKETPLACES.length).toBeGreaterThanOrEqual(1);
});
});
describe('getUninstalledDefaults', () => {
it('returns all defaults when none are installed', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ version: 1, marketplaces: {} })
);
const result = getUninstalledDefaults();
expect(result.length).toBe(DEFAULT_MARKETPLACES.length);
expect(result[0]!.isDefault).toBe(true);
});
it('returns empty array when all defaults are installed', () => {
const installedMarketplaces: Record<string, MarketplaceEntry> = {};
for (const def of DEFAULT_MARKETPLACES) {
installedMarketplaces[def.name] = {
name: def.name,
source: def.source,
installLocation: `/path/to/${def.name}`,
};
}
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ version: 1, marketplaces: installedMarketplaces })
);
const result = getUninstalledDefaults();
expect(result).toHaveLength(0);
});
it('returns only uninstalled defaults', () => {
// Install first default, leave others uninstalled
const firstDefault = DEFAULT_MARKETPLACES[0]!;
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
version: 1,
marketplaces: {
[firstDefault.name]: {
name: firstDefault.name,
source: firstDefault.source,
installLocation: `/path/to/${firstDefault.name}`,
},
},
})
);
const result = getUninstalledDefaults();
expect(result.length).toBe(DEFAULT_MARKETPLACES.length - 1);
expect(result.find((d) => d.name === firstDefault.name)).toBeUndefined();
});
});
describe('isDefaultMarketplace', () => {
it('returns true for default marketplace names', () => {
for (const def of DEFAULT_MARKETPLACES) {
expect(isDefaultMarketplace(def.name)).toBe(true);
}
});
it('returns false for non-default marketplace names', () => {
expect(isDefaultMarketplace('custom-marketplace')).toBe(false);
expect(isDefaultMarketplace('my-plugins')).toBe(false);
});
});
});

View File

@@ -0,0 +1,32 @@
/**
* Plugin Marketplace Error Codes
*/
export enum MarketplaceErrorCode {
// Registry errors
REGISTRY_READ_FAILED = 'marketplace_registry_read_failed',
REGISTRY_WRITE_FAILED = 'marketplace_registry_write_failed',
// Add marketplace errors
ADD_ALREADY_EXISTS = 'marketplace_add_already_exists',
ADD_CLONE_FAILED = 'marketplace_add_clone_failed',
ADD_INVALID_SOURCE = 'marketplace_add_invalid_source',
ADD_LOCAL_NOT_FOUND = 'marketplace_add_local_not_found',
// Remove marketplace errors
REMOVE_NOT_FOUND = 'marketplace_remove_not_found',
REMOVE_DELETE_FAILED = 'marketplace_remove_delete_failed',
// Update marketplace errors
UPDATE_NOT_FOUND = 'marketplace_update_not_found',
UPDATE_PULL_FAILED = 'marketplace_update_pull_failed',
UPDATE_LOCAL_NOT_SUPPORTED = 'marketplace_update_local_not_supported',
// Install from marketplace errors
INSTALL_MARKETPLACE_NOT_FOUND = 'marketplace_install_marketplace_not_found',
INSTALL_PLUGIN_NOT_FOUND = 'marketplace_install_plugin_not_found',
INSTALL_COPY_FAILED = 'marketplace_install_copy_failed',
// Scan errors
SCAN_FAILED = 'marketplace_scan_failed',
}

View File

@@ -0,0 +1,180 @@
import { DextoRuntimeError, ErrorScope, ErrorType } from '@dexto/core';
import { MarketplaceErrorCode } from './error-codes.js';
/**
* Marketplace error factory methods
* Creates properly typed errors for marketplace operations
*/
export class MarketplaceError {
// Registry errors
static registryReadFailed(path: string, cause: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.REGISTRY_READ_FAILED,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to read marketplace registry at ${path}: ${cause}`,
{ path, cause },
'Check file permissions and ensure the file exists'
);
}
static registryWriteFailed(path: string, cause: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.REGISTRY_WRITE_FAILED,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to write marketplace registry at ${path}: ${cause}`,
{ path, cause },
'Check file permissions and disk space'
);
}
// Add marketplace errors
static addAlreadyExists(name: string, existingPath: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.ADD_ALREADY_EXISTS,
ErrorScope.CONFIG,
ErrorType.USER,
`Marketplace '${name}' already exists at ${existingPath}`,
{ name, existingPath },
'Use a different name or remove the existing marketplace first'
);
}
static addCloneFailed(source: string, cause: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.ADD_CLONE_FAILED,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to clone marketplace from ${source}: ${cause}`,
{ source, cause },
'Check the URL is correct and you have network access'
);
}
static addInvalidSource(source: string, reason: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.ADD_INVALID_SOURCE,
ErrorScope.CONFIG,
ErrorType.USER,
`Invalid marketplace source '${source}': ${reason}`,
{ source, reason },
'Use format: owner/repo (GitHub), git URL, or local path'
);
}
static addLocalNotFound(path: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.ADD_LOCAL_NOT_FOUND,
ErrorScope.CONFIG,
ErrorType.USER,
`Local marketplace path not found: ${path}`,
{ path },
'Check the path exists and is a directory'
);
}
// Remove marketplace errors
static removeNotFound(name: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.REMOVE_NOT_FOUND,
ErrorScope.CONFIG,
ErrorType.USER,
`Marketplace '${name}' not found`,
{ name },
'Use `dexto plugin marketplace list` to see registered marketplaces'
);
}
static removeDeleteFailed(name: string, path: string, cause: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.REMOVE_DELETE_FAILED,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to delete marketplace '${name}' at ${path}: ${cause}`,
{ name, path, cause },
'Check file permissions'
);
}
// Update marketplace errors
static updateNotFound(name: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.UPDATE_NOT_FOUND,
ErrorScope.CONFIG,
ErrorType.USER,
`Marketplace '${name}' not found`,
{ name },
'Use `dexto plugin marketplace list` to see registered marketplaces'
);
}
static updatePullFailed(name: string, cause: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.UPDATE_PULL_FAILED,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to update marketplace '${name}': ${cause}`,
{ name, cause },
'Check network connectivity and that the repository is accessible'
);
}
static updateLocalNotSupported(name: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.UPDATE_LOCAL_NOT_SUPPORTED,
ErrorScope.CONFIG,
ErrorType.USER,
`Cannot update local marketplace '${name}'`,
{ name },
'Local marketplaces do not support automatic updates'
);
}
// Install from marketplace errors
static installMarketplaceNotFound(marketplace: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.INSTALL_MARKETPLACE_NOT_FOUND,
ErrorScope.CONFIG,
ErrorType.USER,
`Marketplace '${marketplace}' not found`,
{ marketplace },
'Use `dexto plugin marketplace list` to see registered marketplaces, or `dexto plugin marketplace add` to register one'
);
}
static installPluginNotFound(pluginName: string, marketplace?: string) {
const marketplaceInfo = marketplace ? ` in marketplace '${marketplace}'` : '';
return new DextoRuntimeError(
MarketplaceErrorCode.INSTALL_PLUGIN_NOT_FOUND,
ErrorScope.CONFIG,
ErrorType.USER,
`Plugin '${pluginName}' not found${marketplaceInfo}`,
{ pluginName, marketplace },
'Use `dexto plugin marketplace` to browse available plugins'
);
}
static installCopyFailed(pluginName: string, cause: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.INSTALL_COPY_FAILED,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to copy plugin '${pluginName}' from marketplace: ${cause}`,
{ pluginName, cause },
'Check file permissions and disk space'
);
}
// Scan errors
static scanFailed(marketplacePath: string, cause: string) {
return new DextoRuntimeError(
MarketplaceErrorCode.SCAN_FAILED,
ErrorScope.CONFIG,
ErrorType.SYSTEM,
`Failed to scan marketplace at ${marketplacePath}: ${cause}`,
{ marketplacePath, cause },
'Check the marketplace directory is accessible'
);
}
}

View File

@@ -0,0 +1,72 @@
/**
* Plugin Marketplace Module
*
* Provides functionality for managing plugin marketplaces and installing
* plugins from marketplace repositories.
*/
// Types
export type {
MarketplaceSourceType,
MarketplaceSource,
MarketplaceEntry,
KnownMarketplacesFile,
MarketplacePlugin,
MarketplaceAddResult,
MarketplaceRemoveResult,
MarketplaceUpdateResult,
MarketplaceInstallResult,
MarketplaceAddOptions,
MarketplaceInstallOptions,
MarketplaceManifest,
} from './types.js';
// Schemas
export {
MarketplaceSourceSchema,
MarketplaceEntrySchema,
KnownMarketplacesFileSchema,
MarketplaceManifestSchema,
MarketplacePluginEntrySchema,
MarketplaceAddCommandSchema,
MarketplaceInstallCommandSchema,
} from './schemas.js';
// Error codes and errors
export { MarketplaceErrorCode } from './error-codes.js';
export { MarketplaceError } from './errors.js';
// Registry operations
export {
DEFAULT_MARKETPLACES,
getMarketplacesRegistryPath,
getMarketplacesDir,
getMarketplaceCacheDir,
loadKnownMarketplaces,
saveKnownMarketplaces,
getMarketplaceEntry,
marketplaceExists,
getAllMarketplaces,
getUninstalledDefaults,
isDefaultMarketplace,
} from './registry.js';
// Marketplace operations
export {
parseMarketplaceSource,
deriveMarketplaceName,
addMarketplace,
removeMarketplace,
updateMarketplace,
listMarketplaces,
scanMarketplacePlugins,
listAllMarketplacePlugins,
findPluginInMarketplaces,
} from './operations.js';
// Install from marketplace
export {
parsePluginSpec,
installPluginFromMarketplace,
searchMarketplacePlugins,
} from './install-from-marketplace.js';

View File

@@ -0,0 +1,185 @@
/**
* Plugin Installation from Marketplace
*
* Handles installing plugins from registered marketplaces.
*/
import * as path from 'path';
import { existsSync, mkdirSync } from 'fs';
import { execSync } from 'child_process';
import { copyDirectory } from '../../utils/path.js';
import { getMarketplaceCacheDir, getMarketplaceEntry } from './registry.js';
import {
findPluginInMarketplaces,
scanMarketplacePlugins,
listAllMarketplacePlugins,
} from './operations.js';
import { MarketplaceError } from './errors.js';
import { installPluginFromPath } from '../install-plugin.js';
import type {
MarketplaceInstallOptions,
MarketplaceInstallResult,
MarketplacePlugin,
} from './types.js';
import type { PluginInstallScope } from '../types.js';
/**
* Parse a plugin spec (name or name@marketplace)
*/
export function parsePluginSpec(spec: string): { pluginName: string; marketplace?: string } {
const atIndex = spec.lastIndexOf('@');
// If @ is at position 0 or not found, no marketplace specified
if (atIndex <= 0) {
return { pluginName: spec };
}
return {
pluginName: spec.substring(0, atIndex),
marketplace: spec.substring(atIndex + 1),
};
}
/**
* Get the current git commit SHA in a directory
*/
function getGitSha(dir: string): string | undefined {
try {
const result = execSync('git rev-parse HEAD', {
cwd: dir,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
return result.trim();
} catch {
return undefined;
}
}
/**
* Get a short SHA (first 8 characters)
*/
function getShortSha(sha: string | undefined): string {
if (!sha) return 'unknown';
return sha.substring(0, 8);
}
/**
* Install a plugin from a marketplace
*
* @param pluginSpec Plugin spec in format "name" or "name@marketplace"
* @param options Installation options
*/
export async function installPluginFromMarketplace(
pluginSpec: string,
options: MarketplaceInstallOptions = {}
): Promise<MarketplaceInstallResult> {
const { scope = 'user', projectPath, force = false } = options;
const warnings: string[] = [];
// Parse plugin spec
const { pluginName, marketplace: specifiedMarketplace } = parsePluginSpec(pluginSpec);
// Find the plugin
let plugin: MarketplacePlugin | null = null;
if (specifiedMarketplace) {
// Verify marketplace exists
const marketplaceEntry = getMarketplaceEntry(specifiedMarketplace);
if (!marketplaceEntry) {
throw MarketplaceError.installMarketplaceNotFound(specifiedMarketplace);
}
// Search in specific marketplace
const plugins = scanMarketplacePlugins(
marketplaceEntry.installLocation,
marketplaceEntry.name
);
plugin = plugins.find((p) => p.name.toLowerCase() === pluginName.toLowerCase()) || null;
if (!plugin) {
throw MarketplaceError.installPluginNotFound(pluginName, specifiedMarketplace);
}
} else {
// Search all marketplaces
plugin = findPluginInMarketplaces(pluginName);
if (!plugin) {
throw MarketplaceError.installPluginNotFound(pluginName);
}
warnings.push(`Found plugin in marketplace: ${plugin.marketplace}`);
}
// Get git SHA from marketplace for version tracking
const marketplaceEntry = getMarketplaceEntry(plugin.marketplace);
let gitCommitSha: string | undefined;
if (marketplaceEntry) {
gitCommitSha = getGitSha(marketplaceEntry.installLocation);
}
// Determine cache path
const cacheDir = getMarketplaceCacheDir();
const shortSha = getShortSha(gitCommitSha);
const cachePath = path.join(cacheDir, plugin.marketplace, plugin.name, shortSha);
// Copy to cache if not already there
if (!existsSync(cachePath)) {
try {
mkdirSync(cachePath, { recursive: true });
await copyDirectory(plugin.sourcePath, cachePath);
} catch (error) {
throw MarketplaceError.installCopyFailed(
pluginName,
error instanceof Error ? error.message : String(error)
);
}
}
// Use existing install mechanism with cache path
const installOptions = {
scope: scope as PluginInstallScope,
force,
...(projectPath !== undefined && { projectPath }),
};
const installResult = await installPluginFromPath(cachePath, installOptions);
return {
success: installResult.success,
pluginName: installResult.pluginName,
marketplace: plugin.marketplace,
installPath: installResult.installPath,
...(gitCommitSha !== undefined && { gitCommitSha }),
warnings: [...warnings, ...installResult.warnings],
};
}
/**
* Search for plugins by name pattern across all marketplaces
*/
export function searchMarketplacePlugins(
query: string,
marketplaceName?: string
): MarketplacePlugin[] {
const lowerQuery = query.toLowerCase();
let plugins: MarketplacePlugin[];
if (marketplaceName) {
const entry = getMarketplaceEntry(marketplaceName);
if (!entry || !existsSync(entry.installLocation)) {
return [];
}
plugins = scanMarketplacePlugins(entry.installLocation, entry.name);
} else {
plugins = listAllMarketplacePlugins();
}
// Filter by query (matches name or description)
return plugins.filter((p) => {
const nameMatch = p.name.toLowerCase().includes(lowerQuery);
const descMatch = p.description?.toLowerCase().includes(lowerQuery);
return nameMatch || descMatch;
});
}

View File

@@ -0,0 +1,497 @@
/**
* Plugin Marketplace Operations
*
* Handles adding, removing, updating, and listing marketplaces.
*/
import * as path from 'path';
import { existsSync, readdirSync, readFileSync, rmSync } from 'fs';
import { execSync } from 'child_process';
import {
getMarketplacesDir,
addMarketplaceEntry,
removeMarketplaceEntry,
getMarketplaceEntry,
getAllMarketplaces,
updateMarketplaceTimestamp,
} from './registry.js';
import { MarketplaceError } from './errors.js';
import { MarketplaceManifestSchema } from './schemas.js';
import { tryLoadManifest } from '../validate-plugin.js';
import type {
MarketplaceSource,
MarketplaceEntry,
MarketplacePlugin,
MarketplaceAddOptions,
MarketplaceAddResult,
MarketplaceRemoveResult,
MarketplaceUpdateResult,
MarketplaceManifest,
} from './types.js';
/**
* Parse a source string to determine its type and value
*/
export function parseMarketplaceSource(source: string): MarketplaceSource {
const trimmed = source.trim();
// Local path (starts with / or ./ or ~/ or contains path separators)
if (
trimmed.startsWith('/') ||
trimmed.startsWith('./') ||
trimmed.startsWith('../') ||
trimmed.startsWith('~/')
) {
return { type: 'local', value: trimmed };
}
// Git URL (contains :// or ends with .git)
if (trimmed.includes('://') || trimmed.endsWith('.git')) {
return { type: 'git', value: trimmed };
}
// GitHub shorthand (owner/repo format)
const githubMatch = /^([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_.-]+)$/.exec(trimmed);
if (githubMatch) {
return { type: 'github', value: trimmed };
}
// Default to treating as git URL
return { type: 'git', value: trimmed };
}
/**
* Derive a marketplace name from its source
*/
export function deriveMarketplaceName(source: MarketplaceSource): string {
switch (source.type) {
case 'github': {
// owner/repo -> repo
const parts = source.value.split('/');
return parts[parts.length - 1] ?? source.value;
}
case 'git': {
// Extract repo name from URL
const url = source.value.replace(/\.git$/, '');
const parts = url.split('/');
return parts[parts.length - 1] ?? 'marketplace';
}
case 'local': {
// Use directory name
const resolved = source.value.startsWith('~')
? source.value.replace('~', process.env.HOME || '')
: path.resolve(source.value);
return path.basename(resolved);
}
}
}
/**
* Get the git clone URL for a source
*/
function getCloneUrl(source: MarketplaceSource): string {
switch (source.type) {
case 'github':
return `https://github.com/${source.value}.git`;
case 'git':
return source.value;
case 'local':
throw new Error('Cannot clone local source');
}
}
/**
* Get the current git commit SHA in a directory
*/
function getGitSha(dir: string): string | undefined {
try {
const result = execSync('git rev-parse HEAD', {
cwd: dir,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
return result.trim();
} catch {
return undefined;
}
}
/**
* Add a marketplace
*/
export async function addMarketplace(
source: string,
options: MarketplaceAddOptions = {}
): Promise<MarketplaceAddResult> {
const parsedSource = parseMarketplaceSource(source);
const name = options.name || deriveMarketplaceName(parsedSource);
const warnings: string[] = [];
// Check if already exists
const existing = getMarketplaceEntry(name);
if (existing) {
throw MarketplaceError.addAlreadyExists(name, existing.installLocation);
}
let installLocation: string;
if (parsedSource.type === 'local') {
// Resolve local path
const localPath = parsedSource.value.startsWith('~')
? parsedSource.value.replace('~', process.env.HOME || '')
: path.resolve(parsedSource.value);
if (!existsSync(localPath)) {
throw MarketplaceError.addLocalNotFound(localPath);
}
installLocation = localPath;
} else {
// Clone git repository
const marketplacesDir = getMarketplacesDir();
installLocation = path.join(marketplacesDir, name);
const cloneUrl = getCloneUrl(parsedSource);
try {
execSync(`git clone "${cloneUrl}" "${installLocation}"`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (error) {
throw MarketplaceError.addCloneFailed(
source,
error instanceof Error ? error.message : String(error)
);
}
}
// Scan for plugins to verify it's a valid marketplace
const plugins = scanMarketplacePlugins(installLocation, name);
if (plugins.length === 0) {
warnings.push('No plugins found in marketplace');
}
// Add to registry
const entry: MarketplaceEntry = {
name,
source: parsedSource,
installLocation,
lastUpdated: new Date().toISOString(),
};
addMarketplaceEntry(entry);
return {
success: true,
name,
pluginCount: plugins.length,
warnings,
};
}
/**
* Remove a marketplace
*/
export async function removeMarketplace(name: string): Promise<MarketplaceRemoveResult> {
const entry = getMarketplaceEntry(name);
if (!entry) {
throw MarketplaceError.removeNotFound(name);
}
// Delete the directory (only if it was cloned, not local)
if (entry.source.type !== 'local') {
try {
if (existsSync(entry.installLocation)) {
rmSync(entry.installLocation, { recursive: true, force: true });
}
} catch (error) {
throw MarketplaceError.removeDeleteFailed(
name,
entry.installLocation,
error instanceof Error ? error.message : String(error)
);
}
}
// Remove from registry
removeMarketplaceEntry(name);
return {
success: true,
name,
};
}
/**
* Update a marketplace (git pull)
*/
export async function updateMarketplace(name?: string): Promise<MarketplaceUpdateResult[]> {
const results: MarketplaceUpdateResult[] = [];
if (name) {
// Update single marketplace
const result = await updateSingleMarketplace(name);
results.push(result);
} else {
// Update all marketplaces
const marketplaces = getAllMarketplaces();
for (const marketplace of marketplaces) {
const result = await updateSingleMarketplace(marketplace.name);
results.push(result);
}
}
return results;
}
/**
* Update a single marketplace
*/
async function updateSingleMarketplace(name: string): Promise<MarketplaceUpdateResult> {
const entry = getMarketplaceEntry(name);
if (!entry) {
throw MarketplaceError.updateNotFound(name);
}
const warnings: string[] = [];
// Local marketplaces can't be updated
if (entry.source.type === 'local') {
return {
success: true,
name,
hasChanges: false,
warnings: ['Local marketplaces do not support automatic updates'],
};
}
// Check if directory exists
if (!existsSync(entry.installLocation)) {
warnings.push('Marketplace directory not found, re-cloning');
// Re-clone
const cloneUrl = getCloneUrl(entry.source);
try {
execSync(`git clone "${cloneUrl}" "${entry.installLocation}"`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (error) {
throw MarketplaceError.updatePullFailed(
name,
error instanceof Error ? error.message : String(error)
);
}
updateMarketplaceTimestamp(name);
const newShaValue = getGitSha(entry.installLocation);
return {
success: true,
name,
...(newShaValue !== undefined && { newSha: newShaValue }),
hasChanges: true,
warnings,
};
}
// Get current SHA
const previousShaValue = getGitSha(entry.installLocation);
// Pull updates
try {
execSync('git pull --ff-only', {
cwd: entry.installLocation,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (error) {
throw MarketplaceError.updatePullFailed(
name,
error instanceof Error ? error.message : String(error)
);
}
// Get new SHA
const newShaValue = getGitSha(entry.installLocation);
const hasChanges = previousShaValue !== newShaValue;
if (hasChanges) {
updateMarketplaceTimestamp(name);
}
return {
success: true,
name,
...(previousShaValue !== undefined && { previousSha: previousShaValue }),
...(newShaValue !== undefined && { newSha: newShaValue }),
hasChanges,
warnings,
};
}
/**
* List all registered marketplaces
*/
export function listMarketplaces(): MarketplaceEntry[] {
return getAllMarketplaces();
}
/**
* Scan a marketplace directory for plugins
*/
export function scanMarketplacePlugins(
marketplacePath: string,
marketplaceName: string
): MarketplacePlugin[] {
const plugins: MarketplacePlugin[] = [];
// First try to load marketplace manifest (marketplace.json)
const manifestPath = path.join(marketplacePath, 'marketplace.json');
if (existsSync(manifestPath)) {
try {
const content = readFileSync(manifestPath, 'utf-8');
const parsed = JSON.parse(content);
const result = MarketplaceManifestSchema.safeParse(parsed);
if (result.success) {
const manifest = result.data as MarketplaceManifest;
if (manifest.plugins && manifest.plugins.length > 0) {
for (const plugin of manifest.plugins) {
const pluginPath = path.join(marketplacePath, plugin.source);
if (existsSync(pluginPath)) {
plugins.push({
name: plugin.name,
...(plugin.description !== undefined && {
description: plugin.description,
}),
...(plugin.version !== undefined && { version: plugin.version }),
...(plugin.category !== undefined && { category: plugin.category }),
sourcePath: pluginPath,
marketplace: marketplaceName,
});
}
}
return plugins;
}
}
} catch {
// Fall through to directory scan
}
}
// Scan common plugin directories: plugins/, external_plugins/
const pluginDirs = ['plugins', 'external_plugins'];
for (const dir of pluginDirs) {
const dirPath = path.join(marketplacePath, dir);
if (!existsSync(dirPath)) continue;
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const pluginPath = path.join(dirPath, entry.name);
// Try to load plugin manifest
const manifest = tryLoadManifest(pluginPath);
if (manifest) {
plugins.push({
name: manifest.name,
...(manifest.description !== undefined && {
description: manifest.description,
}),
...(manifest.version !== undefined && { version: manifest.version }),
sourcePath: pluginPath,
marketplace: marketplaceName,
});
}
}
} catch {
// Skip directories we can't read
}
}
// Also scan root level for plugins (some marketplaces may have flat structure)
try {
const rootEntries = readdirSync(marketplacePath, { withFileTypes: true });
for (const entry of rootEntries) {
if (!entry.isDirectory()) continue;
// Skip known non-plugin directories
if (['plugins', 'external_plugins', '.git', 'node_modules'].includes(entry.name))
continue;
const pluginPath = path.join(marketplacePath, entry.name);
// Try to load plugin manifest
const manifest = tryLoadManifest(pluginPath);
if (manifest) {
// Check if we already found this plugin
const existing = plugins.find((p) => p.name === manifest.name);
if (!existing) {
plugins.push({
name: manifest.name,
...(manifest.description !== undefined && {
description: manifest.description,
}),
...(manifest.version !== undefined && { version: manifest.version }),
sourcePath: pluginPath,
marketplace: marketplaceName,
});
}
}
}
} catch {
// Skip if we can't read root
}
return plugins;
}
/**
* List all plugins across all marketplaces
*/
export function listAllMarketplacePlugins(): MarketplacePlugin[] {
const marketplaces = listMarketplaces();
const allPlugins: MarketplacePlugin[] = [];
for (const marketplace of marketplaces) {
if (!existsSync(marketplace.installLocation)) continue;
const plugins = scanMarketplacePlugins(marketplace.installLocation, marketplace.name);
allPlugins.push(...plugins);
}
return allPlugins;
}
/**
* Find a plugin by name across all marketplaces
*/
export function findPluginInMarketplaces(
pluginName: string,
marketplaceName?: string
): MarketplacePlugin | null {
const marketplaces = marketplaceName
? ([getMarketplaceEntry(marketplaceName)].filter(Boolean) as MarketplaceEntry[])
: listMarketplaces();
for (const marketplace of marketplaces) {
if (!existsSync(marketplace.installLocation)) continue;
const plugins = scanMarketplacePlugins(marketplace.installLocation, marketplace.name);
const found = plugins.find((p) => p.name.toLowerCase() === pluginName.toLowerCase());
if (found) {
return found;
}
}
return null;
}

View File

@@ -0,0 +1,193 @@
/**
* Plugin Marketplace Registry
*
* Manages the known_marketplaces.json file that tracks registered marketplaces.
*/
import * as path from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { homedir } from 'os';
import { KnownMarketplacesFileSchema } from './schemas.js';
import { MarketplaceError } from './errors.js';
import type { KnownMarketplacesFile, MarketplaceEntry, MarketplaceSource } from './types.js';
/**
* Default marketplace configuration
* Claude Code's official plugin marketplace is included by default
*/
export const DEFAULT_MARKETPLACES: Array<{
name: string;
source: MarketplaceSource;
}> = [
{
name: 'claude-plugins-official',
source: {
type: 'github',
value: 'anthropics/claude-plugins-official',
},
},
];
/**
* Get the path to known_marketplaces.json
*/
export function getMarketplacesRegistryPath(): string {
return path.join(homedir(), '.dexto', 'plugins', 'known_marketplaces.json');
}
/**
* Get the directory where marketplaces are cloned
*/
export function getMarketplacesDir(): string {
return path.join(homedir(), '.dexto', 'plugins', 'marketplaces');
}
/**
* Get the marketplace cache directory (for versioned plugin copies)
*/
export function getMarketplaceCacheDir(): string {
return path.join(homedir(), '.dexto', 'plugins', 'cache');
}
/**
* Load the known marketplaces registry
*/
export function loadKnownMarketplaces(): KnownMarketplacesFile {
const filePath = getMarketplacesRegistryPath();
if (!existsSync(filePath)) {
return { version: 1, marketplaces: {} };
}
try {
const content = readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(content);
const result = KnownMarketplacesFileSchema.safeParse(parsed);
if (!result.success) {
// Invalid file - return fresh structure
return { version: 1, marketplaces: {} };
}
return result.data as KnownMarketplacesFile;
} catch {
// File read/parse error - return fresh structure
return { version: 1, marketplaces: {} };
}
}
/**
* Save the known marketplaces registry
*/
export function saveKnownMarketplaces(data: KnownMarketplacesFile): void {
const filePath = getMarketplacesRegistryPath();
const dirPath = path.dirname(filePath);
// Ensure directory exists
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
}
try {
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
} catch (error) {
throw MarketplaceError.registryWriteFailed(
filePath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Get a specific marketplace entry by name
*/
export function getMarketplaceEntry(name: string): MarketplaceEntry | null {
const registry = loadKnownMarketplaces();
return registry.marketplaces[name] || null;
}
/**
* Check if a marketplace exists by name
*/
export function marketplaceExists(name: string): boolean {
return getMarketplaceEntry(name) !== null;
}
/**
* Get all registered marketplaces
*/
export function getAllMarketplaces(): MarketplaceEntry[] {
const registry = loadKnownMarketplaces();
return Object.values(registry.marketplaces);
}
/**
* Add a marketplace entry to the registry
*/
export function addMarketplaceEntry(entry: MarketplaceEntry): void {
const registry = loadKnownMarketplaces();
registry.marketplaces[entry.name] = entry;
saveKnownMarketplaces(registry);
}
/**
* Remove a marketplace entry from the registry
*/
export function removeMarketplaceEntry(name: string): boolean {
const registry = loadKnownMarketplaces();
if (!registry.marketplaces[name]) {
return false;
}
delete registry.marketplaces[name];
saveKnownMarketplaces(registry);
return true;
}
/**
* Update a marketplace entry's lastUpdated timestamp
*/
export function updateMarketplaceTimestamp(name: string): void {
const registry = loadKnownMarketplaces();
if (registry.marketplaces[name]) {
registry.marketplaces[name].lastUpdated = new Date().toISOString();
saveKnownMarketplaces(registry);
}
}
/**
* Get default marketplaces that are not yet installed
* Returns entries with a special flag indicating they need to be added
*/
export function getUninstalledDefaults(): Array<{
name: string;
source: MarketplaceSource;
isDefault: true;
}> {
const registry = loadKnownMarketplaces();
const uninstalled: Array<{
name: string;
source: MarketplaceSource;
isDefault: true;
}> = [];
for (const defaultMarket of DEFAULT_MARKETPLACES) {
if (!registry.marketplaces[defaultMarket.name]) {
uninstalled.push({
...defaultMarket,
isDefault: true,
});
}
}
return uninstalled;
}
/**
* Check if a marketplace is a default marketplace
*/
export function isDefaultMarketplace(name: string): boolean {
return DEFAULT_MARKETPLACES.some((m) => m.name === name);
}

View File

@@ -0,0 +1,93 @@
/**
* Plugin Marketplace Zod Schemas
*/
import { z } from 'zod';
/**
* Marketplace source specification
*/
export const MarketplaceSourceSchema = z
.object({
type: z.enum(['github', 'git', 'local']).describe('Type of marketplace source'),
value: z.string().min(1).describe('Source value: owner/repo, git URL, or local path'),
})
.strict();
/**
* Entry in the known marketplaces registry
*/
export const MarketplaceEntrySchema = z
.object({
name: z.string().min(1).describe('Unique name of the marketplace'),
source: MarketplaceSourceSchema.describe('Source specification'),
installLocation: z.string().describe('Local path where marketplace is installed'),
lastUpdated: z.string().datetime().optional().describe('ISO timestamp of last update'),
})
.strict();
/**
* Structure of known_marketplaces.json
*/
export const KnownMarketplacesFileSchema = z
.object({
version: z.number().default(1).describe('File format version'),
marketplaces: z
.record(MarketplaceEntrySchema)
.default({})
.describe('Registered marketplaces by name'),
})
.strict();
/**
* Plugin entry in a marketplace manifest
*/
export const MarketplacePluginEntrySchema = z
.object({
name: z.string().min(1).describe('Plugin name'),
description: z.string().optional().describe('Plugin description'),
source: z.string().describe('Path to plugin within marketplace'),
category: z.string().optional().describe('Plugin category'),
version: z.string().optional().describe('Plugin version'),
})
.passthrough(); // Allow unknown fields for forward compatibility
/**
* Marketplace manifest format (marketplace.json in repo root)
* Compatible with Claude Code marketplace format
*/
export const MarketplaceManifestSchema = z
.object({
name: z.string().describe('Marketplace name'),
version: z.string().optional().describe('Marketplace version'),
owner: z
.object({
name: z.string(),
email: z.string().optional(),
})
.optional()
.describe('Owner information'),
plugins: z.array(MarketplacePluginEntrySchema).optional().describe('Listed plugins'),
})
.passthrough(); // Allow unknown fields for forward compatibility
/**
* CLI command schemas
*/
export 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();
export 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();

View File

@@ -0,0 +1,167 @@
/**
* Plugin Marketplace Types
*
* Types for managing plugin marketplaces - repositories of plugins
* that can be browsed and installed.
*/
/**
* Type of marketplace source
*/
export type MarketplaceSourceType = 'github' | 'git' | 'local';
/**
* Marketplace source specification
*/
export interface MarketplaceSource {
/** Type of source */
type: MarketplaceSourceType;
/** Value: owner/repo for GitHub, URL for git, path for local */
value: string;
}
/**
* Entry in the known marketplaces registry
*/
export interface MarketplaceEntry {
/** Unique name of the marketplace */
name: string;
/** Source specification */
source: MarketplaceSource;
/** Local path where marketplace is installed */
installLocation: string;
/** ISO timestamp of last update */
lastUpdated?: string | undefined;
}
/**
* Structure of known_marketplaces.json
*/
export interface KnownMarketplacesFile {
/** File format version */
version: number;
/** Registered marketplaces by name */
marketplaces: Record<string, MarketplaceEntry>;
}
/**
* Plugin metadata from a marketplace
*/
export interface MarketplacePlugin {
/** Plugin name */
name: string;
/** Plugin description */
description?: string | undefined;
/** Plugin version */
version?: string | undefined;
/** Plugin category */
category?: string | undefined;
/** Path within the marketplace */
sourcePath: string;
/** Marketplace this plugin belongs to */
marketplace: string;
}
/**
* Result of adding a marketplace
*/
export interface MarketplaceAddResult {
/** Whether the operation succeeded */
success: boolean;
/** Name of the marketplace */
name: string;
/** Number of plugins found */
pluginCount: number;
/** Non-fatal warnings */
warnings: string[];
}
/**
* Result of removing a marketplace
*/
export interface MarketplaceRemoveResult {
/** Whether the operation succeeded */
success: boolean;
/** Name of the marketplace removed */
name: string;
}
/**
* Result of updating a marketplace
*/
export interface MarketplaceUpdateResult {
/** Whether the operation succeeded */
success: boolean;
/** Name of the marketplace */
name: string;
/** Previous git commit SHA (if applicable) */
previousSha?: string | undefined;
/** New git commit SHA (if applicable) */
newSha?: string | undefined;
/** Whether there were changes */
hasChanges: boolean;
/** Non-fatal warnings */
warnings: string[];
}
/**
* Result of installing a plugin from marketplace
*/
export interface MarketplaceInstallResult {
/** Whether the operation succeeded */
success: boolean;
/** Name of the plugin installed */
pluginName: string;
/** Name of the marketplace it came from */
marketplace: string;
/** Local installation path */
installPath: string;
/** Git commit SHA at time of install */
gitCommitSha?: string | undefined;
/** Non-fatal warnings */
warnings: string[];
}
/**
* Options for adding a marketplace
*/
export interface MarketplaceAddOptions {
/** Custom name for the marketplace (defaults to derived from source) */
name?: string | undefined;
}
/**
* Options for installing from marketplace
*/
export interface MarketplaceInstallOptions {
/** Installation scope */
scope?: 'user' | 'project' | 'local' | undefined;
/** Project path for project-scoped installation */
projectPath?: string | undefined;
/** Force reinstall if already exists */
force?: boolean | undefined;
}
/**
* Marketplace manifest format (marketplace.json in repo root)
* Compatible with Claude Code marketplace format
*/
export interface MarketplaceManifest {
/** Marketplace name */
name: string;
/** Marketplace version */
version?: string;
/** Owner information */
owner?: {
name: string;
email?: string;
};
/** List of plugins in the marketplace */
plugins?: Array<{
name: string;
description?: string;
source: string;
category?: string;
version?: string;
}>;
}

View File

@@ -0,0 +1,371 @@
import { describe, it, expect } from 'vitest';
import {
PluginManifestSchema,
PluginMCPConfigSchema,
InstalledPluginEntrySchema,
InstalledPluginsFileSchema,
} from './schemas.js';
describe('PluginManifestSchema', () => {
it('should validate a minimal manifest with only name', () => {
const manifest = { name: 'my-plugin' };
const result = PluginManifestSchema.safeParse(manifest);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('my-plugin');
}
});
it('should validate a complete manifest', () => {
const manifest = {
name: 'my-plugin',
description: 'A test plugin',
version: '1.0.0',
author: 'Test Author',
};
const result = PluginManifestSchema.safeParse(manifest);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(manifest);
}
});
it('should reject manifest without name', () => {
const manifest = { description: 'No name' };
const result = PluginManifestSchema.safeParse(manifest);
expect(result.success).toBe(false);
});
it('should reject manifest with empty name', () => {
const manifest = { name: '' };
const result = PluginManifestSchema.safeParse(manifest);
expect(result.success).toBe(false);
});
it('should allow unknown fields (passthrough mode for Claude Code compatibility)', () => {
const manifest = {
name: 'my-plugin',
unknownField: 'allowed for compatibility',
};
const result = PluginManifestSchema.safeParse(manifest);
expect(result.success).toBe(true);
});
it('should validate author as a string', () => {
const manifest = {
name: 'my-plugin',
author: 'Test Author',
};
const result = PluginManifestSchema.safeParse(manifest);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.author).toBe('Test Author');
}
});
it('should validate author as an object with name and email', () => {
const manifest = {
name: 'my-plugin',
author: {
name: 'Anthropic',
email: 'support@anthropic.com',
},
};
const result = PluginManifestSchema.safeParse(manifest);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.author).toEqual({
name: 'Anthropic',
email: 'support@anthropic.com',
});
}
});
it('should validate author as an object with only name', () => {
const manifest = {
name: 'my-plugin',
author: {
name: 'Anthropic',
},
};
const result = PluginManifestSchema.safeParse(manifest);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.author).toEqual({ name: 'Anthropic' });
}
});
});
describe('PluginMCPConfigSchema', () => {
it('should validate an empty config', () => {
const config = {};
const result = PluginMCPConfigSchema.safeParse(config);
expect(result.success).toBe(true);
});
it('should validate config with mcpServers', () => {
const config = {
mcpServers: {
filesystem: {
type: 'stdio',
command: 'npx',
args: ['@modelcontextprotocol/server-filesystem'],
},
},
};
const result = PluginMCPConfigSchema.safeParse(config);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.mcpServers).toBeDefined();
}
});
it('should allow unknown fields (passthrough mode)', () => {
const config = {
mcpServers: {},
customField: 'allowed',
};
const result = PluginMCPConfigSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
describe('InstalledPluginEntrySchema', () => {
it('should validate a minimal entry with required fields', () => {
const entry = {
scope: 'user',
installPath: '/home/user/.dexto/plugins/cache/marketplace/plugin/1.0.0',
};
const result = InstalledPluginEntrySchema.safeParse(entry);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.scope).toBe('user');
expect(result.data.installPath).toBe(
'/home/user/.dexto/plugins/cache/marketplace/plugin/1.0.0'
);
}
});
it('should validate a complete entry with all fields', () => {
const entry = {
scope: 'project',
installPath: '/home/user/.dexto/plugins/cache/marketplace/my-plugin/1.0.0',
version: '1.0.0',
installedAt: '2026-01-21T10:52:10.027Z',
lastUpdated: '2026-01-21T10:52:10.027Z',
gitCommitSha: 'a6a8045031de9ff3e44683264e2ed6d434a8c0b6',
projectPath: '/Users/test/my-project',
isLocal: false,
};
const result = InstalledPluginEntrySchema.safeParse(entry);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toMatchObject(entry);
}
});
it('should validate all scope values', () => {
const scopes = ['project', 'user', 'local', 'managed'] as const;
for (const scope of scopes) {
const entry = {
scope,
installPath: '/path/to/plugin',
};
const result = InstalledPluginEntrySchema.safeParse(entry);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.scope).toBe(scope);
}
}
});
it('should reject invalid scope values', () => {
const entry = {
scope: 'invalid',
installPath: '/path/to/plugin',
};
const result = InstalledPluginEntrySchema.safeParse(entry);
expect(result.success).toBe(false);
});
it('should reject entry without installPath', () => {
const entry = {
scope: 'user',
};
const result = InstalledPluginEntrySchema.safeParse(entry);
expect(result.success).toBe(false);
});
it('should reject entry without scope', () => {
const entry = {
installPath: '/path/to/plugin',
};
const result = InstalledPluginEntrySchema.safeParse(entry);
expect(result.success).toBe(false);
});
it('should allow unknown fields (passthrough mode)', () => {
const entry = {
scope: 'user',
installPath: '/path/to/plugin',
customField: 'allowed',
};
const result = InstalledPluginEntrySchema.safeParse(entry);
expect(result.success).toBe(true);
});
});
describe('InstalledPluginsFileSchema', () => {
it('should validate a minimal file with empty plugins', () => {
const file = {
plugins: {},
};
const result = InstalledPluginsFileSchema.safeParse(file);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.plugins).toEqual({});
}
});
it('should validate a file with version', () => {
const file = {
version: 2,
plugins: {},
};
const result = InstalledPluginsFileSchema.safeParse(file);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.version).toBe(2);
}
});
it('should validate a complete installed_plugins.json structure', () => {
const file = {
version: 2,
plugins: {
'code-review@claude-code-plugins': [
{
scope: 'user',
installPath:
'/home/user/.dexto/plugins/cache/claude-code-plugins/code-review/1.0.0',
version: '1.0.0',
installedAt: '2026-01-21T10:52:10.027Z',
lastUpdated: '2026-01-21T10:52:10.027Z',
gitCommitSha: 'a6a8045031de9ff3e44683264e2ed6d434a8c0b6',
},
],
'another-plugin@marketplace': [
{
scope: 'project',
installPath:
'/home/user/.dexto/plugins/cache/marketplace/another-plugin/2.0.0',
version: '2.0.0',
projectPath: '/Users/test/my-project',
},
{
scope: 'project',
installPath:
'/home/user/.dexto/plugins/cache/marketplace/another-plugin/2.0.0',
version: '2.0.0',
projectPath: '/Users/test/other-project',
},
],
},
};
const result = InstalledPluginsFileSchema.safeParse(file);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.version).toBe(2);
expect(Object.keys(result.data.plugins)).toHaveLength(2);
expect(result.data.plugins['code-review@claude-code-plugins']).toHaveLength(1);
expect(result.data.plugins['another-plugin@marketplace']).toHaveLength(2);
}
});
it('should validate multiple installations for the same plugin', () => {
const file = {
plugins: {
'multi-install-plugin@marketplace': [
{
scope: 'user',
installPath: '/home/user/.dexto/plugins/cache/marketplace/plugin/1.0.0',
},
{
scope: 'project',
installPath: '/home/user/.dexto/plugins/cache/marketplace/plugin/1.0.0',
projectPath: '/project1',
},
{
scope: 'project',
installPath: '/home/user/.dexto/plugins/cache/marketplace/plugin/1.0.0',
projectPath: '/project2',
},
],
},
};
const result = InstalledPluginsFileSchema.safeParse(file);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.plugins['multi-install-plugin@marketplace']).toHaveLength(3);
}
});
it('should reject file without plugins field', () => {
const file = {
version: 2,
};
const result = InstalledPluginsFileSchema.safeParse(file);
expect(result.success).toBe(false);
});
it('should reject file with invalid plugin entry', () => {
const file = {
plugins: {
'invalid-plugin': [
{
// Missing required fields
version: '1.0.0',
},
],
},
};
const result = InstalledPluginsFileSchema.safeParse(file);
expect(result.success).toBe(false);
});
it('should allow unknown fields at top level (passthrough mode)', () => {
const file = {
version: 2,
plugins: {},
customTopLevelField: 'allowed',
};
const result = InstalledPluginsFileSchema.safeParse(file);
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,122 @@
/**
* Zod schemas for plugin validation
*
* Supports two plugin formats:
* - .claude-plugin/plugin.json: Claude Code compatible format
* - .dexto-plugin/plugin.json: Dexto-native format with extended features
*/
import { z } from 'zod';
/**
* Schema for author field - can be a string or an object with name/email
*/
const AuthorSchema = z.union([
z.string(),
z.object({
name: z.string(),
email: z.string().optional(),
}),
]);
/**
* Schema for Claude Code plugin.json manifest
* Uses passthrough to allow unknown fields from Claude Code plugins
*/
export const PluginManifestSchema = z
.object({
name: z.string().min(1).describe('Unique plugin name (used for namespacing commands)'),
description: z.string().optional().describe('Human-readable plugin description'),
version: z.string().optional().describe('Semantic version (e.g., 1.0.0)'),
author: AuthorSchema.optional().describe('Plugin author - string or {name, email} object'),
})
.passthrough()
.describe('Claude Code plugin manifest from .claude-plugin/plugin.json');
/**
* Schema for Dexto-native plugin.json manifest
* Extends Claude Code format with Dexto-specific features
*/
export const DextoPluginManifestSchema = z
.object({
name: z.string().min(1).describe('Unique plugin name (used for namespacing commands)'),
description: z.string().optional().describe('Human-readable plugin description'),
version: z.string().optional().describe('Semantic version (e.g., 1.0.0)'),
author: AuthorSchema.optional().describe('Plugin author - string or {name, email} object'),
// Dexto-specific extensions
customToolProviders: z
.array(z.string())
.optional()
.describe('Custom tool provider types bundled with this plugin (e.g., ["plan-tools"])'),
})
.passthrough()
.describe('Dexto-native plugin manifest from .dexto-plugin/plugin.json');
/**
* Schema for .mcp.json configuration
* Uses passthrough to allow unknown MCP server configurations
*/
export const PluginMCPConfigSchema = z
.object({
mcpServers: z.record(z.unknown()).optional().describe('MCP servers to register'),
})
.passthrough()
.describe('MCP configuration from .mcp.json');
/**
* Type for validated Claude Code plugin manifest
*/
export type ValidatedPluginManifest = z.output<typeof PluginManifestSchema>;
/**
* Type for validated Dexto-native plugin manifest
*/
export type ValidatedDextoPluginManifest = z.output<typeof DextoPluginManifestSchema>;
/**
* Type for validated MCP config
*/
export type ValidatedPluginMCPConfig = z.output<typeof PluginMCPConfigSchema>;
/**
* Schema for individual plugin installation entry in installed_plugins.json
*/
export const InstalledPluginEntrySchema = z
.object({
scope: z.enum(['project', 'user', 'local', 'managed']).describe('Installation scope'),
installPath: z.string().describe('Absolute path to the installed plugin'),
version: z.string().optional().describe('Plugin version'),
installedAt: z.string().optional().describe('ISO timestamp of installation'),
lastUpdated: z.string().optional().describe('ISO timestamp of last update'),
gitCommitSha: z
.string()
.optional()
.describe('Git commit SHA if installed from marketplace'),
projectPath: z.string().optional().describe('Project path for project-scoped plugins'),
isLocal: z.boolean().optional().describe('Whether this is a local plugin'),
})
.passthrough()
.describe('Plugin installation entry');
/**
* Schema for ~/.dexto/plugins/installed_plugins.json
*/
export const InstalledPluginsFileSchema = z
.object({
version: z.number().optional().describe('Schema version'),
plugins: z
.record(z.array(InstalledPluginEntrySchema))
.describe('Map of plugin identifiers to installation entries'),
})
.passthrough()
.describe('Claude Code installed plugins manifest');
/**
* Type for validated installed plugins file
*/
export type ValidatedInstalledPluginsFile = z.output<typeof InstalledPluginsFileSchema>;
/**
* Type for validated installed plugin entry
*/
export type ValidatedInstalledPluginEntry = z.output<typeof InstalledPluginEntrySchema>;

View File

@@ -0,0 +1,197 @@
/**
* Claude Code Plugin Loader Types
*
* Supports loading bundled plugins from community sources (e.g., Vercel skills repo)
* with compatible features. Emits warnings for unsupported features (hooks, LSP).
*
* Plugin Format:
* ```
* my-plugin/
* ├── .claude-plugin/
* │ └── plugin.json # {name, description, version, author?}
* ├── commands/*.md # Commands (→ prompts, user-invocable by default)
* ├── skills/* /SKILL.md # Skills (→ prompts, user-invocable by default)
* ├── hooks/hooks.json # UNSUPPORTED - shell injection
* ├── .mcp.json # MCP servers to merge into config
* └── .lsp.json # UNSUPPORTED - language servers
* ```
*/
/**
* Author can be a string or an object with name/email
*/
export type PluginAuthor = string | { name: string; email?: string | undefined };
/**
* Plugin manifest from .claude-plugin/plugin.json
*/
export interface PluginManifest {
name: string;
description?: string | undefined;
version?: string | undefined;
author?: PluginAuthor | undefined;
}
/**
* Dexto-native plugin manifest from .dexto-plugin/plugin.json
* Extends PluginManifest with Dexto-specific features
*/
export interface DextoPluginManifest extends PluginManifest {
/** Custom tool provider types bundled with this plugin (e.g., ["plan-tools"]) */
customToolProviders?: string[] | undefined;
}
/**
* Plugin format type
*/
export type PluginFormat = 'claude-code' | 'dexto';
/**
* A discovered plugin directory with its manifest
*/
export interface DiscoveredPlugin {
/** Absolute path to plugin directory */
path: string;
/** Parsed and validated plugin manifest */
manifest: PluginManifest | DextoPluginManifest;
/** Source location type */
source: 'project' | 'user';
/** Plugin format (claude-code or dexto) */
format: PluginFormat;
}
/**
* A command or skill discovered within a plugin
*/
export interface PluginCommand {
/** Absolute path to .md file */
file: string;
/** Plugin name for prefixing (namespace) */
namespace: string;
/** true = from skills/ directory, false = from commands/ directory.
* Note: This is metadata only; both are user-invocable by default. */
isSkill: boolean;
}
/**
* MCP configuration from .mcp.json
*/
export interface PluginMCPConfig {
mcpServers?: Record<string, unknown> | undefined;
}
/**
* A fully loaded plugin with all discovered content
*/
export interface LoadedPlugin {
/** Plugin manifest metadata */
manifest: PluginManifest | DextoPluginManifest;
/** Plugin format (claude-code or dexto) */
format: PluginFormat;
/** Discovered commands and skills */
commands: PluginCommand[];
/** MCP servers to merge into agent config */
mcpConfig?: PluginMCPConfig | undefined;
/** Custom tool provider types to register (Dexto-native plugins only) */
customToolProviders: string[];
/** Warnings for unsupported features found */
warnings: string[];
}
/**
* Installation scope for plugins
* - user: Installed to ~/.dexto/plugins/<name>/
* - project: Installed to <cwd>/.dexto/plugins/<name>/
* - local: Registered in-place (no copy)
*/
export type PluginInstallScope = 'user' | 'project' | 'local';
/**
* Entry in installed_plugins.json for Dexto's plugin tracking
*/
export interface InstalledPluginEntry {
/** Installation scope */
scope: PluginInstallScope;
/** Absolute path to the installed plugin */
installPath: string;
/** Plugin version from manifest */
version?: string | undefined;
/** ISO timestamp of installation */
installedAt: string;
/** ISO timestamp of last update */
lastUpdated?: string | undefined;
/** Project path for project-scoped plugins */
projectPath?: string | undefined;
/** Whether this is a local plugin (registered in-place) */
isLocal?: boolean | undefined;
/** Whether this plugin is imported from Claude Code (not copied, just referenced) */
isImported?: boolean | undefined;
}
/**
* Structure of installed_plugins.json
*/
export interface InstalledPluginsFile {
/** Schema version for future compatibility */
version: number;
/** Map of plugin names to installation entries */
plugins: Record<string, InstalledPluginEntry[]>;
}
/**
* Plugin with source tracking for listing
*/
export interface ListedPlugin {
/** Plugin name from manifest */
name: string;
/** Plugin description */
description?: string | undefined;
/** Plugin version */
version?: string | undefined;
/** Absolute path to plugin directory */
path: string;
/** Source of plugin discovery (always 'dexto' now) */
source: 'dexto';
/** Installation scope if installed via Dexto */
scope?: PluginInstallScope | undefined;
/** ISO timestamp of installation */
installedAt?: string | undefined;
}
/**
* Result of plugin validation
*/
export interface PluginValidationResult {
/** Whether the plugin is valid */
valid: boolean;
/** Validated manifest if valid */
manifest?: PluginManifest | undefined;
/** Validation errors */
errors: string[];
/** Validation warnings */
warnings: string[];
}
/**
* Result of plugin installation
*/
export interface PluginInstallResult {
/** Whether installation succeeded */
success: boolean;
/** Plugin name from manifest */
pluginName: string;
/** Path where plugin was installed */
installPath: string;
/** Installation warnings */
warnings: string[];
}
/**
* Result of plugin uninstallation
*/
export interface PluginUninstallResult {
/** Whether uninstallation succeeded */
success: boolean;
/** Path that was removed */
removedPath?: string | undefined;
}

View File

@@ -0,0 +1,173 @@
/**
* Plugin Uninstallation
*
* Uninstalls plugins from Dexto's plugin directory.
* Removes plugin files and updates installed_plugins.json.
*/
import * as path from 'path';
import { existsSync, readFileSync, rmSync } from 'fs';
import { loadDextoInstalledPlugins, saveDextoInstalledPlugins } from './install-plugin.js';
import { PluginError } from './errors.js';
import type { PluginUninstallResult, InstalledPluginEntry } from './types.js';
/**
* Options for plugin uninstallation
*/
export interface UninstallPluginOptions {
/** Project path for filtering project-scoped plugins */
projectPath?: string;
}
/**
* Finds a plugin installation entry by name.
*
* @param pluginName Plugin name to find
* @param projectPath Optional project path for project-scoped filtering
* @returns Installation entry if found, null otherwise
*/
function findPluginInstallation(
pluginName: string,
projectPath?: string
): { entry: InstalledPluginEntry; pluginId: string } | null {
const installed = loadDextoInstalledPlugins();
const normalizedName = pluginName.toLowerCase();
const currentProjectPath = projectPath || process.cwd();
// First check if pluginName is a direct key
if (installed.plugins[pluginName]) {
const installations = installed.plugins[pluginName];
for (const entry of installations) {
// For project-scoped, match project
if ((entry.scope === 'project' || entry.scope === 'local') && entry.projectPath) {
const normalizedInstallProject = path.resolve(entry.projectPath).toLowerCase();
const normalizedCurrentProject = path.resolve(currentProjectPath).toLowerCase();
if (normalizedInstallProject === normalizedCurrentProject) {
return { entry, pluginId: pluginName };
}
continue;
}
// User-scoped - return first match
return { entry, pluginId: pluginName };
}
}
// Search by manifest name
for (const [pluginId, installations] of Object.entries(installed.plugins)) {
for (const entry of installations) {
// Load manifest to check name
const manifestPath = path.join(entry.installPath, '.claude-plugin', 'plugin.json');
if (!existsSync(manifestPath)) continue;
try {
const content = readFileSync(manifestPath, 'utf-8');
const manifest = JSON.parse(content);
if (manifest.name?.toLowerCase() !== normalizedName) continue;
// For project-scoped, match project
if ((entry.scope === 'project' || entry.scope === 'local') && entry.projectPath) {
const normalizedInstallProject = path.resolve(entry.projectPath).toLowerCase();
const normalizedCurrentProject = path.resolve(currentProjectPath).toLowerCase();
if (normalizedInstallProject === normalizedCurrentProject) {
return { entry, pluginId };
}
continue;
}
// User-scoped - return first match
return { entry, pluginId };
} catch {
continue;
}
}
}
return null;
}
/**
* Uninstalls a plugin by name.
* Accepts both "name" and "name@version" formats.
*
* @param pluginName Plugin name to uninstall (with optional @version suffix)
* @param options Uninstallation options
* @returns Uninstallation result with success status
*/
export async function uninstallPlugin(
pluginName: string,
options?: UninstallPluginOptions
): Promise<PluginUninstallResult> {
const { projectPath } = options || {};
// Strip @version suffix if present (user may copy from list output)
// Only strip when the suffix is actually a version (semver pattern), not a namespace
const atIndex = pluginName.lastIndexOf('@');
const SEMVER_SUFFIX = /^(?:v)?\d+\.\d+\.\d+(?:-[\w.-]+)?$/;
const nameWithoutVersion =
atIndex > 0 && SEMVER_SUFFIX.test(pluginName.slice(atIndex + 1))
? pluginName.slice(0, atIndex)
: pluginName;
// Find the plugin installation
const found = findPluginInstallation(nameWithoutVersion, projectPath);
if (!found) {
throw PluginError.uninstallNotFound(nameWithoutVersion);
}
const { entry, pluginId } = found;
// Delete plugin files (unless it's a local plugin)
// Local plugins are just references - we don't own the files
let removedPath: string | undefined;
const shouldDeleteFiles = !entry.isLocal;
if (shouldDeleteFiles) {
try {
rmSync(entry.installPath, { recursive: true, force: true });
removedPath = entry.installPath;
} catch (error) {
throw PluginError.uninstallDeleteFailed(
entry.installPath,
error instanceof Error ? error.message : String(error)
);
}
} else {
// For local plugins, just remove from manifest (don't delete files)
removedPath = entry.installPath;
}
// Update installed_plugins.json
const installed = loadDextoInstalledPlugins();
const currentProjectPath = projectPath || process.cwd();
if (installed.plugins[pluginId]) {
// Remove the specific entry that matches scope and project
installed.plugins[pluginId] = installed.plugins[pluginId].filter((e) => {
// Different install path = different entry
if (e.installPath !== entry.installPath) return true;
// Same install path, same scope = match
if (e.scope === entry.scope) {
// For project/local scope, also check project path
if ((e.scope === 'project' || e.scope === 'local') && e.projectPath) {
const normalizedEntryProject = path.resolve(e.projectPath).toLowerCase();
const normalizedCurrentProject = path.resolve(currentProjectPath).toLowerCase();
return normalizedEntryProject !== normalizedCurrentProject;
}
return false; // Remove user-scoped entry
}
return true;
});
// Remove plugin key if no more installations
if (installed.plugins[pluginId].length === 0) {
delete installed.plugins[pluginId];
}
}
saveDextoInstalledPlugins(installed);
return {
success: true,
removedPath,
};
}

View File

@@ -0,0 +1,251 @@
/**
* Plugin Validation
*
* Validates plugin directory structure and manifest.
* Checks for required files, valid JSON, and schema compliance.
*
* Supports two plugin formats:
* - .claude-plugin/plugin.json: Claude Code compatible format
* - .dexto-plugin/plugin.json: Dexto-native format with extended features (preferred)
*/
import * as path from 'path';
import { existsSync, readFileSync, readdirSync } from 'fs';
import { PluginManifestSchema, DextoPluginManifestSchema } from './schemas.js';
import type {
PluginValidationResult,
PluginManifest,
DextoPluginManifest,
PluginFormat,
} from './types.js';
/**
* Extended validation result with plugin format
*/
export interface ExtendedPluginValidationResult extends PluginValidationResult {
/** Plugin format detected */
format?: PluginFormat;
}
/**
* Validates a plugin directory structure and manifest.
*
* Checks:
* 1. Directory exists
* 2. .dexto-plugin/plugin.json OR .claude-plugin/plugin.json exists (Dexto format preferred)
* 3. plugin.json is valid JSON
* 4. plugin.json matches schema (name is required)
* 5. At least one command or skill exists (warning if none)
*
* @param pluginPath Absolute or relative path to plugin directory
* @returns Validation result with manifest (if valid), errors, and warnings
*/
export function validatePluginDirectory(pluginPath: string): ExtendedPluginValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
let manifest: PluginManifest | DextoPluginManifest | undefined;
let format: PluginFormat | undefined;
// Resolve to absolute path
const absolutePath = path.isAbsolute(pluginPath) ? pluginPath : path.resolve(pluginPath);
// Check directory exists
if (!existsSync(absolutePath)) {
errors.push(`Directory does not exist: ${absolutePath}`);
return { valid: false, errors, warnings };
}
// Check for plugin manifest (prefer .dexto-plugin over .claude-plugin)
const dextoPluginDir = path.join(absolutePath, '.dexto-plugin');
const claudePluginDir = path.join(absolutePath, '.claude-plugin');
let manifestPath: string;
if (existsSync(dextoPluginDir)) {
manifestPath = path.join(dextoPluginDir, 'plugin.json');
format = 'dexto';
} else if (existsSync(claudePluginDir)) {
manifestPath = path.join(claudePluginDir, 'plugin.json');
format = 'claude-code';
} else {
errors.push('Missing .dexto-plugin or .claude-plugin directory');
return { valid: false, errors, warnings };
}
// Check plugin.json exists
if (!existsSync(manifestPath)) {
errors.push(
`Missing ${format === 'dexto' ? '.dexto-plugin' : '.claude-plugin'}/plugin.json`
);
return { valid: false, errors, warnings };
}
// Parse and validate manifest
try {
const content = readFileSync(manifestPath, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch (parseError) {
errors.push(
`Invalid JSON in plugin.json: ${parseError instanceof Error ? parseError.message : String(parseError)}`
);
return { valid: false, errors, warnings };
}
// Validate against appropriate schema
const schema = format === 'dexto' ? DextoPluginManifestSchema : PluginManifestSchema;
const result = schema.safeParse(parsed);
if (!result.success) {
const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`);
errors.push(`Schema validation failed: ${issues.join('; ')}`);
return { valid: false, errors, warnings };
}
manifest = result.data;
} catch (error) {
errors.push(
`Failed to read plugin.json: ${error instanceof Error ? error.message : String(error)}`
);
return { valid: false, errors, warnings };
}
// Check for commands or skills (warning if none)
const hasCommands = checkDirectoryHasFiles(path.join(absolutePath, 'commands'), '.md');
const hasSkills = checkDirectoryHasSkills(path.join(absolutePath, 'skills'));
if (!hasCommands && !hasSkills) {
warnings.push('Plugin has no commands or skills');
}
// Check for unsupported features (warnings only)
if (existsSync(path.join(absolutePath, 'hooks'))) {
warnings.push('hooks/ directory found - hooks are not supported (security risk)');
}
if (existsSync(path.join(absolutePath, '.lsp.json'))) {
warnings.push('.lsp.json found - LSP configuration is not supported');
}
// Check for MCP config
const mcpPath = path.join(absolutePath, '.mcp.json');
if (existsSync(mcpPath)) {
try {
const mcpContent = readFileSync(mcpPath, 'utf-8');
JSON.parse(mcpContent);
} catch {
warnings.push('.mcp.json exists but contains invalid JSON');
}
}
return {
valid: errors.length === 0,
manifest,
format,
errors,
warnings,
};
}
/**
* Checks if a directory has files with a specific extension.
*/
function checkDirectoryHasFiles(dirPath: string, extension: string): boolean {
if (!existsSync(dirPath)) {
return false;
}
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
return entries.some((entry) => entry.isFile() && entry.name.endsWith(extension));
} catch {
return false;
}
}
/**
* Checks if a skills directory has valid SKILL.md files.
* Skills are subdirectories containing SKILL.md.
*/
function checkDirectoryHasSkills(skillsDir: string): boolean {
if (!existsSync(skillsDir)) {
return false;
}
try {
const entries = readdirSync(skillsDir, { withFileTypes: true });
return entries.some((entry) => {
if (!entry.isDirectory()) return false;
const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md');
return existsSync(skillMdPath);
});
} catch {
return false;
}
}
/**
* Result of manifest loading with format information
*/
export interface LoadedManifestResult {
manifest: PluginManifest | DextoPluginManifest;
format: PluginFormat;
}
/**
* Attempts to load and validate a plugin manifest from a directory.
* Returns null if the manifest doesn't exist, is invalid JSON, or fails schema validation.
*
* Checks for .dexto-plugin first (preferred), then falls back to .claude-plugin.
*
* This is a shared utility used by discover-plugins, list-plugins, and import-plugin.
*
* @param pluginPath Absolute path to the plugin directory
* @returns Validated manifest with format or null if not a valid plugin
*/
export function tryLoadManifest(pluginPath: string): PluginManifest | null;
export function tryLoadManifest(
pluginPath: string,
returnFormat: true
): LoadedManifestResult | null;
export function tryLoadManifest(
pluginPath: string,
returnFormat?: boolean
): PluginManifest | LoadedManifestResult | null {
// Check for .dexto-plugin first (preferred), then .claude-plugin
const dextoManifestPath = path.join(pluginPath, '.dexto-plugin', 'plugin.json');
const claudeManifestPath = path.join(pluginPath, '.claude-plugin', 'plugin.json');
let manifestPath: string;
let format: PluginFormat;
if (existsSync(dextoManifestPath)) {
manifestPath = dextoManifestPath;
format = 'dexto';
} else if (existsSync(claudeManifestPath)) {
manifestPath = claudeManifestPath;
format = 'claude-code';
} else {
return null;
}
try {
const content = readFileSync(manifestPath, 'utf-8');
const parsed = JSON.parse(content);
// Use appropriate schema based on format
const schema = format === 'dexto' ? DextoPluginManifestSchema : PluginManifestSchema;
const result = schema.safeParse(parsed);
if (!result.success) {
return null;
}
if (returnFormat) {
return { manifest: result.data, format };
}
return result.data;
} catch {
return null;
}
}

View File

@@ -0,0 +1,3 @@
// packages/core/src/preferences/constants.ts
export const PREFERENCES_FILE = 'preferences.yml';

View File

@@ -0,0 +1,11 @@
// packages/agent-management/src/preferences/error-codes.ts
export enum PreferenceErrorCode {
FILE_NOT_FOUND = 'preference_file_not_found',
FILE_READ_ERROR = 'preference_file_read_error',
FILE_WRITE_ERROR = 'preference_file_write_error',
VALIDATION_ERROR = 'preference_validation_error',
MODEL_INCOMPATIBLE = 'preference_model_incompatible',
INVALID_PREFERENCE_VALUE = 'preference_invalid_value',
MISSING_PREFERENCE = 'preference_missing_required',
}

View File

@@ -0,0 +1,54 @@
// packages/core/src/preferences/errors.ts
import { DextoRuntimeError, DextoValidationError, ErrorType } from '@dexto/core';
import { type ZodError } from 'zod';
import { PreferenceErrorCode } from './error-codes.js';
export { PreferenceErrorCode } from './error-codes.js';
export class PreferenceError {
static fileNotFound(preferencesPath: string) {
return new DextoRuntimeError(
PreferenceErrorCode.FILE_NOT_FOUND,
'preference',
ErrorType.USER,
`Preferences file not found: ${preferencesPath}`,
{ preferencesPath },
'Run `dexto setup` to create preferences'
);
}
static fileReadError(preferencesPath: string, cause: string) {
return new DextoRuntimeError(
PreferenceErrorCode.FILE_READ_ERROR,
'preference',
ErrorType.SYSTEM,
`Failed to read preferences: ${cause}`,
{ preferencesPath, cause },
'Check file permissions and ensure the file is not corrupted'
);
}
static fileWriteError(preferencesPath: string, cause: string) {
return new DextoRuntimeError(
PreferenceErrorCode.FILE_WRITE_ERROR,
'preference',
ErrorType.SYSTEM,
`Failed to save preferences: ${cause}`,
{ preferencesPath, cause },
'Check file permissions and available disk space'
);
}
static validationFailed(zodError: ZodError) {
const issues = zodError.issues.map((issue) => ({
code: PreferenceErrorCode.VALIDATION_ERROR,
message: `${issue.path.join('.')}: ${issue.message}`,
scope: 'preference',
type: ErrorType.USER,
severity: 'error' as const,
}));
return new DextoValidationError(issues);
}
}

View File

@@ -0,0 +1,31 @@
// packages/core/src/preferences/index.ts
export type {
GlobalPreferences,
PreferenceLLM,
PreferenceDefaults,
PreferenceSetup,
PreferenceSounds,
} from './schemas.js';
export {
GlobalPreferencesSchema,
PreferenceLLMSchema,
PreferenceDefaultsSchema,
PreferenceSetupSchema,
PreferenceSoundsSchema,
} from './schemas.js';
export { PREFERENCES_FILE } from './constants.js';
export {
loadGlobalPreferences,
saveGlobalPreferences,
globalPreferencesExist,
getGlobalPreferencesPath,
createInitialPreferences,
updateGlobalPreferences,
type CreatePreferencesOptions,
} from './loader.js';
export { PreferenceError, PreferenceErrorCode } from './errors.js';

View File

@@ -0,0 +1,518 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import {
loadGlobalPreferences,
saveGlobalPreferences,
globalPreferencesExist,
getGlobalPreferencesPath,
createInitialPreferences,
updateGlobalPreferences,
} from './loader.js';
import { type GlobalPreferences } from './schemas.js';
import { PreferenceErrorCode } from './error-codes.js';
import { ErrorType } from '@dexto/core';
// Mock getDextoGlobalPath to use a temporary directory
import * as pathUtils from '../utils/path.js';
import { vi } from 'vitest';
describe('Preferences Loader', () => {
let tempDir: string;
let mockPreferencesPath: string;
let samplePreferences: GlobalPreferences;
beforeEach(async () => {
// Create temporary directory for each test
tempDir = await fs.mkdtemp(path.join(tmpdir(), 'dexto-preferences-test-'));
mockPreferencesPath = path.join(tempDir, 'preferences.yml');
// Mock getDextoGlobalPath to return our test path
vi.spyOn(pathUtils, 'getDextoGlobalPath').mockImplementation(
(type: string, filename?: string) => {
if (type === 'preferences.yml' || filename === 'preferences.yml') {
return mockPreferencesPath;
}
if (filename) {
return path.join(tempDir, filename);
}
// For nested directory test, return proper structure
if (type.includes('nested')) {
return type;
}
return tempDir;
}
);
// Sample preferences object
samplePreferences = {
llm: {
provider: 'anthropic',
model: 'claude-4-sonnet-20250514',
apiKey: '$ANTHROPIC_API_KEY',
},
defaults: {
defaultAgent: 'test-agent',
defaultMode: 'web',
},
setup: {
completed: true,
apiKeyPending: false,
baseURLPending: false,
},
sounds: {
enabled: true,
onApprovalRequired: true,
onTaskComplete: true,
},
};
});
afterEach(async () => {
vi.restoreAllMocks();
// Clean up temporary directory
await fs.rm(tempDir, { recursive: true, force: true });
});
describe('globalPreferencesExist', () => {
it('should return false when preferences file does not exist', () => {
expect(globalPreferencesExist()).toBe(false);
});
it('should return true when preferences file exists', async () => {
await saveGlobalPreferences(samplePreferences);
expect(globalPreferencesExist()).toBe(true);
});
});
describe('getGlobalPreferencesPath', () => {
it('should return the correct preferences file path', () => {
const preferencesPath = getGlobalPreferencesPath();
expect(preferencesPath).toBe(mockPreferencesPath);
});
});
describe('saveGlobalPreferences', () => {
it('should save preferences to YAML file', async () => {
await saveGlobalPreferences(samplePreferences);
// Verify file was created
expect(
await fs.access(mockPreferencesPath).then(
() => true,
() => false
)
).toBe(true);
// Verify content is valid YAML with correct values
const fileContent = await fs.readFile(mockPreferencesPath, 'utf-8');
expect(fileContent).toContain('provider: anthropic');
expect(fileContent).toContain('model: claude-4-sonnet-20250514');
expect(fileContent).toContain('apiKey: $ANTHROPIC_API_KEY');
expect(fileContent).toContain('defaultAgent: test-agent');
expect(fileContent).toContain('completed: true');
});
it('should create directory structure if it does not exist', async () => {
// Use a nested path that doesn't exist
const nestedDir = path.join(tempDir, 'nested', 'deep');
const nestedPreferencesPath = path.join(nestedDir, 'preferences.yml');
// Restore the original mock and create new one for this test
vi.restoreAllMocks();
vi.spyOn(pathUtils, 'getDextoGlobalPath').mockImplementation(
(type: string, filename?: string) => {
if (type === 'preferences.yml' || filename === 'preferences.yml') {
return nestedPreferencesPath;
}
return nestedDir;
}
);
await saveGlobalPreferences(samplePreferences);
// Directory should be created
expect(
await fs.access(nestedPreferencesPath).then(
() => true,
() => false
)
).toBe(true);
});
it('should format YAML with proper indentation and line width', async () => {
const preferencesWithLongValues = {
...samplePreferences,
llm: {
provider: 'openai' as const,
model: 'gpt-4o-audio-preview', // Valid long model name
apiKey: '$OPENAI_API_KEY',
},
};
await saveGlobalPreferences(preferencesWithLongValues);
const fileContent = await fs.readFile(mockPreferencesPath, 'utf-8');
// Should be properly formatted
expect(fileContent).toMatch(/^llm:/m);
expect(fileContent).toMatch(/^ {2}provider:/m);
expect(fileContent).toMatch(/^defaults:/m);
});
it('should throw validation error for invalid preferences', async () => {
const invalidPreferences = {
llm: {
provider: 'invalid-provider', // Invalid provider
model: 'some-model',
apiKey: '$API_KEY',
},
defaults: {
defaultAgent: 'test-agent', // Required field
},
setup: {
completed: true, // Required field
},
} as any;
await expect(saveGlobalPreferences(invalidPreferences)).rejects.toThrow(
expect.objectContaining({
issues: expect.arrayContaining([
expect.objectContaining({
code: PreferenceErrorCode.VALIDATION_ERROR,
scope: 'preference',
type: ErrorType.USER,
}),
]),
})
);
});
});
describe('loadGlobalPreferences', () => {
beforeEach(async () => {
// Create preferences file for loading tests
await saveGlobalPreferences(samplePreferences);
});
it('should load preferences from YAML file', async () => {
const loadedPreferences = await loadGlobalPreferences();
expect(loadedPreferences).toEqual(samplePreferences);
});
it('should validate loaded preferences against schema', async () => {
const loadedPreferences = await loadGlobalPreferences();
// Should have all required fields
expect(loadedPreferences.llm.provider).toBeDefined();
expect(loadedPreferences.llm.model).toBeDefined();
expect(loadedPreferences.llm.apiKey).toBeDefined();
expect(loadedPreferences.defaults.defaultAgent).toBeDefined();
expect(loadedPreferences.setup.completed).toBeDefined();
});
it('should throw file not found error when preferences file does not exist', async () => {
// Remove the preferences file
await fs.unlink(mockPreferencesPath);
await expect(loadGlobalPreferences()).rejects.toThrow(
expect.objectContaining({
code: PreferenceErrorCode.FILE_NOT_FOUND,
scope: 'preference',
type: ErrorType.USER,
})
);
});
it('should throw validation error for invalid YAML content', async () => {
// Write invalid YAML
await fs.writeFile(mockPreferencesPath, 'invalid: yaml: [}', 'utf-8');
await expect(loadGlobalPreferences()).rejects.toThrow(
expect.objectContaining({
code: PreferenceErrorCode.FILE_READ_ERROR,
scope: 'preference',
type: ErrorType.SYSTEM,
})
);
});
it('should throw validation error for preferences with missing required fields', async () => {
const incompletePreferences = {
llm: {
provider: 'openai',
// Missing model and apiKey
},
// Missing defaults and setup sections
};
await fs.writeFile(mockPreferencesPath, JSON.stringify(incompletePreferences), 'utf-8');
await expect(loadGlobalPreferences()).rejects.toThrow(
expect.objectContaining({
issues: expect.arrayContaining([
expect.objectContaining({
code: PreferenceErrorCode.VALIDATION_ERROR,
scope: 'preference',
type: ErrorType.USER,
}),
]),
})
);
});
it('should throw validation error for preferences with invalid provider', async () => {
const yamlContent = `llm:
provider: invalid-provider
model: some-model
apiKey: $API_KEY
defaults:
defaultAgent: test-agent
setup:
completed: true`;
await fs.writeFile(mockPreferencesPath, yamlContent, 'utf-8');
await expect(loadGlobalPreferences()).rejects.toThrow(
expect.objectContaining({
issues: expect.arrayContaining([
expect.objectContaining({
code: PreferenceErrorCode.VALIDATION_ERROR,
scope: 'preference',
type: ErrorType.USER,
}),
]),
})
);
});
});
describe('createInitialPreferences', () => {
it('should create preferences with provided values', () => {
const preferences = createInitialPreferences({
provider: 'openai',
model: 'gpt-5',
apiKeyVar: 'OPENAI_API_KEY',
defaultAgent: 'my-agent',
});
expect(preferences).toEqual({
llm: {
provider: 'openai',
model: 'gpt-5',
apiKey: '$OPENAI_API_KEY',
},
defaults: {
defaultAgent: 'my-agent',
defaultMode: 'web',
},
setup: {
completed: true,
apiKeyPending: false,
baseURLPending: false,
},
sounds: {
enabled: true,
onApprovalRequired: true,
onTaskComplete: true,
},
});
});
it('should use default agent name when not provided', () => {
const preferences = createInitialPreferences({
provider: 'anthropic',
model: 'claude-4-sonnet-20250514',
apiKeyVar: 'ANTHROPIC_API_KEY',
});
expect(preferences.defaults.defaultAgent).toBe('coding-agent');
});
it('should format API key with $ prefix', () => {
const preferences = createInitialPreferences({
provider: 'google',
model: 'gemini-pro',
apiKeyVar: 'GOOGLE_API_KEY',
});
expect(preferences.llm.apiKey).toBe('$GOOGLE_API_KEY');
});
it('should populate sounds with defaults', () => {
const preferences = createInitialPreferences({
provider: 'google',
model: 'gemini-pro',
});
expect(preferences.sounds).toEqual({
enabled: true,
onApprovalRequired: true,
onTaskComplete: true,
});
});
it('should allow custom sounds configuration', () => {
const preferences = createInitialPreferences({
provider: 'google',
model: 'gemini-pro',
sounds: {
enabled: true,
onApprovalRequired: false,
},
});
expect(preferences.sounds).toEqual({
enabled: true,
onApprovalRequired: false,
onTaskComplete: true,
});
});
});
describe('updateGlobalPreferences', () => {
beforeEach(async () => {
// Create initial preferences file
await saveGlobalPreferences(samplePreferences);
});
it('should replace complete sections while preserving others', async () => {
const updates: Partial<GlobalPreferences> = {
llm: {
provider: 'openai',
model: 'gpt-5',
apiKey: '$OPENAI_API_KEY',
},
};
const updatedPreferences = await updateGlobalPreferences(updates);
// LLM section should be completely replaced
expect(updatedPreferences.llm.provider).toBe('openai');
expect(updatedPreferences.llm.model).toBe('gpt-5');
expect(updatedPreferences.llm.apiKey).toBe('$OPENAI_API_KEY');
// Other sections should remain unchanged
expect(updatedPreferences.defaults.defaultAgent).toBe('test-agent'); // Preserved from original
expect(updatedPreferences.setup.completed).toBe(true); // Preserved from original
});
it('should allow partial updates for defaults section', async () => {
// Update only the defaults section
const updates = {
defaults: {
defaultAgent: 'new-default-agent',
},
};
const updatedPreferences = await updateGlobalPreferences(updates);
// Defaults section should be updated
expect(updatedPreferences.defaults.defaultAgent).toBe('new-default-agent');
// Other sections should remain unchanged
expect(updatedPreferences.llm.provider).toBe('anthropic'); // Preserved
expect(updatedPreferences.setup.completed).toBe(true); // Preserved
});
it('should allow partial updates for setup section', async () => {
// Update only the setup section
const updates = {
setup: {
completed: false,
},
};
const updatedPreferences = await updateGlobalPreferences(updates);
// Setup section should be updated
expect(updatedPreferences.setup.completed).toBe(false);
// Other sections should remain unchanged
expect(updatedPreferences.llm.provider).toBe('anthropic'); // Preserved
expect(updatedPreferences.defaults.defaultAgent).toBe('test-agent'); // Preserved
});
it('should save updated preferences to file', async () => {
const updates = {
llm: {
provider: 'anthropic' as const,
model: 'claude-4-opus-20250514',
apiKey: '$ANTHROPIC_API_KEY',
},
};
await updateGlobalPreferences(updates);
// Verify file was updated
const fileContent = await fs.readFile(mockPreferencesPath, 'utf-8');
expect(fileContent).toContain('model: claude-4-opus-20250514');
});
it('should throw validation error for invalid merged preferences', async () => {
const invalidUpdates = {
llm: {
provider: 'invalid-provider' as any,
model: 'some-model',
apiKey: '$API_KEY',
},
};
await expect(updateGlobalPreferences(invalidUpdates)).rejects.toThrow(
expect.objectContaining({
issues: expect.arrayContaining([
expect.objectContaining({
code: PreferenceErrorCode.VALIDATION_ERROR,
scope: 'preference',
type: ErrorType.USER,
}),
]),
})
);
});
it('should handle multiple nested updates', async () => {
const updates = {
llm: {
provider: 'google' as const,
model: 'gemini-2.0-flash',
apiKey: '$GOOGLE_API_KEY',
},
defaults: {
defaultAgent: 'updated-agent',
},
setup: {
completed: false,
},
};
const updatedPreferences = await updateGlobalPreferences(updates);
expect(updatedPreferences.llm.provider).toBe('google');
expect(updatedPreferences.llm.model).toBe('gemini-2.0-flash');
expect(updatedPreferences.defaults.defaultAgent).toBe('updated-agent');
expect(updatedPreferences.setup.completed).toBe(false);
});
it('should return the updated preferences object', async () => {
const updates = {
llm: {
provider: 'groq' as const,
model: 'llama-3.3-70b-versatile', // Valid groq model
apiKey: '$GROQ_API_KEY',
},
};
const result = await updateGlobalPreferences(updates);
expect(result.llm.provider).toBe('groq');
expect(result.llm.model).toBe('llama-3.3-70b-versatile');
expect(result).toMatchObject({
llm: expect.objectContaining({
provider: 'groq',
}),
defaults: expect.any(Object),
setup: expect.any(Object),
});
});
});
});

View File

@@ -0,0 +1,243 @@
// packages/core/src/preferences/loader.ts
import { existsSync } from 'fs';
import { promises as fs } from 'fs';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { getDextoGlobalPath } from '../utils/path.js';
import { logger } from '@dexto/core';
import { DextoValidationError, DextoRuntimeError } from '@dexto/core';
import type { LLMProvider } from '@dexto/core';
import { GlobalPreferencesSchema, type GlobalPreferences } from './schemas.js';
import { PREFERENCES_FILE } from './constants.js';
import { PreferenceError } from './errors.js';
/**
* Load global preferences from ~/.dexto/preferences.yml
* @returns Global preferences object
* @throws DextoRuntimeError if file not found or corrupted
* @throws DextoValidationError if preferences are invalid
*/
export async function loadGlobalPreferences(): Promise<GlobalPreferences> {
const preferencesPath = getDextoGlobalPath(PREFERENCES_FILE);
// Check if preferences file exists
if (!existsSync(preferencesPath)) {
throw PreferenceError.fileNotFound(preferencesPath);
}
try {
// Read and parse YAML
const fileContent = await fs.readFile(preferencesPath, 'utf-8');
const rawPreferences = parseYaml(fileContent);
// Validate with schema
const validation = GlobalPreferencesSchema.safeParse(rawPreferences);
if (!validation.success) {
throw PreferenceError.validationFailed(validation.error);
}
logger.debug(`Loaded global preferences from: ${preferencesPath}`);
return validation.data;
} catch (error) {
if (error instanceof DextoValidationError || error instanceof DextoRuntimeError) {
throw error; // Re-throw our own errors
}
throw PreferenceError.fileReadError(
preferencesPath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Header comment for preferences.yml file
*/
const PREFERENCES_FILE_HEADER = `# Dexto Global Preferences
# Documentation: https://dexto.dev/docs/configuration/preferences
#
# Sound Notifications:
# Dexto plays sounds for approval requests and task completion.
# To customize sounds, place audio files in ~/.dexto/sounds/:
# - approval.wav (or .mp3, .ogg, .aiff, .m4a) - played when tool approval is needed
# - complete.wav (or .mp3, .ogg, .aiff, .m4a) - played when agent finishes a task
# Set sounds.enabled: false to disable all sounds.
`;
/**
* Save global preferences to ~/.dexto/preferences.yml
* @param preferences Validated preferences object
* @throws DextoRuntimeError if write fails
*/
export async function saveGlobalPreferences(preferences: GlobalPreferences): Promise<void> {
const preferencesPath = getDextoGlobalPath(PREFERENCES_FILE);
// Validate preferences against schema before saving
const validation = GlobalPreferencesSchema.safeParse(preferences);
if (!validation.success) {
throw PreferenceError.validationFailed(validation.error);
}
try {
logger.debug(`Saving global preferences to: ${preferencesPath}`);
// Ensure ~/.dexto directory exists
const dextoDir = getDextoGlobalPath('');
await fs.mkdir(dextoDir, { recursive: true });
// Convert to YAML with nice formatting
const yamlContent = stringifyYaml(preferences, {
indent: 2,
lineWidth: 100,
minContentWidth: 20,
});
// Write to file with header comment
await fs.writeFile(preferencesPath, PREFERENCES_FILE_HEADER + yamlContent, 'utf-8');
logger.debug(
`✓ Saved global preferences ${JSON.stringify(preferences)} to: ${preferencesPath}`
);
} catch (error) {
throw PreferenceError.fileWriteError(
preferencesPath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Check if global preferences exist (for first-time detection)
* @returns true if preferences.yml exists
*/
export function globalPreferencesExist(): boolean {
const preferencesPath = getDextoGlobalPath(PREFERENCES_FILE);
return existsSync(preferencesPath);
}
/**
* Get global preferences file path
* @returns Absolute path to preferences.yml
*/
export function getGlobalPreferencesPath(): string {
return getDextoGlobalPath(PREFERENCES_FILE);
}
/**
* Options for creating initial preferences
*/
export interface CreatePreferencesOptions {
provider: LLMProvider;
model: string;
/** API key env var (optional for providers like Ollama that don't need auth) */
apiKeyVar?: string;
defaultAgent?: string;
defaultMode?: 'cli' | 'web' | 'server' | 'discord' | 'telegram' | 'mcp';
baseURL?: string;
/** Reasoning effort for OpenAI reasoning models (o1, o3, codex, gpt-5.x) */
reasoningEffort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
setupCompleted?: boolean;
/** Whether API key setup was skipped and needs to be configured later */
apiKeyPending?: boolean;
/** Whether baseURL setup was skipped and needs to be configured later */
baseURLPending?: boolean;
/** Sound notification preferences */
sounds?: {
enabled?: boolean;
onApprovalRequired?: boolean;
onTaskComplete?: boolean;
};
}
/**
* Create initial preferences from setup data
* @param options Configuration options for preferences
*/
export function createInitialPreferences(options: CreatePreferencesOptions): GlobalPreferences {
const llmConfig: GlobalPreferences['llm'] = {
provider: options.provider,
model: options.model,
};
// Only add apiKey if provided (optional for local providers like Ollama)
if (options.apiKeyVar) {
llmConfig.apiKey = '$' + options.apiKeyVar;
}
// Only add baseURL if provided
if (options.baseURL) {
llmConfig.baseURL = options.baseURL;
}
// Only add reasoningEffort if provided
if (options.reasoningEffort) {
llmConfig.reasoningEffort = options.reasoningEffort;
}
return {
llm: llmConfig,
defaults: {
defaultAgent: options.defaultAgent || 'coding-agent',
defaultMode: options.defaultMode || 'web',
},
setup: {
completed: options.setupCompleted ?? true,
apiKeyPending: options.apiKeyPending ?? false,
baseURLPending: options.baseURLPending ?? false,
},
sounds: {
enabled: options.sounds?.enabled ?? true,
onApprovalRequired: options.sounds?.onApprovalRequired ?? true,
onTaskComplete: options.sounds?.onTaskComplete ?? true,
},
};
}
/**
* Updates type that allows partial nested objects
*/
export type GlobalPreferencesUpdates = {
llm?: GlobalPreferences['llm'];
defaults?: Partial<GlobalPreferences['defaults']>;
setup?: Partial<GlobalPreferences['setup']>;
sounds?: Partial<GlobalPreferences['sounds']>;
};
/**
* Update specific preference sections
* @param updates Partial preference updates
* @returns Updated preferences object
* @throws DextoRuntimeError if load/save fails
* @throws DextoValidationError if merged preferences are invalid
*/
export async function updateGlobalPreferences(
updates: GlobalPreferencesUpdates
): Promise<GlobalPreferences> {
// Load existing preferences
const existing = await loadGlobalPreferences();
// Hybrid merge strategy: different sections have different coherence requirements
const merged = {
...existing,
...updates,
// LLM section requires complete replacement (high coherence - provider/model/apiKey must match)
llm: updates.llm || existing.llm,
// Defaults, setup, and sounds sections allow partial updates (low coherence - independent fields)
defaults: updates.defaults
? { ...existing.defaults, ...updates.defaults }
: existing.defaults,
setup: updates.setup ? { ...existing.setup, ...updates.setup } : existing.setup,
sounds: updates.sounds ? { ...existing.sounds, ...updates.sounds } : existing.sounds,
};
// Validate merged result
const validation = GlobalPreferencesSchema.safeParse(merged);
if (!validation.success) {
throw PreferenceError.validationFailed(validation.error);
}
// Save updated preferences
await saveGlobalPreferences(validation.data);
return validation.data;
}

View File

@@ -0,0 +1,153 @@
// packages/agent-management/src/preferences/schemas.ts
import { z } from 'zod';
import {
isValidProviderModel,
getSupportedModels,
acceptsAnyModel,
supportsCustomModels,
supportsBaseURL,
} from '@dexto/core';
import { LLM_PROVIDERS } from '@dexto/core';
import { NonEmptyTrimmed } from '@dexto/core';
import { PreferenceErrorCode } from './error-codes.js';
import { ErrorType } from '@dexto/core';
export const PreferenceLLMSchema = z
.object({
provider: z.enum(LLM_PROVIDERS).describe('LLM provider (openai, anthropic, google, etc.)'),
model: NonEmptyTrimmed.describe('Model name for the provider'),
apiKey: z
.string()
.regex(
/^\$[A-Z_][A-Z0-9_]*$/,
'Must be environment variable reference (e.g., $OPENAI_API_KEY)'
)
.optional()
.describe(
'Environment variable reference for API key (optional for local providers like Ollama)'
),
baseURL: z
.string()
.url('Must be a valid URL (e.g., http://localhost:11434/v1)')
.optional()
.describe('Custom base URL for providers that support it (openai-compatible, litellm)'),
reasoningEffort: z
.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh'])
.optional()
.describe(
'Reasoning effort level for OpenAI reasoning models (o1, o3, codex, gpt-5.x). Auto-detected if not set.'
),
})
.strict()
.superRefine((data, ctx) => {
// NOTE: API key validation is intentionally NOT done here to allow saving
// incomplete preferences. Users should be able to skip API key setup and
// configure it later. The apiKeyPending flag in setup tracks this state.
// Runtime validation happens when actually trying to use the LLM.
// Skip model validation for providers that accept any model or support custom models
const skipModelValidation =
acceptsAnyModel(data.provider) || supportsCustomModels(data.provider);
if (!skipModelValidation && !isValidProviderModel(data.provider, data.model)) {
const supportedModels = getSupportedModels(data.provider);
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['model'],
message: `Model '${data.model}' is not supported by provider '${data.provider}'. Supported models: ${supportedModels.join(', ')}`,
params: {
code: PreferenceErrorCode.MODEL_INCOMPATIBLE,
scope: 'preference',
type: ErrorType.USER,
},
});
}
// Validate baseURL format if provided (but don't require it - allow incomplete setup)
if (data.baseURL && !supportsBaseURL(data.provider)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['baseURL'],
message: `Provider '${data.provider}' does not support custom baseURL. Use 'openai-compatible' for custom endpoints.`,
params: {
code: PreferenceErrorCode.INVALID_PREFERENCE_VALUE,
scope: 'preference',
type: ErrorType.USER,
},
});
}
// NOTE: baseURL requirement validation also relaxed - allow saving without baseURL
// and let runtime validation catch missing baseURL when actually trying to connect.
});
export const PreferenceDefaultsSchema = z
.object({
defaultAgent: z
.string()
.min(1)
.describe('Default agent name for global CLI usage (required)'),
defaultMode: z
.enum(['cli', 'web', 'server', 'discord', 'telegram', 'mcp'])
.default('web')
.describe('Default run mode when --mode flag is not specified (default: web)'),
})
.strict();
export const PreferenceSetupSchema = z
.object({
completed: z.boolean().default(false).describe('Whether initial setup has been completed'),
apiKeyPending: z
.boolean()
.default(false)
.describe('Whether API key setup was skipped and needs to be configured later'),
baseURLPending: z
.boolean()
.default(false)
.describe('Whether baseURL setup was skipped and needs to be configured later'),
})
.strict();
export const PreferenceSoundsSchema = z
.object({
enabled: z.boolean().default(true).describe('Enable sound notifications (default: true)'),
onApprovalRequired: z
.boolean()
.default(true)
.describe(
'Play sound when tool approval is required (default: true when sounds enabled)'
),
onTaskComplete: z
.boolean()
.default(true)
.describe('Play sound when agent task completes (default: true when sounds enabled)'),
})
.strict();
export const GlobalPreferencesSchema = z
.object({
llm: PreferenceLLMSchema.describe('LLM configuration preferences'),
defaults: PreferenceDefaultsSchema.describe('Default behavior preferences (required)'),
setup: PreferenceSetupSchema.default({ completed: false }).describe(
'Setup completion tracking'
),
sounds: PreferenceSoundsSchema.default({}).describe(
'Sound notification preferences (defaults applied for legacy preferences)'
),
})
.strict();
// Output types
export type PreferenceLLM = z.output<typeof PreferenceLLMSchema>;
export type PreferenceDefaults = z.output<typeof PreferenceDefaultsSchema>;
export type PreferenceSetup = z.output<typeof PreferenceSetupSchema>;
export type PreferenceSounds = z.output<typeof PreferenceSoundsSchema>;
export type GlobalPreferences = z.output<typeof GlobalPreferencesSchema>;

View File

@@ -0,0 +1,31 @@
/**
* Registry-specific error codes
* Includes agent resolution, installation, and registry management errors
*/
export enum RegistryErrorCode {
// Agent lookup errors
AGENT_NOT_FOUND = 'registry_agent_not_found',
AGENT_INVALID_ENTRY = 'registry_agent_invalid_entry',
AGENT_ALREADY_EXISTS = 'registry_agent_already_exists',
// Installation errors
INSTALLATION_FAILED = 'registry_installation_failed',
INSTALLATION_VALIDATION_FAILED = 'registry_installation_validation_failed',
// Registry file errors
REGISTRY_NOT_FOUND = 'registry_file_not_found',
REGISTRY_PARSE_ERROR = 'registry_parse_error',
REGISTRY_WRITE_ERROR = 'registry_write_error',
// Config file errors
CONFIG_NOT_FOUND = 'registry_config_not_found',
MAIN_CONFIG_MISSING = 'registry_main_config_missing',
// Uninstallation errors
AGENT_NOT_INSTALLED = 'registry_agent_not_installed',
AGENT_PROTECTED = 'registry_agent_protected',
UNINSTALLATION_FAILED = 'registry_uninstallation_failed',
// Auto-install control
AGENT_NOT_INSTALLED_AUTO_INSTALL_DISABLED = 'registry_agent_not_installed_auto_install_disabled',
}

View File

@@ -0,0 +1,179 @@
import { DextoRuntimeError, ErrorType } from '@dexto/core';
import { RegistryErrorCode } from './error-codes.js';
/**
* Registry runtime error factory methods
* Creates properly typed errors for registry operations
*/
export class RegistryError {
// Agent lookup errors
static agentNotFound(agentId: string, availableAgents: string[]) {
return new DextoRuntimeError(
RegistryErrorCode.AGENT_NOT_FOUND,
'agent_registry',
ErrorType.USER,
`Agent '${agentId}' not found in registry`,
{ agentId, availableAgents },
`Available agents: ${availableAgents.join(', ')}. Use a file path for custom agents.`
);
}
static agentInvalidEntry(agentId: string, reason: string) {
return new DextoRuntimeError(
RegistryErrorCode.AGENT_INVALID_ENTRY,
'agent_registry',
ErrorType.SYSTEM,
`Registry entry for '${agentId}' is invalid: ${reason}`,
{ agentId, reason },
'This indicates a problem with the agent registry - please report this issue'
);
}
static agentAlreadyExists(agentId: string) {
return new DextoRuntimeError(
RegistryErrorCode.AGENT_ALREADY_EXISTS,
'agent_registry',
ErrorType.USER,
`Agent '${agentId}' already exists in user registry`,
{ agentId },
'Choose a different name or uninstall the existing agent first'
);
}
static customAgentNameConflict(agentId: string) {
return new DextoRuntimeError(
RegistryErrorCode.AGENT_ALREADY_EXISTS,
'agent_registry',
ErrorType.USER,
`Cannot create custom agent '${agentId}': name conflicts with builtin agent`,
{ agentId, conflictType: 'builtin' },
'Choose a different name for your custom agent'
);
}
// Installation errors
static installationFailed(agentId: string, cause: string) {
return new DextoRuntimeError(
RegistryErrorCode.INSTALLATION_FAILED,
'agent_registry',
ErrorType.SYSTEM,
`Failed to install agent '${agentId}': ${cause}`,
{ agentId, cause },
'Check network connection and available disk space'
);
}
static installationValidationFailed(agentId: string, missingPath: string) {
return new DextoRuntimeError(
RegistryErrorCode.INSTALLATION_VALIDATION_FAILED,
'agent_registry',
ErrorType.SYSTEM,
`Installation validation failed for '${agentId}': missing main config`,
{ agentId, missingPath },
'This indicates a problem with the agent bundle - please report this issue'
);
}
// Config file errors
static configNotFound(configPath: string) {
return new DextoRuntimeError(
RegistryErrorCode.CONFIG_NOT_FOUND,
'agent_registry',
ErrorType.SYSTEM,
`Agent config file not found: ${configPath}`,
{ configPath },
'This indicates a problem with the agent installation'
);
}
static mainConfigMissing(agentId: string, expectedPath: string) {
return new DextoRuntimeError(
RegistryErrorCode.MAIN_CONFIG_MISSING,
'agent_registry',
ErrorType.SYSTEM,
`Main config file not found for agent '${agentId}': ${expectedPath}`,
{ agentId, expectedPath },
'This indicates a problem with the agent bundle structure'
);
}
// Uninstallation errors
static agentNotInstalled(agentId: string) {
return new DextoRuntimeError(
RegistryErrorCode.AGENT_NOT_INSTALLED,
'agent_registry',
ErrorType.USER,
`Agent '${agentId}' is not installed`,
{ agentId },
'Use "dexto list-agents --installed" to see installed agents'
);
}
static agentProtected(agentId: string) {
return new DextoRuntimeError(
RegistryErrorCode.AGENT_PROTECTED,
'agent_registry',
ErrorType.USER,
`Agent '${agentId}' is protected and cannot be uninstalled. Use --force to override (not recommended for critical agents)`,
{ agentId },
'Use --force to override (not recommended for critical agents)'
);
}
static uninstallationFailed(agentId: string, cause: string) {
return new DextoRuntimeError(
RegistryErrorCode.UNINSTALLATION_FAILED,
'agent_registry',
ErrorType.SYSTEM,
`Failed to uninstall agent '${agentId}': ${cause}`,
{ agentId, cause },
'Check file permissions and ensure no processes are using the agent'
);
}
// Registry file errors
static registryNotFound(registryPath: string, cause: string) {
return new DextoRuntimeError(
RegistryErrorCode.REGISTRY_NOT_FOUND,
'agent_registry',
ErrorType.SYSTEM,
`Agent registry not found: ${registryPath}: ${cause}`,
{ registryPath },
'This indicates a problem with the Dexto installation - please reinstall or report this issue'
);
}
static registryParseError(registryPath: string, cause: string) {
return new DextoRuntimeError(
RegistryErrorCode.REGISTRY_PARSE_ERROR,
'agent_registry',
ErrorType.SYSTEM,
`Failed to parse agent registry from ${registryPath}: ${cause}`,
{ registryPath, cause },
'This indicates a corrupted registry file - please reinstall Dexto'
);
}
static registryWriteError(registryPath: string, cause: string) {
return new DextoRuntimeError(
RegistryErrorCode.REGISTRY_WRITE_ERROR,
'agent_registry',
ErrorType.SYSTEM,
`Failed to save agent registry to ${registryPath}: ${cause}`,
{ registryPath, cause },
'Check file permissions and available disk space'
);
}
// Auto-install control errors
static agentNotInstalledAutoInstallDisabled(agentId: string, availableAgents: string[]) {
return new DextoRuntimeError(
RegistryErrorCode.AGENT_NOT_INSTALLED_AUTO_INSTALL_DISABLED,
'agent_registry',
ErrorType.USER,
`Agent '${agentId}' is not installed locally and auto-install is disabled`,
{ agentId, availableAgents },
`Use 'dexto install ${agentId}' to install it manually, or use a file path for custom agents`
);
}
}

View File

@@ -0,0 +1,352 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { LocalAgentRegistry } from './registry.js';
import type { Registry } from './types.js';
vi.mock('../utils/path.js');
vi.mock('@dexto/core', async () => {
const actual = await vi.importActual<typeof import('@dexto/core')>('@dexto/core');
return {
...actual,
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
});
vi.mock('../preferences/loader.js', () => ({
loadGlobalPreferences: vi.fn().mockResolvedValue({
defaults: { defaultAgent: 'default-agent' },
}),
}));
vi.mock('../writer.js', () => ({
writePreferencesToAgent: vi.fn().mockResolvedValue(undefined),
}));
describe('LocalAgentRegistry - Integration Tests', () => {
let tempDir: string;
let mockGetDextoGlobalPath: any;
let mockResolveBundledScript: any;
let registry: LocalAgentRegistry;
beforeEach(async () => {
vi.clearAllMocks();
tempDir = fs.mkdtempSync(path.join(tmpdir(), 'registry-integ-test-'));
// Create bundled registry
const bundledRegistryDir = path.join(tempDir, 'bundled');
fs.mkdirSync(bundledRegistryDir, { recursive: true });
const bundledRegistry: Registry = {
version: '1.0.0',
agents: {
'default-agent': {
id: 'default-agent',
name: 'Default Agent',
description: 'Default builtin agent',
author: 'Dexto',
tags: ['builtin'],
source: 'default-agent.yml',
type: 'builtin',
},
'coding-agent': {
id: 'coding-agent',
name: 'Coding Agent',
description: 'Coding builtin agent',
author: 'Dexto',
tags: ['builtin', 'coding'],
source: 'coding-agent/',
main: 'agent.yml',
type: 'builtin',
},
},
};
fs.writeFileSync(
path.join(bundledRegistryDir, 'agent-registry.json'),
JSON.stringify(bundledRegistry, null, 2)
);
// Create sample bundled agent files
fs.writeFileSync(
path.join(bundledRegistryDir, 'default-agent.yml'),
'name: default-agent\nversion: 1.0.0'
);
const codingAgentDir = path.join(bundledRegistryDir, 'coding-agent');
fs.mkdirSync(codingAgentDir, { recursive: true });
fs.writeFileSync(
path.join(codingAgentDir, 'agent.yml'),
'name: coding-agent\nversion: 1.0.0'
);
// Mock path utilities
const pathUtils = await import('../utils/path.js');
mockGetDextoGlobalPath = vi.mocked(pathUtils.getDextoGlobalPath);
mockGetDextoGlobalPath.mockImplementation((type: string, filename?: string) => {
if (filename) {
return path.join(tempDir, filename);
}
return path.join(tempDir, type);
});
mockResolveBundledScript = vi.mocked(pathUtils.resolveBundledScript);
mockResolveBundledScript.mockImplementation((scriptPath: string) => {
return path.join(bundledRegistryDir, scriptPath.replace('agents/', ''));
});
// Mock copyDirectory to use fs operations
const mockCopyDirectory = vi.mocked(pathUtils.copyDirectory);
mockCopyDirectory.mockImplementation(async (src: string, dest: string) => {
// Simple recursive copy for testing
const copyRecursive = async (source: string, destination: string) => {
await fs.promises.mkdir(destination, { recursive: true });
const entries = await fs.promises.readdir(source, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(source, entry.name);
const destPath = path.join(destination, entry.name);
if (entry.isDirectory()) {
await copyRecursive(srcPath, destPath);
} else {
await fs.promises.copyFile(srcPath, destPath);
}
}
};
await copyRecursive(src, dest);
});
registry = new LocalAgentRegistry();
});
afterEach(() => {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('Merged Registry', () => {
it('should load bundled agents', () => {
const agents = registry.getAvailableAgents();
expect(agents).toHaveProperty('default-agent');
expect(agents).toHaveProperty('coding-agent');
expect(Object.keys(agents)).toHaveLength(2);
});
it('should merge bundled and custom agents', async () => {
// Create a custom agent file
const customAgentPath = path.join(tempDir, 'custom-test.yml');
fs.writeFileSync(customAgentPath, 'name: custom-test\nversion: 1.0.0');
// Install custom agent
await registry.installCustomAgentFromPath('custom-test', customAgentPath, {
description: 'Custom test agent',
author: 'Test User',
tags: ['custom'],
});
// Get merged registry
const agents = registry.getAvailableAgents();
expect(agents).toHaveProperty('default-agent');
expect(agents).toHaveProperty('coding-agent');
expect(agents).toHaveProperty('custom-test');
expect(agents['custom-test']).toBeDefined();
expect(agents['custom-test']!.type).toBe('custom');
expect(Object.keys(agents)).toHaveLength(3);
});
});
describe('Custom Agent Installation', () => {
it('should install custom agent from YAML file', async () => {
const customAgentPath = path.join(tempDir, 'my-agent.yml');
fs.writeFileSync(customAgentPath, 'name: my-agent\nversion: 1.0.0');
const mainConfigPath = await registry.installCustomAgentFromPath(
'my-agent',
customAgentPath,
{
description: 'My custom agent',
author: 'John Doe',
tags: ['custom'],
}
);
expect(fs.existsSync(mainConfigPath)).toBe(true);
expect(registry.hasAgent('my-agent')).toBe(true);
const agents = registry.getAvailableAgents();
expect(agents['my-agent']).toBeDefined();
expect(agents['my-agent']!.type).toBe('custom');
expect(agents['my-agent']!.description).toBe('My custom agent');
});
it('should install custom agent from directory', async () => {
const customAgentDir = path.join(tempDir, 'my-dir-agent');
fs.mkdirSync(customAgentDir, { recursive: true });
fs.writeFileSync(
path.join(customAgentDir, 'agent.yml'),
'name: my-dir-agent\nversion: 1.0.0'
);
fs.writeFileSync(path.join(customAgentDir, 'prompts.md'), '# Custom prompts');
const mainConfigPath = await registry.installCustomAgentFromPath(
'my-dir-agent',
customAgentDir,
{
description: 'Directory-based custom agent',
author: 'Jane Doe',
tags: ['custom', 'advanced'],
main: 'agent.yml',
}
);
expect(fs.existsSync(mainConfigPath)).toBe(true);
expect(registry.hasAgent('my-dir-agent')).toBe(true);
// Verify all files copied
const installedDir = path.dirname(mainConfigPath);
expect(fs.existsSync(path.join(installedDir, 'prompts.md'))).toBe(true);
});
it('should throw error if directory agent missing main field', async () => {
const customAgentDir = path.join(tempDir, 'missing-main-agent');
fs.mkdirSync(customAgentDir, { recursive: true });
fs.writeFileSync(
path.join(customAgentDir, 'agent.yml'),
'name: missing-main-agent\nversion: 1.0.0'
);
await expect(
registry.installCustomAgentFromPath('missing-main-agent', customAgentDir, {
description: 'Directory agent without main field',
author: 'Test',
tags: ['test'],
// main field intentionally omitted
})
).rejects.toThrow(
"Failed to install agent 'missing-main-agent': main field is required for directory-based agents"
);
});
it('should throw error if custom agent name conflicts with builtin', async () => {
const customAgentPath = path.join(tempDir, 'default-agent.yml');
fs.writeFileSync(customAgentPath, 'name: default-agent\nversion: 1.0.0');
await expect(
registry.installCustomAgentFromPath('default-agent', customAgentPath, {
description: 'Conflicting agent',
author: 'Test',
tags: [],
})
).rejects.toThrow(/name conflicts with builtin agent/);
});
it('should throw error if agent already installed', async () => {
const customAgentPath = path.join(tempDir, 'duplicate.yml');
fs.writeFileSync(customAgentPath, 'name: duplicate\nversion: 1.0.0');
await registry.installCustomAgentFromPath('duplicate', customAgentPath, {
description: 'First install',
author: 'Test',
tags: [],
});
await expect(
registry.installCustomAgentFromPath('duplicate', customAgentPath, {
description: 'Second install',
author: 'Test',
tags: [],
})
).rejects.toThrow(/already exists/);
});
it('should throw error if source file does not exist', async () => {
await expect(
registry.installCustomAgentFromPath('nonexistent', '/nonexistent/path.yml', {
description: 'Test',
author: 'Test',
tags: [],
})
).rejects.toThrow(/not found/);
});
});
describe('Custom Agent Uninstallation', () => {
it('should uninstall custom agent and remove from registry', async () => {
const customAgentPath = path.join(tempDir, 'to-uninstall.yml');
fs.writeFileSync(customAgentPath, 'name: to-uninstall\nversion: 1.0.0');
await registry.installCustomAgentFromPath('to-uninstall', customAgentPath, {
description: 'Will be uninstalled',
author: 'Test',
tags: [],
});
expect(registry.hasAgent('to-uninstall')).toBe(true);
await registry.uninstallAgent('to-uninstall');
expect(registry.hasAgent('to-uninstall')).toBe(false);
});
it('should not allow uninstalling default agent without force', async () => {
// Install default-agent first
await registry.installAgent('default-agent');
await expect(registry.uninstallAgent('default-agent')).rejects.toThrow(/protected/);
});
it('should allow uninstalling default agent with force flag', async () => {
await registry.installAgent('default-agent');
await expect(registry.uninstallAgent('default-agent', true)).resolves.not.toThrow();
});
it('should throw error if agent not installed', async () => {
await expect(registry.uninstallAgent('nonexistent')).rejects.toThrow(/not installed/);
});
});
describe('Agent Resolution', () => {
it('should resolve installed builtin agent', async () => {
await registry.installAgent('default-agent');
const configPath = await registry.resolveAgent('default-agent', false);
expect(fs.existsSync(configPath)).toBe(true);
expect(configPath).toContain('default-agent.yml');
});
it('should auto-install missing builtin agent', async () => {
const configPath = await registry.resolveAgent('coding-agent', true);
expect(fs.existsSync(configPath)).toBe(true);
expect(registry.hasAgent('coding-agent')).toBe(true);
});
it('should resolve custom agent', async () => {
const customAgentPath = path.join(tempDir, 'custom.yml');
fs.writeFileSync(customAgentPath, 'name: custom\nversion: 1.0.0');
await registry.installCustomAgentFromPath('custom', customAgentPath, {
description: 'Custom',
author: 'Test',
tags: [],
});
const configPath = await registry.resolveAgent('custom', false);
expect(fs.existsSync(configPath)).toBe(true);
});
});
});

View File

@@ -0,0 +1,624 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { LocalAgentRegistry } from './registry.js';
import { RegistryErrorCode } from './error-codes.js';
import { ErrorType } from '@dexto/core';
// Mock dependencies
vi.mock('../utils/path.js', () => ({
resolveBundledScript: vi.fn(),
getDextoGlobalPath: vi.fn(),
copyDirectory: vi.fn(),
}));
vi.mock('@dexto/core', async () => {
const actual = await vi.importActual<typeof import('@dexto/core')>('@dexto/core');
return {
...actual,
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
});
vi.mock('../preferences/loader.js');
describe('LocalAgentRegistry', () => {
let tempDir: string;
let registry: LocalAgentRegistry;
let mockResolveBundledScript: any;
let mockGetDextoGlobalPath: any;
let mockLoadGlobalPreferences: any;
function createTempDir() {
return fs.mkdtempSync(path.join(tmpdir(), 'registry-test-'));
}
function createRegistryFile(registryPath: string, agents: Record<string, any>) {
fs.writeFileSync(
registryPath,
JSON.stringify({
version: '1.0.0',
agents,
})
);
}
beforeEach(async () => {
vi.clearAllMocks();
tempDir = createTempDir();
// Import and mock path utilities
const pathUtils = await import('../utils/path.js');
const prefUtils = await import('../preferences/loader.js');
mockResolveBundledScript = vi.mocked(pathUtils.resolveBundledScript);
mockGetDextoGlobalPath = vi.mocked(pathUtils.getDextoGlobalPath);
mockLoadGlobalPreferences = vi.mocked(prefUtils.loadGlobalPreferences);
// Setup registry file
const registryPath = path.join(tempDir, 'user-agent-registry.json');
createRegistryFile(registryPath, {
'test-agent': {
id: 'test-agent',
name: 'Test Agent',
description: 'Test agent',
author: 'Test',
tags: ['test'],
source: 'test-agent.yml',
},
'dir-agent': {
id: 'dir-agent',
name: 'Dir Agent',
description: 'Directory agent',
author: 'Test',
tags: ['test'],
source: 'dir-agent/',
main: 'main.yml',
},
'auto-test-agent': {
id: 'auto-test-agent',
name: 'Auto Test Agent',
description: 'Auto-install test agent',
author: 'Test',
tags: ['test'],
source: 'auto-test-agent.yml',
},
});
// Mock path functions
mockResolveBundledScript.mockReturnValue(registryPath);
mockGetDextoGlobalPath.mockImplementation((type: string, filename?: string) => {
if (filename) {
return path.join(tempDir, filename);
}
if (type === 'agents') {
return path.join(tempDir, 'global', type);
}
return path.join(tempDir, 'global');
});
// Mock preferences
mockLoadGlobalPreferences.mockResolvedValue({
llm: { provider: 'openai', model: 'gpt-5', apiKey: '$OPENAI_API_KEY' },
});
registry = new LocalAgentRegistry();
});
afterEach(() => {
// Clean up temp directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('hasAgent', () => {
it('returns true for agents in registry', () => {
expect(registry.hasAgent('test-agent')).toBe(true);
expect(registry.hasAgent('dir-agent')).toBe(true);
});
it('returns false for agents not in registry', () => {
expect(registry.hasAgent('nonexistent-agent')).toBe(false);
});
});
describe('getAvailableAgents', () => {
it('returns registry data with full metadata for all agents', () => {
const agents = registry.getAvailableAgents();
// Should return the full registry agents object
expect(agents).toEqual({
'test-agent': {
id: 'test-agent',
name: 'Test Agent',
description: 'Test agent',
author: 'Test',
tags: ['test'],
source: 'test-agent.yml',
type: 'builtin',
},
'dir-agent': {
id: 'dir-agent',
name: 'Dir Agent',
description: 'Directory agent',
author: 'Test',
tags: ['test'],
source: 'dir-agent/',
main: 'main.yml',
type: 'builtin',
},
'auto-test-agent': {
id: 'auto-test-agent',
name: 'Auto Test Agent',
description: 'Auto-install test agent',
author: 'Test',
tags: ['test'],
source: 'auto-test-agent.yml',
type: 'builtin',
},
});
// Should have correct number of agents
expect(Object.keys(agents)).toHaveLength(3);
});
});
describe('getRegistry', () => {
it('returns parsed registry data with correct structure', () => {
const registryData = registry.getRegistry();
expect(registryData).toEqual({
version: '1.0.0',
agents: {
'test-agent': {
id: 'test-agent',
name: 'Test Agent',
description: 'Test agent',
author: 'Test',
tags: ['test'],
source: 'test-agent.yml',
type: 'builtin',
},
'dir-agent': {
id: 'dir-agent',
name: 'Dir Agent',
description: 'Directory agent',
author: 'Test',
tags: ['test'],
source: 'dir-agent/',
main: 'main.yml',
type: 'builtin',
},
'auto-test-agent': {
id: 'auto-test-agent',
name: 'Auto Test Agent',
description: 'Auto-install test agent',
author: 'Test',
tags: ['test'],
source: 'auto-test-agent.yml',
type: 'builtin',
},
},
});
});
it('caches registry data on subsequent calls', () => {
const first = registry.getRegistry();
const second = registry.getRegistry();
// Should return the same object instance (cached)
expect(first).toBe(second);
});
});
describe('installAgent', () => {
it('throws proper error for unknown agent', async () => {
await expect(registry.installAgent('unknown-agent')).rejects.toMatchObject({
code: RegistryErrorCode.AGENT_NOT_FOUND,
scope: 'agent_registry',
type: ErrorType.USER,
context: {
agentId: 'unknown-agent',
availableAgents: expect.arrayContaining(['test-agent', 'dir-agent']),
},
});
});
it('installs single-file agent', async () => {
// Create the bundled agent file
const bundledAgentsPath = path.join(tempDir, 'bundled', 'agents');
fs.mkdirSync(bundledAgentsPath, { recursive: true });
fs.writeFileSync(
path.join(bundledAgentsPath, 'test-agent.yml'),
'llm:\n provider: anthropic\n model: claude-sonnet-4-5-20250929'
);
// Mock resolveBundledScript to return our test files
mockResolveBundledScript.mockImplementation((relativePath: string) => {
if (relativePath === 'agents/test-agent.yml') {
return path.join(bundledAgentsPath, 'test-agent.yml');
}
// Return original registry path
return path.join(tempDir, 'user-agent-registry.json');
});
// Create a fresh registry instance to pick up the new mock
const freshRegistry = new LocalAgentRegistry();
const result = await freshRegistry.installAgent('test-agent');
// Should return path to installed config
expect(result).toMatch(/test-agent\.yml$/);
expect(fs.existsSync(result)).toBe(true);
// Verify the file was actually copied
const installedContent = fs.readFileSync(result, 'utf-8');
expect(installedContent).toContain('provider: anthropic');
});
it('installs directory agent with main config file', async () => {
// Create directory agent structure
const bundledAgentsPath = path.join(tempDir, 'bundled', 'agents');
const dirAgentPath = path.join(bundledAgentsPath, 'dir-agent');
fs.mkdirSync(dirAgentPath, { recursive: true });
fs.writeFileSync(
path.join(dirAgentPath, 'main.yml'),
'llm:\n provider: openai\n model: gpt-5'
);
fs.writeFileSync(path.join(dirAgentPath, 'extra.md'), '# Documentation');
// We also need to mock copyDirectory since it's used for directory agents
const pathUtils = await import('../utils/path.js');
const mockCopyDirectory = vi.mocked(pathUtils.copyDirectory);
mockCopyDirectory.mockImplementation(async (src: string, dest: string) => {
// Manually copy the directory structure for the test
fs.mkdirSync(dest, { recursive: true });
const files = fs.readdirSync(src);
for (const file of files) {
const srcFile = path.join(src, file);
const destFile = path.join(dest, file);
if (fs.statSync(srcFile).isFile()) {
fs.copyFileSync(srcFile, destFile);
}
}
});
// Mock resolveBundledScript for directory
mockResolveBundledScript.mockImplementation((relativePath: string) => {
if (relativePath === 'agents/dir-agent/') {
return dirAgentPath;
}
return path.join(tempDir, 'user-agent-registry.json');
});
// Create fresh registry
const freshRegistry = new LocalAgentRegistry();
const result = await freshRegistry.installAgent('dir-agent');
// Should return path to main config file
expect(result).toMatch(/main\.yml$/);
expect(fs.existsSync(result)).toBe(true);
// Should have installed the whole directory
const installedDirPath = path.dirname(result);
expect(fs.existsSync(path.join(installedDirPath, 'extra.md'))).toBe(true);
// Verify actual file contents
const mainContent = fs.readFileSync(result, 'utf-8');
expect(mainContent).toContain('provider: openai');
const extraContent = fs.readFileSync(path.join(installedDirPath, 'extra.md'), 'utf-8');
expect(extraContent).toContain('# Documentation');
});
});
describe('resolveAgent', () => {
it('throws structured RegistryError for unknown agent with complete error properties', async () => {
await expect(registry.resolveAgent('unknown-agent')).rejects.toMatchObject({
code: RegistryErrorCode.AGENT_NOT_FOUND,
scope: 'agent_registry',
type: ErrorType.USER,
context: {
agentId: 'unknown-agent',
availableAgents: expect.arrayContaining(['test-agent', 'dir-agent']),
},
recovery: expect.stringContaining('Available agents:'),
});
});
it('resolves already installed single-file agent', async () => {
// Create installed agent file structure
const agentsDir = path.join(tempDir, 'global', 'agents');
const agentPath = path.join(agentsDir, 'test-agent');
fs.mkdirSync(agentPath, { recursive: true });
fs.writeFileSync(path.join(agentPath, 'test-agent.yml'), 'test: config');
const result = await registry.resolveAgent('test-agent');
expect(result).toBe(path.join(agentPath, 'test-agent.yml'));
});
it('resolves already installed directory agent with main config', async () => {
// Create installed directory agent structure
const agentsDir = path.join(tempDir, 'global', 'agents');
const agentPath = path.join(agentsDir, 'dir-agent');
fs.mkdirSync(agentPath, { recursive: true });
fs.writeFileSync(path.join(agentPath, 'main.yml'), 'test: config');
const result = await registry.resolveAgent('dir-agent');
expect(result).toBe(path.join(agentPath, 'main.yml'));
});
describe('auto-install behavior', () => {
beforeEach(() => {
// Create bundled agent for auto-install tests
const bundledPath = path.join(tempDir, 'bundled', 'agents', 'auto-test-agent.yml');
fs.mkdirSync(path.dirname(bundledPath), { recursive: true });
fs.writeFileSync(
bundledPath,
'name: auto-test-agent\ndescription: Test auto-install'
);
});
it('auto-installs missing agent when autoInstall=true (default)', async () => {
// Set up mocks for this specific test
const bundledPath = path.join(tempDir, 'bundled', 'agents', 'auto-test-agent.yml');
mockResolveBundledScript
.mockReturnValueOnce(path.join(tempDir, 'user-agent-registry.json'))
.mockReturnValueOnce(bundledPath);
const result = await registry.resolveAgent('auto-test-agent');
// Should return path to installed agent
const expectedPath = path.join(
tempDir,
'global',
'agents',
'auto-test-agent',
'auto-test-agent.yml'
);
expect(result).toBe(expectedPath);
// Verify agent was actually installed
expect(fs.existsSync(expectedPath)).toBe(true);
});
it('auto-installs missing agent when autoInstall=true explicitly', async () => {
// Set up mocks for this specific test
const bundledPath = path.join(tempDir, 'bundled', 'agents', 'auto-test-agent.yml');
mockResolveBundledScript
.mockReturnValueOnce(path.join(tempDir, 'user-agent-registry.json'))
.mockReturnValueOnce(bundledPath);
const result = await registry.resolveAgent('auto-test-agent', true);
const expectedPath = path.join(
tempDir,
'global',
'agents',
'auto-test-agent',
'auto-test-agent.yml'
);
expect(result).toBe(expectedPath);
expect(fs.existsSync(expectedPath)).toBe(true);
});
it('throws error when autoInstall=false and agent not installed', async () => {
await expect(registry.resolveAgent('auto-test-agent', false)).rejects.toMatchObject(
{
code: RegistryErrorCode.AGENT_NOT_INSTALLED_AUTO_INSTALL_DISABLED,
scope: 'agent_registry',
type: ErrorType.USER,
context: {
agentId: 'auto-test-agent',
availableAgents: expect.arrayContaining([
'test-agent',
'dir-agent',
'auto-test-agent',
]),
},
recovery: expect.stringContaining('dexto install auto-test-agent'),
}
);
// Verify agent was NOT installed
const expectedPath = path.join(tempDir, 'global', 'agents', 'auto-test-agent');
expect(fs.existsSync(expectedPath)).toBe(false);
});
});
});
describe('resolveMainConfig', () => {
it('handles single-file agents correctly', () => {
// Create the expected file structure
const agentDir = path.join(tempDir, 'test-agent-dir');
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(path.join(agentDir, 'test-agent.yml'), 'test: config');
const result = registry.resolveMainConfig(agentDir, 'test-agent');
expect(result).toBe(path.join(agentDir, 'test-agent.yml'));
});
it('handles directory agents with main field', () => {
// Create the expected file structure
const agentDir = path.join(tempDir, 'dir-agent-dir');
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(path.join(agentDir, 'main.yml'), 'test: config');
const result = registry.resolveMainConfig(agentDir, 'dir-agent');
expect(result).toBe(path.join(agentDir, 'main.yml'));
});
it('throws structured error for directory agent missing main field', () => {
// Create registry with bad entry and mock it
const badRegistryPath = path.join(tempDir, 'bad-registry.json');
createRegistryFile(badRegistryPath, {
'bad-dir-agent': {
id: 'bad-dir-agent',
name: 'Bad Dir Agent',
description: 'Bad directory agent',
author: 'Test',
tags: ['test'],
source: 'bad-dir-agent/',
// missing main field
},
});
mockResolveBundledScript.mockReturnValue(badRegistryPath);
const badRegistry = new LocalAgentRegistry();
expect(() => badRegistry.resolveMainConfig('/path', 'bad-dir-agent')).toThrow(
expect.objectContaining({
code: RegistryErrorCode.AGENT_INVALID_ENTRY,
scope: 'agent_registry',
type: ErrorType.SYSTEM,
context: {
agentId: 'bad-dir-agent',
reason: 'directory entry missing main field',
},
})
);
});
});
describe('getInstalledAgents', () => {
it('returns empty array when agents directory does not exist', async () => {
const installedAgents = await registry.getInstalledAgents();
expect(installedAgents).toEqual([]);
});
it('returns list of installed agent directories', async () => {
const agentsDir = path.join(tempDir, 'global', 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
// Create agent directories
fs.mkdirSync(path.join(agentsDir, 'agent1'));
fs.mkdirSync(path.join(agentsDir, 'agent2'));
fs.mkdirSync(path.join(agentsDir, 'default-agent'));
// Create temp directory (should be filtered out)
fs.mkdirSync(path.join(agentsDir, '.tmp.123456'));
// Create a file (should be filtered out)
fs.writeFileSync(path.join(agentsDir, 'not-a-directory.txt'), 'content');
const installedAgents = await registry.getInstalledAgents();
expect(installedAgents.sort()).toEqual(['agent1', 'agent2', 'default-agent']);
});
});
describe('uninstallAgent', () => {
let agentsDir: string;
beforeEach(() => {
agentsDir = path.join(tempDir, 'global', 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
});
it('successfully removes agent directory and all contents', async () => {
const agentPath = path.join(agentsDir, 'test-agent');
const subDir = path.join(agentPath, 'subdir');
// Create agent with nested structure
fs.mkdirSync(subDir, { recursive: true });
fs.writeFileSync(path.join(agentPath, 'config.yml'), 'test config');
fs.writeFileSync(path.join(subDir, 'nested.txt'), 'nested content');
// Verify it exists
expect(fs.existsSync(agentPath)).toBe(true);
expect(fs.existsSync(path.join(subDir, 'nested.txt'))).toBe(true);
await registry.uninstallAgent('test-agent');
// Verify complete removal
expect(fs.existsSync(agentPath)).toBe(false);
});
it('throws error when agent is not installed', async () => {
await expect(registry.uninstallAgent('nonexistent-agent')).rejects.toThrow(
expect.objectContaining({
code: RegistryErrorCode.AGENT_NOT_INSTALLED,
})
);
});
it('protects coding-agent from deletion without force', async () => {
const codingAgentPath = path.join(agentsDir, 'coding-agent');
fs.mkdirSync(codingAgentPath);
fs.writeFileSync(path.join(codingAgentPath, 'config.yml'), 'important');
await expect(registry.uninstallAgent('coding-agent')).rejects.toThrow(
expect.objectContaining({
code: RegistryErrorCode.AGENT_PROTECTED,
})
);
// Verify it still exists
expect(fs.existsSync(codingAgentPath)).toBe(true);
expect(fs.readFileSync(path.join(codingAgentPath, 'config.yml'), 'utf-8')).toBe(
'important'
);
});
it('allows force uninstall of coding-agent', async () => {
const codingAgentPath = path.join(agentsDir, 'coding-agent');
fs.mkdirSync(codingAgentPath);
fs.writeFileSync(path.join(codingAgentPath, 'config.yml'), 'config');
await registry.uninstallAgent('coding-agent', true);
expect(fs.existsSync(codingAgentPath)).toBe(false);
});
it('maintains other agents when removing one', async () => {
const agent1Path = path.join(agentsDir, 'keep-me');
const agent2Path = path.join(agentsDir, 'remove-me');
fs.mkdirSync(agent1Path);
fs.mkdirSync(agent2Path);
fs.writeFileSync(path.join(agent1Path, 'config.yml'), 'keep');
fs.writeFileSync(path.join(agent2Path, 'config.yml'), 'remove');
await registry.uninstallAgent('remove-me');
expect(fs.existsSync(agent1Path)).toBe(true);
expect(fs.existsSync(agent2Path)).toBe(false);
expect(fs.readFileSync(path.join(agent1Path, 'config.yml'), 'utf-8')).toBe('keep');
});
});
describe('install and uninstall integration', () => {
it('can install then uninstall an agent', async () => {
// Create bundled agent file
const bundledPath = path.join(tempDir, 'bundled', 'agents', 'test-agent.yml');
fs.mkdirSync(path.dirname(bundledPath), { recursive: true });
fs.writeFileSync(bundledPath, 'name: test-agent\ndescription: Test');
mockResolveBundledScript
.mockReturnValueOnce(path.join(tempDir, 'user-agent-registry.json'))
.mockReturnValueOnce(bundledPath);
// Install agent
const configPath = await registry.installAgent('test-agent');
const installedPath = path.join(tempDir, 'global', 'agents', 'test-agent');
expect(configPath).toBe(path.join(installedPath, 'test-agent.yml'));
// Verify installation
expect(fs.existsSync(installedPath)).toBe(true);
const installed = await registry.getInstalledAgents();
expect(installed).toContain('test-agent');
// Uninstall agent
await registry.uninstallAgent('test-agent');
// Verify removal
expect(fs.existsSync(installedPath)).toBe(false);
const installedAfter = await registry.getInstalledAgents();
expect(installedAfter).not.toContain('test-agent');
});
});
});

View File

@@ -0,0 +1,645 @@
import { existsSync, readFileSync } from 'fs';
import { promises as fs } from 'fs';
import path from 'path';
import { logger } from '@dexto/core';
import { resolveBundledScript, getDextoGlobalPath, copyDirectory } from '../utils/path.js';
import {
Registry,
RegistrySchema,
AgentRegistry,
AgentRegistryEntry,
normalizeRegistryJson,
} from './types.js';
import { RegistryError } from './errors.js';
import {
loadUserRegistry,
mergeRegistries,
removeAgentFromUserRegistry,
addAgentToUserRegistry,
} from './user-registry.js';
import { loadGlobalPreferences } from '../preferences/loader.js';
// Cached registry instance
let cachedRegistry: LocalAgentRegistry | null = null;
/**
* Local agent registry implementation
*
* TODO: ARCHITECTURAL REFACTOR - Move registry, preferences, and agent resolution to CLI
*
* PROBLEM: Registry operations are CLI concerns but live in Core, causing:
* - Missing analytics for auto-install (when running `dexto`, registry installs agents but doesn't track)
* - Wrong separation of concerns (Core = execution engine, not discovery/setup)
* - Registry manages ~/.dexto/agents filesystem which is CLI-level setup
*
* THE RIGHT ARCHITECTURE:
*
* Move to CLI:
* 1. Agent Registry (packages/core/src/agent/registry/) → packages/cli/src/registry/
* - installAgent(), uninstallAgent(), resolveAgent(), listAgents()
* - Can directly call capture() for analytics
* - Manages ~/.dexto/agents installation directory
*
* 2. Global Preferences (packages/core/src/preferences/) → packages/cli/src/preferences/
* - User's default LLM, model, default agent
* - Used by `dexto setup` command
* - Manages ~/.dexto/preferences.json
*
* 3. Agent Resolution (packages/core/src/config/agent-resolver.ts) → packages/cli/src/agent-resolver.ts
* - Discovery logic: check registry, trigger installs, apply preferences
* - Returns resolved config PATH to core
*
* Core keeps:
* - config/loader.ts - Load YAML from path
* - config/schemas.ts - Zod validation
* - Agent execution (DextoAgent, LLM, tools, MCP)
*
* FLOW AFTER REFACTOR:
* CLI index.ts:
* → CLI: resolveAgentPath() (discovery logic)
* → CLI: registry.resolveAgent()
* → CLI: registry.installAgent() if needed
* → CLI: capture('dexto_install_agent', ...) ✓ Natural!
* → Core: new DextoAgent(configPath) (just loads & runs)
*
* BENEFITS:
* - Clear separation: CLI = setup/discovery, Core = execution
* - Analytics naturally colocated with operations
* - Core is portable (no CLI dependencies)
* - No circular deps (CLI → Core, correct direction)
*
* ESTIMATE: ~3-4 hours (mostly moving code + updating imports)
*/
export class LocalAgentRegistry implements AgentRegistry {
private _registry: Registry | null = null;
/**
* Lazy load registry from JSON file
*/
getRegistry(): Registry {
if (this._registry === null) {
this._registry = this.loadRegistry();
}
return this._registry;
}
/**
* Load and merge bundled + user registries
* Uses fail-fast approach - throws RegistryError for any loading issues
*/
private loadRegistry(): Registry {
// Load bundled registry
let jsonPath: string;
try {
jsonPath = resolveBundledScript('agents/agent-registry.json');
} catch (error) {
// Preserve typed error semantics for missing registry
throw RegistryError.registryNotFound(
'agents/agent-registry.json',
error instanceof Error ? error.message : String(error)
);
}
if (!existsSync(jsonPath)) {
throw RegistryError.registryNotFound(jsonPath, "File doesn't exist");
}
let bundledRegistry: Registry;
try {
const jsonData = readFileSync(jsonPath, 'utf-8');
const rawRegistry = JSON.parse(jsonData);
bundledRegistry = RegistrySchema.parse(normalizeRegistryJson(rawRegistry));
} catch (error) {
throw RegistryError.registryParseError(
jsonPath,
error instanceof Error ? error.message : String(error)
);
}
// Load user registry and merge
const userRegistry = loadUserRegistry();
const merged = mergeRegistries(bundledRegistry, userRegistry);
logger.debug(
`Loaded registry: ${Object.keys(bundledRegistry.agents).length} bundled, ${Object.keys(userRegistry.agents).length} custom`
);
return merged;
}
/**
* Check if agent exists in registry by ID
*/
hasAgent(agentId: string): boolean {
const registry = this.getRegistry();
return agentId in registry.agents;
}
/**
* Get available agents with their metadata from registry
*/
getAvailableAgents(): Record<string, AgentRegistryEntry> {
const registry = this.getRegistry();
return registry.agents;
}
/**
* Validate custom agent ID doesn't conflict with bundled registry
* @throws RegistryError if ID conflicts with builtin agent
*/
private validateCustomAgentId(agentId: string): void {
let jsonPath: string;
try {
jsonPath = resolveBundledScript('agents/agent-registry.json');
} catch (error) {
throw RegistryError.registryNotFound(
'agents/agent-registry.json',
error instanceof Error ? error.message : String(error)
);
}
try {
const jsonData = readFileSync(jsonPath, 'utf-8');
const bundledRegistry = RegistrySchema.parse(
normalizeRegistryJson(JSON.parse(jsonData))
);
if (agentId in bundledRegistry.agents) {
throw RegistryError.customAgentNameConflict(agentId);
}
} catch (error) {
// Preserve original customAgentNameConflict throws
if (error instanceof Error && /name conflicts with builtin agent/.test(error.message)) {
throw error;
}
throw RegistryError.registryParseError(
jsonPath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Resolve main config file for installed agent
* Handles both directory agents (with main field) and single-file agents
*/
resolveMainConfig(agentDir: string, agentId: string): string {
const registry = this.getRegistry();
const agentData = registry.agents[agentId];
if (!agentData) {
const available = Object.keys(registry.agents);
throw RegistryError.agentNotFound(agentId, available);
}
if (agentData.source.endsWith('/')) {
// Directory agent - main field is required
if (!agentData.main) {
throw RegistryError.agentInvalidEntry(
agentId,
'directory entry missing main field'
);
}
const mainConfigPath = path.join(agentDir, agentData.main);
if (!existsSync(mainConfigPath)) {
throw RegistryError.mainConfigMissing(agentId, mainConfigPath);
}
return mainConfigPath;
} else {
// Single file agent - use the source filename
const filename = path.basename(agentData.source);
const configPath = path.join(agentDir, filename);
if (!existsSync(configPath)) {
throw RegistryError.configNotFound(configPath);
}
return configPath;
}
}
// TODO: Consider removing install/uninstall methods from LocalAgentRegistry class.
// Installing/uninstalling from registry to local agents/ is better suited as a CLI command.
// A bundler/opinionated project structure should help - agents/ will by default be their registry.
// For now these methods remain for backward compatibility.
/**
* Install agent atomically using temp + rename pattern
* @param agentId ID of the agent to install
*/
async installAgent(agentId: string): Promise<string> {
logger.info(`Installing agent: ${agentId}`);
const registry = this.getRegistry();
const agentData = registry.agents[agentId];
if (!agentData) {
const available = Object.keys(registry.agents);
throw RegistryError.agentNotFound(agentId, available);
}
const globalAgentsDir = getDextoGlobalPath('agents');
const targetDir = path.resolve(globalAgentsDir, agentId);
const relTarget = path.relative(globalAgentsDir, targetDir);
if (relTarget.startsWith('..') || path.isAbsolute(relTarget)) {
throw RegistryError.installationFailed(
agentId,
'invalid agentId: path traversal detected'
);
}
// Check if already installed
if (existsSync(targetDir)) {
logger.info(`Agent '${agentId}' already installed`);
return this.resolveMainConfig(targetDir, agentId);
}
// Ensure agents directory exists
await fs.mkdir(globalAgentsDir, { recursive: true });
// Determine source path
const sourcePath = resolveBundledScript(`agents/${agentData.source}`);
// Create temp directory for atomic operation
const tempDir = `${targetDir}.tmp.${Date.now()}`;
try {
// Copy to temp directory first
if (agentData.source.endsWith('/')) {
// Directory agent - copy entire directory
await copyDirectory(sourcePath, tempDir);
} else {
// Single file agent - create directory and copy file
await fs.mkdir(tempDir, { recursive: true });
const targetFile = path.join(tempDir, path.basename(sourcePath));
await fs.copyFile(sourcePath, targetFile);
}
// Validate installation
const mainConfigPath = this.resolveMainConfig(tempDir, agentId);
if (!existsSync(mainConfigPath)) {
throw RegistryError.installationValidationFailed(agentId, mainConfigPath);
}
// Atomic rename
await fs.rename(tempDir, targetDir);
logger.info(`✓ Installed agent '${agentId}' to ${targetDir}`);
return this.resolveMainConfig(targetDir, agentId);
} catch (error) {
// Clean up temp directory on failure
try {
if (existsSync(tempDir)) {
await fs.rm(tempDir, { recursive: true, force: true });
}
} catch (cleanupError) {
logger.error(
`Failed to clean up temp directory: ${
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
}. Skipping cleanup...`
);
}
throw RegistryError.installationFailed(
agentId,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Install a custom agent from a local file path
* @param agentId Unique identifier for the custom agent
* @param sourcePath Absolute path to agent YAML file or directory
* @param metadata Agent metadata (name for display, description, author, tags, main)
* @returns Path to the installed agent's main config file
*/
async installCustomAgentFromPath(
agentId: string,
sourcePath: string,
metadata: {
name?: string;
description: string;
author: string;
tags: string[];
main?: string;
}
): Promise<string> {
logger.info(`Installing custom agent '${agentId}' from ${sourcePath}`);
// Validate agent ID doesn't conflict with bundled registry
this.validateCustomAgentId(agentId);
// Check if source exists
if (!existsSync(sourcePath)) {
throw RegistryError.configNotFound(sourcePath);
}
const globalAgentsDir = getDextoGlobalPath('agents');
const targetDir = path.resolve(globalAgentsDir, agentId);
const relTarget = path.relative(globalAgentsDir, targetDir);
if (relTarget.startsWith('..') || path.isAbsolute(relTarget)) {
throw RegistryError.installationFailed(
agentId,
'invalid agentId: path traversal detected'
);
}
// Check if already installed
if (existsSync(targetDir)) {
throw RegistryError.agentAlreadyExists(agentId);
}
// Ensure agents directory exists
await fs.mkdir(globalAgentsDir, { recursive: true });
// Determine if source is file or directory
const stats = await fs.stat(sourcePath);
const isDirectory = stats.isDirectory();
// For single-file agents, use agent ID for filename
const configFileName = isDirectory ? undefined : `${agentId}.yml`;
// Validate metadata
if (!metadata.description) {
throw RegistryError.installationFailed(agentId, 'description is required');
}
if (isDirectory && !metadata.main) {
throw RegistryError.installationFailed(
agentId,
'main field is required for directory-based agents'
);
}
// Build registry entry
// Auto-generate display name from ID if not provided
const displayName =
metadata.name ||
agentId
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
const registryEntry: Omit<AgentRegistryEntry, 'type'> = {
id: agentId,
name: displayName,
description: metadata.description,
author: metadata.author,
tags: metadata.tags,
source: isDirectory ? `${agentId}/` : configFileName!,
main: metadata.main,
};
// Create temp directory for atomic operation
const tempDir = `${targetDir}.tmp.${Date.now()}`;
try {
// Copy to temp directory first
if (isDirectory) {
await copyDirectory(sourcePath, tempDir);
} else {
await fs.mkdir(tempDir, { recursive: true });
const targetFile = path.join(tempDir, configFileName!);
await fs.copyFile(sourcePath, targetFile);
}
// Validate installation - check main config exists
// After validation above, we know metadata.main exists for directories
const tempMainConfigPath = isDirectory
? path.join(tempDir, metadata.main!)
: path.join(tempDir, configFileName!);
if (!existsSync(tempMainConfigPath)) {
throw RegistryError.installationValidationFailed(agentId, tempMainConfigPath);
}
// Atomic rename
await fs.rename(tempDir, targetDir);
logger.info(`✓ Installed custom agent '${agentId}' to ${targetDir}`);
// Calculate final main config path after rename
const mainConfigPath =
isDirectory && metadata.main
? path.join(targetDir, metadata.main)
: path.join(targetDir, configFileName!);
// Add to user registry (with rollback on failure)
try {
await addAgentToUserRegistry(agentId, registryEntry);
logger.info(`✓ Added '${agentId}' to user registry`);
// Clear cached registry to force reload
this._registry = null;
} catch (registryError) {
// Rollback: remove installed directory
try {
if (existsSync(targetDir)) {
await fs.rm(targetDir, { recursive: true, force: true });
logger.info(`Rolled back installation: removed ${targetDir}`);
}
} catch (rollbackError) {
logger.error(
`Rollback failed for '${agentId}': ${
rollbackError instanceof Error
? rollbackError.message
: String(rollbackError)
}`
);
}
// Re-throw original registry error
throw registryError;
}
return mainConfigPath;
} catch (error) {
// Clean up temp directory on failure
try {
if (existsSync(tempDir)) {
await fs.rm(tempDir, { recursive: true, force: true });
}
} catch (cleanupError) {
logger.error(
`Failed to clean up temp directory: ${
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
}. Skipping cleanup...`
);
}
throw RegistryError.installationFailed(
agentId,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Resolve a registry agent ID to a config path
* NOTE: Only handles registry IDs, not file paths (routing done in loadAgentConfig)
* Handles installing agent if needed
* @param agentId ID of the agent to resolve
* @param autoInstall Whether to automatically install missing agents from registry (default: true)
*/
async resolveAgent(agentId: string, autoInstall: boolean = true): Promise<string> {
logger.debug(`Resolving registry agent: ${agentId}`);
// 1. Check if installed
const globalAgentsDir = getDextoGlobalPath('agents');
const installedPath = path.resolve(globalAgentsDir, agentId);
const relInstalled = path.relative(globalAgentsDir, installedPath);
if (relInstalled.startsWith('..') || path.isAbsolute(relInstalled)) {
throw RegistryError.agentNotFound(agentId, Object.keys(this.getRegistry().agents));
}
if (existsSync(installedPath)) {
const mainConfig = this.resolveMainConfig(installedPath, agentId);
logger.debug(`Resolved installed agent '${agentId}' to: ${mainConfig}`);
return mainConfig;
}
logger.debug(`Agent '${agentId}' not found in installed path: ${installedPath}`);
// 2. Check if available in registry
if (this.hasAgent(agentId)) {
if (autoInstall) {
logger.info(`Installing agent '${agentId}' from registry...`);
return await this.installAgent(agentId);
} else {
// Agent is available in registry but auto-install is disabled
const registry = this.getRegistry();
const available = Object.keys(registry.agents);
throw RegistryError.agentNotInstalledAutoInstallDisabled(agentId, available);
}
}
// 3. Not found in registry
const registry = this.getRegistry();
const available = Object.keys(registry.agents);
throw RegistryError.agentNotFound(agentId, available);
}
/**
* Get list of currently installed agents
*/
async getInstalledAgents(): Promise<string[]> {
const globalAgentsDir = getDextoGlobalPath('agents');
if (!existsSync(globalAgentsDir)) {
return [];
}
try {
const entries = await fs.readdir(globalAgentsDir, { withFileTypes: true });
return (
entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
// Exclude temp directories both when prefixed and suffixed (agentId.tmp.<ts>)
.filter((name) => !name.startsWith('.tmp') && !name.includes('.tmp.'))
);
} catch (error) {
logger.error(`Failed to read installed agents directory: ${error}`);
return [];
}
}
/**
* Check if an agent is safe to uninstall (not the default agent from preferences)
*/
private async isAgentSafeToUninstall(agentId: string): Promise<boolean> {
try {
const preferences = await loadGlobalPreferences();
const defaultAgent = preferences.defaults.defaultAgent;
return agentId !== defaultAgent;
} catch {
// If preferences can't be loaded, protect 'coding-agent' as fallback
logger.warn('Could not load preferences, using fallback protection for coding-agent');
return agentId !== 'coding-agent';
}
}
/**
* Uninstall an agent by removing its directory
* For custom agents: also removes from user registry
* For builtin agents: only removes from disk
* @param agentId ID of the agent to uninstall
* @param force Whether to force uninstall even if agent is protected (default: false)
*/
async uninstallAgent(agentId: string, force: boolean = false): Promise<void> {
const globalAgentsDir = getDextoGlobalPath('agents');
const agentDir = path.resolve(globalAgentsDir, agentId);
const relAgent = path.relative(globalAgentsDir, agentDir);
if (relAgent.startsWith('..') || path.isAbsolute(relAgent)) {
throw RegistryError.uninstallationFailed(
agentId,
'invalid agentId: path traversal detected'
);
}
logger.info(`Uninstalling agent: ${agentId} from ${agentDir}`);
if (!existsSync(agentDir)) {
throw RegistryError.agentNotInstalled(agentId);
}
// Safety check for default agent unless forced
if (!force && !(await this.isAgentSafeToUninstall(agentId))) {
throw RegistryError.agentProtected(agentId);
}
// Check if this is a custom agent (exists in user registry)
const registry = this.getRegistry();
const agentData = registry.agents[agentId];
const isCustomAgent = agentData?.type === 'custom';
try {
// Remove from disk
await fs.rm(agentDir, { recursive: true, force: true });
logger.info(`✓ Removed agent '${agentId}' from ${agentDir}`);
// If custom agent, also remove from user registry
if (isCustomAgent) {
await removeAgentFromUserRegistry(agentId);
logger.info(`✓ Removed custom agent '${agentId}' from user registry`);
// Clear cached registry to force reload
this._registry = null;
}
} catch (error) {
throw RegistryError.uninstallationFailed(
agentId,
error instanceof Error ? error.message : String(error)
);
}
}
}
/**
* Get cached registry instance (singleton pattern)
*/
export function getAgentRegistry(): LocalAgentRegistry {
if (cachedRegistry === null) {
cachedRegistry = new LocalAgentRegistry();
}
return cachedRegistry;
}
/**
* Load bundled agent registry (agents field only)
* Returns empty object on error - use for non-critical lookups like display names
*/
export function loadBundledRegistryAgents(): Record<string, AgentRegistryEntry> {
try {
const registryPath = resolveBundledScript('agents/agent-registry.json');
const content = readFileSync(registryPath, 'utf-8');
const registry = JSON.parse(content);
return registry.agents || {};
} catch (error) {
logger.warn(
`Could not load bundled registry: ${error instanceof Error ? error.message : String(error)}`
);
return {};
}
}

View File

@@ -0,0 +1,125 @@
import { z } from 'zod';
export function deriveDisplayName(slug: string): string {
return slug
.split('-')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
/**
* Schema for agent data in registry JSON
*/
export const AgentRegistryEntrySchema = z
.object({
id: z.string().describe('Unique identifier for the agent'),
name: z.string().describe('Display name for the agent'),
description: z.string(),
author: z.string(),
tags: z.array(z.string()),
source: z.string(),
main: z.string().optional(),
type: z.enum(['builtin', 'custom']).default('builtin').describe('Agent type'),
})
.strict();
export type AgentRegistryEntry = z.output<typeof AgentRegistryEntrySchema>;
/**
* Schema for complete registry JSON
*/
export const RegistrySchema = z
.object({
version: z.string(),
agents: z.record(z.string(), AgentRegistryEntrySchema),
})
.strict();
export type Registry = z.output<typeof RegistrySchema>;
type RawRegistry = {
version?: unknown;
agents?: Record<string, unknown> | unknown;
};
/**
* Normalize registry JSON data to ensure consistency.
* Validates that id field matches the registry key and derives display names if missing.
*/
export function normalizeRegistryJson(raw: unknown): RawRegistry {
if (!raw || typeof raw !== 'object') {
return { version: '1.0.0', agents: {} };
}
const input = raw as RawRegistry;
const normalizedAgents: Record<string, unknown> = {};
const agents =
input.agents && typeof input.agents === 'object' && input.agents !== null
? (input.agents as Record<string, unknown>)
: {};
for (const [agentId, value] of Object.entries(agents)) {
if (!value || typeof value !== 'object') continue;
const entry = { ...(value as Record<string, unknown>) };
// Ensure id field exists and matches the key
if (!entry.id || typeof entry.id !== 'string' || entry.id.trim() !== agentId) {
entry.id = agentId;
}
// Derive display name if missing
if (!entry.name || typeof entry.name !== 'string' || !entry.name.trim()) {
entry.name = deriveDisplayName(agentId);
}
normalizedAgents[agentId] = entry;
}
return {
version:
typeof input.version === 'string' && input.version.trim().length > 0
? input.version
: '1.0.0',
agents: normalizedAgents,
} satisfies RawRegistry;
}
/**
* Agent registry interface
*/
export interface AgentRegistry {
/**
* Returns true if the registry contains an agent with the provided ID
*/
hasAgent(agentId: string): boolean;
/**
* Returns a map of available agent IDs to their registry entries
*/
getAvailableAgents(): Record<string, AgentRegistryEntry>;
/**
* Installs an agent from the registry by ID
* @param agentId - Unique agent identifier
* @returns Path to the installed agent config
*/
installAgent(agentId: string): Promise<string>;
/**
* Uninstalls an agent by ID
* @param agentId - Unique agent identifier
* @param force - Whether to force uninstall protected agents (default: false)
*/
uninstallAgent(agentId: string, force?: boolean): Promise<void>;
/**
* Returns list of currently installed agent IDs
*/
getInstalledAgents(): Promise<string[]>;
/**
* Resolves an agent ID or path and optionally auto-installs if needed
* @param idOrPath - Agent ID from registry or filesystem path
* @param autoInstall - Whether to auto-install from registry (default: true)
* @returns Path to the agent config file
*/
resolveAgent(idOrPath: string, autoInstall?: boolean): Promise<string>;
}

View File

@@ -0,0 +1,262 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import {
loadUserRegistry,
saveUserRegistry,
mergeRegistries,
addAgentToUserRegistry,
removeAgentFromUserRegistry,
userRegistryHasAgent,
getUserRegistryPath,
} from './user-registry.js';
import type { Registry, AgentRegistryEntry } from './types.js';
vi.mock('../utils/path.js');
vi.mock('@dexto/core', async () => {
const actual = await vi.importActual<typeof import('@dexto/core')>('@dexto/core');
return {
...actual,
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
});
describe('user-registry', () => {
let tempDir: string;
let mockGetDextoGlobalPath: any;
beforeEach(async () => {
vi.clearAllMocks();
tempDir = fs.mkdtempSync(path.join(tmpdir(), 'user-registry-test-'));
const pathUtils = await import('../utils/path.js');
mockGetDextoGlobalPath = vi.mocked(pathUtils.getDextoGlobalPath);
mockGetDextoGlobalPath.mockImplementation((type: string, filename?: string) => {
if (filename) {
return path.join(tempDir, filename);
}
return tempDir;
});
});
afterEach(() => {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('getUserRegistryPath', () => {
it('should return correct path', () => {
const registryPath = getUserRegistryPath();
expect(registryPath).toBe(path.join(tempDir, 'user-agent-registry.json'));
});
});
describe('loadUserRegistry', () => {
it('should return empty registry if file does not exist', () => {
const registry = loadUserRegistry();
expect(registry).toEqual({ version: '1.0.0', agents: {} });
});
it('should load existing user registry', () => {
const userRegistry: Registry = {
version: '1.0.0',
agents: {
'custom-agent': {
id: 'custom-agent',
name: 'Custom Agent',
description: 'Custom agent',
author: 'User',
tags: ['custom'],
source: 'custom-agent/',
type: 'custom',
},
},
};
fs.writeFileSync(getUserRegistryPath(), JSON.stringify(userRegistry));
const loaded = loadUserRegistry();
expect(loaded).toEqual(userRegistry);
});
it('should throw error if registry is invalid JSON', () => {
fs.writeFileSync(getUserRegistryPath(), 'invalid json');
expect(() => loadUserRegistry()).toThrow();
});
});
describe('saveUserRegistry', () => {
it('should save user registry atomically', async () => {
const registry: Registry = {
version: '1.0.0',
agents: {
'test-agent': {
id: 'test-agent',
name: 'Test Agent',
description: 'Test',
author: 'User',
tags: [],
source: 'test-agent.yml',
type: 'custom',
},
},
};
await saveUserRegistry(registry);
const registryPath = getUserRegistryPath();
expect(fs.existsSync(registryPath)).toBe(true);
const loaded = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(loaded).toEqual(registry);
});
it('should create directory if it does not exist', async () => {
// Remove temp dir to test creation
fs.rmSync(tempDir, { recursive: true, force: true });
const registry: Registry = { version: '1.0.0', agents: {} };
await saveUserRegistry(registry);
expect(fs.existsSync(getUserRegistryPath())).toBe(true);
});
});
describe('mergeRegistries', () => {
it('should merge bundled and user registries', () => {
const bundled: Registry = {
version: '1.0.0',
agents: {
'builtin-agent': {
id: 'builtin-agent',
name: 'Builtin Agent',
description: 'Builtin',
author: 'Dexto',
tags: [],
source: 'builtin.yml',
type: 'builtin',
},
},
};
const user: Registry = {
version: '1.0.0',
agents: {
'custom-agent': {
id: 'custom-agent',
name: 'Custom Agent',
description: 'Custom',
author: 'User',
tags: [],
source: 'custom.yml',
type: 'custom',
},
},
};
const merged = mergeRegistries(bundled, user);
expect(merged.agents).toHaveProperty('builtin-agent');
expect(merged.agents).toHaveProperty('custom-agent');
expect(Object.keys(merged.agents)).toHaveLength(2);
});
it('should use bundled version number', () => {
const bundled: Registry = { version: '2.0.0', agents: {} };
const user: Registry = { version: '1.0.0', agents: {} };
const merged = mergeRegistries(bundled, user);
expect(merged.version).toBe('2.0.0');
});
});
describe('userRegistryHasAgent', () => {
it('should return false if user registry is empty', () => {
expect(userRegistryHasAgent('nonexistent')).toBe(false);
});
it('should return true if agent exists in user registry', async () => {
const entry: AgentRegistryEntry = {
id: 'my-agent',
name: 'My Agent',
description: 'Test',
author: 'User',
tags: [],
source: 'test.yml',
type: 'custom',
};
await addAgentToUserRegistry('test-agent', entry);
expect(userRegistryHasAgent('test-agent')).toBe(true);
});
});
describe('addAgentToUserRegistry', () => {
it('should add custom agent to user registry', async () => {
const entry: Omit<AgentRegistryEntry, 'type'> = {
id: 'my-agent',
name: 'My Agent',
description: 'My custom agent',
author: 'John Doe',
tags: ['custom', 'coding'],
source: 'my-agent/',
main: 'agent.yml',
};
await addAgentToUserRegistry('my-agent', entry);
const registry = loadUserRegistry();
expect(registry.agents['my-agent']).toEqual({
...entry,
type: 'custom',
});
});
it('should throw error if agent already exists', async () => {
const entry: Omit<AgentRegistryEntry, 'type'> = {
id: 'test-agent',
name: 'Test Agent',
description: 'Test',
author: 'User',
tags: [],
source: 'test.yml',
};
await addAgentToUserRegistry('test-agent', entry);
await expect(addAgentToUserRegistry('test-agent', entry)).rejects.toThrow();
});
});
describe('removeAgentFromUserRegistry', () => {
it('should remove agent from user registry', async () => {
const entry: Omit<AgentRegistryEntry, 'type'> = {
id: 'test-agent',
name: 'Test Agent',
description: 'Test',
author: 'User',
tags: [],
source: 'test.yml',
};
await addAgentToUserRegistry('test-agent', entry);
expect(userRegistryHasAgent('test-agent')).toBe(true);
await removeAgentFromUserRegistry('test-agent');
expect(userRegistryHasAgent('test-agent')).toBe(false);
});
it('should throw error if agent does not exist', async () => {
await expect(removeAgentFromUserRegistry('nonexistent')).rejects.toThrow();
});
});
});

View File

@@ -0,0 +1,151 @@
import { promises as fs, readFileSync } from 'fs';
import { existsSync } from 'fs';
import path from 'path';
import { getDextoGlobalPath } from '../utils/path.js';
import {
Registry,
RegistrySchema,
AgentRegistryEntry,
normalizeRegistryJson,
deriveDisplayName,
} from './types.js';
import { RegistryError } from './errors.js';
import { logger } from '@dexto/core';
const USER_REGISTRY_FILENAME = 'user-agent-registry.json';
/**
* Get path to user registry file
*/
export function getUserRegistryPath(): string {
return getDextoGlobalPath('', USER_REGISTRY_FILENAME);
}
/**
* Load user registry from ~/.dexto/user-agent-registry.json
* Returns empty registry if file doesn't exist
*/
export function loadUserRegistry(): Registry {
const registryPath = getUserRegistryPath();
if (!existsSync(registryPath)) {
logger.debug('User registry not found, returning empty registry');
return { version: '1.0.0', agents: {} };
}
try {
const content = readFileSync(registryPath, 'utf-8');
const data = JSON.parse(content);
return RegistrySchema.parse(normalizeRegistryJson(data));
} catch (error) {
throw RegistryError.registryParseError(
registryPath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Save user registry atomically using temp file + rename
*/
export async function saveUserRegistry(registry: Registry): Promise<void> {
const registryPath = getUserRegistryPath();
const tempPath = `${registryPath}.tmp.${Date.now()}`;
const dextoDir = path.dirname(registryPath);
try {
// Ensure ~/.dexto directory exists
await fs.mkdir(dextoDir, { recursive: true });
// Write to temp file
await fs.writeFile(tempPath, JSON.stringify(registry, null, 2), {
encoding: 'utf-8',
mode: 0o600,
});
// Atomic rename
await fs.rename(tempPath, registryPath);
logger.debug(`Saved user registry to ${registryPath}`);
} catch (error) {
// Clean up temp file on failure
try {
if (existsSync(tempPath)) {
await fs.rm(tempPath, { force: true });
}
} catch {
// Ignore cleanup errors
}
throw RegistryError.registryWriteError(
registryPath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Merge bundled and user registries
* User registry only contains custom agents
* Name conflicts are not allowed (validated before adding to user registry)
*/
export function mergeRegistries(bundled: Registry, user: Registry): Registry {
return {
version: bundled.version,
agents: {
...bundled.agents,
...user.agents,
},
};
}
/**
* Check if agent exists in user registry
*/
export function userRegistryHasAgent(agentId: string): boolean {
const userRegistry = loadUserRegistry();
return agentId in userRegistry.agents;
}
/**
* Add custom agent to user registry
* Validates that name doesn't conflict with bundled registry
*/
export async function addAgentToUserRegistry(
agentId: string,
entry: Omit<AgentRegistryEntry, 'type'>
): Promise<void> {
const userRegistry = loadUserRegistry();
// Check if already exists in user registry
if (agentId in userRegistry.agents) {
throw RegistryError.agentAlreadyExists(agentId);
}
// Add with type: 'custom', enforcing invariants
userRegistry.agents[agentId] = {
...entry,
id: agentId, // Force consistency between key and id field
name: entry.name && entry.name.trim().length > 0 ? entry.name : deriveDisplayName(agentId), // Ensure name is never undefined or whitespace-only
type: 'custom',
};
await saveUserRegistry(userRegistry);
logger.info(`Added custom agent '${agentId}' to user registry`);
}
/**
* Remove custom agent from user registry
*/
export async function removeAgentFromUserRegistry(agentId: string): Promise<void> {
const userRegistry = loadUserRegistry();
if (!(agentId in userRegistry.agents)) {
throw RegistryError.agentNotFound(agentId, Object.keys(userRegistry.agents));
}
delete userRegistry.agents[agentId];
await saveUserRegistry(userRegistry);
logger.info(`Removed custom agent '${agentId}' from user registry`);
}

View File

@@ -0,0 +1,710 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs, mkdtempSync } from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { resolveAgentPath, updateDefaultAgentPreference } from './resolver.js';
import { ErrorScope, ErrorType } from '@dexto/core';
import { ConfigErrorCode } from './config/index.js';
// Mock dependencies - use vi.fn() in factory to avoid hoisting issues
vi.mock('./utils/execution-context.js', () => ({
getExecutionContext: vi.fn(),
findDextoSourceRoot: vi.fn(),
findDextoProjectRoot: vi.fn(),
}));
vi.mock('./utils/path.js', () => ({
isPath: (str: string) => str.endsWith('.yml') || str.includes('/') || str.includes('\\'),
getDextoGlobalPath: vi.fn(),
resolveBundledScript: vi.fn(),
}));
vi.mock('./preferences/loader.js', () => ({
globalPreferencesExist: vi.fn(),
loadGlobalPreferences: vi.fn(),
updateGlobalPreferences: vi.fn(),
}));
vi.mock('./installation.js', () => ({
installBundledAgent: vi.fn(),
}));
function createTempDir() {
return mkdtempSync(path.join(tmpdir(), 'agent-resolver-test-'));
}
describe('Agent Resolver', () => {
let tempDir: string;
let mockGetExecutionContext: any;
let mockFindDextoSourceRoot: any;
let mockFindDextoProjectRoot: any;
let mockGlobalPreferencesExist: any;
let mockLoadGlobalPreferences: any;
let mockUpdateGlobalPreferences: any;
let mockInstallBundledAgent: any;
let mockGetDextoGlobalPath: any;
let mockResolveBundledScript: any;
beforeEach(async () => {
tempDir = createTempDir();
// Reset all mocks
vi.clearAllMocks();
// Get mocked functions
const execContext = await import('./utils/execution-context.js');
const prefs = await import('./preferences/loader.js');
const pathUtils = await import('./utils/path.js');
const installation = await import('./installation.js');
mockGetExecutionContext = vi.mocked(execContext.getExecutionContext);
mockFindDextoSourceRoot = vi.mocked(execContext.findDextoSourceRoot);
mockFindDextoProjectRoot = vi.mocked(execContext.findDextoProjectRoot);
mockGlobalPreferencesExist = vi.mocked(prefs.globalPreferencesExist);
mockLoadGlobalPreferences = vi.mocked(prefs.loadGlobalPreferences);
mockUpdateGlobalPreferences = vi.mocked(prefs.updateGlobalPreferences);
mockInstallBundledAgent = vi.mocked(installation.installBundledAgent);
mockGetDextoGlobalPath = vi.mocked(pathUtils.getDextoGlobalPath);
mockResolveBundledScript = vi.mocked(pathUtils.resolveBundledScript);
// Setup execution context mocks with default values
mockGetExecutionContext.mockReturnValue('global-cli');
mockFindDextoSourceRoot.mockReturnValue(null);
mockFindDextoProjectRoot.mockReturnValue(null);
// Setup path mocks with default values
mockGetDextoGlobalPath.mockImplementation((type: string) => {
return path.join(tempDir, '.dexto', type);
});
mockResolveBundledScript.mockImplementation((scriptPath: string) => {
return path.join(tempDir, 'bundled', scriptPath);
});
});
afterEach(async () => {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
describe('resolveAgentPath - Explicit File Paths', () => {
it('resolves existing absolute file path', async () => {
const configFile = path.join(tempDir, 'agent.yml');
await fs.writeFile(configFile, 'test: config');
const result = await resolveAgentPath(configFile);
expect(result).toBe(configFile);
});
it('resolves existing relative file path', async () => {
const configFile = path.join(tempDir, 'agent.yml');
await fs.writeFile(configFile, 'test: config');
const originalCwd = process.cwd();
process.chdir(tempDir);
try {
const relativePath = './agent.yml';
const expectedPath = path.resolve(relativePath);
const result = await resolveAgentPath(relativePath);
expect(result).toBe(expectedPath);
} finally {
process.chdir(originalCwd);
}
});
it('throws ConfigError.fileNotFound for non-existent file path', async () => {
const nonExistentFile = path.join(tempDir, 'missing.yml');
await expect(resolveAgentPath(nonExistentFile)).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.FILE_NOT_FOUND,
scope: ErrorScope.CONFIG,
type: ErrorType.USER,
})
);
});
it('recognizes .yml extension as file path', async () => {
const configFile = path.join(tempDir, 'config.yml');
await fs.writeFile(configFile, 'test: config');
const result = await resolveAgentPath(configFile);
expect(result).toBe(configFile);
});
it('recognizes path separators as file path', async () => {
const configFile = path.join(tempDir, 'subdir', 'agent.yml');
await fs.mkdir(path.dirname(configFile), { recursive: true });
await fs.writeFile(configFile, 'test: config');
const result = await resolveAgentPath(configFile);
expect(result).toBe(configFile);
});
});
describe('resolveAgentPath - Registry Names', () => {
it('resolves valid registry agent name', async () => {
const agentConfigPath = path.join(
tempDir,
'.dexto',
'agents',
'database-agent',
'agent.yml'
);
const registryPath = path.join(tempDir, '.dexto', 'agents', 'registry.json');
// Create mock registry file
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(
registryPath,
JSON.stringify({
agents: [
{
id: 'database-agent',
name: 'Database Agent',
description: 'Test agent',
configPath: './database-agent/agent.yml',
},
],
})
);
// Create mock agent config with valid YAML
await fs.mkdir(path.dirname(agentConfigPath), { recursive: true });
await fs.writeFile(
agentConfigPath,
'llm:\n provider: anthropic\n model: claude-4-sonnet-20250514'
);
// Mock install to return the expected path (in case auto-install is triggered)
mockInstallBundledAgent.mockResolvedValue(agentConfigPath);
const result = await resolveAgentPath('database-agent');
expect(result).toBe(agentConfigPath);
});
it('throws error for invalid registry agent name', async () => {
const registryPath = path.join(tempDir, '.dexto', 'agents', 'registry.json');
// Create empty registry
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(registryPath, JSON.stringify({ agents: [] }));
// Mock installBundledAgent to fail
mockInstallBundledAgent.mockRejectedValue(
new Error('Agent not found in bundled registry')
);
await expect(resolveAgentPath('non-existent-agent')).rejects.toThrow(
"Agent 'non-existent-agent' not found in registry"
);
});
});
describe('resolveAgentPath - Default Resolution - Dexto Source Context', () => {
let repoConfigPath: string;
const originalEnv = process.env.DEXTO_DEV_MODE;
beforeEach(async () => {
mockGetExecutionContext.mockReturnValue('dexto-source');
mockFindDextoSourceRoot.mockReturnValue(tempDir);
repoConfigPath = path.join(tempDir, 'agents', 'coding-agent', 'coding-agent.yml');
await fs.mkdir(path.join(tempDir, 'agents', 'coding-agent'), { recursive: true });
await fs.writeFile(
repoConfigPath,
'llm:\n provider: anthropic\n model: claude-4-sonnet-20250514'
);
});
afterEach(() => {
// Restore original env
if (originalEnv === undefined) {
delete process.env.DEXTO_DEV_MODE;
} else {
process.env.DEXTO_DEV_MODE = originalEnv;
}
});
it('uses repo config when DEXTO_DEV_MODE=true', async () => {
process.env.DEXTO_DEV_MODE = 'true';
const result = await resolveAgentPath();
expect(result).toBe(repoConfigPath);
expect(mockGlobalPreferencesExist).not.toHaveBeenCalled();
});
it('uses user preferences when DEXTO_DEV_MODE=false and setup complete', async () => {
process.env.DEXTO_DEV_MODE = 'false';
mockGlobalPreferencesExist.mockReturnValue(true);
mockLoadGlobalPreferences.mockResolvedValue({
setup: { completed: true },
defaults: { defaultAgent: 'my-agent' },
});
const agentConfigPath = path.join(tempDir, '.dexto', 'agents', 'my-agent', 'agent.yml');
const registryPath = path.join(tempDir, '.dexto', 'agents', 'registry.json');
// Create mock registry and agent
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(
registryPath,
JSON.stringify({
agents: [
{
id: 'my-agent',
name: 'My Agent',
description: 'Test agent',
configPath: './my-agent/agent.yml',
},
],
})
);
await fs.mkdir(path.dirname(agentConfigPath), { recursive: true });
await fs.writeFile(
agentConfigPath,
'llm:\n provider: anthropic\n model: claude-4-sonnet-20250514'
);
// Mock install to return the expected path (in case auto-install is triggered)
mockInstallBundledAgent.mockResolvedValue(agentConfigPath);
const result = await resolveAgentPath();
expect(result).toBe(agentConfigPath);
});
it('uses user preferences when DEXTO_DEV_MODE is not set and setup complete', async () => {
delete process.env.DEXTO_DEV_MODE;
mockGlobalPreferencesExist.mockReturnValue(true);
mockLoadGlobalPreferences.mockResolvedValue({
setup: { completed: true },
defaults: { defaultAgent: 'gemini-agent' },
});
const agentConfigPath = path.join(
tempDir,
'.dexto',
'agents',
'gemini-agent',
'agent.yml'
);
const registryPath = path.join(tempDir, '.dexto', 'agents', 'registry.json');
// Create mock registry and agent
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(
registryPath,
JSON.stringify({
agents: [
{
id: 'gemini-agent',
name: 'Gemini Agent',
description: 'Test agent',
configPath: './gemini-agent/agent.yml',
},
],
})
);
await fs.mkdir(path.dirname(agentConfigPath), { recursive: true });
await fs.writeFile(
agentConfigPath,
'llm:\n provider: google\n model: gemini-2.0-flash'
);
// Mock install to return the expected path (in case auto-install is triggered)
mockInstallBundledAgent.mockResolvedValue(agentConfigPath);
const result = await resolveAgentPath();
expect(result).toBe(agentConfigPath);
});
it('falls back to repo config when no preferences exist', async () => {
delete process.env.DEXTO_DEV_MODE;
mockGlobalPreferencesExist.mockReturnValue(false);
const result = await resolveAgentPath();
expect(result).toBe(repoConfigPath);
});
it('falls back to repo config when setup incomplete', async () => {
delete process.env.DEXTO_DEV_MODE;
mockGlobalPreferencesExist.mockReturnValue(true);
mockLoadGlobalPreferences.mockResolvedValue({
setup: { completed: false },
defaults: { defaultAgent: 'my-agent' },
});
const result = await resolveAgentPath();
expect(result).toBe(repoConfigPath);
});
it('falls back to repo config when preferences loading fails', async () => {
delete process.env.DEXTO_DEV_MODE;
mockGlobalPreferencesExist.mockReturnValue(true);
mockLoadGlobalPreferences.mockRejectedValue(new Error('Failed to load preferences'));
const result = await resolveAgentPath();
expect(result).toBe(repoConfigPath);
});
it('throws ConfigError.bundledNotFound when repo config missing in dev mode', async () => {
process.env.DEXTO_DEV_MODE = 'true';
await fs.rm(repoConfigPath); // Delete the config file
await expect(resolveAgentPath()).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.BUNDLED_NOT_FOUND,
scope: ErrorScope.CONFIG,
type: ErrorType.NOT_FOUND,
})
);
});
it('throws ConfigError.bundledNotFound when repo config missing and no preferences', async () => {
delete process.env.DEXTO_DEV_MODE;
mockGlobalPreferencesExist.mockReturnValue(false);
await fs.rm(repoConfigPath); // Delete the config file
await expect(resolveAgentPath()).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.BUNDLED_NOT_FOUND,
scope: ErrorScope.CONFIG,
type: ErrorType.NOT_FOUND,
})
);
});
});
describe('resolveAgentPath - Default Resolution - Dexto Project Context', () => {
beforeEach(() => {
mockGetExecutionContext.mockReturnValue('dexto-project');
mockFindDextoProjectRoot.mockReturnValue(tempDir);
});
it('uses project-local src/dexto/agents/coding-agent.yml when exists', async () => {
const projectDefault = path.join(tempDir, 'src', 'dexto', 'agents', 'coding-agent.yml');
await fs.mkdir(path.join(tempDir, 'src', 'dexto', 'agents'), { recursive: true });
await fs.writeFile(projectDefault, 'test: config');
// Mock fs.access to succeed for the project default file
const mockAccess = vi.spyOn(fs, 'access').mockImplementation(async (filePath) => {
if (filePath === projectDefault) {
return Promise.resolve();
}
throw new Error('File not found');
});
const result = await resolveAgentPath();
expect(result).toBe(projectDefault);
mockAccess.mockRestore();
});
it('falls back to preferences when no project default', async () => {
// No project default file (don't create the file)
mockGlobalPreferencesExist.mockReturnValue(true);
mockLoadGlobalPreferences.mockResolvedValue({
setup: { completed: true },
defaults: { defaultAgent: 'my-agent' },
});
const agentConfigPath = path.join(tempDir, '.dexto', 'agents', 'my-agent', 'agent.yml');
const registryPath = path.join(tempDir, '.dexto', 'agents', 'registry.json');
// Create mock registry and agent
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(
registryPath,
JSON.stringify({
agents: [
{
id: 'my-agent',
name: 'My Agent',
description: 'Test agent',
configPath: './my-agent/agent.yml',
},
],
})
);
await fs.mkdir(path.dirname(agentConfigPath), { recursive: true });
await fs.writeFile(
agentConfigPath,
'llm:\n provider: anthropic\n model: claude-4-sonnet-20250514'
);
// Mock install to return the expected path (in case auto-install is triggered)
mockInstallBundledAgent.mockResolvedValue(agentConfigPath);
const result = await resolveAgentPath();
expect(result).toBe(agentConfigPath);
});
it('throws ConfigError.noProjectDefault when no project default and no preferences', async () => {
// No project default file
mockGlobalPreferencesExist.mockReturnValue(false);
await expect(resolveAgentPath()).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.NO_PROJECT_DEFAULT,
})
);
});
it('throws ConfigError.setupIncomplete when preferences setup incomplete', async () => {
// No project default file
mockGlobalPreferencesExist.mockReturnValue(true);
mockLoadGlobalPreferences.mockResolvedValue({
setup: { completed: false },
defaults: { defaultAgent: 'my-agent' },
});
await expect(resolveAgentPath()).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.SETUP_INCOMPLETE,
scope: ErrorScope.CONFIG,
type: ErrorType.USER,
})
);
});
});
describe('resolveAgentPath - Default Resolution - Global CLI Context', () => {
beforeEach(() => {
mockGetExecutionContext.mockReturnValue('global-cli');
});
it('resolves using preferences default agent', async () => {
mockGlobalPreferencesExist.mockReturnValue(true);
mockLoadGlobalPreferences.mockResolvedValue({
setup: { completed: true },
defaults: { defaultAgent: 'my-default' },
});
const agentConfigPath = path.join(
tempDir,
'.dexto',
'agents',
'my-default',
'agent.yml'
);
const registryPath = path.join(tempDir, '.dexto', 'agents', 'registry.json');
// Create mock registry and agent
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(
registryPath,
JSON.stringify({
agents: [
{
id: 'my-default',
name: 'My Default',
description: 'Test agent',
configPath: './my-default/agent.yml',
},
],
})
);
await fs.mkdir(path.dirname(agentConfigPath), { recursive: true });
await fs.writeFile(
agentConfigPath,
'llm:\n provider: anthropic\n model: claude-4-sonnet-20250514'
);
// Mock install to return the expected path (in case auto-install is triggered)
mockInstallBundledAgent.mockResolvedValue(agentConfigPath);
const result = await resolveAgentPath();
expect(result).toBe(agentConfigPath);
});
it('throws ConfigError.noGlobalPreferences when no preferences exist', async () => {
mockGlobalPreferencesExist.mockReturnValue(false);
await expect(resolveAgentPath()).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.NO_GLOBAL_PREFERENCES,
scope: ErrorScope.CONFIG,
type: ErrorType.USER,
})
);
});
it('throws ConfigError.setupIncomplete when setup incomplete', async () => {
mockGlobalPreferencesExist.mockReturnValue(true);
mockLoadGlobalPreferences.mockResolvedValue({
setup: { completed: false },
defaults: { defaultAgent: 'my-agent' },
});
await expect(resolveAgentPath()).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.SETUP_INCOMPLETE,
scope: ErrorScope.CONFIG,
type: ErrorType.USER,
})
);
});
});
describe('resolveAgentPath - Unknown Execution Context', () => {
it('throws ConfigError.unknownContext for unknown execution context', async () => {
mockGetExecutionContext.mockReturnValue('unknown-context');
await expect(resolveAgentPath()).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.UNKNOWN_CONTEXT,
scope: ErrorScope.CONFIG,
type: ErrorType.SYSTEM,
})
);
});
});
describe('updateDefaultAgentPreference', () => {
// Note: These tests expose a bug in the implementation where hasAgent() is called
// before the registry is loaded. hasAgent() is synchronous but requires the registry
// to be loaded first (which is async). The production code should either:
// 1. Make loadRegistry() public and await it before calling hasAgent()
// 2. Create an async hasAgent() method
// 3. Use try/catch with createAgent() instead
it.skip('updates preference for valid agent', async () => {
const registryPath = path.join(tempDir, '.dexto', 'agents', 'registry.json');
const bundledRegistryPath = path.join(
tempDir,
'bundled',
'agents',
'agent-registry.json'
);
const agentConfigPath = path.join(tempDir, '.dexto', 'agents', 'my-agent', 'agent.yml');
mockUpdateGlobalPreferences.mockResolvedValue(undefined);
// Create mock installed registry
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(
registryPath,
JSON.stringify({
agents: [
{
id: 'my-agent',
name: 'My Agent',
description: 'Test agent',
configPath: './my-agent/agent.yml',
},
],
})
);
// Create bundled registry (as fallback) - must use array format like installed registry
await fs.mkdir(path.dirname(bundledRegistryPath), { recursive: true });
await fs.writeFile(
bundledRegistryPath,
JSON.stringify({
agents: [
{
id: 'my-agent',
name: 'My Agent',
description: 'Test agent',
configPath: './my-agent/agent.yml',
},
],
})
);
// Create agent config file
await fs.mkdir(path.dirname(agentConfigPath), { recursive: true });
await fs.writeFile(
agentConfigPath,
'llm:\n provider: anthropic\n model: claude-4-sonnet-20250514'
);
await updateDefaultAgentPreference('my-agent');
expect(mockUpdateGlobalPreferences).toHaveBeenCalledWith({
defaults: { defaultAgent: 'my-agent' },
});
});
it('throws error for invalid agent', async () => {
const registryPath = path.join(tempDir, '.dexto', 'agents', 'registry.json');
const bundledRegistryPath = path.join(
tempDir,
'bundled',
'agents',
'agent-registry.json'
);
// Create empty registries
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(registryPath, JSON.stringify({ agents: [] }));
await fs.mkdir(path.dirname(bundledRegistryPath), { recursive: true });
await fs.writeFile(bundledRegistryPath, JSON.stringify({ agents: {} }));
await expect(updateDefaultAgentPreference('invalid-agent')).rejects.toThrow(
'not found'
);
expect(mockUpdateGlobalPreferences).not.toHaveBeenCalled();
});
it.skip('throws error when preference update fails', async () => {
const registryPath = path.join(tempDir, '.dexto', 'agents', 'registry.json');
const bundledRegistryPath = path.join(
tempDir,
'bundled',
'agents',
'agent-registry.json'
);
const agentConfigPath = path.join(tempDir, '.dexto', 'agents', 'my-agent', 'agent.yml');
// Create mock installed registry
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(
registryPath,
JSON.stringify({
agents: [
{
id: 'my-agent',
name: 'My Agent',
description: 'Test agent',
configPath: './my-agent/agent.yml',
},
],
})
);
// Create bundled registry (as fallback) - must use array format like installed registry
await fs.mkdir(path.dirname(bundledRegistryPath), { recursive: true });
await fs.writeFile(
bundledRegistryPath,
JSON.stringify({
agents: [
{
id: 'my-agent',
name: 'My Agent',
description: 'Test agent',
configPath: './my-agent/agent.yml',
},
],
})
);
// Create agent config file
await fs.mkdir(path.dirname(agentConfigPath), { recursive: true });
await fs.writeFile(
agentConfigPath,
'llm:\n provider: anthropic\n model: claude-4-sonnet-20250514'
);
// Mock update to fail after agent is found
mockUpdateGlobalPreferences.mockRejectedValue(new Error('Update failed'));
await expect(updateDefaultAgentPreference('my-agent')).rejects.toThrow('Update failed');
});
});
});

View File

@@ -0,0 +1,300 @@
// packages/agent-management/src/resolver.ts
import { promises as fs } from 'fs';
import path from 'path';
import { isPath, getDextoGlobalPath, resolveBundledScript } from './utils/path.js';
import {
getExecutionContext,
findDextoSourceRoot,
findDextoProjectRoot,
} from './utils/execution-context.js';
import { logger } from '@dexto/core';
import { loadGlobalPreferences, globalPreferencesExist } from './preferences/loader.js';
import { ConfigError } from './config/index.js';
import { RegistryError } from './registry/errors.js';
import { AgentManager } from './AgentManager.js';
import { installBundledAgent } from './installation.js';
/**
* Entry in the installed agents registry (registry.json)
*/
interface InstalledAgentEntry {
id: string;
name: string;
description: string;
configPath: string;
author: string;
tags: string[];
}
/**
* Installed agents registry format
*/
interface InstalledRegistry {
agents: InstalledAgentEntry[];
}
/**
* Resolve agent path with automatic installation if needed
* @param nameOrPath Optional agent name or explicit path
* @param autoInstall Whether to automatically install missing agents from bundled registry (default: true)
* @returns Resolved absolute path to agent config
* @throws {ConfigError} For path/config issues (file not found, unknown context, setup incomplete)
* @throws {RegistryError} For agent lookup failures (agent not found, not installed)
*/
export async function resolveAgentPath(
nameOrPath?: string,
autoInstall: boolean = true
): Promise<string> {
// 1. Handle explicit paths (highest priority)
if (nameOrPath && isPath(nameOrPath)) {
const resolved = path.resolve(nameOrPath);
// Verify an actual file exists - fail fast if not
try {
const stat = await fs.stat(resolved);
if (!stat.isFile()) {
throw ConfigError.fileNotFound(resolved);
}
return resolved;
} catch {
throw ConfigError.fileNotFound(resolved);
}
}
// 2. Handle agent names from installed registry
if (nameOrPath) {
return await resolveAgentByName(nameOrPath, autoInstall);
}
// 3. Default agent resolution based on execution context
return await resolveDefaultAgentByContext(autoInstall);
}
/**
* Resolve agent by name from installed or bundled registry
*/
async function resolveAgentByName(agentId: string, autoInstall: boolean): Promise<string> {
const agentsDir = getDextoGlobalPath('agents');
const installedRegistryPath = path.join(agentsDir, 'registry.json');
// Check if installed
try {
const manager = new AgentManager(installedRegistryPath);
await manager.loadRegistry();
if (manager.hasAgent(agentId)) {
const agentPath = await getAgentConfigPath(agentId);
return agentPath;
}
} catch (error) {
// Registry doesn't exist or agent not found, continue to auto-install
logger.debug(`Agent '${agentId}' not found in installed registry: ${error}`);
}
// Auto-install from bundled if available
if (autoInstall) {
try {
logger.info(`Auto-installing agent '${agentId}' from bundled registry`);
const configPath = await installBundledAgent(agentId);
return configPath;
} catch (error) {
// installBundledAgent throws RegistryError.agentNotFound if not in bundled registry
// Re-throw with context that we checked both registries
logger.debug(`Failed to auto-install agent '${agentId}': ${error}`);
throw RegistryError.agentNotFound(agentId, []);
}
}
throw RegistryError.agentNotInstalledAutoInstallDisabled(agentId, []);
}
/**
* Get config path for an agent from the installed registry
*/
async function getAgentConfigPath(agentId: string): Promise<string> {
// Extract config path from agent - we need to find the actual config file
// The agent was created from the config, so we can derive the path from the registry
const agentsDir = getDextoGlobalPath('agents');
const installedRegistryPath = path.join(agentsDir, 'registry.json');
const registryContent = await fs.readFile(installedRegistryPath, 'utf-8');
const registry = JSON.parse(registryContent) as InstalledRegistry;
const agentEntry = registry.agents.find((a) => a.id === agentId);
if (!agentEntry) {
const available = registry.agents.map((a) => a.id);
throw RegistryError.agentNotFound(agentId, available);
}
return path.resolve(path.dirname(installedRegistryPath), agentEntry.configPath);
}
/**
* Resolve default agent based on execution context
*/
async function resolveDefaultAgentByContext(autoInstall: boolean = true): Promise<string> {
const executionContext = getExecutionContext();
switch (executionContext) {
case 'dexto-source':
return await resolveDefaultAgentForDextoSource(autoInstall);
case 'dexto-project':
return await resolveDefaultAgentForDextoProject(autoInstall);
case 'global-cli':
return await resolveDefaultAgentForGlobalCLI(autoInstall);
default:
throw ConfigError.unknownContext(executionContext);
}
}
/**
* Resolution for Dexto source code context
* - Dev mode (DEXTO_DEV_MODE=true): Always use repo config file
* - User with setup: Use their preferences
* - Otherwise: Fallback to repo config file
*/
async function resolveDefaultAgentForDextoSource(autoInstall: boolean = true): Promise<string> {
logger.debug('Resolving default agent for dexto source context');
const sourceRoot = findDextoSourceRoot();
if (!sourceRoot) {
throw ConfigError.bundledNotFound('dexto source directory not found');
}
const repoConfigPath = path.join(sourceRoot, 'agents', 'coding-agent', 'coding-agent.yml');
// Check if we're in dev mode (maintainers testing the repo config)
const isDevMode = process.env.DEXTO_DEV_MODE === 'true';
if (isDevMode) {
logger.debug('Dev mode: using repository config file');
try {
await fs.access(repoConfigPath);
return repoConfigPath;
} catch {
throw ConfigError.bundledNotFound(repoConfigPath);
}
}
// Prefer user preferences if setup is complete
if (globalPreferencesExist()) {
try {
const preferences = await loadGlobalPreferences();
if (preferences.setup.completed) {
logger.debug('Using user preferences in dexto-source context');
const preferredAgentName = preferences.defaults.defaultAgent;
return await resolveAgentByName(preferredAgentName, autoInstall);
}
} catch (error) {
logger.warn(`Failed to load preferences, falling back to repo config: ${error}`);
}
}
// Fallback to repo config
logger.debug('Using repository config (no preferences or setup incomplete)');
try {
await fs.access(repoConfigPath);
return repoConfigPath;
} catch {
throw ConfigError.bundledNotFound(repoConfigPath);
}
}
/**
* Resolution for Dexto project context - project default OR preferences default
*/
async function resolveDefaultAgentForDextoProject(autoInstall: boolean = true): Promise<string> {
logger.debug('Resolving default agent for dexto project context');
const projectRoot = findDextoProjectRoot();
if (!projectRoot) {
throw ConfigError.unknownContext('dexto-project: project root not found');
}
// 1. Try project-local coding-agent.yml first
const candidatePaths = [
path.join(projectRoot, 'coding-agent.yml'),
path.join(projectRoot, 'agents', 'coding-agent.yml'),
path.join(projectRoot, 'src', 'dexto', 'agents', 'coding-agent.yml'),
];
for (const p of candidatePaths) {
try {
await fs.access(p);
return p;
} catch {
// continue
}
}
logger.debug(`No project-local coding-agent.yml found in ${projectRoot}`);
// 2. Use preferences default agent name - REQUIRED if no project default
if (!globalPreferencesExist()) {
throw ConfigError.noProjectDefault(projectRoot);
}
const preferences = await loadGlobalPreferences();
if (!preferences.setup.completed) {
throw ConfigError.setupIncomplete();
}
const preferredAgentName = preferences.defaults.defaultAgent;
return await resolveAgentByName(preferredAgentName, autoInstall);
}
/**
* Resolution for Global CLI context - preferences default REQUIRED
*/
async function resolveDefaultAgentForGlobalCLI(autoInstall: boolean = true): Promise<string> {
logger.debug('Resolving default agent for global CLI context');
if (!globalPreferencesExist()) {
throw ConfigError.noGlobalPreferences();
}
const preferences = await loadGlobalPreferences();
if (!preferences.setup.completed) {
throw ConfigError.setupIncomplete();
}
const preferredAgentName = preferences.defaults.defaultAgent;
return await resolveAgentByName(preferredAgentName, autoInstall);
}
/**
* Update default agent preference
* @param agentName The agent name to set as the new default
* @throws {RegistryError} If the agent is not found in installed or bundled registry
*/
export async function updateDefaultAgentPreference(agentName: string): Promise<void> {
// Validate agent exists in bundled or installed registry
const agentsDir = getDextoGlobalPath('agents');
const installedRegistryPath = path.join(agentsDir, 'registry.json');
const bundledRegistryPath = resolveBundledScript('agents/agent-registry.json');
// Check both registries
const registriesToCheck = [
{ path: installedRegistryPath, name: 'installed' },
{ path: bundledRegistryPath, name: 'bundled' },
];
for (const registry of registriesToCheck) {
try {
const manager = new AgentManager(registry.path);
await manager.loadRegistry();
if (manager.hasAgent(agentName)) {
const { updateGlobalPreferences } = await import('./preferences/loader.js');
await updateGlobalPreferences({
defaults: { defaultAgent: agentName },
});
logger.info(`Updated default agent preference to: ${agentName}`);
return;
}
} catch (error) {
logger.debug(`Agent '${agentName}' not found in ${registry.name} registry: ${error}`);
}
}
// Agent not found in either registry
throw RegistryError.agentNotFound(agentName, []);
}

View File

@@ -0,0 +1,194 @@
/**
* AgentPool - Manages a pool of agent instances
*
* Tracks active agents, enforces resource limits, and provides
* lookup and lifecycle management capabilities.
*/
import type { IDextoLogger } from '@dexto/core';
import type { AgentHandle, AgentStatus, AgentFilter } from './types.js';
import {
DEFAULT_MAX_AGENTS,
DEFAULT_TASK_TIMEOUT,
type ValidatedAgentRuntimeConfig,
} from './schemas.js';
import { RuntimeError } from './errors.js';
export class AgentPool {
private agents: Map<string, AgentHandle> = new Map();
private config: ValidatedAgentRuntimeConfig;
private logger: IDextoLogger;
constructor(config: Partial<ValidatedAgentRuntimeConfig>, logger: IDextoLogger) {
this.config = {
maxAgents: config.maxAgents ?? DEFAULT_MAX_AGENTS,
defaultTaskTimeout: config.defaultTaskTimeout ?? DEFAULT_TASK_TIMEOUT,
};
this.logger = logger;
}
/**
* Add an agent handle to the pool
* @throws RuntimeError if agent with same ID already exists or limit exceeded
*/
add(handle: AgentHandle): void {
if (this.agents.has(handle.agentId)) {
throw RuntimeError.agentAlreadyExists(handle.agentId);
}
if (this.agents.size >= this.config.maxAgents) {
throw RuntimeError.maxAgentsExceeded(this.agents.size, this.config.maxAgents);
}
this.agents.set(handle.agentId, handle);
this.logger.debug(
`Added agent '${handle.agentId}' to pool${handle.group ? ` (group: ${handle.group})` : ''}`
);
}
/**
* Remove an agent from the pool
* @returns The removed handle, or undefined if not found
*/
remove(agentId: string): AgentHandle | undefined {
const handle = this.agents.get(agentId);
if (handle) {
this.agents.delete(agentId);
this.logger.debug(`Removed agent '${agentId}' from pool`);
}
return handle;
}
/**
* Get an agent handle by ID
*/
get(agentId: string): AgentHandle | undefined {
return this.agents.get(agentId);
}
/**
* Check if another agent can be spawned
*/
canSpawn(): boolean {
return this.agents.size < this.config.maxAgents;
}
/**
* List agents matching the given filter
*/
list(filter?: AgentFilter): AgentHandle[] {
if (!filter) {
return Array.from(this.agents.values());
}
const results: AgentHandle[] = [];
for (const handle of this.agents.values()) {
if (this.matchesFilter(handle, filter)) {
results.push(handle);
}
}
return results;
}
/**
* Get all agents in a specific group
*/
getByGroup(group: string): AgentHandle[] {
return this.list({ group });
}
/**
* Get the count of agents in a specific group
*/
getGroupCount(group: string): number {
return this.getByGroup(group).length;
}
/**
* Update the status of an agent
* @throws RuntimeError if agent not found
*/
updateStatus(agentId: string, status: AgentStatus, error?: string): void {
const handle = this.agents.get(agentId);
if (!handle) {
throw RuntimeError.agentNotFound(agentId);
}
const previousStatus = handle.status;
handle.status = status;
if (status !== 'error') {
delete handle.error;
} else if (error !== undefined) {
handle.error = error;
}
this.logger.debug(
`Agent '${agentId}' status changed: ${previousStatus} -> ${status}${error ? ` (error: ${error})` : ''}`
);
}
/**
* Check if an agent exists in the pool
*/
has(agentId: string): boolean {
return this.agents.has(agentId);
}
/**
* Get all agent handles in the pool
*/
getAll(): AgentHandle[] {
return Array.from(this.agents.values());
}
/**
* Get the total count of agents in the pool
*/
get size(): number {
return this.agents.size;
}
/**
* Get the configuration
*/
getConfig(): ValidatedAgentRuntimeConfig {
return { ...this.config };
}
/**
* Clear all agents from the pool
* Note: This does NOT stop the agents - use AgentRuntime.stopAll instead
*/
clear(): void {
const count = this.agents.size;
this.agents.clear();
if (count > 0) {
this.logger.debug(`Cleared ${count} agents from pool`);
}
}
/**
* Check if an agent handle matches a filter
*/
private matchesFilter(handle: AgentHandle, filter: AgentFilter): boolean {
// Filter by group
if (filter.group !== undefined && handle.group !== filter.group) {
return false;
}
// Filter by status
if (filter.status !== undefined) {
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
if (!statuses.includes(handle.status)) {
return false;
}
}
// Filter by ephemeral
if (filter.ephemeral !== undefined && handle.ephemeral !== filter.ephemeral) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,305 @@
/**
* AgentRuntime - General-Purpose Agent Lifecycle Manager
*
* Manages the lifecycle of multiple agent instances. Can be used for:
* - Dashboard spawning multiple independent agents
* - Agent task delegation (parent spawns sub-agents)
* - Test harnesses managing multiple agents
* - Any scenario requiring multiple concurrent agents
*
* Key responsibilities:
* - Spawn and manage agents with configurable limits
* - Execute tasks on agents with timeout handling
* - Track agent status and lifecycle
* - Clean up agents when no longer needed
*/
import { randomUUID } from 'crypto';
import { DextoAgent, type IDextoLogger, type GenerateResponse } from '@dexto/core';
import { enrichAgentConfig } from '../config/index.js';
import { AgentPool } from './AgentPool.js';
import { RuntimeError } from './errors.js';
import type {
AgentRuntimeConfig,
SpawnConfig,
AgentHandle,
TaskResult,
AgentFilter,
} from './types.js';
import { AgentRuntimeConfigSchema, type ValidatedAgentRuntimeConfig } from './schemas.js';
/**
* Options for creating an AgentRuntime
*/
export interface AgentRuntimeOptions {
/** Runtime configuration */
config?: AgentRuntimeConfig;
/** Logger instance */
logger: IDextoLogger;
}
export class AgentRuntime {
private pool: AgentPool;
private config: ValidatedAgentRuntimeConfig;
private logger: IDextoLogger;
constructor(options: AgentRuntimeOptions) {
// Validate and apply defaults
this.config = AgentRuntimeConfigSchema.parse(options.config ?? {});
this.logger = options.logger;
this.pool = new AgentPool(this.config, this.logger);
this.logger.debug('AgentRuntime initialized', {
maxAgents: this.config.maxAgents,
defaultTaskTimeout: this.config.defaultTaskTimeout,
});
}
/**
* Spawn a new agent
*
* @param config - Configuration for the agent
* @returns Handle to the spawned agent
*/
async spawnAgent(config: SpawnConfig): Promise<AgentHandle> {
// Check global limit
if (!this.pool.canSpawn()) {
throw RuntimeError.maxAgentsExceeded(this.pool.size, this.config.maxAgents);
}
// Generate agent ID if not provided
const agentId = config.agentId ?? `agent-${randomUUID().slice(0, 8)}`;
// Check for duplicate ID
if (this.pool.has(agentId)) {
throw RuntimeError.agentAlreadyExists(agentId);
}
try {
// Enrich the config with runtime paths
// Skip plugin discovery for subagents to avoid duplicate warnings
const enrichedConfig = enrichAgentConfig(
config.agentConfig,
undefined, // No config path
{ isInteractiveCli: false, skipPluginDiscovery: true }
);
// Override agentId in enriched config
enrichedConfig.agentId = agentId;
// Create the agent
const agent = new DextoAgent(enrichedConfig);
// Create the handle (status: starting)
const sessionId = `session-${randomUUID().slice(0, 8)}`;
const handle: AgentHandle = {
agentId,
agent,
status: 'starting',
ephemeral: config.ephemeral ?? true,
createdAt: new Date(),
sessionId,
};
// Add optional fields only if defined (exactOptionalPropertyTypes)
if (config.group !== undefined) {
handle.group = config.group;
}
if (config.metadata !== undefined) {
handle.metadata = config.metadata;
}
// Add to pool
this.pool.add(handle);
// Call onBeforeStart hook if provided (e.g., to set approval handlers)
if (config.onBeforeStart) {
await config.onBeforeStart(agent);
}
// Start the agent
await agent.start();
// Update status to idle
this.pool.updateStatus(agentId, 'idle');
this.logger.info(
`Spawned agent '${agentId}'${handle.group ? ` (group: ${handle.group})` : ''} (ephemeral: ${handle.ephemeral})`
);
return handle;
} catch (error) {
// Clean up on failure
this.pool.remove(agentId);
const errorMessage = error instanceof Error ? error.message : String(error);
throw RuntimeError.spawnFailed(errorMessage, agentId);
}
}
/**
* Execute a task on an agent
*
* @param agentId - ID of the agent
* @param task - Task description to execute
* @param timeout - Optional timeout in milliseconds
* @returns Task result with response or error
*/
async executeTask(agentId: string, task: string, timeout?: number): Promise<TaskResult> {
const handle = this.pool.get(agentId);
if (!handle) {
throw RuntimeError.agentNotFound(agentId);
}
if (handle.status === 'stopped' || handle.status === 'error') {
throw RuntimeError.agentAlreadyStopped(agentId);
}
const taskTimeout = timeout ?? this.config.defaultTaskTimeout;
// Update status to running
this.pool.updateStatus(agentId, 'running');
try {
// Create timeout promise
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(RuntimeError.taskTimeout(agentId, taskTimeout));
}, taskTimeout);
});
// Execute the task with timeout
const generatePromise = handle.agent.generate(task, handle.sessionId);
const response = (await Promise.race([
generatePromise,
timeoutPromise,
])) as GenerateResponse;
// Update status back to idle
this.pool.updateStatus(agentId, 'idle');
// Build result
const result: TaskResult = {
success: true,
response: response.content,
agentId,
tokenUsage: {
input: response.usage.inputTokens,
output: response.usage.outputTokens,
total: response.usage.totalTokens,
},
};
this.logger.debug(`Task completed for agent '${agentId}'`);
return result;
} catch (error) {
// Update status to error
const errorMessage = error instanceof Error ? error.message : String(error);
this.pool.updateStatus(agentId, 'error', errorMessage);
// Check if it's a timeout error
if (error instanceof Error && error.message.includes('Task execution timed out')) {
return {
success: false,
error: errorMessage,
agentId,
};
}
// Re-throw unexpected errors as task failures
throw RuntimeError.taskFailed(agentId, errorMessage);
}
}
/**
* Get an agent handle by ID
*/
getAgent(agentId: string): AgentHandle | undefined {
return this.pool.get(agentId);
}
/**
* List agents matching the given filter
*/
listAgents(filter?: AgentFilter): AgentHandle[] {
return this.pool.list(filter);
}
/**
* Stop a specific agent
*/
async stopAgent(agentId: string): Promise<void> {
const handle = this.pool.get(agentId);
if (!handle) {
throw RuntimeError.agentNotFound(agentId);
}
if (handle.status === 'stopped') {
this.logger.debug(`Agent '${agentId}' already stopped`);
return;
}
// Update status
this.pool.updateStatus(agentId, 'stopping');
try {
// Cancel any pending approvals
handle.agent.services.approvalManager.cancelAllApprovals();
// Stop the agent
await handle.agent.stop();
// Update status
this.pool.updateStatus(agentId, 'stopped');
this.logger.debug(`Stopped agent '${agentId}'`);
// Remove from pool if ephemeral
if (handle.ephemeral) {
this.pool.remove(agentId);
this.logger.debug(`Removed ephemeral agent '${agentId}' from pool`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.pool.updateStatus(agentId, 'error', errorMessage);
this.logger.error(`Failed to stop agent '${agentId}': ${errorMessage}`);
}
}
/**
* Stop all agents matching the given filter
*/
async stopAll(filter?: AgentFilter): Promise<void> {
const agents = this.pool.list(filter);
this.logger.debug(`Stopping ${agents.length} agents`);
// Stop all in parallel
await Promise.allSettled(agents.map((handle) => this.stopAgent(handle.agentId)));
}
/**
* Get the runtime configuration
*/
getConfig(): ValidatedAgentRuntimeConfig {
return { ...this.config };
}
/**
* Get statistics about the runtime
*/
getStats(): { totalAgents: number; byStatus: Record<string, number> } {
const agents = this.pool.getAll();
const byStatus: Record<string, number> = {};
for (const agent of agents) {
byStatus[agent.status] = (byStatus[agent.status] ?? 0) + 1;
}
return {
totalAgents: agents.length,
byStatus,
};
}
}

View File

@@ -0,0 +1,137 @@
/**
* Approval Delegation for Sub-Agents
*
* Creates ApprovalHandler instances that delegate approval requests
* from sub-agents to the parent agent's approval system.
*/
import type {
ApprovalHandler,
ApprovalRequest,
ApprovalResponse,
ApprovalRequestDetails,
ApprovalManager,
IDextoLogger,
} from '@dexto/core';
/**
* Extended metadata type that includes delegation information
*/
interface DelegatedMetadata {
/** Original metadata from the request */
[key: string]: unknown;
/** ID of the sub-agent that originated the request */
delegatedFromAgent: string;
}
/**
* Creates an ApprovalHandler that delegates requests to a parent's ApprovalManager.
*
* This allows sub-agent tool approvals to flow through the parent's approval system,
* ensuring the user sees a unified approval UI regardless of which agent made the request.
*
* @param parentApprovalManager - The parent agent's ApprovalManager
* @param subAgentId - ID of the sub-agent for tracking
* @param logger - Logger for debugging
* @returns An ApprovalHandler that can be set on the sub-agent's ApprovalManager
*
* @example
* ```typescript
* const delegatingHandler = createDelegatingApprovalHandler(
* parentAgent.services.approvalManager,
* subAgentId,
* logger
* );
* subAgent.services.approvalManager.setHandler(delegatingHandler);
* ```
*/
export function createDelegatingApprovalHandler(
parentApprovalManager: ApprovalManager,
subAgentId: string,
logger: IDextoLogger
): ApprovalHandler {
// Track pending approvals for this sub-agent (for cancellation)
const pendingApprovalIds = new Set<string>();
const handler: ApprovalHandler = Object.assign(
async (request: ApprovalRequest): Promise<ApprovalResponse> => {
logger.debug(
`Delegating approval '${request.approvalId}' (type: ${request.type}) from sub-agent '${subAgentId}'`
);
// Track this approval
pendingApprovalIds.add(request.approvalId);
try {
// Build delegated request details with sub-agent context
const delegatedDetails: ApprovalRequestDetails = {
type: request.type,
timeout: request.timeout,
sessionId: request.sessionId,
metadata: {
...request.metadata,
delegatedFromAgent: subAgentId,
} as DelegatedMetadata,
};
// Forward to parent's approval manager
const response = await parentApprovalManager.requestApproval(delegatedDetails);
logger.debug(
`Approval '${request.approvalId}' delegated response: ${response.status}`
);
return response;
} finally {
// Remove from pending set
pendingApprovalIds.delete(request.approvalId);
}
},
{
/**
* Cancel a specific pending approval request
*/
cancel: (approvalId: string): void => {
if (pendingApprovalIds.has(approvalId)) {
logger.debug(
`Cancelling delegated approval '${approvalId}' for sub-agent '${subAgentId}'`
);
parentApprovalManager.cancelApproval(approvalId);
pendingApprovalIds.delete(approvalId);
}
},
/**
* Cancel all pending approval requests for this sub-agent
*/
cancelAll: (): void => {
if (pendingApprovalIds.size > 0) {
logger.debug(
`Cancelling all ${pendingApprovalIds.size} delegated approvals for sub-agent '${subAgentId}'`
);
for (const approvalId of pendingApprovalIds) {
parentApprovalManager.cancelApproval(approvalId);
}
pendingApprovalIds.clear();
}
},
/**
* Get list of pending approval IDs for this sub-agent
*/
getPending: (): string[] => {
return Array.from(pendingApprovalIds);
},
/**
* Get pending approval requests (not available for delegated handlers)
*/
getPendingRequests: (): ApprovalRequest[] => {
// We don't have access to the full requests, just IDs
return [];
},
}
);
return handler;
}

View File

@@ -0,0 +1,23 @@
/**
* Runtime-specific error codes
* Includes errors for agent spawning, lifecycle management, and task execution
*/
export enum RuntimeErrorCode {
// Limit errors
MAX_AGENTS_EXCEEDED = 'runtime_max_agents_exceeded',
// Agent lifecycle errors
AGENT_NOT_FOUND = 'runtime_agent_not_found',
AGENT_ALREADY_EXISTS = 'runtime_agent_already_exists',
AGENT_NOT_STARTED = 'runtime_agent_not_started',
AGENT_ALREADY_STOPPED = 'runtime_agent_already_stopped',
// Spawn errors
SPAWN_FAILED = 'runtime_spawn_failed',
INVALID_CONFIG = 'runtime_invalid_config',
// Task execution errors
TASK_TIMEOUT = 'runtime_task_timeout',
TASK_FAILED = 'runtime_task_failed',
TASK_CANCELLED = 'runtime_task_cancelled',
}

View File

@@ -0,0 +1,124 @@
import { DextoRuntimeError, ErrorScope, ErrorType } from '@dexto/core';
import { RuntimeErrorCode } from './error-codes.js';
/**
* Runtime error factory methods
* Creates properly typed errors for agent runtime operations
*/
export class RuntimeError {
// Limit errors
static maxAgentsExceeded(currentCount: number, maxAllowed: number) {
return new DextoRuntimeError(
RuntimeErrorCode.MAX_AGENTS_EXCEEDED,
ErrorScope.AGENT,
ErrorType.USER,
`Maximum agents limit exceeded. Current: ${currentCount}, Max: ${maxAllowed}`,
{ currentCount, maxAllowed },
'Stop some existing agents before spawning new ones'
);
}
// Agent lifecycle errors
static agentNotFound(agentId: string) {
return new DextoRuntimeError(
RuntimeErrorCode.AGENT_NOT_FOUND,
ErrorScope.AGENT,
ErrorType.NOT_FOUND,
`Agent '${agentId}' not found`,
{ agentId },
'Ensure the agent ID is correct and the agent has been spawned'
);
}
static agentAlreadyExists(agentId: string) {
return new DextoRuntimeError(
RuntimeErrorCode.AGENT_ALREADY_EXISTS,
ErrorScope.AGENT,
ErrorType.USER,
`Agent with ID '${agentId}' already exists`,
{ agentId },
'Use a different agent ID or stop the existing agent first'
);
}
static agentNotStarted(agentId: string) {
return new DextoRuntimeError(
RuntimeErrorCode.AGENT_NOT_STARTED,
ErrorScope.AGENT,
ErrorType.USER,
`Agent '${agentId}' has not been started`,
{ agentId },
'Start the agent before executing tasks'
);
}
static agentAlreadyStopped(agentId: string) {
return new DextoRuntimeError(
RuntimeErrorCode.AGENT_ALREADY_STOPPED,
ErrorScope.AGENT,
ErrorType.USER,
`Agent '${agentId}' has already been stopped`,
{ agentId },
'Spawn a new agent if you need to continue'
);
}
// Spawn errors
static spawnFailed(cause: string, agentId?: string) {
return new DextoRuntimeError(
RuntimeErrorCode.SPAWN_FAILED,
ErrorScope.AGENT,
ErrorType.SYSTEM,
agentId
? `Failed to spawn agent '${agentId}': ${cause}`
: `Failed to spawn agent: ${cause}`,
{ agentId, cause },
'Check the agent configuration and try again'
);
}
static invalidConfig(message: string, details?: Record<string, unknown>) {
return new DextoRuntimeError(
RuntimeErrorCode.INVALID_CONFIG,
ErrorScope.AGENT,
ErrorType.USER,
`Invalid agent configuration: ${message}`,
details ?? {},
'Check the configuration and ensure all required fields are provided'
);
}
// Task execution errors
static taskTimeout(agentId: string, timeoutMs: number) {
return new DextoRuntimeError(
RuntimeErrorCode.TASK_TIMEOUT,
ErrorScope.AGENT,
ErrorType.TIMEOUT,
`Task execution timed out for agent '${agentId}' after ${timeoutMs}ms`,
{ agentId, timeoutMs },
'Increase the timeout or simplify the task'
);
}
static taskFailed(agentId: string, cause: string) {
return new DextoRuntimeError(
RuntimeErrorCode.TASK_FAILED,
ErrorScope.AGENT,
ErrorType.SYSTEM,
`Task execution failed for agent '${agentId}': ${cause}`,
{ agentId, cause },
'Check the task requirements and agent configuration'
);
}
static taskCancelled(agentId: string) {
return new DextoRuntimeError(
RuntimeErrorCode.TASK_CANCELLED,
ErrorScope.AGENT,
ErrorType.USER,
`Task execution was cancelled for agent '${agentId}'`,
{ agentId },
'The task was cancelled by user or system request'
);
}
}

View File

@@ -0,0 +1,48 @@
/**
* Agent Runtime Module
*
* Provides infrastructure for spawning and managing agents.
* General-purpose runtime that can be used for:
* - Dashboard managing multiple independent agents
* - Agent task delegation (parent spawns sub-agents)
* - Test harnesses managing multiple agents
*/
// Main runtime class
export { AgentRuntime } from './AgentRuntime.js';
export type { AgentRuntimeOptions } from './AgentRuntime.js';
// Agent pool management
export { AgentPool } from './AgentPool.js';
// Approval delegation (for parent-child relationships)
export { createDelegatingApprovalHandler } from './approval-delegation.js';
// Types
export type {
SpawnConfig,
AgentStatus,
AgentHandle,
TaskResult,
AgentRuntimeConfig,
AgentFilter,
} from './types.js';
// Schemas
export {
AgentRuntimeConfigSchema,
SpawnConfigSchema,
AgentStatusSchema,
AgentFilterSchema,
DEFAULT_MAX_AGENTS,
DEFAULT_TASK_TIMEOUT,
} from './schemas.js';
export type {
ValidatedAgentRuntimeConfig,
ValidatedSpawnConfig,
ValidatedAgentFilter,
} from './schemas.js';
// Errors
export { RuntimeError } from './errors.js';
export { RuntimeErrorCode } from './error-codes.js';

View File

@@ -0,0 +1,122 @@
/**
* Agent Runtime Schemas
*
* Zod schemas for validating runtime configuration and inputs.
*/
import { z } from 'zod';
// ============================================================================
// Constants
// ============================================================================
export const DEFAULT_MAX_AGENTS = 20;
export const DEFAULT_TASK_TIMEOUT = 300000; // 5 minutes
// ============================================================================
// Runtime Configuration Schema
// ============================================================================
/**
* Schema for AgentRuntime configuration
*/
export const AgentRuntimeConfigSchema = z
.object({
maxAgents: z
.number()
.int()
.positive()
.default(DEFAULT_MAX_AGENTS)
.describe('Maximum total agents managed by this runtime'),
defaultTaskTimeout: z
.number()
.int()
.positive()
.default(DEFAULT_TASK_TIMEOUT)
.describe('Default task timeout in milliseconds'),
})
.strict()
.describe('Configuration for the AgentRuntime');
export type ValidatedAgentRuntimeConfig = z.output<typeof AgentRuntimeConfigSchema>;
// ============================================================================
// Spawn Configuration Schema
// ============================================================================
/**
* Schema for SpawnConfig
* Note: agentConfig is not validated here as it uses the core AgentConfigSchema
*/
export const SpawnConfigSchema = z
.object({
agentConfig: z.record(z.unknown()).describe('Base agent configuration'),
ephemeral: z
.boolean()
.default(true)
.describe('Whether agent should be destroyed after task completion'),
agentId: z
.string()
.min(1)
.optional()
.describe('Optional custom agent ID (auto-generated if not provided)'),
group: z
.string()
.min(1)
.optional()
.describe('Optional group identifier for logical grouping'),
metadata: z
.record(z.unknown())
.optional()
.describe('Optional metadata for tracking relationships or context'),
})
.strict()
.describe('Configuration for spawning an agent');
export type ValidatedSpawnConfig = z.output<typeof SpawnConfigSchema>;
// ============================================================================
// Agent Status Schema
// ============================================================================
/**
* Schema for AgentStatus
*/
export const AgentStatusSchema = z.enum([
'starting',
'idle',
'running',
'stopping',
'stopped',
'error',
]);
export type ValidatedAgentStatus = z.output<typeof AgentStatusSchema>;
// ============================================================================
// Agent Filter Schema
// ============================================================================
/**
* Schema for AgentFilter
*/
export const AgentFilterSchema = z
.object({
group: z.string().optional().describe('Filter by group'),
status: z
.union([AgentStatusSchema, z.array(AgentStatusSchema)])
.optional()
.describe('Filter by status'),
ephemeral: z.boolean().optional().describe('Filter by ephemeral flag'),
})
.strict()
.describe('Filter options for listing agents');
export type ValidatedAgentFilter = z.output<typeof AgentFilterSchema>;

View File

@@ -0,0 +1,120 @@
/**
* Agent Runtime Types
*
* Type definitions for the general-purpose agent runtime system that manages
* the lifecycle of multiple agent instances.
*/
import type { DextoAgent, AgentConfig } from '@dexto/core';
/**
* Configuration for spawning an agent
*/
export interface SpawnConfig {
/** Base agent config (LLM, system prompt, tools, etc.) */
agentConfig: AgentConfig;
/** Whether agent should be destroyed after task completion (default: true) */
ephemeral?: boolean;
/** Optional custom agent ID (auto-generated if not provided) */
agentId?: string;
/** Optional group identifier for logical grouping (e.g., parent agent ID) */
group?: string;
/** Optional metadata for tracking relationships or context */
metadata?: Record<string, unknown>;
/**
* Optional callback invoked after agent is created but before it starts.
* Use this to configure approval handlers or other pre-start setup.
*/
onBeforeStart?: (agent: DextoAgent) => void | Promise<void>;
}
/**
* Status of a managed agent
*/
export type AgentStatus = 'starting' | 'idle' | 'running' | 'stopping' | 'stopped' | 'error';
/**
* Handle to an agent managed by the runtime
*/
export interface AgentHandle {
/** Unique identifier for this agent */
agentId: string;
/** The DextoAgent instance */
agent: DextoAgent;
/** Current status of the agent */
status: AgentStatus;
/** Whether this agent should be destroyed after task completion */
ephemeral: boolean;
/** When this agent was created */
createdAt: Date;
/** Session ID for the agent's conversation */
sessionId: string;
/** Optional group identifier (e.g., parent agent ID for sub-agents) */
group?: string;
/** Optional metadata */
metadata?: Record<string, unknown>;
/** Optional error message if status is 'error' */
error?: string;
}
/**
* Result from executing a task on an agent
*/
export interface TaskResult {
/** Whether the task completed successfully */
success: boolean;
/** Final response from the agent */
response?: string;
/** Error message if the task failed */
error?: string;
/** ID of the agent that executed the task */
agentId: string;
/** Token usage for the task */
tokenUsage?: {
input: number;
output: number;
total: number;
};
}
/**
* Configuration for the AgentRuntime
*/
export interface AgentRuntimeConfig {
/** Maximum total agents managed by this runtime (default: 20) */
maxAgents?: number;
/** Default task timeout in milliseconds (default: 300000 = 5 min) */
defaultTaskTimeout?: number;
}
/**
* Filter options for listing agents
*/
export interface AgentFilter {
/** Filter by group */
group?: string;
/** Filter by status */
status?: AgentStatus | AgentStatus[];
/** Filter by ephemeral flag */
ephemeral?: boolean;
}

View File

@@ -0,0 +1,17 @@
/**
* Agent Spawner Tool Error Codes
*/
export enum AgentSpawnerErrorCode {
// Spawning errors
SPAWNING_DISABLED = 'agent_spawner_spawning_disabled',
SPAWN_FAILED = 'agent_spawner_spawn_failed',
// Agent errors
AGENT_NOT_FOUND = 'agent_spawner_agent_not_found',
// Task errors
TASK_FAILED = 'agent_spawner_task_failed',
// Configuration errors
INVALID_CONFIG = 'agent_spawner_invalid_config',
}

View File

@@ -0,0 +1,62 @@
import { DextoRuntimeError, ErrorScope, ErrorType } from '@dexto/core';
import { AgentSpawnerErrorCode } from './error-codes.js';
/**
* Agent Spawner error factory methods
*/
export class AgentSpawnerError {
static spawningDisabled() {
return new DextoRuntimeError(
AgentSpawnerErrorCode.SPAWNING_DISABLED,
ErrorScope.TOOLS,
ErrorType.USER,
'Agent spawning is disabled in configuration',
{},
'Enable spawning in the agent-spawner tool configuration'
);
}
static spawnFailed(cause: string) {
return new DextoRuntimeError(
AgentSpawnerErrorCode.SPAWN_FAILED,
ErrorScope.TOOLS,
ErrorType.SYSTEM,
`Failed to spawn sub-agent: ${cause}`,
{ cause },
'Check the configuration and try again'
);
}
static agentNotFound(agentId: string) {
return new DextoRuntimeError(
AgentSpawnerErrorCode.AGENT_NOT_FOUND,
ErrorScope.TOOLS,
ErrorType.NOT_FOUND,
`Sub-agent '${agentId}' not found`,
{ agentId },
'Ensure the agent ID is correct and the agent is still active'
);
}
static taskFailed(agentId: string, cause: string) {
return new DextoRuntimeError(
AgentSpawnerErrorCode.TASK_FAILED,
ErrorScope.TOOLS,
ErrorType.SYSTEM,
`Task execution failed for agent '${agentId}': ${cause}`,
{ agentId, cause },
'Check the task requirements and try again'
);
}
static invalidConfig(message: string) {
return new DextoRuntimeError(
AgentSpawnerErrorCode.INVALID_CONFIG,
ErrorScope.TOOLS,
ErrorType.USER,
`Invalid agent spawner configuration: ${message}`,
{},
'Check the configuration and ensure all required fields are provided'
);
}
}

View File

@@ -0,0 +1,29 @@
/**
* Agent Spawner Tool Provider
*
* Enables agents to spawn sub-agents for task delegation.
*/
// Main provider export
export { agentSpawnerToolsProvider } from './tool-provider.js';
// Configuration types
export { AgentSpawnerConfigSchema } from './schemas.js';
export type { AgentSpawnerConfig } from './schemas.js';
// Service for advanced usage
export { RuntimeService } from './runtime-service.js';
// Tool creator for custom integration
export { createSpawnAgentTool } from './spawn-agent-tool.js';
// Input schema for validation
export { SpawnAgentInputSchema } from './schemas.js';
export type { SpawnAgentInput } from './schemas.js';
// Output type
export type { SpawnAgentOutput } from './types.js';
// Error handling
export { AgentSpawnerError } from './errors.js';
export { AgentSpawnerErrorCode } from './error-codes.js';

View File

@@ -0,0 +1,281 @@
import { describe, test, expect } from 'vitest';
import { resolveSubAgentLLM } from './llm-resolution.js';
import type { LLMConfig } from '@dexto/core';
describe('resolveSubAgentLLM', () => {
// Common sub-agent config (like explore-agent)
const exploreAgentLLM: LLMConfig = {
provider: 'anthropic',
model: 'claude-haiku-4-5-20251001',
apiKey: '$ANTHROPIC_API_KEY',
};
describe('gateway provider scenarios (dexto/openrouter)', () => {
test('parent with dexto + sub-agent with anthropic -> dexto + transformed model', () => {
const parentLLM: LLMConfig = {
provider: 'dexto',
model: 'anthropic/claude-sonnet-4',
apiKey: '$DEXTO_API_KEY',
};
const result = resolveSubAgentLLM({
subAgentLLM: exploreAgentLLM,
parentLLM,
subAgentId: 'explore-agent',
});
expect(result.resolution).toBe('gateway-transform');
expect(result.llm.provider).toBe('dexto');
expect(result.llm.model).toBe('anthropic/claude-haiku-4.5'); // Transformed
expect(result.llm.apiKey).toBe('$DEXTO_API_KEY'); // Parent's key
expect(result.reason).toContain('gateway');
expect(result.reason).toContain('transformed');
});
test('parent with openrouter + sub-agent with openai -> openrouter + transformed model', () => {
const subAgentLLM: LLMConfig = {
provider: 'openai',
model: 'gpt-5-mini',
apiKey: '$OPENAI_API_KEY',
};
const parentLLM: LLMConfig = {
provider: 'openrouter',
model: 'anthropic/claude-sonnet-4',
apiKey: '$OPENROUTER_API_KEY',
};
const result = resolveSubAgentLLM({
subAgentLLM,
parentLLM,
subAgentId: 'test-agent',
});
expect(result.resolution).toBe('gateway-transform');
expect(result.llm.provider).toBe('openrouter');
expect(result.llm.model).toBe('openai/gpt-5-mini'); // Transformed
expect(result.llm.apiKey).toBe('$OPENROUTER_API_KEY');
});
test('parent with dexto + sub-agent with google -> dexto + transformed model', () => {
const subAgentLLM: LLMConfig = {
provider: 'google',
model: 'gemini-2.0-flash',
apiKey: '$GOOGLE_API_KEY',
};
const parentLLM: LLMConfig = {
provider: 'dexto',
model: 'anthropic/claude-opus-4',
apiKey: '$DEXTO_API_KEY',
};
const result = resolveSubAgentLLM({
subAgentLLM,
parentLLM,
});
expect(result.resolution).toBe('gateway-transform');
expect(result.llm.provider).toBe('dexto');
expect(result.llm.model).toBe('google/gemini-2.0-flash-001'); // Transformed
expect(result.llm.apiKey).toBe('$DEXTO_API_KEY');
});
});
describe('same provider scenarios', () => {
test('parent with anthropic + sub-agent with anthropic -> keeps sub-agent model', () => {
const parentLLM: LLMConfig = {
provider: 'anthropic',
model: 'claude-opus-4-5-20251101',
apiKey: '$MY_ANTHROPIC_KEY',
};
const result = resolveSubAgentLLM({
subAgentLLM: exploreAgentLLM,
parentLLM,
subAgentId: 'explore-agent',
});
expect(result.resolution).toBe('same-provider');
expect(result.llm.provider).toBe('anthropic');
expect(result.llm.model).toBe('claude-haiku-4-5-20251001'); // Sub-agent's model preserved
expect(result.llm.apiKey).toBe('$MY_ANTHROPIC_KEY'); // Parent's credentials
expect(result.reason).toContain("parent's credentials");
});
test('parent with openai + sub-agent with openai -> keeps sub-agent model', () => {
const subAgentLLM: LLMConfig = {
provider: 'openai',
model: 'gpt-5-mini',
apiKey: '$OPENAI_API_KEY',
};
const parentLLM: LLMConfig = {
provider: 'openai',
model: 'gpt-5',
apiKey: '$USER_OPENAI_KEY',
};
const result = resolveSubAgentLLM({
subAgentLLM,
parentLLM,
});
expect(result.resolution).toBe('same-provider');
expect(result.llm.provider).toBe('openai');
expect(result.llm.model).toBe('gpt-5-mini'); // Sub-agent's model
expect(result.llm.apiKey).toBe('$USER_OPENAI_KEY'); // Parent's key
});
});
describe('incompatible provider scenarios (fallback)', () => {
test('parent with openai + sub-agent with anthropic -> fallback to parent config', () => {
const parentLLM: LLMConfig = {
provider: 'openai',
model: 'gpt-5',
apiKey: '$OPENAI_API_KEY',
};
const result = resolveSubAgentLLM({
subAgentLLM: exploreAgentLLM,
parentLLM,
subAgentId: 'explore-agent',
});
expect(result.resolution).toBe('parent-fallback');
expect(result.llm.provider).toBe('openai'); // Parent's provider
expect(result.llm.model).toBe('gpt-5'); // Parent's model
expect(result.llm.apiKey).toBe('$OPENAI_API_KEY');
expect(result.reason).toContain('cannot use');
expect(result.reason).toContain('dexto login');
});
test('parent with google + sub-agent with anthropic -> fallback to parent config', () => {
const parentLLM: LLMConfig = {
provider: 'google',
model: 'gemini-2.0-pro',
apiKey: '$GOOGLE_API_KEY',
};
const result = resolveSubAgentLLM({
subAgentLLM: exploreAgentLLM,
parentLLM,
});
expect(result.resolution).toBe('parent-fallback');
expect(result.llm.provider).toBe('google');
expect(result.llm.model).toBe('gemini-2.0-pro');
});
});
describe('edge cases', () => {
test('works without subAgentId parameter', () => {
const parentLLM: LLMConfig = {
provider: 'dexto',
model: 'anthropic/claude-sonnet-4',
apiKey: '$DEXTO_API_KEY',
};
const result = resolveSubAgentLLM({
subAgentLLM: exploreAgentLLM,
parentLLM,
// No subAgentId
});
expect(result.resolution).toBe('gateway-transform');
expect(result.reason).toContain('sub-agent'); // Uses generic label
});
test('preserves additional LLM config fields from sub-agent', () => {
const subAgentLLM: LLMConfig = {
provider: 'anthropic',
model: 'claude-haiku-4-5-20251001',
apiKey: '$ANTHROPIC_API_KEY',
maxOutputTokens: 1000,
temperature: 0.5,
};
const parentLLM: LLMConfig = {
provider: 'dexto',
model: 'anthropic/claude-sonnet-4',
apiKey: '$DEXTO_API_KEY',
};
const result = resolveSubAgentLLM({
subAgentLLM,
parentLLM,
});
expect(result.llm.maxOutputTokens).toBe(1000); // Preserved from sub-agent
expect(result.llm.temperature).toBe(0.5); // Preserved from sub-agent
});
});
describe('real-world explore-agent scenarios', () => {
/**
* These tests simulate what happens when coding-agent spawns explore-agent
* in different user configurations.
*/
test('new user with dexto (most common) -> explore-agent uses dexto + haiku', () => {
// User ran `dexto setup` and chose dexto provider
const codingAgentLLM: LLMConfig = {
provider: 'dexto',
model: 'anthropic/claude-sonnet-4',
apiKey: '$DEXTO_API_KEY',
};
const result = resolveSubAgentLLM({
subAgentLLM: exploreAgentLLM, // explore-agent's bundled config
parentLLM: codingAgentLLM,
subAgentId: 'explore-agent',
});
// explore-agent should use dexto provider with haiku model
expect(result.llm.provider).toBe('dexto');
expect(result.llm.model).toBe('anthropic/claude-haiku-4.5');
expect(result.llm.apiKey).toBe('$DEXTO_API_KEY');
expect(result.resolution).toBe('gateway-transform');
});
test('user with direct anthropic API key -> explore-agent uses anthropic + haiku', () => {
// User has their own Anthropic API key configured
const codingAgentLLM: LLMConfig = {
provider: 'anthropic',
model: 'claude-opus-4-5-20251101',
apiKey: '$ANTHROPIC_API_KEY',
};
const result = resolveSubAgentLLM({
subAgentLLM: exploreAgentLLM,
parentLLM: codingAgentLLM,
subAgentId: 'explore-agent',
});
// explore-agent should use user's anthropic credentials with haiku model
expect(result.llm.provider).toBe('anthropic');
expect(result.llm.model).toBe('claude-haiku-4-5-20251001'); // Original model preserved
expect(result.llm.apiKey).toBe('$ANTHROPIC_API_KEY');
expect(result.resolution).toBe('same-provider');
});
test('user with openai only -> explore-agent falls back to openai', () => {
// User only has OpenAI configured (can't use Anthropic Haiku)
const codingAgentLLM: LLMConfig = {
provider: 'openai',
model: 'gpt-5',
apiKey: '$OPENAI_API_KEY',
};
const result = resolveSubAgentLLM({
subAgentLLM: exploreAgentLLM,
parentLLM: codingAgentLLM,
subAgentId: 'explore-agent',
});
// explore-agent falls back to parent's OpenAI config
expect(result.llm.provider).toBe('openai');
expect(result.llm.model).toBe('gpt-5');
expect(result.llm.apiKey).toBe('$OPENAI_API_KEY');
expect(result.resolution).toBe('parent-fallback');
// Warning should suggest dexto login
expect(result.reason).toContain('dexto login');
});
});
});

View File

@@ -0,0 +1,125 @@
/**
* Sub-agent LLM resolution logic
*
* When a parent agent spawns a sub-agent (e.g., coding-agent spawns explore-agent),
* this module determines which LLM configuration the sub-agent should use.
*
* Resolution priority:
* 1. If parent's provider can serve sub-agent's model (dexto/openrouter/same provider)
* → Use parent's provider + sub-agent's model (transformed if needed)
* 2. If incompatible providers
* → Fall back to parent's full LLM config (with warning)
*
* Future enhancement (when .local.yml is implemented):
* 0. Check sub-agent's .local.yml for LLM override (highest priority)
*/
import type { LLMConfig } from '@dexto/core';
import { hasAllRegistryModelsSupport, transformModelNameForProvider } from '@dexto/core';
/**
* Result of resolving a sub-agent's LLM configuration
*/
export interface SubAgentLLMResolution {
/** The resolved LLM configuration to use */
llm: LLMConfig;
/** How the resolution was determined */
resolution:
| 'gateway-transform' // Parent has gateway provider, sub-agent's model transformed
| 'same-provider' // Parent and sub-agent use same provider
| 'parent-fallback'; // Incompatible providers, using parent's full config
/** Human-readable explanation for debugging */
reason: string;
}
export interface ResolveSubAgentLLMOptions {
/** The sub-agent's bundled LLM configuration */
subAgentLLM: LLMConfig;
/** The parent agent's LLM configuration (already has preferences applied) */
parentLLM: LLMConfig;
/** Sub-agent ID for logging purposes */
subAgentId?: string;
}
/**
* Resolves which LLM configuration a sub-agent should use.
*
* The goal is to use the sub-agent's intended model (e.g., Haiku for explore-agent)
* when possible, while leveraging the parent's provider/credentials.
*
* @example
* // Parent uses dexto, sub-agent wants anthropic/haiku
* resolveSubAgentLLM({
* subAgentLLM: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', apiKey: '$ANTHROPIC_API_KEY' },
* parentLLM: { provider: 'dexto', model: 'anthropic/claude-sonnet-4', apiKey: '$DEXTO_API_KEY' }
* })
* // Returns: { provider: 'dexto', model: 'anthropic/claude-haiku-4.5', apiKey: '$DEXTO_API_KEY' }
*/
export function resolveSubAgentLLM(options: ResolveSubAgentLLMOptions): SubAgentLLMResolution {
const { subAgentLLM, parentLLM, subAgentId } = options;
const agentLabel = subAgentId ? `'${subAgentId}'` : 'sub-agent';
const subAgentProvider = subAgentLLM.provider;
const subAgentModel = subAgentLLM.model;
const parentProvider = parentLLM.provider;
// Case 1: Parent's provider is a gateway that can serve all models (dexto, openrouter)
// Transform sub-agent's model to gateway format and use parent's credentials
if (hasAllRegistryModelsSupport(parentProvider)) {
try {
const transformedModel = transformModelNameForProvider(
subAgentModel,
subAgentProvider,
parentProvider
);
return {
llm: {
...subAgentLLM,
provider: parentProvider,
model: transformedModel,
apiKey: parentLLM.apiKey,
baseURL: parentLLM.baseURL,
},
resolution: 'gateway-transform',
reason:
`${agentLabel} using ${parentProvider} gateway with model ${transformedModel} ` +
`(transformed from ${subAgentProvider}/${subAgentModel})`,
};
} catch {
// Transform failed (model not in registry) - fall through to fallback
}
}
// Case 2: Same provider - sub-agent can use its model with parent's credentials
if (parentProvider === subAgentProvider) {
return {
llm: {
...subAgentLLM,
apiKey: parentLLM.apiKey, // Use parent's credentials
baseURL: parentLLM.baseURL, // Inherit custom endpoint (e.g., Azure OpenAI)
},
resolution: 'same-provider',
reason:
`${agentLabel} using ${subAgentProvider}/${subAgentModel} ` +
`with parent's credentials`,
};
}
// Case 3: Incompatible providers - fall back to parent's full LLM config
// This means sub-agent won't use its intended cheap/fast model, but it will work
//
// TODO: Future enhancement - add model tier system (fast/standard/flagship) to registry.
// Instead of falling back to parent's full config, find the "fast" tier model for
// parent's provider (e.g., openai->gpt-4o-mini, google->gemini-flash). This preserves
// the intent (cheap/fast) rather than the specific model. Low priority since most
// users will use dexto/openrouter which already handles this via gateway transform.
return {
llm: parentLLM,
resolution: 'parent-fallback',
reason:
`${agentLabel} cannot use ${subAgentProvider}/${subAgentModel} with parent's ` +
`${parentProvider} provider. Falling back to parent's LLM config. ` +
`Tip: Use 'dexto login' for Dexto Credits which supports all models.`,
};
}

View File

@@ -0,0 +1,609 @@
/**
* RuntimeService - Bridge between tools and AgentRuntime
*
* Manages the relationship between a parent agent and its sub-agents,
* providing methods that tools can call to spawn and execute tasks.
*
* This service adds parent-child semantics on top of the general-purpose AgentRuntime:
* - Uses `group` to associate spawned agents with the parent
* - Wires up approval delegation so sub-agent tool requests go to parent
* - Enforces per-parent agent limits
* - Always cleans up agents after task completion (synchronous model)
*/
import type { DextoAgent, IDextoLogger, AgentConfig, TaskForker } from '@dexto/core';
import { AgentRuntime } from '../runtime/AgentRuntime.js';
import { createDelegatingApprovalHandler } from '../runtime/approval-delegation.js';
import { loadAgentConfig } from '../config/loader.js';
import { getAgentRegistry } from '../registry/registry.js';
import type { AgentRegistryEntry } from '../registry/types.js';
import type { AgentSpawnerConfig } from './schemas.js';
import type { SpawnAgentOutput } from './types.js';
import { resolveSubAgentLLM } from './llm-resolution.js';
export class RuntimeService implements TaskForker {
private runtime: AgentRuntime;
private parentId: string;
private parentAgent: DextoAgent;
private config: AgentSpawnerConfig;
private logger: IDextoLogger;
constructor(parentAgent: DextoAgent, config: AgentSpawnerConfig, logger: IDextoLogger) {
this.parentAgent = parentAgent;
this.config = config;
this.logger = logger;
// Use parent agent ID as the group identifier
this.parentId = parentAgent.config.agentId ?? `parent-${Date.now()}`;
// Create runtime with config
// Note: maxAgents is global, we enforce per-parent limits in this service
this.runtime = new AgentRuntime({
config: {
maxAgents: config.maxConcurrentAgents,
defaultTaskTimeout: config.defaultTimeout,
},
logger,
});
this.logger.debug(
`RuntimeService initialized for parent '${this.parentId}' (maxAgents: ${config.maxConcurrentAgents})`
);
}
/**
* Get count of sub-agents belonging to this parent
*/
private getSubAgentCount(): number {
return this.runtime.listAgents({ group: this.parentId }).length;
}
/**
* Check if this parent can spawn another sub-agent
*/
private canSpawn(): boolean {
return this.getSubAgentCount() < this.config.maxConcurrentAgents;
}
/**
* Spawn a sub-agent and execute a task
*
* This is the main method for the spawn_agent tool.
* It creates a sub-agent, executes the task, and cleans up after completion.
* If the sub-agent's LLM config fails, automatically falls back to parent's LLM.
*
* @param input.task - Short task description (for logging/UI)
* @param input.instructions - Full prompt sent to sub-agent
* @param input.agentId - Optional agent ID from registry
* @param input.autoApprove - Optional override for auto-approve (used by fork skills)
* @param input.timeout - Optional task timeout in milliseconds
* @param input.toolCallId - Optional tool call ID for progress events
* @param input.sessionId - Optional session ID for progress events
*/
async spawnAndExecute(input: {
task: string;
instructions: string;
agentId?: string;
autoApprove?: boolean;
timeout?: number;
toolCallId?: string;
sessionId?: string;
}): Promise<SpawnAgentOutput> {
// Check if spawning is enabled
if (!this.config.allowSpawning) {
return {
success: false,
error: 'Agent spawning is disabled in configuration',
};
}
// Check per-parent limit
if (!this.canSpawn()) {
return {
success: false,
error: `Maximum sub-agents limit (${this.config.maxConcurrentAgents}) reached for this parent`,
};
}
// Validate agentId against allowedAgents if configured
if (input.agentId && this.config.allowedAgents) {
if (!this.config.allowedAgents.includes(input.agentId)) {
return {
success: false,
error: `Agent '${input.agentId}' is not in the allowed agents list. Allowed: ${this.config.allowedAgents.join(', ')}`,
};
}
}
const timeout = input.timeout ?? this.config.defaultTimeout;
// Determine autoApprove: explicit input > config-level autoApproveAgents > false
const autoApprove =
input.autoApprove !== undefined
? input.autoApprove
: !!(input.agentId && this.config.autoApproveAgents?.includes(input.agentId));
// Try with sub-agent's config first, fall back to parent's LLM if it fails
const result = await this.trySpawnWithFallback(
input,
timeout,
autoApprove,
input.toolCallId,
input.sessionId
);
return result;
}
/**
* Fork execution to an isolated subagent.
* Implements TaskForker interface for use by invoke_skill when context: fork is set.
*
* @param options.task - Short description for UI/logs
* @param options.instructions - Full instructions for the subagent
* @param options.agentId - Optional agent ID from registry to use for execution
* @param options.autoApprove - Auto-approve tool calls (default: true for fork skills)
* @param options.toolCallId - Optional tool call ID for progress events
* @param options.sessionId - Optional session ID for progress events
*/
async fork(options: {
task: string;
instructions: string;
agentId?: string;
autoApprove?: boolean;
toolCallId?: string;
sessionId?: string;
}): Promise<{ success: boolean; response?: string; error?: string }> {
// Delegate to spawnAndExecute, passing options
// Only include optional properties when they have values (exactOptionalPropertyTypes)
const spawnOptions: {
task: string;
instructions: string;
agentId?: string;
autoApprove?: boolean;
toolCallId?: string;
sessionId?: string;
} = {
task: options.task,
instructions: options.instructions,
};
if (options.agentId) {
spawnOptions.agentId = options.agentId;
}
if (options.autoApprove !== undefined) {
spawnOptions.autoApprove = options.autoApprove;
}
if (options.toolCallId) {
spawnOptions.toolCallId = options.toolCallId;
}
if (options.sessionId) {
spawnOptions.sessionId = options.sessionId;
}
return this.spawnAndExecute(spawnOptions);
}
/**
* Set up progress event emission for a sub-agent.
* Subscribes to llm:tool-call and llm:response events and emits service:event with progress data.
*
* @returns Cleanup function to unsubscribe from events
*/
private setupProgressTracking(
subAgentHandle: { agentId: string; agent: DextoAgent },
input: { task: string; agentId?: string },
toolCallId?: string,
sessionId?: string
): () => void {
// Don't set up progress tracking if no toolCallId or sessionId (no parent to report to)
if (!toolCallId || !sessionId) {
this.logger.debug(
`[Progress] Skipping progress tracking - missing toolCallId (${toolCallId}) or sessionId (${sessionId})`
);
return () => {};
}
this.logger.debug(
`[Progress] Setting up progress tracking for sub-agent ${subAgentHandle.agentId} (toolCallId: ${toolCallId}, sessionId: ${sessionId})`
);
let toolCount = 0;
// Token usage tracking - reflects context window utilization (matches parent CLI formula):
// - input: REPLACED each call (current context size, not cumulative API billing)
// - output: ACCUMULATED across all calls (total generated tokens)
// - total: lastInput + cumulativeOutput
// This shows "how full is the context window" rather than "total API cost across all calls".
// For billing, you'd need to sum all inputTokens across calls, but that's not useful for
// understanding context limits. See: processStream.ts line 728 for parent formula.
const tokenUsage = { input: 0, output: 0, total: 0 };
// Track current tool for emissions (persists between events)
let currentTool = '';
const subAgentBus = subAgentHandle.agent.agentEventBus;
const parentBus = this.parentAgent.agentEventBus;
// Helper to emit progress event
const emitProgress = (tool: string, args?: Record<string, unknown>) => {
parentBus.emit('service:event', {
service: 'agent-spawner',
event: 'progress',
toolCallId,
sessionId,
data: {
task: input.task,
agentId: input.agentId ?? 'default',
toolsCalled: toolCount,
currentTool: tool,
currentArgs: args,
tokenUsage: { ...tokenUsage },
},
});
};
// Handler for llm:tool-call events
const toolCallHandler = (event: {
toolName: string;
args: Record<string, unknown>;
sessionId: string;
}) => {
toolCount++;
// Strip prefixes from tool name for cleaner display
let displayToolName = event.toolName;
if (displayToolName.startsWith('internal--')) {
displayToolName = displayToolName.replace('internal--', '');
} else if (displayToolName.startsWith('custom--')) {
displayToolName = displayToolName.replace('custom--', '');
} else if (displayToolName.startsWith('mcp--')) {
// For MCP tools, extract just the tool name (skip server prefix)
const parts = displayToolName.split('--');
if (parts.length >= 3) {
displayToolName = parts.slice(2).join('--');
}
}
currentTool = displayToolName;
this.logger.debug(
`[Progress] Sub-agent tool call #${toolCount}: ${displayToolName} (toolCallId: ${toolCallId})`
);
emitProgress(displayToolName, event.args);
};
// Handler for llm:response events - accumulate token usage
const responseHandler = (event: {
tokenUsage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
sessionId: string;
}) => {
if (event.tokenUsage) {
// Replace input tokens (most recent call's context) - matches parent CLI formula
tokenUsage.input = event.tokenUsage.inputTokens ?? 0;
// Accumulate output tokens
tokenUsage.output += event.tokenUsage.outputTokens ?? 0;
// Total = lastInput + cumulativeOutput (consistent with parent)
tokenUsage.total = tokenUsage.input + tokenUsage.output;
this.logger.debug(
`[Progress] Sub-agent tokens: input=${tokenUsage.input}, cumOutput=${tokenUsage.output}, total=${tokenUsage.total}`
);
// Emit updated progress with new token counts
emitProgress(currentTool || 'processing');
}
};
// Subscribe to sub-agent's events
subAgentBus.on('llm:tool-call', toolCallHandler);
subAgentBus.on('llm:response', responseHandler);
// Return cleanup function
return () => {
subAgentBus.off('llm:tool-call', toolCallHandler);
subAgentBus.off('llm:response', responseHandler);
};
}
/**
* Try to spawn agent, falling back to parent's LLM config if the sub-agent's config fails
*/
private async trySpawnWithFallback(
input: { task: string; instructions: string; agentId?: string },
timeout: number,
autoApprove: boolean,
toolCallId?: string,
sessionId?: string
): Promise<SpawnAgentOutput> {
let spawnedAgentId: string | undefined;
let usedFallback = false;
let cleanupProgressTracking: (() => void) | undefined;
try {
// Build options object
const buildOptions: {
agentId?: string;
inheritLlm?: boolean;
autoApprove?: boolean;
} = {};
if (input.agentId !== undefined) {
buildOptions.agentId = input.agentId;
}
if (autoApprove) {
buildOptions.autoApprove = autoApprove;
}
// Try with sub-agent's config first
let subAgentConfig = await this.buildSubAgentConfig(buildOptions);
let handle: { agentId: string; agent: DextoAgent };
try {
// Spawn the agent
handle = await this.runtime.spawnAgent({
agentConfig: subAgentConfig,
ephemeral: true,
group: this.parentId,
metadata: {
parentId: this.parentId,
task: input.task,
autoApprove,
spawnedAt: new Date().toISOString(),
},
onBeforeStart: (agent) => {
if (!autoApprove) {
const delegatingHandler = createDelegatingApprovalHandler(
this.parentAgent.services.approvalManager,
agent.config.agentId ?? 'unknown',
this.logger
);
agent.setApprovalHandler(delegatingHandler);
}
},
});
spawnedAgentId = handle.agentId;
} catch (spawnError) {
// Check if it's an LLM-related error (model not supported, API key missing, etc.)
const errorMsg =
spawnError instanceof Error ? spawnError.message : String(spawnError);
const isLlmError =
errorMsg.includes('Model') ||
errorMsg.includes('model') ||
errorMsg.includes('API') ||
errorMsg.includes('apiKey') ||
errorMsg.includes('provider');
if (isLlmError && input.agentId) {
// Fallback: retry with parent's full LLM config
// This can happen if:
// - Model transformation failed for the sub-agent's model
// - API rate limits or other provider-specific errors
// - Edge cases in LLM resolution
this.logger.warn(
`Sub-agent '${input.agentId}' LLM config failed: ${errorMsg}. ` +
`Falling back to parent's full LLM config.`
);
usedFallback = true;
buildOptions.inheritLlm = true;
subAgentConfig = await this.buildSubAgentConfig(buildOptions);
handle = await this.runtime.spawnAgent({
agentConfig: subAgentConfig,
ephemeral: true,
group: this.parentId,
metadata: {
parentId: this.parentId,
task: input.task,
autoApprove,
usedLlmFallback: true,
spawnedAt: new Date().toISOString(),
},
onBeforeStart: (agent) => {
if (!autoApprove) {
const delegatingHandler = createDelegatingApprovalHandler(
this.parentAgent.services.approvalManager,
agent.config.agentId ?? 'unknown',
this.logger
);
agent.setApprovalHandler(delegatingHandler);
}
},
});
spawnedAgentId = handle.agentId;
} else {
// Not an LLM error or no agentId, re-throw
throw spawnError;
}
}
this.logger.info(
`Spawned sub-agent '${spawnedAgentId}' for task: ${input.task}${autoApprove ? ' (auto-approve)' : ''}${usedFallback ? ' (using parent LLM)' : ''}`
);
// Set up progress event tracking before executing
cleanupProgressTracking = this.setupProgressTracking(
handle,
input,
toolCallId,
sessionId
);
// Execute with the full instructions
const result = await this.runtime.executeTask(
spawnedAgentId,
input.instructions,
timeout
);
// Build output
const output: SpawnAgentOutput = {
success: result.success,
};
if (result.response !== undefined) {
output.response = result.response;
}
if (result.error !== undefined) {
output.error = result.error;
}
if (usedFallback) {
output.warning = `Sub-agent '${input.agentId}' used fallback LLM (parent's full config) due to an error with its configured model.`;
}
return output;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to spawn and execute: ${errorMessage}`);
return {
success: false,
error: errorMessage,
};
} finally {
// Clean up progress tracking
if (cleanupProgressTracking) {
cleanupProgressTracking();
}
// Always clean up the agent after task completion
if (spawnedAgentId) {
try {
await this.runtime.stopAgent(spawnedAgentId);
} catch {
// Ignore cleanup errors
}
}
}
}
/**
* Build sub-agent config based on registry agent ID or parent config
*
* @param options.agentId - Agent ID from registry
* @param options.inheritLlm - Use parent's LLM config instead of sub-agent's
* @param options.autoApprove - Auto-approve all tool calls
*/
private async buildSubAgentConfig(options: {
agentId?: string;
inheritLlm?: boolean;
autoApprove?: boolean;
}): Promise<AgentConfig> {
const { agentId, inheritLlm, autoApprove } = options;
const parentConfig = this.parentAgent.config;
// Determine tool confirmation mode
const toolConfirmationMode = autoApprove ? ('auto-approve' as const) : ('manual' as const);
// If agentId is provided, resolve from registry
if (agentId) {
const registry = getAgentRegistry();
if (!registry.hasAgent(agentId)) {
this.logger.warn(`Agent '${agentId}' not found in registry. Using default config.`);
} else {
// resolveAgent handles installation if needed
const configPath = await registry.resolveAgent(agentId);
this.logger.debug(`Loading agent config from registry: ${configPath}`);
const loadedConfig = await loadAgentConfig(configPath, this.logger);
// Determine LLM config based on options
let llmConfig = loadedConfig.llm;
if (inheritLlm) {
// Use parent's full LLM config (fallback mode after first attempt failed)
this.logger.debug(
`Sub-agent '${agentId}' using parent LLM config (inheritLlm=true)`
);
llmConfig = { ...parentConfig.llm };
} else {
// Resolve optimal LLM: try to use sub-agent's model with parent's provider
const resolution = resolveSubAgentLLM({
subAgentLLM: loadedConfig.llm,
parentLLM: parentConfig.llm,
subAgentId: agentId,
});
this.logger.debug(`Sub-agent LLM resolution: ${resolution.reason}`);
llmConfig = resolution.llm;
}
// Override certain settings for sub-agent behavior
return {
...loadedConfig,
llm: llmConfig,
toolConfirmation: {
...loadedConfig.toolConfirmation,
mode: toolConfirmationMode,
},
// Suppress sub-agent console logs entirely using silent transport
logger: {
level: 'error' as const,
transports: [{ type: 'silent' as const }],
},
};
}
}
// Start with a config inheriting LLM and tools from parent
const config: AgentConfig = {
llm: { ...parentConfig.llm },
// Default system prompt for sub-agents
systemPrompt:
'You are a helpful sub-agent. Complete the task given to you efficiently and concisely.',
toolConfirmation: {
mode: toolConfirmationMode,
},
// Inherit MCP servers from parent so subagent has tool access
mcpServers: parentConfig.mcpServers ? { ...parentConfig.mcpServers } : {},
// Inherit internal tools from parent, excluding tools that don't work in subagent context
// - ask_user: Subagents can't interact with the user directly
// - invoke_skill: Avoid nested skill invocations for simplicity
internalTools: parentConfig.internalTools
? parentConfig.internalTools.filter(
(tool) => tool !== 'ask_user' && tool !== 'invoke_skill'
)
: [],
// Inherit custom tools from parent
customTools: parentConfig.customTools ? [...parentConfig.customTools] : [],
// Suppress sub-agent console logs entirely using silent transport
logger: {
level: 'error' as const,
transports: [{ type: 'silent' as const }],
},
};
return config;
}
/**
* Get information about available agents for tool description.
* Returns agent metadata from registry, filtered by allowedAgents if configured.
*/
getAvailableAgents(): AgentRegistryEntry[] {
const registry = getAgentRegistry();
const allAgents = registry.getAvailableAgents();
// If allowedAgents is configured, filter to only those
if (this.config.allowedAgents && this.config.allowedAgents.length > 0) {
const result: AgentRegistryEntry[] = [];
for (const id of this.config.allowedAgents) {
const agent = allAgents[id];
if (agent) {
result.push(agent);
}
}
return result;
}
// Otherwise return all registry agents
return Object.values(allAgents);
}
/**
* Clean up all sub-agents (called when parent stops)
*/
async cleanup(): Promise<void> {
this.logger.debug(`Cleaning up RuntimeService for parent '${this.parentId}'`);
await this.runtime.stopAll({ group: this.parentId });
}
}

View File

@@ -0,0 +1,104 @@
/**
* Agent Spawner Tool Provider Schemas
*
* Zod schemas for the agent spawner tool provider configuration and inputs.
*/
import { z } from 'zod';
// ============================================================================
// Provider Configuration Schema
// ============================================================================
/**
* Configuration schema for the agent spawner tool provider
*/
export const AgentSpawnerConfigSchema = z
.object({
/** Type discriminator for the provider */
type: z.literal('agent-spawner'),
/** Maximum concurrent sub-agents this parent can spawn (default: 5) */
maxConcurrentAgents: z
.number()
.int()
.positive()
.default(5)
.describe('Maximum concurrent sub-agents'),
/** Default timeout for task execution in milliseconds (default: 300000 = 5 min) */
defaultTimeout: z
.number()
.int()
.positive()
.default(300000)
.describe('Default task timeout in milliseconds'),
/** Whether spawning is enabled (default: true) */
allowSpawning: z.boolean().default(true).describe('Whether agent spawning is enabled'),
/**
* List of agent IDs from the registry that this parent can spawn.
* If not provided, any registry agent can be spawned.
*
* Example:
* ```yaml
* customTools:
* - type: agent-spawner
* allowedAgents: ["explore-agent", "research-agent"]
* ```
*/
allowedAgents: z
.array(z.string().min(1))
.optional()
.describe('Agent IDs from registry that can be spawned (omit to allow all)'),
/**
* Agent IDs that should have their tools auto-approved.
* Use for agents with only read-only/safe tools (e.g., explore-agent).
*
* Example:
* ```yaml
* customTools:
* - type: agent-spawner
* allowedAgents: ["explore-agent"]
* autoApproveAgents: ["explore-agent"]
* ```
*/
autoApproveAgents: z
.array(z.string().min(1))
.optional()
.describe('Agent IDs that should have tools auto-approved (read-only agents)'),
})
.strict()
.describe('Configuration for the agent spawner tool provider');
export type AgentSpawnerConfig = z.output<typeof AgentSpawnerConfigSchema>;
// ============================================================================
// Tool Input Schemas
// ============================================================================
/**
* Input schema for spawn_agent tool
*
* Note: Timeout is configured at the provider level (defaultTimeout in config).
* We don't expose timeout as a tool parameter because lowering it just wastes runs.
*/
export const SpawnAgentInputSchema = z
.object({
/** Short task description (shown in UI/logs) */
task: z.string().min(1).describe('Short task description for UI/logs'),
/** Detailed instructions for the sub-agent */
instructions: z
.string()
.min(1)
.describe('Detailed instructions for the sub-agent to execute'),
/** Agent ID from registry (optional - uses default minimal agent if not provided) */
agentId: z.string().min(1).optional().describe('Agent ID from registry'),
})
.strict();
export type SpawnAgentInput = z.output<typeof SpawnAgentInputSchema>;

View File

@@ -0,0 +1,96 @@
/**
* spawn_agent Tool
*
* Spawns a sub-agent to handle a specific task.
* The sub-agent will execute the task and return the result.
*/
import type { InternalTool, ToolExecutionContext } from '@dexto/core';
import { SpawnAgentInputSchema, type SpawnAgentInput } from './schemas.js';
import type { RuntimeService } from './runtime-service.js';
/**
* Build dynamic tool description based on available agents from registry
*/
function buildDescription(service: RuntimeService): string {
const availableAgents = service.getAvailableAgents();
if (availableAgents.length === 0) {
return `Spawn a sub-agent to handle a specific task. The sub-agent executes the task and returns the result.
No specialized agents are configured. The sub-agent will inherit your LLM with a minimal config.
## Parameters
- **task**: Short description for UI/logs (e.g., "Search for authentication code")
- **instructions**: Detailed instructions for the sub-agent`;
}
// Build available agents section with clear use cases
const agentsList = availableAgents
.map((agent) => {
const tags = agent.tags?.length ? ` [${agent.tags.slice(0, 3).join(', ')}]` : '';
return `### ${agent.id}
${agent.description}${tags}`;
})
.join('\n\n');
return `Spawn a specialized sub-agent to handle a task. The sub-agent executes independently and returns the result.
## Available Agents
${agentsList}
## Parameters
- **task**: Short description for UI/logs (e.g., "Explore authentication flow")
- **instructions**: Detailed instructions sent to the sub-agent
- **agentId**: Agent ID from the list above (e.g., "${availableAgents[0]?.id ?? 'explore-agent'}")
## Notes
- Sub-agents have their own tools, LLM, and conversation context
- Read-only agents (like explore-agent) have auto-approved tool calls for speed
- If a sub-agent's LLM fails, it automatically falls back to your LLM`;
}
export function createSpawnAgentTool(service: RuntimeService): InternalTool {
return {
id: 'spawn_agent',
description: buildDescription(service),
inputSchema: SpawnAgentInputSchema,
execute: async (input: unknown, context?: ToolExecutionContext) => {
const validatedInput = input as SpawnAgentInput;
// Build options object - only include optional properties if they have values
const options: {
task: string;
instructions: string;
agentId?: string;
toolCallId?: string;
sessionId?: string;
} = {
task: validatedInput.task,
instructions: validatedInput.instructions,
};
if (validatedInput.agentId !== undefined) {
options.agentId = validatedInput.agentId;
}
if (context?.toolCallId !== undefined) {
options.toolCallId = context.toolCallId;
}
if (context?.sessionId !== undefined) {
options.sessionId = context.sessionId;
}
const result = await service.spawnAndExecute(options);
// Return clean output: just response on success, error message on failure
if (result.success) {
return result.response ?? 'Task completed successfully.';
} else {
return `Error: ${result.error ?? 'Unknown error'}`;
}
},
};
}

View File

@@ -0,0 +1,51 @@
/**
* Agent Spawner Tool Provider
*
* Custom tool provider that enables agents to spawn sub-agents for task delegation.
*/
import type { CustomToolProvider, InternalTool } from '@dexto/core';
import { AgentSpawnerConfigSchema, type AgentSpawnerConfig } from './schemas.js';
import { RuntimeService } from './runtime-service.js';
import { createSpawnAgentTool } from './spawn-agent-tool.js';
/**
* Agent Spawner Tools Provider
*
* Provides the spawn_agent tool for task delegation to sub-agents.
*
* Configuration:
* ```yaml
* tools:
* customTools:
* - type: agent-spawner
* maxConcurrentAgents: 5
* defaultTimeout: 300000
* allowSpawning: true
* ```
*/
export const agentSpawnerToolsProvider: CustomToolProvider<'agent-spawner', AgentSpawnerConfig> = {
type: 'agent-spawner',
configSchema: AgentSpawnerConfigSchema,
create: (config, context): InternalTool[] => {
const { logger, agent } = context;
// Create the runtime service that bridges tools to AgentRuntime
const service = new RuntimeService(agent, config, logger);
// Wire up RuntimeService as taskForker for invoke_skill (context: fork support)
// This enables skills with `context: fork` to execute in isolated subagents
agent.toolManager.setTaskForker(service);
logger.debug('RuntimeService wired as taskForker for context:fork skill support');
return [createSpawnAgentTool(service)];
},
metadata: {
displayName: 'Agent Spawner',
description: 'Spawn sub-agents for task delegation',
category: 'agents',
},
};

View File

@@ -0,0 +1,22 @@
/**
* Agent Spawner Tool Types
*
* Type definitions for tool inputs and outputs.
*/
/**
* Output from spawn_agent tool
*/
export interface SpawnAgentOutput {
/** Whether the task completed successfully */
success: boolean;
/** Final response from the sub-agent */
response?: string;
/** Error message if the task failed */
error?: string;
/** Warning message (e.g., when fallback LLM was used) */
warning?: string;
}

View File

@@ -0,0 +1,72 @@
import type { LLMProvider } from '@dexto/core';
/**
* Utility for resolving API keys from environment variables.
* This consolidates the API key resolution logic used across CLI and core components.
*/
// Map the provider to its corresponding API key name (in order of preference)
export const PROVIDER_API_KEY_MAP: Record<LLMProvider, string[]> = {
openai: ['OPENAI_API_KEY', 'OPENAI_KEY'],
'openai-compatible': ['OPENAI_API_KEY', 'OPENAI_KEY'], // Uses same keys as openai
anthropic: ['ANTHROPIC_API_KEY', 'ANTHROPIC_KEY', 'CLAUDE_API_KEY'],
google: ['GOOGLE_GENERATIVE_AI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_API_KEY'],
groq: ['GROQ_API_KEY'],
cohere: ['COHERE_API_KEY'],
xai: ['XAI_API_KEY', 'X_AI_API_KEY'],
openrouter: ['OPENROUTER_API_KEY'],
litellm: ['LITELLM_API_KEY', 'LITELLM_KEY'],
glama: ['GLAMA_API_KEY'],
// Vertex uses ADC (Application Default Credentials), not API keys
// GOOGLE_APPLICATION_CREDENTIALS points to service account JSON (optional)
// Primary config is GOOGLE_VERTEX_PROJECT (required) + GOOGLE_VERTEX_LOCATION (optional)
vertex: [],
// Bedrock supports two auth methods:
// 1. AWS_BEARER_TOKEN_BEDROCK - Bedrock API key (simplest)
// 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_REGION (IAM credentials)
// AWS_SESSION_TOKEN (optional, for temporary credentials)
bedrock: ['AWS_BEARER_TOKEN_BEDROCK'],
// Local providers don't require API keys
local: [], // Native node-llama-cpp execution
ollama: [], // Ollama server (may optionally use OLLAMA_API_KEY for remote servers)
// Dexto gateway - requires key from `dexto login`
dexto: ['DEXTO_API_KEY'],
// perplexity: ['PERPLEXITY_API_KEY'],
// together: ['TOGETHER_API_KEY'],
// fireworks: ['FIREWORKS_API_KEY'],
// deepseek: ['DEEPSEEK_API_KEY'],
};
/**
* Resolves API key for a given provider from environment variables.
*
* @param provider The LLM provider
* @returns Resolved API key or undefined if not found
*/
export function resolveApiKeyForProvider(provider: LLMProvider): string | undefined {
const envVars = PROVIDER_API_KEY_MAP[provider];
if (!envVars) {
return undefined;
}
// Try each environment variable in order of preference
for (const envVar of envVars) {
const value = process.env[envVar];
if (value && value.trim()) {
return value.trim();
}
}
return undefined;
}
/**
* Gets the primary environment variable name for a provider (for display/error messages).
*
* @param provider The LLM provider
* @returns Primary environment variable name
*/
export function getPrimaryApiKeyEnvVar(provider: LLMProvider): string {
const envVars = PROVIDER_API_KEY_MAP[provider];
return envVars?.[0] || `${provider.toUpperCase()}_API_KEY`;
}

View File

@@ -0,0 +1,146 @@
import type { LLMProvider } from '@dexto/core';
import { getPrimaryApiKeyEnvVar, resolveApiKeyForProvider } from './api-key-resolver.js';
import { LLM_PROVIDERS } from '@dexto/core';
import { getDextoEnvPath } from './path.js';
import { updateEnvFile } from './env-file.js';
/**
* Save provider API key to the correct .env and make it immediately available in-process.
* Never returns the key; only metadata for callers to display.
*/
export async function saveProviderApiKey(
provider: LLMProvider,
apiKey: string,
startPath?: string
): Promise<{ envVar: string; targetEnvPath: string }> {
if (!provider) throw new Error('provider is required');
if (!apiKey || !apiKey.trim()) throw new Error('apiKey is required');
const envVar = getPrimaryApiKeyEnvVar(provider);
const targetEnvPath = getDextoEnvPath(startPath);
await updateEnvFile(targetEnvPath, { [envVar]: apiKey });
process.env[envVar] = apiKey;
return { envVar, targetEnvPath };
}
export function getProviderKeyStatus(provider: LLMProvider): {
hasApiKey: boolean;
envVar: string;
} {
// Vertex AI uses ADC (Application Default Credentials), not API keys.
// Setup instructions:
// 1. Create a Google Cloud account and project
// 2. Enable the Vertex AI API: gcloud services enable aiplatform.googleapis.com
// 3. Enable desired Claude models (requires Anthropic Model Garden)
// 4. Install Google Cloud CLI: https://cloud.google.com/sdk/docs/install
// 5. Configure ADC: gcloud auth application-default login
// 6. Set env vars: GOOGLE_VERTEX_PROJECT (required), GOOGLE_VERTEX_LOCATION (optional)
//
// TODO: Improve Vertex setup flow - add dedicated setup modal with these instructions
// For now, we check GOOGLE_VERTEX_PROJECT as the "key" equivalent
if (provider === 'vertex') {
const projectId = process.env.GOOGLE_VERTEX_PROJECT;
return {
hasApiKey: Boolean(projectId && projectId.trim()),
envVar: 'GOOGLE_VERTEX_PROJECT',
};
}
// Amazon Bedrock supports two auth methods:
// 1. AWS_BEARER_TOKEN_BEDROCK - Bedrock API key (simplest, recommended for dev)
// 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_REGION (IAM credentials, for production)
//
// We check for API key first, then fall back to checking AWS_REGION
if (provider === 'bedrock') {
const apiKey = process.env.AWS_BEARER_TOKEN_BEDROCK;
if (apiKey && apiKey.trim()) {
return {
hasApiKey: true,
envVar: 'AWS_BEARER_TOKEN_BEDROCK',
};
}
// Fall back to checking AWS_REGION (implies IAM credentials)
const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION;
return {
hasApiKey: Boolean(region && region.trim()),
envVar: 'AWS_REGION',
};
}
const envVar = getPrimaryApiKeyEnvVar(provider);
const key = resolveApiKeyForProvider(provider);
return { hasApiKey: Boolean(key && key.trim()), envVar };
}
export function listProviderKeyStatus(): Record<string, { hasApiKey: boolean; envVar: string }> {
const result: Record<string, { hasApiKey: boolean; envVar: string }> = {};
for (const provider of LLM_PROVIDERS) {
result[provider] = getProviderKeyStatus(provider);
}
return result;
}
/**
* Providers that use a shared env var for API keys (vs per-endpoint like openai-compatible).
* For these providers, we save to the env var if none exists, otherwise per-model override.
*/
export const SHARED_API_KEY_PROVIDERS = ['glama', 'openrouter', 'litellm'] as const;
export type ApiKeyStorageStrategy = {
/** Save the key to provider env var (e.g., GLAMA_API_KEY) */
saveToProviderEnvVar: boolean;
/** Save the key as per-model override in custom model config */
saveAsPerModel: boolean;
};
/**
* Determine where to store an API key for a custom model.
*
* Logic:
* - For glama/openrouter/litellm (shared env var providers):
* - If NO provider key exists → save to provider env var for reuse
* - If provider key EXISTS and user entered SAME key → don't save (uses fallback)
* - If provider key EXISTS and user entered DIFFERENT key → save as per-model override
* - For openai-compatible: always save as per-model (each endpoint needs own key)
*
* @param provider - The custom model provider
* @param userEnteredKey - The API key entered by user (trimmed, may be empty)
* @param providerHasKey - Whether the provider already has a key configured
* @param existingProviderKey - The existing provider key value (for comparison)
*/
export function determineApiKeyStorage(
provider: string,
userEnteredKey: string | undefined,
providerHasKey: boolean,
existingProviderKey: string | undefined
): ApiKeyStorageStrategy {
const result: ApiKeyStorageStrategy = {
saveToProviderEnvVar: false,
saveAsPerModel: false,
};
if (!userEnteredKey) {
return result;
}
const hasSharedEnvVarKey = (SHARED_API_KEY_PROVIDERS as readonly string[]).includes(provider);
if (hasSharedEnvVarKey) {
if (!providerHasKey) {
// No provider key exists - save to provider env var for reuse
result.saveToProviderEnvVar = true;
} else if (existingProviderKey && userEnteredKey !== existingProviderKey) {
// Provider has key but user entered different one - save as per-model override
result.saveAsPerModel = true;
}
// If user entered same key as provider, don't save anything (uses fallback)
} else {
// openai-compatible: always save as per-model (each endpoint needs own key)
result.saveAsPerModel = true;
}
return result;
}

View File

@@ -0,0 +1,94 @@
/**
* Dexto Authentication Utilities
*
* Provides functions to check dexto authentication status.
* Used by both CLI and server to determine if user can use dexto features.
*/
import { existsSync, promises as fs } from 'fs';
import { z } from 'zod';
import { getDextoGlobalPath } from '@dexto/core';
const AUTH_CONFIG_FILE = 'auth.json';
/**
* Minimal schema for checking auth status.
* We only need to verify token exists and hasn't expired.
*/
const AuthConfigSchema = z.object({
token: z.string().min(1),
expiresAt: z.number().optional(),
dextoApiKey: z.string().optional(),
});
/**
* Check if user is authenticated with Dexto.
* Returns true if auth.json exists with valid (non-expired) token.
*/
export async function isDextoAuthenticated(): Promise<boolean> {
const authPath = getDextoGlobalPath('', AUTH_CONFIG_FILE);
if (!existsSync(authPath)) {
return false;
}
try {
const content = await fs.readFile(authPath, 'utf-8');
const config = JSON.parse(content);
const validated = AuthConfigSchema.safeParse(config);
if (!validated.success) {
return false;
}
// Check if token has expired
if (validated.data.expiresAt && validated.data.expiresAt < Date.now()) {
return false;
}
return true;
} catch {
return false;
}
}
/**
* Get the dexto API key from auth config or environment.
*/
export async function getDextoApiKeyFromAuth(): Promise<string | null> {
// Check environment variable first
if (process.env.DEXTO_API_KEY) {
return process.env.DEXTO_API_KEY;
}
// Check auth config
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);
return config.dextoApiKey || null;
} catch {
return null;
}
}
/**
* Check if user can use Dexto provider.
* Requires BOTH:
* 1. User is authenticated (valid auth token)
* 2. Has DEXTO_API_KEY (from auth config or environment)
*/
export async function canUseDextoProvider(): Promise<boolean> {
const authenticated = await isDextoAuthenticated();
if (!authenticated) return false;
const apiKey = await getDextoApiKeyFromAuth();
if (!apiKey) return false;
return true;
}

View File

@@ -0,0 +1,52 @@
import * as path from 'node:path';
import { promises as fs } from 'node:fs';
/**
* Update a .env file with the provided key-value pairs.
* Existing keys are updated in place, new keys are appended.
*/
export async function updateEnvFile(
envFilePath: string,
updates: Record<string, string>
): Promise<void> {
await fs.mkdir(path.dirname(envFilePath), { recursive: true });
let content = '';
try {
content = await fs.readFile(envFilePath, 'utf8');
} catch {
// File doesn't exist yet
}
const lines = content.split('\n');
const updatedKeys = new Set<string>();
// Update existing keys in place
const updatedLines = lines.map((line) => {
const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
if (match && match[1] && match[1] in updates) {
const key = match[1];
updatedKeys.add(key);
return `${key}=${updates[key]}`;
}
return line;
});
// Append new keys that weren't found
for (const [key, value] of Object.entries(updates)) {
if (!updatedKeys.has(key)) {
// Ensure there's a newline before appending
if (updatedLines.length > 0 && updatedLines[updatedLines.length - 1] !== '') {
updatedLines.push('');
}
updatedLines.push(`${key}=${value}`);
}
}
// Ensure file ends with newline
if (updatedLines[updatedLines.length - 1] !== '') {
updatedLines.push('');
}
await fs.writeFile(envFilePath, updatedLines.join('\n'), 'utf8');
}

View File

@@ -0,0 +1,190 @@
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import {
getExecutionContext,
findDextoSourceRoot,
findDextoProjectRoot,
} from './execution-context.js';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
function createTempDir() {
return fs.mkdtempSync(path.join(tmpdir(), 'dexto-test-'));
}
function createTempDirStructure(structure: Record<string, any>, baseDir?: string): string {
const tempDir = baseDir || createTempDir();
for (const [filePath, content] of Object.entries(structure)) {
const fullPath = path.join(tempDir, filePath);
const dir = path.dirname(fullPath);
// Create directory if it doesn't exist
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
if (typeof content === 'string') {
fs.writeFileSync(fullPath, content);
} else if (typeof content === 'object') {
fs.writeFileSync(fullPath, JSON.stringify(content, null, 2));
}
}
return tempDir;
}
describe('Execution Context Detection', () => {
let tempDir: string;
afterEach(() => {
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('Project with @dexto/core dependency', () => {
beforeEach(() => {
tempDir = createTempDirStructure({
'package.json': {
name: 'test-project',
dependencies: { '@dexto/core': '^1.0.0' },
},
});
});
it('getExecutionContext returns dexto-project', () => {
const result = getExecutionContext(tempDir);
expect(result).toBe('dexto-project');
});
it('findDextoProjectRoot returns correct root from nested directory', () => {
// Set up nested directory
const nestedDir = path.join(tempDir, 'nested', 'deep');
fs.mkdirSync(nestedDir, { recursive: true });
const result = findDextoProjectRoot(nestedDir);
// Normalize paths for macOS symlink differences (/var vs /private/var)
expect(result ? fs.realpathSync(result) : null).toBe(fs.realpathSync(tempDir));
});
it('findDextoSourceRoot returns null for dexto-project context', () => {
const result = findDextoSourceRoot(tempDir);
expect(result).toBeNull();
});
});
describe('Project with dexto devDependency', () => {
beforeEach(() => {
tempDir = createTempDirStructure({
'package.json': {
name: 'test-project',
devDependencies: { dexto: '^1.0.0' },
},
});
});
it('getExecutionContext returns dexto-project', () => {
const result = getExecutionContext(tempDir);
expect(result).toBe('dexto-project');
});
});
describe('Dexto source project itself', () => {
beforeEach(() => {
tempDir = createTempDirStructure({
'package.json': {
name: 'dexto-monorepo',
version: '1.0.0',
},
});
});
it('getExecutionContext returns dexto-source', () => {
const result = getExecutionContext(tempDir);
expect(result).toBe('dexto-source');
});
it('findDextoSourceRoot returns correct root', () => {
const result = findDextoSourceRoot(tempDir);
// Normalize paths for macOS symlink differences (/var vs /private/var)
expect(result ? fs.realpathSync(result) : null).toBe(fs.realpathSync(tempDir));
});
it('findDextoProjectRoot returns null for dexto-source context', () => {
const result = findDextoProjectRoot(tempDir);
expect(result).toBeNull();
});
});
describe('Internal dexto packages within monorepo', () => {
beforeEach(() => {
tempDir = createTempDirStructure({
// Root monorepo package.json
'package.json': {
name: 'dexto-monorepo',
version: '1.0.0',
},
// Internal webui package
'packages/webui/package.json': {
name: '@dexto/webui',
dependencies: { '@dexto/core': 'workspace:*' },
},
});
});
it('getExecutionContext returns dexto-source when in webui package', () => {
const webuiDir = path.join(tempDir, 'packages', 'webui');
const result = getExecutionContext(webuiDir);
expect(result).toBe('dexto-source');
});
it('findDextoSourceRoot finds monorepo root from webui package', () => {
const webuiDir = path.join(tempDir, 'packages', 'webui');
const result = findDextoSourceRoot(webuiDir);
expect(result ? fs.realpathSync(result) : null).toBe(fs.realpathSync(tempDir));
});
});
describe('Non-dexto project', () => {
beforeEach(() => {
tempDir = createTempDirStructure({
'package.json': {
name: 'regular-project',
dependencies: { express: '^4.0.0' },
},
});
});
it('getExecutionContext returns global-cli', () => {
const result = getExecutionContext(tempDir);
expect(result).toBe('global-cli');
});
it('findDextoSourceRoot returns null for global-cli context', () => {
const result = findDextoSourceRoot(tempDir);
expect(result).toBeNull();
});
it('findDextoProjectRoot returns null for global-cli context', () => {
const result = findDextoProjectRoot(tempDir);
expect(result).toBeNull();
});
});
describe('No package.json', () => {
beforeEach(() => {
tempDir = createTempDir();
});
it('getExecutionContext returns global-cli', () => {
const result = getExecutionContext(tempDir);
expect(result).toBe('global-cli');
});
it('find functions return null for non-dexto directories', () => {
expect(findDextoSourceRoot(tempDir)).toBeNull();
expect(findDextoProjectRoot(tempDir)).toBeNull();
});
});
});

View File

@@ -0,0 +1,93 @@
// packages/agent-management/src/utils/execution-context.ts
// TODO: (migration) This file is duplicated from @dexto/core for short-term compatibility
// This will become the primary location once core services accept paths via initialization
import { walkUpDirectories } from './fs-walk.js';
import { readFileSync } from 'fs';
import * as path from 'path';
export type ExecutionContext = 'dexto-source' | 'dexto-project' | 'global-cli';
/**
* Check if directory is the dexto source code itself
* @param dirPath Directory to check
* @returns True if directory contains the dexto source monorepo (top-level).
*/
function isDextoSourceDirectory(dirPath: string): boolean {
const packageJsonPath = path.join(dirPath, 'package.json');
try {
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
// Monorepo root must be named 'dexto-monorepo'. No other names are treated as source root.
return pkg.name === 'dexto-monorepo';
} catch {
return false;
}
}
/**
* Check if directory is a project that uses dexto as dependency (but is not dexto source)
* @param dirPath Directory to check
* @returns True if directory has dexto as dependency but is not dexto source
*/
function isDextoProjectDirectory(dirPath: string): boolean {
const packageJsonPath = path.join(dirPath, 'package.json');
try {
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
// Not internal dexto packages themselves
if (pkg.name === 'dexto' || pkg.name === '@dexto/core' || pkg.name === '@dexto/webui') {
return false;
}
// Check if has dexto or @dexto/core as dependency
const allDeps = {
...(pkg.dependencies ?? {}),
...(pkg.devDependencies ?? {}),
...(pkg.peerDependencies ?? {}),
};
return 'dexto' in allDeps || '@dexto/core' in allDeps;
} catch {
return false;
}
}
/**
* Find dexto source root directory
* @param startPath Starting directory path
* @returns Dexto source root directory or null if not found
*/
export function findDextoSourceRoot(startPath: string = process.cwd()): string | null {
return walkUpDirectories(startPath, isDextoSourceDirectory);
}
/**
* Find dexto project root directory (projects using dexto as dependency)
* @param startPath Starting directory path
* @returns Dexto project root directory or null if not found
*/
export function findDextoProjectRoot(startPath: string = process.cwd()): string | null {
return walkUpDirectories(startPath, isDextoProjectDirectory);
}
/**
* Detect current execution context - standardized across codebase
* @param startPath Starting directory path (defaults to process.cwd())
* @returns Execution context
*/
export function getExecutionContext(startPath: string = process.cwd()): ExecutionContext {
// Check for Dexto source context first (most specific)
if (findDextoSourceRoot(startPath)) {
return 'dexto-source';
}
// Check for Dexto project context
if (findDextoProjectRoot(startPath)) {
return 'dexto-project';
}
// Default to global CLI context
return 'global-cli';
}

View File

@@ -0,0 +1,23 @@
/**
* Feature flags for Dexto
*
* These flags control the availability of features that are in development
* or being rolled out gradually.
*/
/**
* Check if Dexto authentication/provider is enabled.
*
* When disabled (default), the Dexto provider option is hidden from:
* - Onboarding/setup wizard
* - Model selectors (CLI and WebUI)
* - LLM catalog API responses
*
* The underlying auth commands (dexto login, logout, billing) remain functional
* for users who need to manage their account.
*
* Enable by setting DEXTO_FEATURE_AUTH=true in environment.
*/
export function isDextoAuthEnabled(): boolean {
return process.env.DEXTO_FEATURE_AUTH === 'true';
}

View File

@@ -0,0 +1,70 @@
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { walkUpDirectories } from './fs-walk.js';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
function createTempDir() {
return fs.mkdtempSync(path.join(tmpdir(), 'dexto-test-'));
}
describe('walkUpDirectories', () => {
let tempDir: string;
let nestedDir: string;
beforeEach(() => {
tempDir = createTempDir();
nestedDir = path.join(tempDir, 'nested', 'deep', 'directory');
fs.mkdirSync(nestedDir, { recursive: true });
// Create a marker file in tempDir
fs.writeFileSync(path.join(tempDir, 'marker.txt'), 'found');
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('returns null when no directories match the predicate', () => {
const result = walkUpDirectories(nestedDir, (dir) =>
fs.existsSync(path.join(dir, 'nonexistent.txt'))
);
expect(result).toBeNull();
});
it('finds directory by walking up the tree', () => {
const result = walkUpDirectories(nestedDir, (dir) =>
fs.existsSync(path.join(dir, 'marker.txt'))
);
expect(result).toBe(tempDir);
});
it('returns the immediate directory if it matches', () => {
fs.writeFileSync(path.join(nestedDir, 'immediate.txt'), 'here');
const result = walkUpDirectories(nestedDir, (dir) =>
fs.existsSync(path.join(dir, 'immediate.txt'))
);
expect(result).toBe(nestedDir);
});
it('includes filesystem root in search', () => {
// Test that the function evaluates the predicate for the root path
// Derive root from the startPath under test, not the CWD
const rootPath = path.parse(nestedDir).root;
// Guard: ensure nestedDir is on the same drive/filesystem
expect(nestedDir.startsWith(rootPath)).toBe(true);
// Use a predicate that only matches the root
const result = walkUpDirectories(nestedDir, (dir) => dir === rootPath);
// Should find the root path
expect(result).toBe(rootPath);
});
it('returns null when starting at root and predicate never matches', () => {
const root = path.parse(nestedDir).root;
const result = walkUpDirectories(root, () => false);
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,30 @@
// TODO: (migration) This file is duplicated from @dexto/core for short-term compatibility
// This will become the primary location once path utilities are fully migrated
import * as path from 'path';
/**
* Generic directory walker that searches up the directory tree
* @param startPath Starting directory path
* @param predicate Function that returns true when the desired condition is found
* @returns The directory path where the condition was met, or null if not found
*/
export function walkUpDirectories(
startPath: string,
predicate: (dirPath: string) => boolean
): string | null {
let currentPath = path.resolve(startPath);
const rootPath = path.parse(currentPath).root;
while (true) {
if (predicate(currentPath)) {
return currentPath;
}
if (currentPath === rootPath) break;
const parent = path.dirname(currentPath);
if (parent === currentPath) break; // safety for exotic paths
currentPath = parent;
}
return null;
}

View File

@@ -0,0 +1,408 @@
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir, homedir } from 'os';
import {
getDextoPath,
getDextoGlobalPath,
getDextoEnvPath,
findPackageRoot,
resolveBundledScript,
} from './path.js';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
function createTempDir() {
return fs.mkdtempSync(path.join(tmpdir(), 'dexto-test-'));
}
function createTempDirStructure(structure: Record<string, any>, baseDir?: string): string {
const tempDir = baseDir || createTempDir();
for (const [filePath, content] of Object.entries(structure)) {
const fullPath = path.join(tempDir, filePath);
const dir = path.dirname(fullPath);
// Create directory if it doesn't exist
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
if (typeof content === 'string') {
fs.writeFileSync(fullPath, content);
} else if (typeof content === 'object') {
fs.writeFileSync(fullPath, JSON.stringify(content, null, 2));
}
}
return tempDir;
}
describe('getDextoPath', () => {
let tempDir: string;
afterEach(() => {
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('in dexto project', () => {
beforeEach(() => {
tempDir = createTempDirStructure({
'package.json': {
name: 'test-project',
dependencies: { dexto: '^1.0.0' },
},
});
});
it('returns project-local path for logs', () => {
const result = getDextoPath('logs', 'test.log', tempDir);
expect(result).toBe(path.join(tempDir, '.dexto', 'logs', 'test.log'));
});
it('returns project-local path for database', () => {
const result = getDextoPath('database', 'dexto.db', tempDir);
expect(result).toBe(path.join(tempDir, '.dexto', 'database', 'dexto.db'));
});
it('returns directory path when no filename provided', () => {
const result = getDextoPath('config', undefined, tempDir);
expect(result).toBe(path.join(tempDir, '.dexto', 'config'));
});
it('works from nested directories', () => {
const nestedDir = path.join(tempDir, 'src', 'app');
fs.mkdirSync(nestedDir, { recursive: true });
const result = getDextoPath('logs', 'app.log', nestedDir);
expect(result).toBe(path.join(tempDir, '.dexto', 'logs', 'app.log'));
});
});
describe('outside dexto project (global)', () => {
beforeEach(() => {
tempDir = createTempDirStructure({
'package.json': {
name: 'regular-project',
dependencies: { express: '^4.0.0' },
},
});
});
it('returns global path when not in dexto project', () => {
const originalCwd = process.cwd();
try {
process.chdir(tempDir);
const result = getDextoPath('logs', 'global.log');
expect(result).toContain('.dexto');
expect(result).toContain('logs');
expect(result).toContain('global.log');
expect(result).not.toContain(tempDir);
} finally {
process.chdir(originalCwd);
}
});
});
});
describe('getDextoGlobalPath', () => {
let tempDir: string;
afterEach(() => {
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('basic functionality', () => {
it('returns global agents directory', () => {
const result = getDextoGlobalPath('agents');
expect(result).toContain('.dexto');
expect(result).toContain('agents');
expect(path.isAbsolute(result)).toBe(true);
});
it('returns global path with filename', () => {
const result = getDextoGlobalPath('agents', 'database-agent');
expect(result).toContain('.dexto');
expect(result).toContain('agents');
expect(result).toContain('database-agent');
expect(path.isAbsolute(result)).toBe(true);
});
it('handles different types correctly', () => {
const agents = getDextoGlobalPath('agents');
const logs = getDextoGlobalPath('logs');
const cache = getDextoGlobalPath('cache');
expect(agents).toContain('agents');
expect(logs).toContain('logs');
expect(cache).toContain('cache');
});
});
describe('in dexto project context', () => {
beforeEach(() => {
tempDir = createTempDirStructure({
'package.json': {
name: 'test-project',
dependencies: { dexto: '^1.0.0' },
},
});
});
it('always returns global path, never project-relative', () => {
// getDextoPath returns project-relative
const projectPath = getDextoPath('agents', 'test-agent', tempDir);
expect(projectPath).toBe(path.join(tempDir, '.dexto', 'agents', 'test-agent'));
// getDextoGlobalPath should ALWAYS return global, never project-relative
const globalPath = getDextoGlobalPath('agents', 'test-agent');
expect(globalPath).toContain('.dexto');
expect(globalPath).toContain('agents');
expect(globalPath).toContain('test-agent');
expect(globalPath).not.toContain(tempDir); // Key difference!
expect(path.isAbsolute(globalPath)).toBe(true);
});
});
describe('outside dexto project context', () => {
beforeEach(() => {
tempDir = createTempDirStructure({
'package.json': {
name: 'regular-project',
dependencies: { express: '^4.0.0' },
},
});
});
it('returns global path (same as in project context)', () => {
const globalPath = getDextoGlobalPath('agents', 'test-agent');
expect(globalPath).toContain('.dexto');
expect(globalPath).toContain('agents');
expect(globalPath).toContain('test-agent');
expect(globalPath).not.toContain(tempDir);
expect(path.isAbsolute(globalPath)).toBe(true);
});
});
});
describe('findPackageRoot', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('returns null if no package.json found', () => {
const result = findPackageRoot(tempDir);
expect(result).toBeNull();
});
it('returns the directory containing package.json', () => {
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'test-pkg' }));
const result = findPackageRoot(tempDir);
expect(result).toBe(tempDir);
});
it('finds package.json by walking up directories', () => {
const nestedDir = path.join(tempDir, 'nested', 'deep');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'test-pkg' }));
const result = findPackageRoot(nestedDir);
expect(result).toBe(tempDir);
});
});
describe('resolveBundledScript', () => {
it('resolves a known agent registry path', () => {
const result = resolveBundledScript('agents/agent-registry.json');
expect(path.isAbsolute(result)).toBe(true);
expect(result.endsWith('agents/agent-registry.json')).toBe(true);
});
it('throws error when script cannot be resolved', () => {
expect(() => resolveBundledScript('nonexistent/script.js')).toThrow();
});
});
describe('getDextoEnvPath', () => {
describe('in dexto project', () => {
let tempDir: string;
let originalCwd: string;
beforeEach(() => {
originalCwd = process.cwd();
tempDir = createTempDirStructure({
'package.json': {
name: 'test-project',
dependencies: { dexto: '^1.0.0' },
},
});
});
afterEach(() => {
process.chdir(originalCwd);
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('returns project root .env path', () => {
process.chdir(tempDir);
const result = getDextoEnvPath(tempDir);
expect(result).toBe(path.join(tempDir, '.env'));
});
});
describe('in dexto source', () => {
let tempDir: string;
let originalCwd: string;
const originalDevMode = process.env.DEXTO_DEV_MODE;
beforeEach(() => {
originalCwd = process.cwd();
tempDir = createTempDirStructure({
'package.json': {
name: 'dexto-monorepo',
version: '1.0.0',
},
'agents/default-agent.yml': 'mcpServers: {}',
});
});
afterEach(() => {
process.chdir(originalCwd);
fs.rmSync(tempDir, { recursive: true, force: true });
// Restore original env
if (originalDevMode === undefined) {
delete process.env.DEXTO_DEV_MODE;
} else {
process.env.DEXTO_DEV_MODE = originalDevMode;
}
});
it('returns repo .env when DEXTO_DEV_MODE=true', () => {
process.chdir(tempDir);
process.env.DEXTO_DEV_MODE = 'true';
const result = getDextoEnvPath(tempDir);
expect(result).toBe(path.join(tempDir, '.env'));
});
it('returns global ~/.dexto/.env when DEXTO_DEV_MODE is not set', () => {
process.chdir(tempDir);
delete process.env.DEXTO_DEV_MODE;
const result = getDextoEnvPath(tempDir);
expect(result).toBe(path.join(homedir(), '.dexto', '.env'));
});
it('returns global ~/.dexto/.env when DEXTO_DEV_MODE=false', () => {
process.chdir(tempDir);
process.env.DEXTO_DEV_MODE = 'false';
const result = getDextoEnvPath(tempDir);
expect(result).toBe(path.join(homedir(), '.dexto', '.env'));
});
});
describe('in global-cli context', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDirStructure({
'package.json': {
name: 'regular-project',
dependencies: { express: '^4.0.0' },
},
});
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('returns global ~/.dexto/.env path', () => {
const result = getDextoEnvPath(tempDir);
expect(result).toBe(path.join(homedir(), '.dexto', '.env'));
});
});
});
describe('real-world execution contexts', () => {
describe('SDK usage in project', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDirStructure({
'package.json': {
name: 'my-app',
dependencies: { dexto: '^1.0.0' },
},
'src/dexto/agents/default-agent.yml': 'mcpServers: {}',
});
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('uses project-local storage', () => {
const logPath = getDextoPath('logs', 'dexto.log', tempDir);
const dbPath = getDextoPath('database', 'dexto.db', tempDir);
expect(logPath).toBe(path.join(tempDir, '.dexto', 'logs', 'dexto.log'));
expect(dbPath).toBe(path.join(tempDir, '.dexto', 'database', 'dexto.db'));
});
});
describe('CLI in dexto source', () => {
let tempDir: string;
const originalDevMode = process.env.DEXTO_DEV_MODE;
beforeEach(() => {
tempDir = createTempDirStructure({
'package.json': {
name: 'dexto-monorepo',
version: '1.0.0',
},
'agents/default-agent.yml': 'mcpServers: {}',
});
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
// Restore original env
if (originalDevMode === undefined) {
delete process.env.DEXTO_DEV_MODE;
} else {
process.env.DEXTO_DEV_MODE = originalDevMode;
}
});
it('uses local repo storage when DEXTO_DEV_MODE=true', () => {
process.env.DEXTO_DEV_MODE = 'true';
const logPath = getDextoPath('logs', 'dexto.log', tempDir);
expect(logPath).toBe(path.join(tempDir, '.dexto', 'logs', 'dexto.log'));
});
it('uses global storage when DEXTO_DEV_MODE is not set', () => {
delete process.env.DEXTO_DEV_MODE;
const logPath = getDextoPath('logs', 'dexto.log', tempDir);
expect(logPath).toContain('.dexto');
expect(logPath).toContain('logs');
expect(logPath).toContain('dexto.log');
expect(logPath).not.toContain(tempDir); // Should be global, not local
});
it('uses global storage when DEXTO_DEV_MODE=false', () => {
process.env.DEXTO_DEV_MODE = 'false';
const logPath = getDextoPath('logs', 'dexto.log', tempDir);
expect(logPath).toContain('.dexto');
expect(logPath).toContain('logs');
expect(logPath).toContain('dexto.log');
expect(logPath).not.toContain(tempDir); // Should be global, not local
});
});
});

View File

@@ -0,0 +1,254 @@
// TODO: (migration) This file is duplicated from @dexto/core for short-term compatibility
// This will become the primary location once core services accept paths via initialization
import * as path from 'path';
import { existsSync } from 'fs';
import { promises as fs } from 'fs';
import { homedir } from 'os';
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { walkUpDirectories } from './fs-walk.js';
import {
getExecutionContext,
findDextoSourceRoot,
findDextoProjectRoot,
} from './execution-context.js';
/**
* Standard path resolver for logs/db/config/anything in dexto projects
* Context-aware with dev mode support:
* - dexto-source + DEXTO_DEV_MODE=true: Use local repo .dexto (isolated testing)
* - dexto-source (normal): Use global ~/.dexto (user experience)
* - dexto-project: Use project-local .dexto
* - global-cli: Use global ~/.dexto
* @param type Path type (logs, database, config, etc.)
* @param filename Optional filename to append
* @param startPath Starting directory for project detection
* @returns Absolute path to the requested location
*/
export function getDextoPath(type: string, filename?: string, startPath?: string): string {
const context = getExecutionContext(startPath);
let basePath: string;
switch (context) {
case 'dexto-source': {
// Dev mode: use local repo .dexto for isolated testing
// Normal mode: use global ~/.dexto for user experience
const isDevMode = process.env.DEXTO_DEV_MODE === 'true';
if (isDevMode) {
const sourceRoot = findDextoSourceRoot(startPath);
if (!sourceRoot) {
throw new Error('Not in dexto source context');
}
basePath = path.join(sourceRoot, '.dexto', type);
} else {
basePath = path.join(homedir(), '.dexto', type);
}
break;
}
case 'dexto-project': {
const projectRoot = findDextoProjectRoot(startPath);
if (!projectRoot) {
throw new Error('Not in dexto project context');
}
basePath = path.join(projectRoot, '.dexto', type);
break;
}
case 'global-cli': {
basePath = path.join(homedir(), '.dexto', type);
break;
}
default: {
throw new Error(`Unknown execution context: ${context}`);
}
}
return filename ? path.join(basePath, filename) : basePath;
}
/**
* Global path resolver that ALWAYS returns paths in the user's home directory
* Used for agent registry and other global-only resources that should not be project-relative
* @param type Path type (agents, cache, etc.)
* @param filename Optional filename to append
* @returns Absolute path to the global location (~/.dexto/...)
*/
export function getDextoGlobalPath(type: string, filename?: string): string {
// ALWAYS return global path, ignore project context
const basePath = path.join(homedir(), '.dexto', type);
return filename ? path.join(basePath, filename) : basePath;
}
/**
* Copy entire directory recursively
* @param src Source directory path
* @param dest Destination directory path
*/
export async function copyDirectory(src: string, dest: string): Promise<void> {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectory(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
/**
* Check if string looks like a file path vs registry name
* @param str String to check
* @returns True if looks like a path, false if looks like a registry name
*/
export function isPath(str: string): boolean {
// Absolute paths
if (path.isAbsolute(str)) return true;
// Relative paths with separators
if (/[\\/]/.test(str)) return true;
// File extensions
if (/\.(ya?ml|json)$/i.test(str)) return true;
return false;
}
/**
* Find package root (for other utilities)
* @param startPath Starting directory path
* @returns Directory containing package.json or null
*/
export function findPackageRoot(startPath: string = process.cwd()): string | null {
return walkUpDirectories(startPath, (dirPath) => {
const pkgPath = path.join(dirPath, 'package.json');
return existsSync(pkgPath);
});
}
/**
* Resolve bundled script paths for MCP servers
* @param scriptPath Relative script path
* @returns Absolute path to bundled script
*/
export function resolveBundledScript(scriptPath: string): string {
// Build list of candidate relative paths to try, favoring packaged (dist) first
const candidates = scriptPath.startsWith('dist/')
? [scriptPath, scriptPath.replace(/^dist\//, '')]
: [`dist/${scriptPath}`, scriptPath];
// Keep a list of absolute paths we attempted for better diagnostics
const triedAbs: string[] = [];
const tryRoots = (roots: Array<string | null | undefined>): string | null => {
for (const root of roots) {
if (!root) continue;
for (const rel of candidates) {
const abs = path.resolve(root, rel);
if (existsSync(abs)) return abs;
triedAbs.push(abs);
}
}
return null;
};
// 0) Explicit env override (useful for exotic/linked setups)
const envRoot = process?.env?.DEXTO_PACKAGE_ROOT;
const fromEnv = tryRoots([envRoot]);
if (fromEnv) return fromEnv;
// 1) Try to resolve from installed CLI package root (global/local install)
try {
const require = createRequire(import.meta.url);
const pkgJson = require.resolve('dexto/package.json');
const pkgRoot = path.dirname(pkgJson);
const fromPkg = tryRoots([pkgRoot]);
if (fromPkg) return fromPkg;
} catch {
// ignore, fall through to dev/project resolution
}
// 2) Development fallback anchored to this module's location (monorepo source)
try {
const thisModuleDir = path.dirname(fileURLToPath(import.meta.url));
const sourceRoot = findDextoSourceRoot(thisModuleDir);
const fromSource = tryRoots([sourceRoot ?? undefined]);
if (fromSource) return fromSource;
} catch {
// ignore and continue to legacy fallback
}
// 3) Legacy fallback: repo/project root derived from CWD
const repoRoot = findPackageRoot();
const fromCwd = tryRoots([repoRoot ?? undefined]);
if (fromCwd) return fromCwd;
// Not found anywhere: throw with helpful message and absolute paths attempted
throw new Error(
`Bundled script not found: ${scriptPath} (tried absolute: ${triedAbs.join(', ')})`
);
}
/**
* Ensure ~/.dexto directory exists for global storage
*/
export async function ensureDextoGlobalDirectory(): Promise<void> {
const dextoDir = path.join(homedir(), '.dexto');
try {
await fs.mkdir(dextoDir, { recursive: true });
} catch (error) {
// Directory might already exist, ignore EEXIST errors
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error;
}
}
}
/**
* Get the appropriate .env file path for saving API keys.
* Uses the same project detection logic as other dexto paths.
*
* @param startPath Starting directory for project detection
* @returns Absolute path to .env file for saving
*/
export function getDextoEnvPath(startPath: string = process.cwd()): string {
const context = getExecutionContext(startPath);
let envPath = '';
switch (context) {
case 'dexto-source': {
// Dev mode: use local repo .env for isolated testing
// Normal mode: use global ~/.dexto/.env for user experience
const isDevMode = process.env.DEXTO_DEV_MODE === 'true';
if (isDevMode) {
const sourceRoot = findDextoSourceRoot(startPath);
if (!sourceRoot) {
throw new Error('Not in dexto source context');
}
envPath = path.join(sourceRoot, '.env');
} else {
envPath = path.join(homedir(), '.dexto', '.env');
}
break;
}
case 'dexto-project': {
const projectRoot = findDextoProjectRoot(startPath);
if (!projectRoot) {
throw new Error('Not in dexto project context');
}
envPath = path.join(projectRoot, '.env');
break;
}
case 'global-cli': {
envPath = path.join(homedir(), '.dexto', '.env');
break;
}
}
// logger.debug(`Dexto env path: ${envPath}, context: ${context}`); // TODO: (migration) Removed logger dependency
return envPath;
}

View File

@@ -0,0 +1,383 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import {
writeConfigFile,
writeLLMPreferences,
writePreferencesToAgent,
type LLMOverrides,
} from './writer.js';
import { type AgentConfig, ErrorScope, ErrorType } from '@dexto/core';
import { ConfigErrorCode } from './config/index.js';
import { type GlobalPreferences } from './preferences/schemas.js';
describe('Config Writer', () => {
let tempDir: string;
let tempConfigPath: string;
let sampleConfig: AgentConfig;
let samplePreferences: GlobalPreferences;
beforeEach(async () => {
// Create temporary directory for each test
tempDir = await fs.mkdtemp(path.join(tmpdir(), 'dexto-config-test-'));
tempConfigPath = path.join(tempDir, 'test-agent.yml');
// Sample agent configuration
sampleConfig = {
agentCard: {
name: 'Test Agent',
description: 'A test agent',
url: 'https://example.com',
version: '1.0.0',
},
llm: {
provider: 'openai',
model: 'gpt-5',
apiKey: '$OPENAI_API_KEY',
},
systemPrompt: 'You are a helpful assistant.',
internalTools: ['search_history'],
};
// Sample global preferences
samplePreferences = {
llm: {
provider: 'anthropic',
model: 'claude-4-sonnet-20250514',
apiKey: '$ANTHROPIC_API_KEY',
},
defaults: {
defaultAgent: 'test-agent',
defaultMode: 'web',
},
setup: {
completed: true,
apiKeyPending: false,
baseURLPending: false,
},
sounds: {
enabled: true,
onApprovalRequired: true,
onTaskComplete: true,
},
};
});
afterEach(async () => {
// Clean up temporary directory
await fs.rm(tempDir, { recursive: true, force: true });
});
describe('writeConfigFile', () => {
it('should write agent config to YAML file', async () => {
await writeConfigFile(tempConfigPath, sampleConfig);
// Verify file was created
expect(
await fs.access(tempConfigPath).then(
() => true,
() => false
)
).toBe(true);
// Verify content is valid YAML
const writtenContent = await fs.readFile(tempConfigPath, 'utf-8');
expect(writtenContent).toContain('name: Test Agent');
expect(writtenContent).toContain('provider: openai');
expect(writtenContent).toContain('model: gpt-5');
expect(writtenContent).toContain('apiKey: $OPENAI_API_KEY');
});
it('should handle relative paths by converting to absolute', async () => {
const relativePath = path.relative(process.cwd(), tempConfigPath);
await writeConfigFile(relativePath, sampleConfig);
// File should exist
expect(
await fs.access(tempConfigPath).then(
() => true,
() => false
)
).toBe(true);
});
it('should throw ConfigError when directory does not exist', async () => {
const invalidPath = path.join(tempDir, 'nonexistent', 'config.yml');
await expect(writeConfigFile(invalidPath, sampleConfig)).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.FILE_WRITE_ERROR,
scope: ErrorScope.CONFIG,
type: ErrorType.SYSTEM,
})
);
});
it('should preserve complex nested structures', async () => {
const complexConfig = {
...sampleConfig,
tools: {
bash: { maxOutputChars: 30000 },
read: { maxLines: 2000, maxLineLength: 2000 },
},
// Test deep nesting via mcpServers which supports complex structures
mcpServers: {
customServer: {
type: 'stdio' as const,
command: 'node',
args: ['server.js'],
env: {
NESTED_CONFIG: 'test-value',
},
},
},
};
await writeConfigFile(tempConfigPath, complexConfig);
const content = await fs.readFile(tempConfigPath, 'utf-8');
expect(content).toContain('maxOutputChars: 30000');
expect(content).toContain('NESTED_CONFIG: test-value');
});
});
describe('writeLLMPreferences', () => {
beforeEach(async () => {
// Create initial config file
await writeConfigFile(tempConfigPath, sampleConfig);
});
it('should update LLM section with preferences', async () => {
await writeLLMPreferences(tempConfigPath, samplePreferences);
const updatedContent = await fs.readFile(tempConfigPath, 'utf-8');
expect(updatedContent).toContain('provider: anthropic');
expect(updatedContent).toContain('model: claude-4-sonnet-20250514');
expect(updatedContent).toContain('apiKey: $ANTHROPIC_API_KEY');
});
it('should preserve non-LLM sections when updating', async () => {
await writeLLMPreferences(tempConfigPath, samplePreferences);
const updatedContent = await fs.readFile(tempConfigPath, 'utf-8');
expect(updatedContent).toContain('name: Test Agent');
expect(updatedContent).toContain('You are a helpful assistant');
expect(updatedContent).toContain('search_history');
});
it('should apply CLI overrides over preferences', async () => {
const overrides: LLMOverrides = {
provider: 'openai',
model: 'gpt-3.5-turbo',
apiKey: '$CUSTOM_API_KEY',
};
await writeLLMPreferences(tempConfigPath, samplePreferences, overrides);
const updatedContent = await fs.readFile(tempConfigPath, 'utf-8');
expect(updatedContent).toContain('provider: openai');
expect(updatedContent).toContain('model: gpt-3.5-turbo');
expect(updatedContent).toContain('apiKey: $CUSTOM_API_KEY');
});
it('should apply partial overrides correctly', async () => {
const overrides: LLMOverrides = {
model: 'claude-sonnet-4-5-20250929',
// provider and apiKey from preferences
};
await writeLLMPreferences(tempConfigPath, samplePreferences, overrides);
const updatedContent = await fs.readFile(tempConfigPath, 'utf-8');
expect(updatedContent).toContain('provider: anthropic'); // from preferences
expect(updatedContent).toContain('model: claude-sonnet-4-5-20250929'); // from override
expect(updatedContent).toContain('apiKey: $ANTHROPIC_API_KEY'); // from preferences
});
it('should preserve existing LLM settings not in preferences', async () => {
// Add extra LLM settings to original config
const configWithExtras = {
...sampleConfig,
llm: {
...sampleConfig.llm,
temperature: 0.7,
maxTokens: 4000,
},
};
await writeConfigFile(tempConfigPath, configWithExtras);
await writeLLMPreferences(tempConfigPath, samplePreferences);
const updatedContent = await fs.readFile(tempConfigPath, 'utf-8');
expect(updatedContent).toContain('temperature: 0.7');
expect(updatedContent).toContain('maxTokens: 4000');
});
it('should throw ConfigError for non-existent file', async () => {
const nonExistentPath = path.join(tempDir, 'missing.yml');
await expect(writeLLMPreferences(nonExistentPath, samplePreferences)).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.FILE_READ_ERROR,
scope: ErrorScope.CONFIG,
type: ErrorType.SYSTEM,
})
);
});
it('should throw ConfigError for invalid YAML file', async () => {
// Write invalid YAML
await fs.writeFile(tempConfigPath, 'invalid: yaml: content: [}', 'utf-8');
await expect(writeLLMPreferences(tempConfigPath, samplePreferences)).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.PARSE_ERROR,
scope: ErrorScope.CONFIG,
type: ErrorType.USER,
})
);
});
});
describe('writePreferencesToAgent', () => {
it('should handle single YAML file agents', async () => {
await writeConfigFile(tempConfigPath, sampleConfig);
await writePreferencesToAgent(tempConfigPath, samplePreferences);
const updatedContent = await fs.readFile(tempConfigPath, 'utf-8');
expect(updatedContent).toContain('provider: anthropic');
expect(updatedContent).toContain('model: claude-4-sonnet-20250514');
});
it('should skip non-YAML files', async () => {
const txtFilePath = path.join(tempDir, 'readme.txt');
await fs.writeFile(txtFilePath, 'This is not a config file', 'utf-8');
// Should not throw, just warn and skip
await expect(
writePreferencesToAgent(txtFilePath, samplePreferences)
).resolves.not.toThrow();
});
it('should handle directory-based agents with multiple configs', async () => {
const agentDir = path.join(tempDir, 'multi-agent');
await fs.mkdir(agentDir, { recursive: true });
// Create multiple config files
const config1Path = path.join(agentDir, 'agent1.yml');
const config2Path = path.join(agentDir, 'agent2.yaml');
const readmePath = path.join(agentDir, 'README.md');
const config1 = {
...sampleConfig,
agentCard: { ...sampleConfig.agentCard!, name: 'Agent 1' },
};
const config2 = {
...sampleConfig,
agentCard: { ...sampleConfig.agentCard!, name: 'Agent 2' },
};
await writeConfigFile(config1Path, config1);
await writeConfigFile(config2Path, config2);
await fs.writeFile(readmePath, '# Agent Documentation', 'utf-8');
await writePreferencesToAgent(agentDir, samplePreferences);
// Both YAML files should be updated
const content1 = await fs.readFile(config1Path, 'utf-8');
const content2 = await fs.readFile(config2Path, 'utf-8');
expect(content1).toContain('provider: anthropic');
expect(content2).toContain('provider: anthropic');
// Names should be preserved
expect(content1).toContain('name: Agent 1');
expect(content2).toContain('name: Agent 2');
});
it('should handle nested directory structure', async () => {
const agentDir = path.join(tempDir, 'nested-agent');
const subDir = path.join(agentDir, 'configs');
await fs.mkdir(subDir, { recursive: true });
const mainConfigPath = path.join(agentDir, 'main.yml');
const subConfigPath = path.join(subDir, 'sub.yml');
const mainConfig = {
...sampleConfig,
agentCard: { ...sampleConfig.agentCard!, name: 'Main Agent' },
};
const subConfig = {
...sampleConfig,
agentCard: { ...sampleConfig.agentCard!, name: 'Sub Agent' },
};
await writeConfigFile(mainConfigPath, mainConfig);
await writeConfigFile(subConfigPath, subConfig);
await writePreferencesToAgent(agentDir, samplePreferences);
const mainContent = await fs.readFile(mainConfigPath, 'utf-8');
const subContent = await fs.readFile(subConfigPath, 'utf-8');
expect(mainContent).toContain('provider: anthropic');
expect(subContent).toContain('provider: anthropic');
});
it('should skip docs and data directories', async () => {
const agentDir = path.join(tempDir, 'agent-with-docs');
const docsDir = path.join(agentDir, 'docs');
const dataDir = path.join(agentDir, 'data');
await fs.mkdir(docsDir, { recursive: true });
await fs.mkdir(dataDir, { recursive: true });
// These should be ignored
const docConfigPath = path.join(docsDir, 'doc-config.yml');
const dataConfigPath = path.join(dataDir, 'data-config.yml');
// This should be processed
const mainConfigPath = path.join(agentDir, 'main.yml');
await writeConfigFile(docConfigPath, sampleConfig);
await writeConfigFile(dataConfigPath, sampleConfig);
await writeConfigFile(mainConfigPath, sampleConfig);
await writePreferencesToAgent(agentDir, samplePreferences);
// Main config should be updated
const mainContent = await fs.readFile(mainConfigPath, 'utf-8');
expect(mainContent).toContain('provider: anthropic');
// Docs and data configs should remain unchanged
const docContent = await fs.readFile(docConfigPath, 'utf-8');
const dataContent = await fs.readFile(dataConfigPath, 'utf-8');
expect(docContent).toContain('provider: openai'); // original
expect(dataContent).toContain('provider: openai'); // original
});
it('should throw ConfigError for non-existent path', async () => {
const nonExistentPath = path.join(tempDir, 'missing-agent');
await expect(
writePreferencesToAgent(nonExistentPath, samplePreferences)
).rejects.toThrow(
expect.objectContaining({
code: ConfigErrorCode.FILE_READ_ERROR,
scope: ErrorScope.CONFIG,
type: ErrorType.SYSTEM,
})
);
});
it('should handle empty directories gracefully', async () => {
const emptyDir = path.join(tempDir, 'empty-agent');
await fs.mkdir(emptyDir);
// Should not throw, just warn about no configs found
await expect(
writePreferencesToAgent(emptyDir, samplePreferences)
).resolves.not.toThrow();
});
});
});

View File

@@ -0,0 +1,264 @@
// packages/core/src/config/writer.ts
import { promises as fs } from 'fs';
import { parseDocument, stringify as stringifyYaml } from 'yaml';
import * as path from 'path';
import type { LLMProvider, AgentConfig } from '@dexto/core';
import { type GlobalPreferences } from './preferences/schemas.js';
import { logger } from '@dexto/core';
import { ConfigError } from './config/index.js';
export interface LLMOverrides {
provider?: LLMProvider;
model?: string;
apiKey?: string;
baseURL?: string;
}
/**
* Asynchronously writes the given agent configuration object to a YAML file.
* This function handles the serialization of the config object to YAML format
* and writes it to the specified file path.
*
* @param configPath - Path where the configuration file should be written (absolute or relative)
* @param config - The `AgentConfig` object to be written to the file
* @returns A Promise that resolves when the file has been successfully written
* @throws {ConfigError} with FILE_WRITE_ERROR if an error occurs during YAML stringification or file writing
*/
export async function writeConfigFile(configPath: string, config: AgentConfig): Promise<void> {
const absolutePath = path.resolve(configPath);
try {
// Convert the AgentConfig object into a YAML string.
const yamlContent = stringifyYaml(config, { indent: 2 });
// Write the YAML content to the specified file.
// The 'utf-8' encoding ensures proper character handling.
await fs.writeFile(absolutePath, yamlContent, 'utf-8');
// Log a debug message indicating successful file write.
logger.debug(`Wrote dexto config to: ${absolutePath}`);
} catch (error: unknown) {
// Catch any errors that occur during YAML stringification or file writing.
// Throw a specific `ConfigFileWriteError` for better error categorization.
throw ConfigError.fileWriteError(
absolutePath,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Write global LLM preferences to an agent config file
* @param configPath Absolute path to agent configuration file
* @param preferences Global preferences to write
* @param overrides Optional CLI overrides
* @throws DextoRuntimeError for write failures
*/
export async function writeLLMPreferences(
configPath: string,
preferences: GlobalPreferences,
overrides?: LLMOverrides
): Promise<void> {
logger.debug(`Writing LLM preferences to: ${configPath}`, {
provider: overrides?.provider ?? preferences.llm.provider,
model: overrides?.model ?? preferences.llm.model,
hasApiKeyOverride: Boolean(overrides?.apiKey),
hasPreferenceApiKey: Boolean(preferences.llm.apiKey),
});
// Load raw config with proper error mapping
logger.debug(`Reading config file: ${configPath}`);
let fileContent: string;
try {
fileContent = await fs.readFile(configPath, 'utf-8');
logger.debug(`Successfully read config file (${fileContent.length} chars)`);
} catch (error) {
logger.error(`Failed to read config file: ${configPath}`, { error });
throw ConfigError.fileReadError(
configPath,
error instanceof Error ? error.message : String(error)
);
}
// Parse as document to preserve comments
let doc;
try {
doc = parseDocument(fileContent);
if (doc.errors && doc.errors.length > 0) {
throw new Error(doc.errors.map((e) => e.message).join('; '));
}
const config = doc.toJS() as AgentConfig;
logger.debug(`Successfully parsed YAML config`, {
hasLlmSection: Boolean(config.llm),
existingProvider: config.llm?.provider,
existingModel: config.llm?.model,
});
} catch (error) {
logger.error(`Failed to parse YAML config: ${configPath}`, { error });
throw ConfigError.parseError(
configPath,
error instanceof Error ? error.message : String(error)
);
}
// Determine final values (precedence: CLI > preferences > agent defaults)
const provider = overrides?.provider ?? preferences.llm.provider;
const model = overrides?.model ?? preferences.llm.model;
const apiKey = overrides?.apiKey ?? preferences.llm.apiKey;
const baseURL = overrides?.baseURL ?? preferences.llm.baseURL;
logger.debug(`Applying LLM preferences`, {
finalProvider: provider,
finalModel: model,
hasApiKey: Boolean(apiKey),
hasBaseURL: Boolean(baseURL),
source: overrides ? 'CLI overrides + preferences' : 'preferences only',
});
// Note: provider+model validation already handled in preference schema
// Update document in place to preserve comments
// Get or create the llm section
let llmNode = doc.get('llm');
if (!llmNode || typeof llmNode !== 'object') {
// Create new llm section - only include optional fields if defined
const llmConfig: Record<string, string> = { provider, model };
if (apiKey) {
llmConfig.apiKey = apiKey;
}
if (baseURL) {
llmConfig.baseURL = baseURL;
}
doc.set('llm', llmConfig);
} else {
// Update individual fields to preserve other settings and comments
doc.setIn(['llm', 'provider'], provider);
doc.setIn(['llm', 'model'], model);
// Only set apiKey if defined, otherwise remove it (for providers that don't need it)
if (apiKey) {
doc.setIn(['llm', 'apiKey'], apiKey);
} else {
doc.deleteIn(['llm', 'apiKey']);
}
// Only set baseURL if defined, otherwise remove it
if (baseURL) {
doc.setIn(['llm', 'baseURL'], baseURL);
} else {
doc.deleteIn(['llm', 'baseURL']);
}
}
// Write back to file preserving comments
await fs.writeFile(configPath, doc.toString(), 'utf-8');
logger.info(`✓ Applied preferences to: ${path.basename(configPath)} (${provider}/${model})`);
}
/**
* Write preferences to an installed agent (file or directory)
* @param installedPath Path to installed agent file or directory
* @param preferences Global preferences to write
* @param overrides Optional CLI overrides
*/
export async function writePreferencesToAgent(
installedPath: string,
preferences: GlobalPreferences,
overrides?: LLMOverrides
): Promise<void> {
let stat;
try {
stat = await fs.stat(installedPath);
} catch (error) {
throw ConfigError.fileReadError(
installedPath,
error instanceof Error ? error.message : String(error)
);
}
if (stat.isFile()) {
// Single file agent - write directly
if (installedPath.endsWith('.yml') || installedPath.endsWith('.yaml')) {
await writeLLMPreferences(installedPath, preferences, overrides);
logger.info(`✓ Applied preferences to: ${path.basename(installedPath)}`, null, 'green');
} else {
logger.warn(`Skipping non-YAML file: ${installedPath}`, null, 'yellow');
}
} else if (stat.isDirectory()) {
// Directory-based agent - write to all .yml files
await writePreferencesToDirectory(installedPath, preferences, overrides);
} else {
throw ConfigError.fileReadError(installedPath, 'Path is neither a file nor directory');
}
}
/**
* Write preferences to all agent configs in a directory
* @param installedDir Directory containing agent configs
* @param preferences Global preferences to write
* @param overrides Optional CLI overrides
*/
async function writePreferencesToDirectory(
installedDir: string,
preferences: GlobalPreferences,
overrides?: LLMOverrides
): Promise<void> {
// Find all .yml files in the directory (recursively if needed)
const configFiles = await findAgentConfigFiles(installedDir);
if (configFiles.length === 0) {
logger.warn(`No YAML config files found in: ${installedDir}`);
return;
}
// Apply preferences to each config file
let successCount = 0;
const oldProvider = preferences.llm.provider;
const oldModel = preferences.llm.model;
const newProvider = overrides?.provider ?? preferences.llm.provider;
const newModel = overrides?.model ?? preferences.llm.model;
for (const configPath of configFiles) {
try {
await writeLLMPreferences(configPath, preferences, overrides);
logger.debug(`Applied preferences to: ${path.relative(installedDir, configPath)}`);
successCount++;
} catch (error) {
logger.warn(
`Failed to write preferences to ${configPath}: ${error instanceof Error ? error.message : String(error)}`
);
// Continue with other files
}
}
logger.info(
`✓ Applied preferences to ${successCount}/${configFiles.length} config files (${oldProvider}${newProvider}, ${oldModel}${newModel})`
);
}
/**
* Find all agent configuration files in a directory
*/
async function findAgentConfigFiles(dir: string): Promise<string[]> {
const configFiles: string[] = [];
async function walkDir(currentDir: string): Promise<void> {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
// Skip docs and data directories
if (!['docs', 'data'].includes(entry.name)) {
await walkDir(fullPath);
}
} else if (entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')) {
configFiles.push(fullPath);
}
}
}
await walkDir(dir);
return configFiles;
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": false,
"paths": {
"@agent-management/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts", "**/*.spec.ts", "**/*.integration.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["dist", "node_modules"]
}

Some files were not shown because too many files have changed in this diff Show More