mirror of
https://github.com/coleam00/Archon
synced 2026-04-21 13:37:41 +00:00
Deprecate database command templates (#425)
* Fix: Deprecate database command templates (#231) Database command templates still influenced routing and /command-invoke, preventing file-based commands from fully replacing them. Changes: - Remove template routing, handlers, and DB module - Add .archon/commands fallback for /command-invoke - Drop template table from schema/migrations and update docs/tests Fixes #231 * docs: update CLAUDE.md and README.md for template deprecation Remove all references to deprecated database command templates: - Update table count from 8 to 7 tables - Remove command_templates table from database schema sections - Remove "Command Templates (Global)" section from README - Remove /template-add, /template-delete, /templates command docs - Update "Custom command templates" to "Custom commands" - Remove references to global templates stored in database * Fix review findings: error handling, tests, migration guidance - Fix commandFileExists to only catch ENOENT, throw on unexpected errors (permissions, I/O) instead of silently returning false - Add test for file read failure after successful existence check - Add test for path traversal rejection via isValidCommandName - Add test for registered command taking precedence over fallback - Add migration guidance comments for users with existing templates
This commit is contained in:
parent
d3a8fc0823
commit
7dc2e444f9
17 changed files with 167 additions and 474 deletions
23
CLAUDE.md
23
CLAUDE.md
|
|
@ -8,7 +8,7 @@
|
|||
- No multi-tenant complexity
|
||||
- Commands versioned with Git (not stored in database)
|
||||
- All credentials in environment variables only
|
||||
- 8-table database schema (see Database Schema section)
|
||||
- 7-table database schema (see Database Schema section)
|
||||
|
||||
**User-Controlled Workflows**
|
||||
- Manual phase transitions via slash commands
|
||||
|
|
@ -342,21 +342,19 @@ import * as core from '@archon/core'; // Don't do this
|
|||
|
||||
### Database Schema
|
||||
|
||||
**8 Tables (all prefixed with `remote_agent_`):**
|
||||
**7 Tables (all prefixed with `remote_agent_`):**
|
||||
1. **`codebases`** - Repository metadata and commands (JSONB)
|
||||
2. **`conversations`** - Track platform conversations with titles and soft-delete support
|
||||
3. **`sessions`** - Track AI SDK sessions with resume capability
|
||||
4. **`command_templates`** - Global command templates (manually added via `/template-add`)
|
||||
5. **`isolation_environments`** - Git worktree isolation tracking
|
||||
6. **`workflow_runs`** - Workflow execution tracking and state
|
||||
7. **`workflow_events`** - Step-level workflow event log (step transitions, artifacts, errors)
|
||||
8. **`messages`** - Conversation message history with tool call metadata (JSONB)
|
||||
4. **`isolation_environments`** - Git worktree isolation tracking
|
||||
5. **`workflow_runs`** - Workflow execution tracking and state
|
||||
6. **`workflow_events`** - Step-level workflow event log (step transitions, artifacts, errors)
|
||||
7. **`messages`** - Conversation message history with tool call metadata (JSONB)
|
||||
|
||||
**Key Patterns:**
|
||||
- Conversation ID format: Platform-specific (`thread_ts`, `chat_id`, `user/repo#123`)
|
||||
- One active session per conversation
|
||||
- Codebase commands stored in filesystem, paths in `codebases.commands` JSONB
|
||||
- Global templates stored in database, added via `/template-add`
|
||||
|
||||
**Session Transitions:**
|
||||
- Sessions are immutable - transitions create new linked sessions
|
||||
|
|
@ -513,7 +511,7 @@ All Archon-managed files are organized under a dedicated namespace:
|
|||
**Repo-level (`.archon/` in any repository):**
|
||||
```
|
||||
.archon/
|
||||
├── commands/ # Custom command templates
|
||||
├── commands/ # Custom commands
|
||||
├── workflows/ # Future: workflow definitions
|
||||
└── config.yaml # Repo-specific configuration
|
||||
```
|
||||
|
|
@ -658,12 +656,7 @@ log.warn({ envVar: 'MISSING_KEY' }, 'optional_config_missing');
|
|||
- Auto-detected via `/clone` or `/load-commands <folder>`
|
||||
- Invoked via `/command-invoke <name> [args]`
|
||||
|
||||
2. **Global Templates** (database):
|
||||
- Stored in `remote_agent_command_templates` table
|
||||
- Added via `/template-add <name> <file-path>`
|
||||
- Invoked directly via `/<name> [args]`
|
||||
|
||||
3. **Workflows** (YAML-based):
|
||||
2. **Workflows** (YAML-based):
|
||||
- Stored in `.archon/workflows/` (searched recursively)
|
||||
- Multi-step AI execution chains, discovered at runtime
|
||||
- Provider inherited from `.archon/config.yaml` unless explicitly set
|
||||
|
|
|
|||
34
README.md
34
README.md
|
|
@ -268,11 +268,10 @@ DATABASE_URL=postgresql://user:password@host:5432/dbname
|
|||
psql $DATABASE_URL < migrations/000_combined.sql
|
||||
```
|
||||
|
||||
This creates 8 tables:
|
||||
This creates 7 tables:
|
||||
- `remote_agent_codebases` - Repository metadata
|
||||
- `remote_agent_conversations` - Platform conversation tracking
|
||||
- `remote_agent_sessions` - AI session management
|
||||
- `remote_agent_command_templates` - Global command templates
|
||||
- `remote_agent_isolation_environments` - Worktree isolation tracking
|
||||
- `remote_agent_workflow_runs` - Workflow execution tracking
|
||||
- `remote_agent_workflow_events` - Step-level workflow event log
|
||||
|
|
@ -820,15 +819,6 @@ docker compose --profile with-db down # If using Option B
|
|||
|
||||
Once your platform adapter is running, you can use these commands. Type `/help` to see this list.
|
||||
|
||||
#### Command Templates (Global)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/<name> [args]` | Invoke a template directly (e.g., `/plan "Add dark mode"`) |
|
||||
| `/templates` | List all available templates |
|
||||
| `/template-add <name> <path>` | Add template from file |
|
||||
| `/template-delete <name>` | Remove a template |
|
||||
|
||||
#### Codebase Commands (Per-Project)
|
||||
|
||||
| Command | Description |
|
||||
|
|
@ -1231,10 +1221,9 @@ prompt: |
|
|||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ SQLite / PostgreSQL (8 Tables) │
|
||||
│ SQLite / PostgreSQL (7 Tables) │
|
||||
│ Codebases • Conversations • Sessions • Workflow Runs │
|
||||
│ Command Templates • Isolation Environments • Messages │
|
||||
│ Workflow Events │
|
||||
│ Isolation Environments • Messages • Workflow Events │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
|
|
@ -1252,7 +1241,7 @@ prompt: |
|
|||
### Database Schema
|
||||
|
||||
<details>
|
||||
<summary><b>8 tables with `remote_agent_` prefix</b></summary>
|
||||
<summary><b>7 tables with `remote_agent_` prefix</b></summary>
|
||||
|
||||
1. **`remote_agent_codebases`** - Repository metadata
|
||||
- Commands stored as JSONB: `{command_name: {path, description}}`
|
||||
|
|
@ -1269,25 +1258,21 @@ prompt: |
|
|||
- Session ID for resume capability
|
||||
- Metadata JSONB for command context
|
||||
|
||||
4. **`remote_agent_command_templates`** - Global command templates
|
||||
- Shared command definitions (like `/plan`, `/commit`)
|
||||
- Available across all codebases
|
||||
|
||||
5. **`remote_agent_isolation_environments`** - Worktree isolation
|
||||
4. **`remote_agent_isolation_environments`** - Worktree isolation
|
||||
- Tracks git worktrees per issue/PR
|
||||
- Enables worktree sharing between linked issues and PRs
|
||||
|
||||
6. **`remote_agent_workflow_runs`** - Workflow execution tracking
|
||||
5. **`remote_agent_workflow_runs`** - Workflow execution tracking
|
||||
- Tracks active workflows per conversation
|
||||
- Prevents concurrent workflow execution
|
||||
- Stores workflow state, step progress, and parent conversation linkage
|
||||
|
||||
7. **`remote_agent_workflow_events`** - Step-level workflow event log
|
||||
6. **`remote_agent_workflow_events`** - Step-level workflow event log
|
||||
- Records step transitions, artifacts, and errors per workflow run
|
||||
- Lean UI-relevant events (verbose logs stored in JSONL files)
|
||||
- Enables workflow run detail views and debugging
|
||||
|
||||
8. **`remote_agent_messages`** - Conversation message history
|
||||
7. **`remote_agent_messages`** - Conversation message history
|
||||
- Persists user and assistant messages with timestamps
|
||||
- Stores tool call metadata (name, input, duration) in JSONB
|
||||
- Enables message history in Web UI across page refreshes
|
||||
|
|
@ -1359,7 +1344,8 @@ psql $DATABASE_URL -c "SELECT 1"
|
|||
docker compose exec postgres psql -U postgres -d remote_coding_agent -c "\dt"
|
||||
|
||||
# Should show: remote_agent_codebases, remote_agent_conversations, remote_agent_sessions,
|
||||
# remote_agent_command_templates, remote_agent_isolation_environments
|
||||
# remote_agent_isolation_environments, remote_agent_workflow_runs, remote_agent_workflow_events,
|
||||
# remote_agent_messages
|
||||
```
|
||||
|
||||
### Clone Command Fails
|
||||
|
|
|
|||
|
|
@ -1040,12 +1040,6 @@ remote_agent_sessions
|
|||
├── transition_reason (TEXT) -- Why this session was created (TransitionTrigger)
|
||||
└── metadata (JSONB) -- {lastCommand: "plan-feature", ...}
|
||||
|
||||
remote_agent_command_templates
|
||||
├── id (UUID)
|
||||
├── name (VARCHAR, UNIQUE)
|
||||
├── description (TEXT)
|
||||
└── content (TEXT)
|
||||
|
||||
remote_agent_isolation_environments
|
||||
├── id (UUID)
|
||||
├── codebase_id (UUID → remote_agent_codebases.id)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ Archon is the unified directory and configuration system for the remote-coding-a
|
|||
|
||||
```
|
||||
any-repo/.archon/
|
||||
├── commands/ # Custom command templates
|
||||
├── commands/ # Custom commands
|
||||
│ ├── plan.md
|
||||
│ └── execute.md
|
||||
├── workflows/ # Future: workflow definitions
|
||||
|
|
@ -43,7 +43,7 @@ any-repo/.archon/
|
|||
```
|
||||
|
||||
**Purpose:**
|
||||
- `commands/` - Slash command templates (auto-loaded on clone)
|
||||
- `commands/` - Slash commands (auto-loaded on clone)
|
||||
- `workflows/` - Future workflow engine definitions
|
||||
- `config.yaml` - Project-specific settings
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ Archon supports a layered configuration system with sensible defaults, optional
|
|||
|
||||
```
|
||||
.archon/
|
||||
├── commands/ # Custom command templates
|
||||
├── commands/ # Custom commands
|
||||
│ └── plan.md
|
||||
├── workflows/ # Future: workflow definitions
|
||||
└── config.yaml # Repo-specific configuration (optional)
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ Add an `.archon/` directory to your target repo for repo-specific behavior:
|
|||
your-repo/
|
||||
└── .archon/
|
||||
├── config.yaml # AI assistant, worktree copy rules
|
||||
├── commands/ # Custom command templates (.md files)
|
||||
├── commands/ # Custom commands (.md files)
|
||||
└── workflows/ # Custom multi-step workflows (.yaml files)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -55,21 +55,6 @@ CREATE TABLE IF NOT EXISTS remote_agent_sessions (
|
|||
CREATE INDEX IF NOT EXISTS idx_remote_agent_sessions_conversation ON remote_agent_sessions(conversation_id, active);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_agent_sessions_codebase ON remote_agent_sessions(codebase_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- Migration 002: Command Templates
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_agent_command_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_agent_command_templates_name ON remote_agent_command_templates(name);
|
||||
|
||||
-- ============================================================================
|
||||
-- Migration 003: Add Worktree Support
|
||||
-- ============================================================================
|
||||
|
|
|
|||
11
migrations/017_drop_command_templates.sql
Normal file
11
migrations/017_drop_command_templates.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- Migration: Drop deprecated command templates table
|
||||
-- Command templates were replaced by file-based commands in .archon/commands
|
||||
--
|
||||
-- BREAKING CHANGE: If you have existing templates, export them before upgrading:
|
||||
-- SELECT name, content FROM remote_agent_command_templates;
|
||||
-- Then save each as .archon/commands/<name>.md in your repositories.
|
||||
--
|
||||
-- After this migration, use /command-invoke <name> instead of /<name>
|
||||
|
||||
DROP TABLE IF EXISTS remote_agent_command_templates;
|
||||
DROP INDEX IF EXISTS idx_remote_agent_command_templates_name;
|
||||
|
|
@ -245,16 +245,6 @@ export class SqliteAdapter implements IDatabase {
|
|||
ended_reason TEXT
|
||||
);
|
||||
|
||||
-- Command templates table
|
||||
CREATE TABLE IF NOT EXISTS remote_agent_command_templates (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Isolation environments table
|
||||
CREATE TABLE IF NOT EXISTS remote_agent_isolation_environments (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
import { mock, describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { createQueryResult } from '../test/mocks/database';
|
||||
import { CommandTemplate } from '../types';
|
||||
|
||||
const mockQuery = mock(() => Promise.resolve(createQueryResult([])));
|
||||
|
||||
mock.module('./connection', () => ({
|
||||
pool: {
|
||||
query: mockQuery,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createTemplate,
|
||||
getTemplate,
|
||||
getAllTemplates,
|
||||
deleteTemplate,
|
||||
upsertTemplate,
|
||||
} from './command-templates';
|
||||
|
||||
describe('command-templates', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockClear();
|
||||
});
|
||||
|
||||
const mockTemplate: CommandTemplate = {
|
||||
id: 'template-123',
|
||||
name: 'plan',
|
||||
description: 'Create implementation plan',
|
||||
content: '# Plan\n\n**Input**: $ARGUMENTS',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
describe('createTemplate', () => {
|
||||
test('creates template with all fields', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([mockTemplate]));
|
||||
|
||||
const result = await createTemplate({
|
||||
name: 'plan',
|
||||
description: 'Create implementation plan',
|
||||
content: '# Plan\n\n**Input**: $ARGUMENTS',
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockTemplate);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'INSERT INTO remote_agent_command_templates (name, description, content) VALUES ($1, $2, $3) RETURNING *',
|
||||
['plan', 'Create implementation plan', '# Plan\n\n**Input**: $ARGUMENTS']
|
||||
);
|
||||
});
|
||||
|
||||
test('creates template without description', async () => {
|
||||
const templateWithoutDesc = { ...mockTemplate, description: null };
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([templateWithoutDesc]));
|
||||
|
||||
await createTemplate({
|
||||
name: 'plan',
|
||||
content: '# Plan',
|
||||
});
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['plan', null, '# Plan']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTemplate', () => {
|
||||
test('returns existing template', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([mockTemplate]));
|
||||
|
||||
const result = await getTemplate('plan');
|
||||
|
||||
expect(result).toEqual(mockTemplate);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT * FROM remote_agent_command_templates WHERE name = $1',
|
||||
['plan']
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null for non-existent template', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
const result = await getTemplate('non-existent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllTemplates', () => {
|
||||
test('returns all templates ordered by name', async () => {
|
||||
const templates = [mockTemplate, { ...mockTemplate, id: 'template-456', name: 'commit' }];
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult(templates));
|
||||
|
||||
const result = await getAllTemplates();
|
||||
|
||||
expect(result).toEqual(templates);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT * FROM remote_agent_command_templates ORDER BY name'
|
||||
);
|
||||
});
|
||||
|
||||
test('returns empty array when no templates', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
const result = await getAllTemplates();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTemplate', () => {
|
||||
test('returns true when template deleted', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([], 1));
|
||||
|
||||
const result = await deleteTemplate('plan');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'DELETE FROM remote_agent_command_templates WHERE name = $1',
|
||||
['plan']
|
||||
);
|
||||
});
|
||||
|
||||
test('returns false when template not found', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([], 0));
|
||||
|
||||
const result = await deleteTemplate('non-existent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertTemplate', () => {
|
||||
test('inserts new template', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([mockTemplate]));
|
||||
|
||||
const result = await upsertTemplate({
|
||||
name: 'plan',
|
||||
description: 'Create implementation plan',
|
||||
content: '# Plan',
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockTemplate);
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('ON CONFLICT'), [
|
||||
'plan',
|
||||
'Create implementation plan',
|
||||
'# Plan',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
/**
|
||||
* Database operations for command templates
|
||||
*/
|
||||
import { pool } from './connection';
|
||||
import { CommandTemplate } from '../types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
|
||||
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
|
||||
let cachedLog: ReturnType<typeof createLogger> | undefined;
|
||||
function getLog(): ReturnType<typeof createLogger> {
|
||||
if (!cachedLog) cachedLog = createLogger('db.command-templates');
|
||||
return cachedLog;
|
||||
}
|
||||
|
||||
export async function createTemplate(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
}): Promise<CommandTemplate> {
|
||||
const result = await pool.query<CommandTemplate>(
|
||||
'INSERT INTO remote_agent_command_templates (name, description, content) VALUES ($1, $2, $3) RETURNING *',
|
||||
[data.name, data.description ?? null, data.content]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function getTemplate(name: string): Promise<CommandTemplate | null> {
|
||||
const result = await pool.query<CommandTemplate>(
|
||||
'SELECT * FROM remote_agent_command_templates WHERE name = $1',
|
||||
[name]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getAllTemplates(): Promise<readonly CommandTemplate[]> {
|
||||
const result = await pool.query<CommandTemplate>(
|
||||
'SELECT * FROM remote_agent_command_templates ORDER BY name'
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function deleteTemplate(name: string): Promise<boolean> {
|
||||
const result = await pool.query('DELETE FROM remote_agent_command_templates WHERE name = $1', [
|
||||
name,
|
||||
]);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function upsertTemplate(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
}): Promise<CommandTemplate> {
|
||||
const result = await pool.query<CommandTemplate>(
|
||||
`INSERT INTO remote_agent_command_templates (name, description, content)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
description = EXCLUDED.description,
|
||||
content = EXCLUDED.content,
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[data.name, data.description ?? null, data.content]
|
||||
);
|
||||
getLog().debug({ templateName: data.name }, 'template_upserted');
|
||||
return result.rows[0];
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@ export type { IDatabase, SqlDialect, QueryResult } from './adapters/types';
|
|||
export * as conversationDb from './conversations';
|
||||
export * as codebaseDb from './codebases';
|
||||
export * as sessionDb from './sessions';
|
||||
export * as commandTemplateDb from './command-templates';
|
||||
export * as isolationEnvDb from './isolation-environments';
|
||||
export * as workflowDb from './workflows';
|
||||
|
||||
|
|
@ -26,6 +25,5 @@ export * from './conversations';
|
|||
export * from './codebases';
|
||||
export { SessionNotFoundError } from './sessions';
|
||||
export * from './sessions';
|
||||
export * from './command-templates';
|
||||
export * from './isolation-environments';
|
||||
export * from './workflows';
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { Conversation, CommandResult, ConversationNotFoundError } from '../types
|
|||
import * as db from '../db/conversations';
|
||||
import * as codebaseDb from '../db/codebases';
|
||||
import * as sessionDb from '../db/sessions';
|
||||
import * as templateDb from '../db/command-templates';
|
||||
import { isPathWithinWorkspace } from '../utils/path-validation';
|
||||
import { sanitizeError } from '../utils/credential-sanitizer';
|
||||
import { listWorktrees, execFileAsync } from '../utils/git';
|
||||
|
|
@ -281,12 +280,6 @@ export async function handleCommand(
|
|||
success: true,
|
||||
message: `## Available Commands
|
||||
|
||||
### Command Templates (global)
|
||||
- \`/<name> [args]\` - Invoke a template directly
|
||||
- \`/templates\` - List all templates
|
||||
- \`/template-add <name> <path>\` - Add template from file
|
||||
- \`/template-delete <name>\` - Remove a template
|
||||
|
||||
### Codebase Commands (per-project)
|
||||
- \`/command-set <name> <path> [text]\` - Register command
|
||||
- \`/load-commands <folder>\` - Bulk load (recursive)
|
||||
|
|
@ -1004,84 +997,6 @@ export async function handleCommand(
|
|||
}
|
||||
}
|
||||
|
||||
case 'template-add': {
|
||||
if (args.length < 2) {
|
||||
return { success: false, message: 'Usage: /template-add <name> <file-path>' };
|
||||
}
|
||||
if (!conversation.cwd) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No working directory set. Use /clone or /setcwd first.',
|
||||
};
|
||||
}
|
||||
|
||||
const [templateName, ...pathParts] = args;
|
||||
const filePath = pathParts.join(' ');
|
||||
const fullPath = resolve(conversation.cwd, filePath);
|
||||
|
||||
try {
|
||||
const content = await readFile(fullPath, 'utf-8');
|
||||
|
||||
// Extract description from frontmatter if present
|
||||
const frontmatterMatch = /^---\n([\s\S]*?)\n---/.exec(content);
|
||||
let description: string | undefined;
|
||||
if (frontmatterMatch) {
|
||||
const descMatch = /description:\s*(.+)/.exec(frontmatterMatch[1]);
|
||||
description = descMatch?.[1]?.trim();
|
||||
}
|
||||
|
||||
await templateDb.upsertTemplate({
|
||||
name: templateName,
|
||||
description: description ?? `From ${filePath}`,
|
||||
content,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Template '${templateName}' saved!\n\nUse it with: /${templateName} [args]`,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
return { success: false, message: `Failed to read file: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
case 'template-list':
|
||||
case 'templates': {
|
||||
const templates = await templateDb.getAllTemplates();
|
||||
|
||||
if (templates.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
'No command templates registered.\n\nUse /template-add <name> <file-path> to add one.',
|
||||
};
|
||||
}
|
||||
|
||||
let msg = 'Command Templates:\n\n';
|
||||
for (const t of templates) {
|
||||
msg += `/${t.name}`;
|
||||
if (t.description) {
|
||||
msg += ` - ${t.description}`;
|
||||
}
|
||||
msg += '\n';
|
||||
}
|
||||
msg += '\nUse /<name> [args] to invoke any template.';
|
||||
return { success: true, message: msg };
|
||||
}
|
||||
|
||||
case 'template-delete': {
|
||||
if (args.length < 1) {
|
||||
return { success: false, message: 'Usage: /template-delete <name>' };
|
||||
}
|
||||
|
||||
const deleted = await templateDb.deleteTemplate(args[0]);
|
||||
if (deleted) {
|
||||
return { success: true, message: `Template '${args[0]}' deleted.` };
|
||||
}
|
||||
return { success: false, message: `Template '${args[0]}' not found.` };
|
||||
}
|
||||
|
||||
case 'worktree': {
|
||||
const subcommand = args[0];
|
||||
|
||||
|
|
@ -1652,11 +1567,11 @@ export async function handleCommand(
|
|||
// Create example command
|
||||
const exampleCommand = join(commandsDir, 'example.md');
|
||||
const exampleContent = `---
|
||||
description: Example command template
|
||||
description: Example command
|
||||
---
|
||||
# Example Command
|
||||
|
||||
This is an example command template.
|
||||
This is an example command.
|
||||
|
||||
Arguments:
|
||||
- $1 - First positional argument
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ export {
|
|||
type IsolationEnvironmentRow,
|
||||
type Codebase,
|
||||
type Session,
|
||||
type CommandTemplate,
|
||||
type CommandResult,
|
||||
type IPlatformAdapter,
|
||||
type IWebPlatformAdapter,
|
||||
|
|
@ -46,7 +45,6 @@ export type { IDatabase, SqlDialect } from './db/adapters/types';
|
|||
export * as conversationDb from './db/conversations';
|
||||
export * as codebaseDb from './db/codebases';
|
||||
export * as sessionDb from './db/sessions';
|
||||
export * as commandTemplateDb from './db/command-templates';
|
||||
export * as isolationEnvDb from './db/isolation-environments';
|
||||
export * as workflowDb from './db/workflows';
|
||||
export * as messageDb from './db/messages';
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ const mockTransitionSession = mock(
|
|||
});
|
||||
}
|
||||
);
|
||||
const mockGetTemplate = mock(() => Promise.resolve(null));
|
||||
const mockHandleCommand = mock(() => Promise.resolve({ message: '', modified: false }));
|
||||
const mockParseCommand = mock((message: string) => {
|
||||
const parts = message.split(/\s+/);
|
||||
|
|
@ -59,6 +58,7 @@ const mockGetAssistantClient = mock(() => null);
|
|||
|
||||
// Mock for reading command files (replaces fs/promises mock)
|
||||
const mockReadCommandFile = mock(() => Promise.resolve(''));
|
||||
const mockCommandFileExists = mock(() => Promise.resolve(false));
|
||||
|
||||
// Mock for workflow discovery
|
||||
const mockDiscoverWorkflows = mock(() => Promise.resolve({ workflows: [], errors: [] }));
|
||||
|
|
@ -132,10 +132,6 @@ mock.module('../db/sessions', () => ({
|
|||
transitionSession: mockTransitionSession,
|
||||
}));
|
||||
|
||||
mock.module('../db/command-templates', () => ({
|
||||
getTemplate: mockGetTemplate,
|
||||
}));
|
||||
|
||||
mock.module('../handlers/command-handler', () => ({
|
||||
handleCommand: mockHandleCommand,
|
||||
parseCommand: mockParseCommand,
|
||||
|
|
@ -181,6 +177,7 @@ import * as realOrchestrator from './orchestrator';
|
|||
mock.module('./orchestrator', () => ({
|
||||
...realOrchestrator,
|
||||
readCommandFile: mockReadCommandFile,
|
||||
commandFileExists: mockCommandFileExists,
|
||||
}));
|
||||
|
||||
import { handleMessage, wrapCommandForExecution } from './orchestrator';
|
||||
|
|
@ -271,11 +268,11 @@ describe('orchestrator', () => {
|
|||
mockUpdateSession.mockClear();
|
||||
mockDeactivateSession.mockClear();
|
||||
mockUpdateSessionMetadata.mockClear();
|
||||
mockGetTemplate.mockClear();
|
||||
mockHandleCommand.mockClear();
|
||||
mockParseCommand.mockClear();
|
||||
mockGetAssistantClient.mockClear();
|
||||
mockReadCommandFile.mockClear();
|
||||
mockCommandFileExists.mockClear();
|
||||
mockDiscoverWorkflows.mockClear();
|
||||
mockExecuteWorkflow.mockClear();
|
||||
mockClient.sendQuery.mockClear();
|
||||
|
|
@ -310,7 +307,7 @@ describe('orchestrator', () => {
|
|||
mockGetCodebase.mockResolvedValue(mockCodebase);
|
||||
mockGetActiveSession.mockResolvedValue(null);
|
||||
mockCreateSession.mockResolvedValue(mockSession);
|
||||
mockGetTemplate.mockResolvedValue(null); // No templates by default
|
||||
mockCommandFileExists.mockResolvedValue(false);
|
||||
mockGetAssistantClient.mockReturnValue(mockClient);
|
||||
mockParseCommand.mockImplementation((message: string) => {
|
||||
const parts = message.split(/\s+/);
|
||||
|
|
@ -458,6 +455,91 @@ describe('orchestrator', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('falls back to .archon/commands when command not registered', async () => {
|
||||
mockGetCodebase.mockResolvedValue({
|
||||
...mockCodebase,
|
||||
commands: {},
|
||||
});
|
||||
mockParseCommand.mockReturnValue({
|
||||
command: 'command-invoke',
|
||||
args: ['rca', 'do', 'thing'],
|
||||
});
|
||||
mockCommandFileExists.mockResolvedValue(true);
|
||||
mockReadCommandFile.mockResolvedValue('Do the following: $ARGUMENTS');
|
||||
mockClient.sendQuery.mockImplementation(async function* () {
|
||||
yield { type: 'result', sessionId: 'new-session-id' };
|
||||
});
|
||||
|
||||
await handleMessage(platform, 'chat-456', '/command-invoke rca do thing');
|
||||
|
||||
const expectedPath = join('/workspace/project', '.archon/commands/rca.md');
|
||||
expect(mockReadCommandFile).toHaveBeenCalledWith(expectedPath);
|
||||
expect(mockClient.sendQuery).toHaveBeenCalledWith(
|
||||
wrapCommandForExecution('rca', 'Do the following: do thing'),
|
||||
'/workspace/project',
|
||||
'claude-session-xyz'
|
||||
);
|
||||
});
|
||||
|
||||
test('handles file read error after fallback existence check', async () => {
|
||||
mockGetCodebase.mockResolvedValue({
|
||||
...mockCodebase,
|
||||
commands: {},
|
||||
});
|
||||
mockParseCommand.mockReturnValue({
|
||||
command: 'command-invoke',
|
||||
args: ['plan', 'arg1'],
|
||||
});
|
||||
mockCommandFileExists.mockResolvedValue(true);
|
||||
mockReadCommandFile.mockRejectedValue(new Error('EACCES: permission denied'));
|
||||
|
||||
await handleMessage(platform, 'chat-456', '/command-invoke plan arg1');
|
||||
|
||||
expect(platform.sendMessage).toHaveBeenCalledWith(
|
||||
'chat-456',
|
||||
expect.stringContaining('Failed to read command file')
|
||||
);
|
||||
expect(mockClient.sendQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects fallback for invalid command names (path traversal protection)', async () => {
|
||||
mockGetCodebase.mockResolvedValue({
|
||||
...mockCodebase,
|
||||
commands: {},
|
||||
});
|
||||
mockParseCommand.mockReturnValue({
|
||||
command: 'command-invoke',
|
||||
args: ['../../../etc/passwd'],
|
||||
});
|
||||
|
||||
await handleMessage(platform, 'chat-456', '/command-invoke ../../../etc/passwd');
|
||||
|
||||
expect(mockCommandFileExists).not.toHaveBeenCalled();
|
||||
expect(platform.sendMessage).toHaveBeenCalledWith(
|
||||
'chat-456',
|
||||
expect.stringContaining('not found')
|
||||
);
|
||||
});
|
||||
|
||||
test('uses registered command path instead of fallback', async () => {
|
||||
mockParseCommand.mockReturnValue({
|
||||
command: 'command-invoke',
|
||||
args: ['plan', 'arg1'],
|
||||
});
|
||||
mockReadCommandFile.mockResolvedValue('Plan the following: $ARGUMENTS');
|
||||
mockClient.sendQuery.mockImplementation(async function* () {
|
||||
yield { type: 'result', sessionId: 'new-session-id' };
|
||||
});
|
||||
|
||||
await handleMessage(platform, 'chat-456', '/command-invoke plan arg1');
|
||||
|
||||
// Should NOT check for fallback file since command is registered
|
||||
expect(mockCommandFileExists).not.toHaveBeenCalled();
|
||||
// Should use registered path
|
||||
const expectedPath = join('/workspace/project', '.claude/commands/plan.md');
|
||||
expect(mockReadCommandFile).toHaveBeenCalledWith(expectedPath);
|
||||
});
|
||||
|
||||
test('appends issueContext after command text', async () => {
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
|
||||
mockReadCommandFile.mockResolvedValue('Command text here');
|
||||
|
|
@ -491,37 +573,8 @@ describe('orchestrator', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('router template', () => {
|
||||
test('routes non-slash messages through router template when available', async () => {
|
||||
mockGetTemplate.mockImplementation(async (name: string) => {
|
||||
if (name === 'router') {
|
||||
return {
|
||||
id: 'router-id',
|
||||
name: 'router',
|
||||
description: 'Route requests',
|
||||
content: 'Router prompt with $ARGUMENTS',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
mockClient.sendQuery.mockImplementation(async function* () {
|
||||
yield { type: 'result', sessionId: 'session-id' };
|
||||
});
|
||||
|
||||
await handleMessage(platform, 'chat-456', 'fix the login bug');
|
||||
|
||||
expect(mockGetTemplate).toHaveBeenCalledWith('router');
|
||||
expect(mockClient.sendQuery).toHaveBeenCalledWith(
|
||||
'Router prompt with fix the login bug',
|
||||
'/workspace/project',
|
||||
'claude-session-xyz'
|
||||
);
|
||||
});
|
||||
|
||||
test('passes message directly if router template not available', async () => {
|
||||
mockGetTemplate.mockResolvedValue(null);
|
||||
describe('no workflows', () => {
|
||||
test('passes message directly when no workflows are available', async () => {
|
||||
mockClient.sendQuery.mockImplementation(async function* () {
|
||||
yield { type: 'result', sessionId: 'session-id' };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,12 +2,26 @@
|
|||
* Orchestrator - Main conversation handler
|
||||
* Routes slash commands and AI messages appropriately
|
||||
*/
|
||||
import { readFile as fsReadFile } from 'fs/promises';
|
||||
import { readFile as fsReadFile, access as fsAccess } from 'fs/promises';
|
||||
|
||||
// Wrapper function for reading files - allows mocking without polluting fs/promises globally
|
||||
export async function readCommandFile(path: string): Promise<string> {
|
||||
return fsReadFile(path, 'utf-8');
|
||||
}
|
||||
export async function commandFileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await fsAccess(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
// Unexpected errors (permissions, I/O) should not be swallowed
|
||||
getLog().error({ err, path, code: err.code }, 'command_file_access_error');
|
||||
throw new Error(`Cannot access command file at ${path}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
import { join } from 'path';
|
||||
import { createLogger } from '../utils/logger';
|
||||
|
||||
|
|
@ -29,7 +43,6 @@ import {
|
|||
import * as db from '../db/conversations';
|
||||
import * as codebaseDb from '../db/codebases';
|
||||
import * as sessionDb from '../db/sessions';
|
||||
import * as templateDb from '../db/command-templates';
|
||||
import * as isolationEnvDb from '../db/isolation-environments';
|
||||
import * as commandHandler from '../handlers/command-handler';
|
||||
import { formatToolCall } from '../utils/tool-formatter';
|
||||
|
|
@ -45,6 +58,7 @@ import {
|
|||
parseWorkflowInvocation,
|
||||
findWorkflow,
|
||||
executeWorkflow,
|
||||
isValidCommandName,
|
||||
} from '../workflows';
|
||||
import type { WorkflowDefinition, RouterContext } from '../workflows';
|
||||
import * as workflowDb from '../db/workflows';
|
||||
|
|
@ -718,10 +732,6 @@ export async function handleMessage(
|
|||
'command-set',
|
||||
'load-commands',
|
||||
'commands',
|
||||
'template-add',
|
||||
'template-list',
|
||||
'templates',
|
||||
'template-delete',
|
||||
'worktree',
|
||||
'workflow',
|
||||
];
|
||||
|
|
@ -848,7 +858,23 @@ export async function handleMessage(
|
|||
return;
|
||||
}
|
||||
|
||||
const commandDef = codebase.commands[commandName];
|
||||
// Read command file using the conversation's cwd
|
||||
const commandCwd = conversation.cwd ?? codebase.default_cwd;
|
||||
let commandDef = codebase.commands[commandName];
|
||||
if (!commandDef && isValidCommandName(commandName)) {
|
||||
const fallbackPath = join('.archon', 'commands', `${commandName}.md`);
|
||||
const fallbackFilePath = join(commandCwd, fallbackPath);
|
||||
if (await commandFileExists(fallbackFilePath)) {
|
||||
commandDef = {
|
||||
path: fallbackPath,
|
||||
description: `From ${fallbackPath}`,
|
||||
};
|
||||
getLog().debug(
|
||||
{ commandName, path: fallbackPath },
|
||||
'command_invoke_fallback_file_found'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!commandDef) {
|
||||
await platform.sendMessage(
|
||||
conversationId,
|
||||
|
|
@ -857,8 +883,6 @@ export async function handleMessage(
|
|||
return;
|
||||
}
|
||||
|
||||
// Read command file using the conversation's cwd
|
||||
const commandCwd = conversation.cwd ?? codebase.default_cwd;
|
||||
const commandFilePath = join(commandCwd, commandDef.path);
|
||||
|
||||
try {
|
||||
|
|
@ -883,31 +907,14 @@ export async function handleMessage(
|
|||
return;
|
||||
}
|
||||
} else {
|
||||
// Check if it's a global template command
|
||||
const template = await templateDb.getTemplate(command);
|
||||
if (template) {
|
||||
getLog().debug({ command }, 'template_found');
|
||||
commandName = command;
|
||||
const substituted = substituteVariables(template.content, args);
|
||||
promptToSend = wrapCommandForExecution(commandName, substituted);
|
||||
|
||||
if (issueContext) {
|
||||
promptToSend = promptToSend + '\n\n---\n\n' + issueContext;
|
||||
getLog().debug({ commandName }, 'issue_context_appended_to_template');
|
||||
}
|
||||
|
||||
getLog().debug({ command, argCount: args.length }, 'template_executing');
|
||||
} else {
|
||||
// Unknown command
|
||||
await platform.sendMessage(
|
||||
conversationId,
|
||||
`Unknown command: /${command}\n\nType /help for available commands or /templates for command templates.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
await platform.sendMessage(
|
||||
conversationId,
|
||||
`Unknown command: /${command}\n\nType /help for available commands.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Regular message - route through router template or workflows
|
||||
// Regular message - route through workflows or pass through directly
|
||||
if (!conversation.codebase_id) {
|
||||
await platform.sendMessage(
|
||||
conversationId,
|
||||
|
|
@ -1027,21 +1034,8 @@ export async function handleMessage(
|
|||
'router_context_built'
|
||||
);
|
||||
} else {
|
||||
// Fall back to router template for natural language routing
|
||||
getLog().debug(
|
||||
{ count: availableWorkflows.length },
|
||||
'no_workflows_checking_router_template'
|
||||
);
|
||||
const routerTemplate = await templateDb.getTemplate('router');
|
||||
if (routerTemplate) {
|
||||
getLog().debug('routing_through_router_template');
|
||||
commandName = 'router';
|
||||
// Pass the entire message as $ARGUMENTS for the router
|
||||
promptToSend = substituteVariables(routerTemplate.content, [message]);
|
||||
} else {
|
||||
getLog().debug('no_router_template_using_raw_message');
|
||||
}
|
||||
// If no router template, message passes through as-is (backward compatible)
|
||||
getLog().debug({ count: availableWorkflows.length }, 'no_workflows_using_raw_message');
|
||||
// If no workflows, message passes through as-is (backward compatible)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,15 +97,6 @@ export interface Session {
|
|||
ended_reason: TransitionTrigger | null;
|
||||
}
|
||||
|
||||
export interface CommandTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
content: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue