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:
Wirasm 2025-12-02 10:52:02 +02:00 committed by GitHub
parent 3e5c7404ae
commit 9f73a87830
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1284 additions and 81 deletions

View 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.

View 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);

View 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']
);
});
});
});

View 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];
}

View file

@ -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,

View file

@ -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);

View file

@ -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' };

View file

@ -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 {

View 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)');
}
}

View file

@ -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;