mirror of
https://github.com/coleam00/Archon
synced 2026-04-21 13:37:41 +00:00
Add global command templates feature (#20)
- New database table for storing command templates - /templates, /template-add, /template-delete commands - Direct template invocation via /<name> [args] - Auto-seed builtin commands from .claude/commands/exp-piv-loop on startup Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3e5c7404ae
commit
9f73a87830
10 changed files with 1284 additions and 81 deletions
788
.agents/plans/completed/global-command-templates.plan.md
Normal file
788
.agents/plans/completed/global-command-templates.plan.md
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
# Plan: Global Command Templates
|
||||
|
||||
## Summary
|
||||
|
||||
Add a `remote_agent_command_templates` database table to store reusable command prompts that work across all projects. Users can load any markdown file into this table with a custom name, invoke commands with simpler syntax (`/plan "feature"` instead of `/command-invoke plan "feature"`), and the existing `$ARGUMENTS`, `$1`, `$2` variable substitution works unchanged. The `exp-piv-loop` commands are seeded as defaults on app startup.
|
||||
|
||||
## External Research
|
||||
|
||||
### Documentation
|
||||
- PostgreSQL TEXT type - ideal for storing markdown content (no length limit)
|
||||
- Node.js `pg` library patterns - already used in codebase
|
||||
|
||||
### Gotchas & Best Practices
|
||||
- Use TEXT not VARCHAR for command content (markdown can be large)
|
||||
- Seed data should be idempotent (INSERT ... ON CONFLICT DO NOTHING)
|
||||
- Command lookup priority: codebase-specific > global templates (user expectation)
|
||||
|
||||
## Patterns to Mirror
|
||||
|
||||
### Database Schema Pattern
|
||||
**FROM: `migrations/001_initial_schema.sql:6-15`**
|
||||
```sql
|
||||
CREATE TABLE remote_agent_codebases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
repository_url VARCHAR(500),
|
||||
default_cwd VARCHAR(500) NOT NULL,
|
||||
ai_assistant_type VARCHAR(20) DEFAULT 'claude',
|
||||
commands JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### Database Operations Pattern
|
||||
**FROM: `src/db/codebases.ts:7-19`**
|
||||
```typescript
|
||||
export async function createCodebase(data: {
|
||||
name: string;
|
||||
repository_url?: string;
|
||||
default_cwd: string;
|
||||
ai_assistant_type?: string;
|
||||
}): Promise<Codebase> {
|
||||
const assistantType = data.ai_assistant_type ?? 'claude';
|
||||
const result = await pool.query<Codebase>(
|
||||
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[data.name, data.repository_url ?? null, data.default_cwd, assistantType]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
```
|
||||
|
||||
### Command Handler Pattern
|
||||
**FROM: `src/handlers/command-handler.ts:77-101`**
|
||||
```typescript
|
||||
switch (command) {
|
||||
case 'help':
|
||||
return {
|
||||
success: true,
|
||||
message: `Available Commands:...`,
|
||||
};
|
||||
// ... more cases
|
||||
}
|
||||
```
|
||||
|
||||
### Test Pattern
|
||||
**FROM: `src/db/codebases.test.ts:38-54`**
|
||||
```typescript
|
||||
describe('createCodebase', () => {
|
||||
test('creates codebase with all fields', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([mockCodebase]));
|
||||
|
||||
const result = await createCodebase({...});
|
||||
|
||||
expect(result).toEqual(mockCodebase);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'INSERT INTO ...',
|
||||
[...]
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Files to Change
|
||||
|
||||
| File | Action | Justification |
|
||||
|------|--------|---------------|
|
||||
| `migrations/002_command_templates.sql` | CREATE | New table schema |
|
||||
| `src/types/index.ts` | UPDATE | Add CommandTemplate interface |
|
||||
| `src/db/command-templates.ts` | CREATE | Database operations for templates |
|
||||
| `src/db/command-templates.test.ts` | CREATE | Unit tests for DB operations |
|
||||
| `src/handlers/command-handler.ts` | UPDATE | Add `/template-add`, `/template-list`, `/template-delete` commands |
|
||||
| `src/handlers/command-handler.test.ts` | UPDATE | Tests for new commands |
|
||||
| `src/orchestrator/orchestrator.ts` | UPDATE | Support direct command invocation (`/plan` → lookup template) |
|
||||
| `src/orchestrator/orchestrator.test.ts` | UPDATE | Tests for template invocation |
|
||||
| `src/scripts/seed-commands.ts` | CREATE | Seed exp-piv-loop commands on startup |
|
||||
| `src/index.ts` | UPDATE | Call seed function on startup |
|
||||
|
||||
## NOT Building
|
||||
|
||||
- ❌ Command versioning/history (not needed for MVP)
|
||||
- ❌ Command categories/tags (simple flat list is fine)
|
||||
- ❌ Import/export to files (manual `/template-add` is sufficient)
|
||||
- ❌ Per-user templates (single-developer tool)
|
||||
- ❌ Template validation (user responsibility)
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: CREATE migration file `migrations/002_command_templates.sql`
|
||||
|
||||
**Why**: Need database table to store command templates
|
||||
|
||||
**Mirror**: `migrations/001_initial_schema.sql:6-15`
|
||||
|
||||
**Do**:
|
||||
```sql
|
||||
-- Remote Coding Agent - Command Templates
|
||||
-- Version: 2.0
|
||||
-- Description: Global command templates table
|
||||
|
||||
CREATE TABLE 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 idx_remote_agent_command_templates_name ON remote_agent_command_templates(name);
|
||||
```
|
||||
|
||||
**Don't**:
|
||||
- Don't add foreign keys (templates are global, not per-codebase)
|
||||
- Don't add argument_hint column (extract from frontmatter at runtime)
|
||||
|
||||
**Verify**: `psql $DATABASE_URL -c "\d remote_agent_command_templates"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: UPDATE `src/types/index.ts` - Add CommandTemplate interface
|
||||
|
||||
**Why**: Type safety for template operations
|
||||
|
||||
**Mirror**: `src/types/index.ts:5-14` (Conversation interface)
|
||||
|
||||
**Do**:
|
||||
```typescript
|
||||
export interface CommandTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
content: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
```
|
||||
|
||||
Add after the `Session` interface (around line 37).
|
||||
|
||||
**Don't**:
|
||||
- Don't add optional fields that aren't in the schema
|
||||
|
||||
**Verify**: `npm run type-check`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: CREATE `src/db/command-templates.ts`
|
||||
|
||||
**Why**: Database operations for command templates
|
||||
|
||||
**Mirror**: `src/db/codebases.ts` (entire file structure)
|
||||
|
||||
**Do**:
|
||||
```typescript
|
||||
/**
|
||||
* Database operations for command templates
|
||||
*/
|
||||
import { pool } from './connection';
|
||||
import { CommandTemplate } from '../types';
|
||||
|
||||
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<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]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
```
|
||||
|
||||
**Don't**:
|
||||
- Don't add codebase_id - templates are global
|
||||
|
||||
**Verify**: `npm run type-check`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: CREATE `src/db/command-templates.test.ts`
|
||||
|
||||
**Why**: Unit tests for template database operations
|
||||
|
||||
**Mirror**: `src/db/codebases.test.ts` (entire file structure)
|
||||
|
||||
**Do**:
|
||||
```typescript
|
||||
import { createQueryResult } from '../test/mocks/database';
|
||||
import { CommandTemplate } from '../types';
|
||||
|
||||
const mockQuery = jest.fn();
|
||||
|
||||
jest.mock('./connection', () => ({
|
||||
pool: {
|
||||
query: mockQuery,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createTemplate,
|
||||
getTemplate,
|
||||
getAllTemplates,
|
||||
deleteTemplate,
|
||||
upsertTemplate,
|
||||
} from './command-templates';
|
||||
|
||||
describe('command-templates', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
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']
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Verify**: `npm test -- src/db/command-templates.test.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: CREATE `src/scripts/seed-commands.ts`
|
||||
|
||||
**Why**: Seed exp-piv-loop commands as defaults on startup
|
||||
|
||||
**Mirror**: N/A (new pattern, but uses existing db operations)
|
||||
|
||||
**Do**:
|
||||
```typescript
|
||||
/**
|
||||
* Seed default command templates from .claude/commands/exp-piv-loop
|
||||
*/
|
||||
import { readFile, readdir } from 'fs/promises';
|
||||
import { join, basename } from 'path';
|
||||
import { upsertTemplate } from '../db/command-templates';
|
||||
|
||||
const SEED_COMMANDS_PATH = '.claude/commands/exp-piv-loop';
|
||||
|
||||
/**
|
||||
* Extract description from markdown frontmatter
|
||||
* ---
|
||||
* description: Some description
|
||||
* ---
|
||||
*/
|
||||
function extractDescription(content: string): string | undefined {
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!frontmatterMatch) return undefined;
|
||||
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const descMatch = frontmatter.match(/description:\s*(.+)/);
|
||||
return descMatch?.[1]?.trim();
|
||||
}
|
||||
|
||||
export async function seedDefaultCommands(): Promise<void> {
|
||||
console.log('[Seed] Checking for default command templates...');
|
||||
|
||||
try {
|
||||
const files = await readdir(SEED_COMMANDS_PATH);
|
||||
const mdFiles = files.filter(f => f.endsWith('.md'));
|
||||
|
||||
for (const file of mdFiles) {
|
||||
const name = basename(file, '.md');
|
||||
const filePath = join(SEED_COMMANDS_PATH, file);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const description = extractDescription(content);
|
||||
|
||||
await upsertTemplate({
|
||||
name,
|
||||
description: description ?? `From ${SEED_COMMANDS_PATH}`,
|
||||
content,
|
||||
});
|
||||
|
||||
console.log(`[Seed] Loaded template: ${name}`);
|
||||
}
|
||||
|
||||
console.log(`[Seed] Seeded ${String(mdFiles.length)} default command templates`);
|
||||
} catch (error) {
|
||||
// Don't fail startup if seed commands don't exist
|
||||
console.log('[Seed] No default commands to seed (this is OK)');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Don't**:
|
||||
- Don't fail app startup if seed files don't exist
|
||||
- Don't overwrite user modifications (upsert handles this gracefully)
|
||||
|
||||
**Verify**: `npm run type-check`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: UPDATE `src/handlers/command-handler.ts` - Add template commands
|
||||
|
||||
**Why**: Users need to manage templates via slash commands
|
||||
|
||||
**Mirror**: `src/handlers/command-handler.ts:77-101` (switch cases)
|
||||
|
||||
**Do**:
|
||||
|
||||
Add imports at top:
|
||||
```typescript
|
||||
import * as templateDb from '../db/command-templates';
|
||||
```
|
||||
|
||||
Add new cases in the switch statement (before `default:`):
|
||||
|
||||
```typescript
|
||||
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 = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
let description: string | undefined;
|
||||
if (frontmatterMatch) {
|
||||
const descMatch = frontmatterMatch[1].match(/description:\s*(.+)/);
|
||||
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.` };
|
||||
}
|
||||
```
|
||||
|
||||
Update the `/help` message to include template commands:
|
||||
```typescript
|
||||
case 'help':
|
||||
return {
|
||||
success: true,
|
||||
message: `Available Commands:
|
||||
|
||||
Command Templates:
|
||||
/<name> [args] - Invoke a template directly
|
||||
/templates - List all templates
|
||||
/template-add <name> <path> - Add template from file
|
||||
/template-delete <name> - Remove a template
|
||||
|
||||
Command Management:
|
||||
/command-set <name> <path> [text] - Register codebase command
|
||||
/load-commands <folder> - Bulk load (recursive)
|
||||
/command-invoke <name> [args] - Execute codebase command
|
||||
/commands - List codebase commands
|
||||
|
||||
Codebase:
|
||||
/clone <repo-url> - Clone repository
|
||||
/repos - List workspace repositories
|
||||
/getcwd - Show working directory
|
||||
/setcwd <path> - Set directory
|
||||
|
||||
Session:
|
||||
/status - Show state
|
||||
/reset - Clear session
|
||||
/help - Show help`,
|
||||
};
|
||||
```
|
||||
|
||||
**Don't**:
|
||||
- Don't require codebase for template commands (templates are global)
|
||||
|
||||
**Verify**: `npm run type-check && npm test -- src/handlers/command-handler.test.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: UPDATE `src/orchestrator/orchestrator.ts` - Support direct template invocation
|
||||
|
||||
**Why**: Allow `/plan "feature"` instead of `/command-invoke plan "feature"`
|
||||
|
||||
**Mirror**: `src/orchestrator/orchestrator.ts:29-46` (slash command routing)
|
||||
|
||||
**Do**:
|
||||
|
||||
Add import at top:
|
||||
```typescript
|
||||
import * as templateDb from '../db/command-templates';
|
||||
```
|
||||
|
||||
Modify the slash command routing section (after line 29, before the `/command-invoke` check):
|
||||
|
||||
```typescript
|
||||
// Handle slash commands (except /command-invoke which needs AI)
|
||||
if (message.startsWith('/')) {
|
||||
const { command, args } = commandHandler.parseCommand(message);
|
||||
|
||||
// Check if this is a known deterministic command
|
||||
const deterministicCommands = [
|
||||
'help', 'status', 'getcwd', 'setcwd', 'clone', 'repos', 'reset',
|
||||
'command-set', 'load-commands', 'commands',
|
||||
'template-add', 'template-list', 'templates', 'template-delete'
|
||||
];
|
||||
|
||||
if (deterministicCommands.includes(command)) {
|
||||
console.log(`[Orchestrator] Processing slash command: ${message}`);
|
||||
const result = await commandHandler.handleCommand(conversation, message);
|
||||
await platform.sendMessage(conversationId, result.message);
|
||||
|
||||
if (result.modified) {
|
||||
conversation = await db.getOrCreateConversation(
|
||||
platform.getPlatformType(),
|
||||
conversationId
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's /command-invoke (codebase-specific)
|
||||
if (command === 'command-invoke') {
|
||||
// ... existing command-invoke logic (keep as-is)
|
||||
}
|
||||
|
||||
// Check if it's a global template command
|
||||
const template = await templateDb.getTemplate(command);
|
||||
if (template) {
|
||||
console.log(`[Orchestrator] Found template: ${command}`);
|
||||
commandName = command;
|
||||
promptToSend = substituteVariables(template.content, args);
|
||||
|
||||
if (issueContext) {
|
||||
promptToSend = promptToSend + '\n\n---\n\n' + issueContext;
|
||||
console.log('[Orchestrator] Appended issue/PR context to template prompt');
|
||||
}
|
||||
|
||||
console.log(`[Orchestrator] Executing template '${command}' with ${String(args.length)} args`);
|
||||
// Fall through to AI handling below
|
||||
} else {
|
||||
// Unknown command
|
||||
await platform.sendMessage(
|
||||
conversationId,
|
||||
`Unknown command: /${command}\n\nType /help for available commands or /templates for command templates.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The template invocation path needs to skip the codebase requirement check since templates are global. The AI conversation section should work since it only needs `conversation.cwd` which can default to workspace.
|
||||
|
||||
Also update the "no codebase" check to allow template commands:
|
||||
```typescript
|
||||
// Regular message - require codebase (but templates don't need it)
|
||||
if (!conversation.codebase_id && !commandName) {
|
||||
await platform.sendMessage(conversationId, 'No codebase configured. Use /clone first.');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
**Don't**:
|
||||
- Don't break existing `/command-invoke` flow
|
||||
- Don't require codebase for template commands
|
||||
|
||||
**Verify**: `npm run type-check`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: UPDATE `src/index.ts` - Call seed on startup
|
||||
|
||||
**Why**: Seed default templates when app starts
|
||||
|
||||
**Mirror**: `src/index.ts:51-57` (database connection check pattern)
|
||||
|
||||
**Do**:
|
||||
|
||||
Add import at top:
|
||||
```typescript
|
||||
import { seedDefaultCommands } from './scripts/seed-commands';
|
||||
```
|
||||
|
||||
Add after database connection check (around line 57):
|
||||
```typescript
|
||||
// Seed default command templates
|
||||
await seedDefaultCommands();
|
||||
```
|
||||
|
||||
**Don't**:
|
||||
- Don't block startup if seeding fails
|
||||
|
||||
**Verify**: `npm run dev` - should see seed logs
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Run migration
|
||||
|
||||
**Why**: Create the new table in the database
|
||||
|
||||
**Do**:
|
||||
```bash
|
||||
psql $DATABASE_URL < migrations/002_command_templates.sql
|
||||
```
|
||||
|
||||
**Verify**: `psql $DATABASE_URL -c "SELECT COUNT(*) FROM remote_agent_command_templates"`
|
||||
|
||||
---
|
||||
|
||||
## Validation Strategy
|
||||
|
||||
### Automated Checks
|
||||
- [ ] `npm run type-check` - Types valid
|
||||
- [ ] `npm run lint` - No lint errors
|
||||
- [ ] `npm run test` - All tests pass
|
||||
- [ ] `npm run build` - Build succeeds
|
||||
|
||||
### New Tests to Write
|
||||
| Test File | Test Case | What It Validates |
|
||||
|-----------|-----------|-------------------|
|
||||
| `src/db/command-templates.test.ts` | All CRUD operations | Database layer works |
|
||||
| `src/handlers/command-handler.test.ts` | `/template-add`, `/template-list`, `/template-delete` | Command handler works |
|
||||
| `src/orchestrator/orchestrator.test.ts` | Direct template invocation (`/plan "feature"`) | Template routing works |
|
||||
|
||||
### Manual/E2E Validation
|
||||
|
||||
```bash
|
||||
# 1. Start the app
|
||||
npm run dev
|
||||
|
||||
# 2. In Telegram, test template commands:
|
||||
/templates # Should show seeded exp-piv-loop commands
|
||||
/template-add myplan .claude/commands/exp-piv-loop/plan.md
|
||||
/templates # Should show myplan
|
||||
|
||||
# 3. Test direct invocation (the key feature!)
|
||||
/plan "Add user authentication" # Should work without /command-invoke
|
||||
|
||||
# 4. Test template deletion
|
||||
/template-delete myplan
|
||||
/templates # Should not show myplan
|
||||
```
|
||||
|
||||
### Edge Cases
|
||||
- [ ] `/plan` with no arguments (should still work, $ARGUMENTS = "")
|
||||
- [ ] Template with same name as built-in command (built-in should win)
|
||||
- [ ] Template content with complex markdown and `$1`, `$2`, `$ARGUMENTS`
|
||||
- [ ] Invoking template without codebase set (should work - templates are global)
|
||||
|
||||
### Regression Check
|
||||
- [ ] `/command-invoke plan "feature"` still works (codebase-specific)
|
||||
- [ ] `/load-commands` still works
|
||||
- [ ] `/clone` still works
|
||||
- [ ] Regular messages still work
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Template name conflicts**: If user creates template named `help`, it would shadow built-in. Mitigation: Check deterministicCommands list first.
|
||||
|
||||
2. **Large templates**: No size limit on content TEXT. Mitigation: Not a real risk for single-developer tool.
|
||||
|
||||
3. **Migration on existing DB**: Need to run migration manually. Mitigation: Clear instructions in Task 9.
|
||||
14
migrations/002_command_templates.sql
Normal file
14
migrations/002_command_templates.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-- Remote Coding Agent - Command Templates
|
||||
-- Version: 2.0
|
||||
-- Description: Global command templates table
|
||||
|
||||
CREATE TABLE 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 idx_remote_agent_command_templates_name ON remote_agent_command_templates(name);
|
||||
147
src/db/command-templates.test.ts
Normal file
147
src/db/command-templates.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { createQueryResult } from '../test/mocks/database';
|
||||
import { CommandTemplate } from '../types';
|
||||
|
||||
const mockQuery = jest.fn();
|
||||
|
||||
jest.mock('./connection', () => ({
|
||||
pool: {
|
||||
query: mockQuery,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createTemplate,
|
||||
getTemplate,
|
||||
getAllTemplates,
|
||||
deleteTemplate,
|
||||
upsertTemplate,
|
||||
} from './command-templates';
|
||||
|
||||
describe('command-templates', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
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']
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
src/db/command-templates.ts
Normal file
58
src/db/command-templates.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Database operations for command templates
|
||||
*/
|
||||
import { pool } from './connection';
|
||||
import { CommandTemplate } from '../types';
|
||||
|
||||
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<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]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { Conversation, CommandResult } 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';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
|
@ -80,7 +81,13 @@ export async function handleCommand(
|
|||
success: true,
|
||||
message: `Available Commands:
|
||||
|
||||
Command Management:
|
||||
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)
|
||||
/command-invoke <name> [args] - Execute
|
||||
|
|
@ -740,6 +747,80 @@ Session:
|
|||
}
|
||||
}
|
||||
|
||||
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.` };
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { handleMessage } from './orchestrator/orchestrator';
|
|||
import { pool } from './db/connection';
|
||||
import { ConversationLockManager } from './utils/conversation-lock';
|
||||
import { classifyAndFormatError } from './utils/error-formatter';
|
||||
import { seedDefaultCommands } from './scripts/seed-commands';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('[App] Starting Remote Coding Agent (Telegram + Claude MVP)');
|
||||
|
|
@ -57,6 +58,9 @@ async function main(): Promise<void> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// Seed default command templates
|
||||
await seedDefaultCommands();
|
||||
|
||||
// Initialize conversation lock manager
|
||||
const maxConcurrent = parseInt(process.env.MAX_CONCURRENT_CONVERSATIONS ?? '10');
|
||||
const lockManager = new ConversationLockManager(maxConcurrent);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const mockCreateSession = jest.fn();
|
|||
const mockUpdateSession = jest.fn();
|
||||
const mockDeactivateSession = jest.fn();
|
||||
const mockUpdateSessionMetadata = jest.fn();
|
||||
const mockGetTemplate = jest.fn();
|
||||
const mockHandleCommand = jest.fn();
|
||||
const mockParseCommand = jest.fn();
|
||||
const mockGetAssistantClient = jest.fn();
|
||||
|
|
@ -30,6 +31,10 @@ jest.mock('../db/sessions', () => ({
|
|||
updateSessionMetadata: mockUpdateSessionMetadata,
|
||||
}));
|
||||
|
||||
jest.mock('../db/command-templates', () => ({
|
||||
getTemplate: mockGetTemplate,
|
||||
}));
|
||||
|
||||
jest.mock('../handlers/command-handler', () => ({
|
||||
handleCommand: mockHandleCommand,
|
||||
parseCommand: mockParseCommand,
|
||||
|
|
@ -99,10 +104,11 @@ describe('orchestrator', () => {
|
|||
mockGetCodebase.mockResolvedValue(mockCodebase);
|
||||
mockGetActiveSession.mockResolvedValue(null);
|
||||
mockCreateSession.mockResolvedValue(mockSession);
|
||||
mockGetTemplate.mockResolvedValue(null); // No templates by default
|
||||
mockGetAssistantClient.mockReturnValue(mockClient);
|
||||
mockParseCommand.mockImplementation((message: string) => {
|
||||
const parts = message.split(/\s+/);
|
||||
return { command: parts[0], args: parts.slice(1) };
|
||||
return { command: parts[0].substring(1), args: parts.slice(1) };
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -132,7 +138,7 @@ describe('orchestrator', () => {
|
|||
...mockConversation,
|
||||
codebase_id: null,
|
||||
});
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['plan'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
|
||||
|
||||
await handleMessage(platform, 'chat-456', '/command-invoke plan');
|
||||
|
||||
|
|
@ -143,7 +149,7 @@ describe('orchestrator', () => {
|
|||
});
|
||||
|
||||
test('sends error when no command name provided', async () => {
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: [] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: [] });
|
||||
|
||||
await handleMessage(platform, 'chat-456', '/command-invoke');
|
||||
|
||||
|
|
@ -154,7 +160,7 @@ describe('orchestrator', () => {
|
|||
});
|
||||
|
||||
test('sends error when command not found', async () => {
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['unknown'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['unknown'] });
|
||||
|
||||
await handleMessage(platform, 'chat-456', '/command-invoke unknown');
|
||||
|
||||
|
|
@ -166,7 +172,7 @@ describe('orchestrator', () => {
|
|||
|
||||
test('sends error when codebase not found', async () => {
|
||||
mockGetCodebase.mockResolvedValue(null);
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['plan'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
|
||||
|
||||
await handleMessage(platform, 'chat-456', '/command-invoke plan');
|
||||
|
||||
|
|
@ -174,7 +180,7 @@ describe('orchestrator', () => {
|
|||
});
|
||||
|
||||
test('sends error when file read fails', async () => {
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['plan'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
|
||||
mockReadFile.mockRejectedValue(new Error('ENOENT: no such file'));
|
||||
|
||||
await handleMessage(platform, 'chat-456', '/command-invoke plan');
|
||||
|
|
@ -187,7 +193,7 @@ describe('orchestrator', () => {
|
|||
|
||||
test('reads command file and sends to AI', async () => {
|
||||
mockParseCommand.mockReturnValue({
|
||||
command: '/command-invoke',
|
||||
command: 'command-invoke',
|
||||
args: ['plan', 'Add dark mode'],
|
||||
});
|
||||
mockReadFile.mockResolvedValue('Plan the following: $1');
|
||||
|
|
@ -211,7 +217,7 @@ describe('orchestrator', () => {
|
|||
});
|
||||
|
||||
test('appends issueContext after command text', async () => {
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['plan'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
|
||||
mockReadFile.mockResolvedValue('Command text here');
|
||||
mockClient.sendQuery.mockImplementation(async function* () {
|
||||
yield { type: 'result', sessionId: 'session-id' };
|
||||
|
|
@ -245,7 +251,7 @@ describe('orchestrator', () => {
|
|||
|
||||
describe('session management', () => {
|
||||
test('creates new session when none exists', async () => {
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['plan'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
|
||||
mockReadFile.mockResolvedValue('Plan command');
|
||||
mockGetActiveSession.mockResolvedValue(null);
|
||||
mockClient.sendQuery.mockImplementation(async function* () {
|
||||
|
|
@ -262,7 +268,7 @@ describe('orchestrator', () => {
|
|||
});
|
||||
|
||||
test('resumes existing session', async () => {
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['plan'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
|
||||
mockReadFile.mockResolvedValue('Plan command');
|
||||
mockGetActiveSession.mockResolvedValue(mockSession);
|
||||
mockClient.sendQuery.mockImplementation(async function* () {
|
||||
|
|
@ -280,7 +286,7 @@ describe('orchestrator', () => {
|
|||
});
|
||||
|
||||
test('creates new session for plan-feature→execute transition', async () => {
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['execute'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['execute'] });
|
||||
mockReadFile.mockResolvedValue('Execute command');
|
||||
mockGetActiveSession.mockResolvedValue({
|
||||
...mockSession,
|
||||
|
|
@ -297,7 +303,7 @@ describe('orchestrator', () => {
|
|||
});
|
||||
|
||||
test('updates session with AI session ID', async () => {
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['plan'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
|
||||
mockReadFile.mockResolvedValue('Plan command');
|
||||
mockClient.sendQuery.mockImplementation(async function* () {
|
||||
yield { type: 'result', sessionId: 'ai-session-123' };
|
||||
|
|
@ -309,7 +315,7 @@ describe('orchestrator', () => {
|
|||
});
|
||||
|
||||
test('tracks lastCommand in metadata', async () => {
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['plan'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
|
||||
mockReadFile.mockResolvedValue('Plan command');
|
||||
mockClient.sendQuery.mockImplementation(async function* () {
|
||||
yield { type: 'result', sessionId: 'session-id' };
|
||||
|
|
@ -325,7 +331,7 @@ describe('orchestrator', () => {
|
|||
|
||||
describe('streaming modes', () => {
|
||||
beforeEach(() => {
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['plan'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
|
||||
mockReadFile.mockResolvedValue('Plan command');
|
||||
});
|
||||
|
||||
|
|
@ -430,7 +436,7 @@ describe('orchestrator', () => {
|
|||
|
||||
describe('cwd resolution', () => {
|
||||
test('uses conversation cwd when set', async () => {
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['plan'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
|
||||
mockReadFile.mockResolvedValue('Plan command');
|
||||
mockClient.sendQuery.mockImplementation(async function* () {
|
||||
yield { type: 'result', sessionId: 'session-id' };
|
||||
|
|
@ -450,7 +456,7 @@ describe('orchestrator', () => {
|
|||
...mockConversation,
|
||||
cwd: null,
|
||||
});
|
||||
mockParseCommand.mockReturnValue({ command: '/command-invoke', args: ['plan'] });
|
||||
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
|
||||
mockReadFile.mockResolvedValue('Plan command');
|
||||
mockClient.sendQuery.mockImplementation(async function* () {
|
||||
yield { type: 'result', sessionId: 'session-id' };
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { IPlatformAdapter } 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 * as commandHandler from '../handlers/command-handler';
|
||||
import { formatToolCall } from '../utils/tool-formatter';
|
||||
import { substituteVariables } from '../utils/variable-substitution';
|
||||
|
|
@ -26,9 +27,34 @@ export async function handleMessage(
|
|||
// Get or create conversation
|
||||
let conversation = await db.getOrCreateConversation(platform.getPlatformType(), conversationId);
|
||||
|
||||
// Handle slash commands (except /command-invoke which needs AI)
|
||||
// Parse command upfront if it's a slash command
|
||||
let promptToSend = message;
|
||||
let commandName: string | null = null;
|
||||
|
||||
if (message.startsWith('/')) {
|
||||
if (!message.startsWith('/command-invoke')) {
|
||||
const { command, args } = commandHandler.parseCommand(message);
|
||||
|
||||
// List of deterministic commands (handled by command-handler, no AI)
|
||||
const deterministicCommands = [
|
||||
'help',
|
||||
'status',
|
||||
'getcwd',
|
||||
'setcwd',
|
||||
'clone',
|
||||
'repos',
|
||||
'repo',
|
||||
'repo-remove',
|
||||
'reset',
|
||||
'command-set',
|
||||
'load-commands',
|
||||
'commands',
|
||||
'template-add',
|
||||
'template-list',
|
||||
'templates',
|
||||
'template-delete',
|
||||
];
|
||||
|
||||
if (deterministicCommands.includes(command)) {
|
||||
console.log(`[Orchestrator] Processing slash command: ${message}`);
|
||||
const result = await commandHandler.handleCommand(conversation, message);
|
||||
await platform.sendMessage(conversationId, result.message);
|
||||
|
|
@ -42,68 +68,84 @@ export async function handleMessage(
|
|||
}
|
||||
return;
|
||||
}
|
||||
// /command-invoke falls through to AI handling
|
||||
}
|
||||
|
||||
// Parse /command-invoke if applicable
|
||||
let promptToSend = message;
|
||||
let commandName: string | null = null;
|
||||
|
||||
if (message.startsWith('/command-invoke')) {
|
||||
// Use parseCommand to properly handle quoted arguments
|
||||
// e.g., /command-invoke plan "here is the request" → args = ['plan', 'here is the request']
|
||||
const { args: parsedArgs } = commandHandler.parseCommand(message);
|
||||
|
||||
if (parsedArgs.length < 1) {
|
||||
await platform.sendMessage(conversationId, 'Usage: /command-invoke <name> [args...]');
|
||||
return;
|
||||
}
|
||||
|
||||
commandName = parsedArgs[0];
|
||||
const args = parsedArgs.slice(1);
|
||||
|
||||
if (!conversation.codebase_id) {
|
||||
await platform.sendMessage(conversationId, 'No codebase configured. Use /clone first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up command definition
|
||||
const codebase = await codebaseDb.getCodebase(conversation.codebase_id);
|
||||
if (!codebase) {
|
||||
await platform.sendMessage(conversationId, 'Codebase not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const commandDef = codebase.commands[commandName];
|
||||
if (!commandDef) {
|
||||
await platform.sendMessage(
|
||||
conversationId,
|
||||
`Command '${commandName}' not found. Use /commands to see available.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read command file
|
||||
const cwd = conversation.cwd ?? codebase.default_cwd;
|
||||
const commandFilePath = join(cwd, commandDef.path);
|
||||
|
||||
try {
|
||||
const commandText = await readFile(commandFilePath, 'utf-8');
|
||||
|
||||
// Substitute variables (no metadata needed - file-based workflow)
|
||||
promptToSend = substituteVariables(commandText, args);
|
||||
|
||||
// Append issue/PR context AFTER command loading (if provided)
|
||||
if (issueContext) {
|
||||
promptToSend = promptToSend + '\n\n---\n\n' + issueContext;
|
||||
console.log('[Orchestrator] Appended issue/PR context to command prompt');
|
||||
// Handle /command-invoke (codebase-specific commands)
|
||||
if (command === 'command-invoke') {
|
||||
if (args.length < 1) {
|
||||
await platform.sendMessage(conversationId, 'Usage: /command-invoke <name> [args...]');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Orchestrator] Executing '${commandName}' with ${String(args.length)} args`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
await platform.sendMessage(conversationId, `Failed to read command file: ${err.message}`);
|
||||
return;
|
||||
commandName = args[0];
|
||||
const commandArgs = args.slice(1);
|
||||
|
||||
if (!conversation.codebase_id) {
|
||||
await platform.sendMessage(conversationId, 'No codebase configured. Use /clone first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up command definition
|
||||
const codebase = await codebaseDb.getCodebase(conversation.codebase_id);
|
||||
if (!codebase) {
|
||||
await platform.sendMessage(conversationId, 'Codebase not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const commandDef = codebase.commands[commandName];
|
||||
if (!commandDef) {
|
||||
await platform.sendMessage(
|
||||
conversationId,
|
||||
`Command '${commandName}' not found. Use /commands to see available.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read command file
|
||||
const cwd = conversation.cwd ?? codebase.default_cwd;
|
||||
const commandFilePath = join(cwd, commandDef.path);
|
||||
|
||||
try {
|
||||
const commandText = await readFile(commandFilePath, 'utf-8');
|
||||
|
||||
// Substitute variables (no metadata needed - file-based workflow)
|
||||
promptToSend = substituteVariables(commandText, commandArgs);
|
||||
|
||||
// Append issue/PR context AFTER command loading (if provided)
|
||||
if (issueContext) {
|
||||
promptToSend = promptToSend + '\n\n---\n\n' + issueContext;
|
||||
console.log('[Orchestrator] Appended issue/PR context to command prompt');
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Orchestrator] Executing '${commandName}' with ${String(commandArgs.length)} args`
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
await platform.sendMessage(conversationId, `Failed to read command file: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Check if it's a global template command
|
||||
const template = await templateDb.getTemplate(command);
|
||||
if (template) {
|
||||
console.log(`[Orchestrator] Found template: ${command}`);
|
||||
commandName = command;
|
||||
promptToSend = substituteVariables(template.content, args);
|
||||
|
||||
if (issueContext) {
|
||||
promptToSend = promptToSend + '\n\n---\n\n' + issueContext;
|
||||
console.log('[Orchestrator] Appended issue/PR context to template prompt');
|
||||
}
|
||||
|
||||
console.log(`[Orchestrator] Executing template '${command}' with ${String(args.length)} args`);
|
||||
} else {
|
||||
// Unknown command
|
||||
await platform.sendMessage(
|
||||
conversationId,
|
||||
`Unknown command: /${command}\n\nType /help for available commands or /templates for command templates.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular message - require codebase
|
||||
|
|
@ -121,7 +163,9 @@ export async function handleMessage(
|
|||
|
||||
// Get or create session (handle plan→execute transition)
|
||||
let session = await sessionDb.getActiveSession(conversation.id);
|
||||
const codebase = await codebaseDb.getCodebase(conversation.codebase_id);
|
||||
const codebase = conversation.codebase_id
|
||||
? await codebaseDb.getCodebase(conversation.codebase_id)
|
||||
: null;
|
||||
const cwd = conversation.cwd ?? codebase?.default_cwd ?? '/workspace';
|
||||
|
||||
// Check for plan→execute transition (requires NEW session per PRD)
|
||||
|
|
@ -138,14 +182,14 @@ export async function handleMessage(
|
|||
|
||||
session = await sessionDb.createSession({
|
||||
conversation_id: conversation.id,
|
||||
codebase_id: conversation.codebase_id,
|
||||
codebase_id: conversation.codebase_id ?? undefined,
|
||||
ai_assistant_type: conversation.ai_assistant_type,
|
||||
});
|
||||
} else if (!session) {
|
||||
console.log('[Orchestrator] Creating new session');
|
||||
session = await sessionDb.createSession({
|
||||
conversation_id: conversation.id,
|
||||
codebase_id: conversation.codebase_id,
|
||||
codebase_id: conversation.codebase_id ?? undefined,
|
||||
ai_assistant_type: conversation.ai_assistant_type,
|
||||
});
|
||||
} else {
|
||||
|
|
|
|||
52
src/scripts/seed-commands.ts
Normal file
52
src/scripts/seed-commands.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Seed default command templates from .claude/commands/exp-piv-loop
|
||||
*/
|
||||
import { readFile, readdir } from 'fs/promises';
|
||||
import { join, basename } from 'path';
|
||||
import { upsertTemplate } from '../db/command-templates';
|
||||
|
||||
const SEED_COMMANDS_PATH = '.claude/commands/exp-piv-loop';
|
||||
|
||||
/**
|
||||
* Extract description from markdown frontmatter
|
||||
* ---
|
||||
* description: Some description
|
||||
* ---
|
||||
*/
|
||||
function extractDescription(content: string): string | undefined {
|
||||
const frontmatterMatch = /^---\n([\s\S]*?)\n---/.exec(content);
|
||||
if (!frontmatterMatch) return undefined;
|
||||
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const descMatch = /description:\s*(.+)/.exec(frontmatter);
|
||||
return descMatch?.[1]?.trim();
|
||||
}
|
||||
|
||||
export async function seedDefaultCommands(): Promise<void> {
|
||||
console.log('[Seed] Checking for default command templates...');
|
||||
|
||||
try {
|
||||
const files = await readdir(SEED_COMMANDS_PATH);
|
||||
const mdFiles = files.filter((f) => f.endsWith('.md'));
|
||||
|
||||
for (const file of mdFiles) {
|
||||
const name = basename(file, '.md');
|
||||
const filePath = join(SEED_COMMANDS_PATH, file);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const description = extractDescription(content);
|
||||
|
||||
await upsertTemplate({
|
||||
name,
|
||||
description: description ?? `From ${SEED_COMMANDS_PATH}`,
|
||||
content,
|
||||
});
|
||||
|
||||
console.log(`[Seed] Loaded template: ${name}`);
|
||||
}
|
||||
|
||||
console.log(`[Seed] Seeded ${String(mdFiles.length)} default command templates`);
|
||||
} catch {
|
||||
// Don't fail startup if seed commands don't exist
|
||||
console.log('[Seed] No default commands to seed (this is OK)');
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,15 @@ export interface Session {
|
|||
ended_at: Date | 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