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:
229
dexto/packages/agent-management/CHANGELOG.md
Normal file
229
dexto/packages/agent-management/CHANGELOG.md
Normal 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
|
||||
41
dexto/packages/agent-management/package.json
Normal file
41
dexto/packages/agent-management/package.json
Normal 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
|
||||
}
|
||||
102
dexto/packages/agent-management/scripts/fix-dist-aliases.mjs
Normal file
102
dexto/packages/agent-management/scripts/fix-dist-aliases.mjs
Normal 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();
|
||||
208
dexto/packages/agent-management/src/AgentFactory.ts
Normal file
208
dexto/packages/agent-management/src/AgentFactory.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
269
dexto/packages/agent-management/src/AgentManager.ts
Normal file
269
dexto/packages/agent-management/src/AgentManager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
375
dexto/packages/agent-management/src/config/config-enrichment.ts
Normal file
375
dexto/packages/agent-management/src/config/config-enrichment.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
735
dexto/packages/agent-management/src/config/config-manager.ts
Normal file
735
dexto/packages/agent-management/src/config/config-manager.ts
Normal 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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
199
dexto/packages/agent-management/src/config/discover-prompts.ts
Normal file
199
dexto/packages/agent-management/src/config/discover-prompts.ts
Normal 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;
|
||||
}
|
||||
20
dexto/packages/agent-management/src/config/error-codes.ts
Normal file
20
dexto/packages/agent-management/src/config/error-codes.ts
Normal 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',
|
||||
}
|
||||
110
dexto/packages/agent-management/src/config/errors.ts
Normal file
110
dexto/packages/agent-management/src/config/errors.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
23
dexto/packages/agent-management/src/config/index.ts
Normal file
23
dexto/packages/agent-management/src/config/index.ts
Normal 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';
|
||||
252
dexto/packages/agent-management/src/config/loader.test.ts
Normal file
252
dexto/packages/agent-management/src/config/loader.test.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
185
dexto/packages/agent-management/src/config/loader.ts
Normal file
185
dexto/packages/agent-management/src/config/loader.ts
Normal 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;
|
||||
}
|
||||
230
dexto/packages/agent-management/src/index.ts
Normal file
230
dexto/packages/agent-management/src/index.ts
Normal 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';
|
||||
359
dexto/packages/agent-management/src/installation.ts
Normal file
359
dexto/packages/agent-management/src/installation.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
178
dexto/packages/agent-management/src/models/custom-models.ts
Normal file
178
dexto/packages/agent-management/src/models/custom-models.ts
Normal 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');
|
||||
}
|
||||
50
dexto/packages/agent-management/src/models/index.ts
Normal file
50
dexto/packages/agent-management/src/models/index.ts
Normal 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';
|
||||
176
dexto/packages/agent-management/src/models/path-resolver.ts
Normal file
176
dexto/packages/agent-management/src/models/path-resolver.ts
Normal 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]}`;
|
||||
}
|
||||
359
dexto/packages/agent-management/src/models/state-manager.ts
Normal file
359
dexto/packages/agent-management/src/models/state-manager.ts
Normal 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);
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
260
dexto/packages/agent-management/src/plugins/discover-plugins.ts
Normal file
260
dexto/packages/agent-management/src/plugins/discover-plugins.ts
Normal 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');
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
119
dexto/packages/agent-management/src/plugins/discover-skills.ts
Normal file
119
dexto/packages/agent-management/src/plugins/discover-skills.ts
Normal 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);
|
||||
}
|
||||
36
dexto/packages/agent-management/src/plugins/error-codes.ts
Normal file
36
dexto/packages/agent-management/src/plugins/error-codes.ts
Normal 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',
|
||||
}
|
||||
190
dexto/packages/agent-management/src/plugins/errors.ts
Normal file
190
dexto/packages/agent-management/src/plugins/errors.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
132
dexto/packages/agent-management/src/plugins/index.ts
Normal file
132
dexto/packages/agent-management/src/plugins/index.ts
Normal 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';
|
||||
283
dexto/packages/agent-management/src/plugins/install-plugin.ts
Normal file
283
dexto/packages/agent-management/src/plugins/install-plugin.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
266
dexto/packages/agent-management/src/plugins/list-plugins.test.ts
Normal file
266
dexto/packages/agent-management/src/plugins/list-plugins.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
161
dexto/packages/agent-management/src/plugins/list-plugins.ts
Normal file
161
dexto/packages/agent-management/src/plugins/list-plugins.ts
Normal 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 };
|
||||
}
|
||||
426
dexto/packages/agent-management/src/plugins/load-plugin.test.ts
Normal file
426
dexto/packages/agent-management/src/plugins/load-plugin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
275
dexto/packages/agent-management/src/plugins/load-plugin.ts
Normal file
275
dexto/packages/agent-management/src/plugins/load-plugin.ts
Normal 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)`);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
167
dexto/packages/agent-management/src/plugins/marketplace/types.ts
Normal file
167
dexto/packages/agent-management/src/plugins/marketplace/types.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
371
dexto/packages/agent-management/src/plugins/schemas.test.ts
Normal file
371
dexto/packages/agent-management/src/plugins/schemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
122
dexto/packages/agent-management/src/plugins/schemas.ts
Normal file
122
dexto/packages/agent-management/src/plugins/schemas.ts
Normal 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>;
|
||||
197
dexto/packages/agent-management/src/plugins/types.ts
Normal file
197
dexto/packages/agent-management/src/plugins/types.ts
Normal 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;
|
||||
}
|
||||
173
dexto/packages/agent-management/src/plugins/uninstall-plugin.ts
Normal file
173
dexto/packages/agent-management/src/plugins/uninstall-plugin.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
251
dexto/packages/agent-management/src/plugins/validate-plugin.ts
Normal file
251
dexto/packages/agent-management/src/plugins/validate-plugin.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// packages/core/src/preferences/constants.ts
|
||||
|
||||
export const PREFERENCES_FILE = 'preferences.yml';
|
||||
@@ -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',
|
||||
}
|
||||
54
dexto/packages/agent-management/src/preferences/errors.ts
Normal file
54
dexto/packages/agent-management/src/preferences/errors.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
dexto/packages/agent-management/src/preferences/index.ts
Normal file
31
dexto/packages/agent-management/src/preferences/index.ts
Normal 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';
|
||||
518
dexto/packages/agent-management/src/preferences/loader.test.ts
Normal file
518
dexto/packages/agent-management/src/preferences/loader.test.ts
Normal 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),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
243
dexto/packages/agent-management/src/preferences/loader.ts
Normal file
243
dexto/packages/agent-management/src/preferences/loader.ts
Normal 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;
|
||||
}
|
||||
153
dexto/packages/agent-management/src/preferences/schemas.ts
Normal file
153
dexto/packages/agent-management/src/preferences/schemas.ts
Normal 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>;
|
||||
31
dexto/packages/agent-management/src/registry/error-codes.ts
Normal file
31
dexto/packages/agent-management/src/registry/error-codes.ts
Normal 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',
|
||||
}
|
||||
179
dexto/packages/agent-management/src/registry/errors.ts
Normal file
179
dexto/packages/agent-management/src/registry/errors.ts
Normal 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`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
624
dexto/packages/agent-management/src/registry/registry.test.ts
Normal file
624
dexto/packages/agent-management/src/registry/registry.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
645
dexto/packages/agent-management/src/registry/registry.ts
Normal file
645
dexto/packages/agent-management/src/registry/registry.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
125
dexto/packages/agent-management/src/registry/types.ts
Normal file
125
dexto/packages/agent-management/src/registry/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
151
dexto/packages/agent-management/src/registry/user-registry.ts
Normal file
151
dexto/packages/agent-management/src/registry/user-registry.ts
Normal 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`);
|
||||
}
|
||||
710
dexto/packages/agent-management/src/resolver.test.ts
Normal file
710
dexto/packages/agent-management/src/resolver.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
300
dexto/packages/agent-management/src/resolver.ts
Normal file
300
dexto/packages/agent-management/src/resolver.ts
Normal 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, []);
|
||||
}
|
||||
194
dexto/packages/agent-management/src/runtime/AgentPool.ts
Normal file
194
dexto/packages/agent-management/src/runtime/AgentPool.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
305
dexto/packages/agent-management/src/runtime/AgentRuntime.ts
Normal file
305
dexto/packages/agent-management/src/runtime/AgentRuntime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
23
dexto/packages/agent-management/src/runtime/error-codes.ts
Normal file
23
dexto/packages/agent-management/src/runtime/error-codes.ts
Normal 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',
|
||||
}
|
||||
124
dexto/packages/agent-management/src/runtime/errors.ts
Normal file
124
dexto/packages/agent-management/src/runtime/errors.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
48
dexto/packages/agent-management/src/runtime/index.ts
Normal file
48
dexto/packages/agent-management/src/runtime/index.ts
Normal 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';
|
||||
122
dexto/packages/agent-management/src/runtime/schemas.ts
Normal file
122
dexto/packages/agent-management/src/runtime/schemas.ts
Normal 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>;
|
||||
120
dexto/packages/agent-management/src/runtime/types.ts
Normal file
120
dexto/packages/agent-management/src/runtime/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
62
dexto/packages/agent-management/src/tool-provider/errors.ts
Normal file
62
dexto/packages/agent-management/src/tool-provider/errors.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
29
dexto/packages/agent-management/src/tool-provider/index.ts
Normal file
29
dexto/packages/agent-management/src/tool-provider/index.ts
Normal 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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
104
dexto/packages/agent-management/src/tool-provider/schemas.ts
Normal file
104
dexto/packages/agent-management/src/tool-provider/schemas.ts
Normal 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>;
|
||||
@@ -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'}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
22
dexto/packages/agent-management/src/tool-provider/types.ts
Normal file
22
dexto/packages/agent-management/src/tool-provider/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
146
dexto/packages/agent-management/src/utils/api-key-store.ts
Normal file
146
dexto/packages/agent-management/src/utils/api-key-store.ts
Normal 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;
|
||||
}
|
||||
94
dexto/packages/agent-management/src/utils/dexto-auth.ts
Normal file
94
dexto/packages/agent-management/src/utils/dexto-auth.ts
Normal 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;
|
||||
}
|
||||
52
dexto/packages/agent-management/src/utils/env-file.ts
Normal file
52
dexto/packages/agent-management/src/utils/env-file.ts
Normal 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');
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
23
dexto/packages/agent-management/src/utils/feature-flags.ts
Normal file
23
dexto/packages/agent-management/src/utils/feature-flags.ts
Normal 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';
|
||||
}
|
||||
70
dexto/packages/agent-management/src/utils/fs-walk.test.ts
Normal file
70
dexto/packages/agent-management/src/utils/fs-walk.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
30
dexto/packages/agent-management/src/utils/fs-walk.ts
Normal file
30
dexto/packages/agent-management/src/utils/fs-walk.ts
Normal 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;
|
||||
}
|
||||
408
dexto/packages/agent-management/src/utils/path.test.ts
Normal file
408
dexto/packages/agent-management/src/utils/path.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
254
dexto/packages/agent-management/src/utils/path.ts
Normal file
254
dexto/packages/agent-management/src/utils/path.ts
Normal 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;
|
||||
}
|
||||
383
dexto/packages/agent-management/src/writer.test.ts
Normal file
383
dexto/packages/agent-management/src/writer.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
264
dexto/packages/agent-management/src/writer.ts
Normal file
264
dexto/packages/agent-management/src/writer.ts
Normal 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;
|
||||
}
|
||||
17
dexto/packages/agent-management/tsconfig.json
Normal file
17
dexto/packages/agent-management/tsconfig.json
Normal 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"]
|
||||
}
|
||||
4
dexto/packages/agent-management/tsconfig.typecheck.json
Normal file
4
dexto/packages/agent-management/tsconfig.typecheck.json
Normal 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
Reference in New Issue
Block a user